From b8cb205e690c3a97d30d04a268eec25b6bdde105 Mon Sep 17 00:00:00 2001 From: lakshit verma Date: Wed, 19 Aug 2026 20:03:39 +0530 Subject: [PATCH] =?UTF-8?q?test(backend):=20raise=20AI=20surface=20coverag?= =?UTF-8?q?e=20=E2=80=94=20provider=20client,=20admin=20agent=20API,=20pol?= =?UTF-8?q?icy,=20triage=20skip=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/tests/agent-admin.integration.test.ts | 161 ++++++++++++ backend/tests/ai.middleware.unit.test.ts | 55 ++++ backend/tests/model-mappers.unit.test.ts | 122 +++++++++ backend/tests/policy.unit.test.ts | 147 +++++++++++ backend/tests/provider.unit.test.ts | 248 ++++++++++++++++++ backend/tests/triage.worker.test.ts | 46 ++++ 6 files changed, 779 insertions(+) create mode 100644 backend/tests/agent-admin.integration.test.ts create mode 100644 backend/tests/ai.middleware.unit.test.ts create mode 100644 backend/tests/model-mappers.unit.test.ts create mode 100644 backend/tests/policy.unit.test.ts create mode 100644 backend/tests/provider.unit.test.ts diff --git a/backend/tests/agent-admin.integration.test.ts b/backend/tests/agent-admin.integration.test.ts new file mode 100644 index 0000000..3e4d7f7 --- /dev/null +++ b/backend/tests/agent-admin.integration.test.ts @@ -0,0 +1,161 @@ +import { importAIApp, providerResult, type TestAgent } from './ai-helpers.js'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import request from 'supertest'; +import type { Express } from 'express'; +import { agentRepo } from '../src/repositories/agent.repo.js'; +import { userRepo } from '../src/repositories/user.repo.js'; +import { workOrderRepo } from '../src/repositories/work-order.repo.js'; +import { createAdmin } from './helpers.js'; + +const providerMock = vi.hoisted(() => { + class ProviderError extends Error { + constructor( + message: string, + public readonly code: 'timeout' | 'network' | 'bad_status' | 'invalid_response' | 'size', + ) { + super(message); + this.name = 'ProviderError'; + } + } + return { + chatStream: vi.fn(), + chatComplete: vi.fn(), + providerModel: 'agent-admin-test-model', + ProviderError, + }; +}); + +vi.mock('../src/agent/provider.js', () => providerMock); + +let app: Express; + +beforeAll(async () => { + app = await importAIApp(); +}); + +beforeEach(() => { + providerMock.chatStream.mockReset(); + providerMock.chatComplete.mockReset(); +}); + +async function register(agent: TestAgent, email: string): Promise<{ userId: string }> { + const res = await agent.post('/api/v1/auth/register').send({ email, password: 'Password123', name: 'Test User' }); + expect(res.status).toBe(201); + return { userId: res.body.data.id }; +} + +describe('agent admin API', () => { + it('returns the triage config, creating it on demand (GET /triage)', async () => { + const { agent: admin } = await createAdmin(); + const res = await admin.get('/api/v1/admin/agents/triage'); + expect(res.status).toBe(200); + expect(res.body.data).toMatchObject({ + name: 'triage', + mode: 'suggest', + enabled: true, + workingHours: '*', + dailyActionCap: 50, + }); + expect(typeof res.body.data.updatedAt).toBe('string'); + }); + + it('updates the triage config (PATCH /triage/config)', async () => { + const { agent: admin } = await createAdmin(); + await admin.get('/api/v1/admin/agents/triage'); + const res = await admin.patch('/api/v1/admin/agents/triage/config').send({ mode: 'auto-apply' }); + expect(res.status).toBe(200); + expect(res.body.data.mode).toBe('auto-apply'); + expect(res.body.data.updatedBy).not.toBeNull(); + const after = await agentRepo.getAgentConfig('triage'); + expect(after?.mode).toBe('auto-apply'); + }); + + it('rejects an unknown config field (PATCH /triage/config)', async () => { + const { agent: admin } = await createAdmin(); + const res = await admin.patch('/api/v1/admin/agents/triage/config').send({ bogus: true }); + expect(res.status).toBe(400); + expect(res.body.error.code).toBe('VALIDATION_ERROR'); + }); + + it('disables the triage agent (POST /disable)', async () => { + const { agent: admin } = await createAdmin(); + await admin.get('/api/v1/admin/agents/triage'); + const res = await admin.post('/api/v1/admin/agents/disable').send({}); + expect(res.status).toBe(200); + expect(res.body.data).toEqual({ enabled: false }); + const config = await admin.get('/api/v1/admin/agents/triage'); + expect(config.body.data.enabled).toBe(false); + }); + + it('runs triage manually and exposes the run via /runs and /runs/:id', async () => { + const { agent: admin } = await createAdmin(); + const owner = await userRepo.createUser({ + email: `owner-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`, + passwordHash: 'test-hash', + name: 'Owner', + }); + const wo = await workOrderRepo.create({ + ownerId: owner._id.toString(), + title: 'Triage target', + priority: 'medium', + status: 'pending', + }); + providerMock.chatComplete.mockResolvedValue( + providerResult({ + content: JSON.stringify({ summary: 'Needs review', suggestedPriority: 'high', flagForDispatcher: true }), + inputTokens: 40, + outputTokens: 15, + }), + ); + + const run = await admin.post('/api/v1/admin/agents/triage/run').send({ workOrderId: wo._id.toString() }); + expect(run.status).toBe(200); + expect(run.body.data.outcome).toBe('done'); + + const suggestions = await agentRepo.listSuggestionsForWorkOrder(wo._id.toString()); + expect(suggestions).toHaveLength(1); + expect(suggestions[0]).toMatchObject({ summary: 'Needs review', suggestedPriority: 'high', flagForDispatcher: true }); + + const runs = await admin.get('/api/v1/admin/agents/runs'); + expect(runs.status).toBe(200); + expect(runs.body.data).toMatchObject({ page: 1, limit: 20 }); + expect(runs.body.data.total).toBe(1); + expect(runs.body.data.items[0]).toMatchObject({ + status: 'complete', + mode: 'autonomous', + agentName: 'triage', + inputTokens: 40, + outputTokens: 15, + finishedAt: expect.any(String), + }); + + const runId = runs.body.data.items[0].id; + const detail = await admin.get(`/api/v1/admin/agents/runs/${runId}`); + expect(detail.status).toBe(200); + expect(detail.body.data.run).toMatchObject({ id: runId, status: 'complete' }); + expect(detail.body.data.messages.map((m: { role: string }) => m.role)).toEqual(['system', 'user', 'assistant']); + expect(detail.body.data.toolCalls[0]).toMatchObject({ tool: 'triage_propose', outcome: 'executed' }); + }); + + it('returns 404 for an unknown run id (GET /runs/:id)', async () => { + const { agent: admin } = await createAdmin(); + const res = await admin.get('/api/v1/admin/agents/runs/000000000000000000000000'); + expect(res.status).toBe(404); + expect(res.body.error.code).toBe('NOT_FOUND'); + }); + + it('rejects non-admin users with 403 FORBIDDEN', async () => { + const a = request.agent(app); + await register(a, 'plain-agent@example.com'); + expect((await a.get('/api/v1/admin/agents/triage')).status).toBe(403); + expect((await a.patch('/api/v1/admin/agents/triage/config').send({ mode: 'auto-apply' })).status).toBe(403); + expect((await a.post('/api/v1/admin/agents/disable').send({})).status).toBe(403); + expect((await a.post('/api/v1/admin/agents/triage/run').send({})).status).toBe(403); + expect((await a.get('/api/v1/admin/agents/runs')).status).toBe(403); + }); + + it('requires authentication (401)', async () => { + const res = await request(app).get('/api/v1/admin/agents/triage'); + expect(res.status).toBe(401); + }); +}); diff --git a/backend/tests/ai.middleware.unit.test.ts b/backend/tests/ai.middleware.unit.test.ts new file mode 100644 index 0000000..aa7be6c --- /dev/null +++ b/backend/tests/ai.middleware.unit.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Request, Response } from 'express'; +import { HttpError } from '../src/utils/http-error.js'; + +const mockEnv = vi.hoisted(() => ({ AI_ENABLED: false, AI_RATE_LIMIT_MAX: 2 })); + +vi.mock('../src/config/env.js', () => ({ env: mockEnv })); + +import express from 'express'; +import request from 'supertest'; +import { aiLimiter, requireAIAvailable } from '../src/middleware/ai.middleware.js'; + +describe('requireAIAvailable', () => { + it('rejects with 503 AI_UNAVAILABLE when AI is disabled', () => { + mockEnv.AI_ENABLED = false; + const next = vi.fn(); + requireAIAvailable({} as Request, {} as Response, next); + expect(next).toHaveBeenCalledTimes(1); + const err = next.mock.calls[0]![0] as HttpError; + expect(err).toBeInstanceOf(HttpError); + expect(err.status).toBe(503); + expect(err.code).toBe('AI_UNAVAILABLE'); + }); + + it('calls next without an error when AI is enabled', () => { + mockEnv.AI_ENABLED = true; + const next = vi.fn(); + requireAIAvailable({} as Request, {} as Response, next); + expect(next).toHaveBeenCalledTimes(1); + expect(next.mock.calls[0]?.[0]).toBeUndefined(); + }); +}); + +describe('aiLimiter', () => { + it('responds 429 RATE_LIMITED once the per-minute limit is hit', async () => { + mockEnv.AI_RATE_LIMIT_MAX = 2; + const app = express(); + app.get('/ai', aiLimiter, (_req, res) => res.json({ ok: true })); + const agent = request.agent(app); + + const first = await agent.get('/ai'); + expect(first.status).toBe(200); + + const second = await agent.get('/ai'); + expect(second.status).toBe(200); + + const limited = await agent.get('/ai'); + expect(limited.status).toBe(429); + expect(limited.body).toMatchObject({ + success: false, + error: { code: 'RATE_LIMITED', message: 'Too many requests, try again later' }, + }); + expect(typeof limited.body.requestId).toBe('string'); + }); +}); diff --git a/backend/tests/model-mappers.unit.test.ts b/backend/tests/model-mappers.unit.test.ts new file mode 100644 index 0000000..0aef7f7 --- /dev/null +++ b/backend/tests/model-mappers.unit.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; +import mongoose from 'mongoose'; +import { toAgentConfigPublic, type AgentConfigDoc } from '../src/models/agent-config.model.js'; +import { toTriageSuggestionPublic, type TriageSuggestionDoc } from '../src/models/triage-suggestion.model.js'; +import { toAgentRunPublic, type AgentRunDoc } from '../src/models/agent-run.model.js'; + +describe('model mappers', () => { + it('toAgentConfigPublic maps a config document to the public DTO', () => { + const doc = { + _id: new mongoose.Types.ObjectId(), + name: 'triage', + enabled: true, + mode: 'auto-apply', + allowedFields: ['priority'], + dailyActionCap: 12, + flagThreshold: 'medium', + workingHours: '09-17', + updatedBy: 'admin-1', + updatedAt: new Date('2026-01-01T00:00:00Z'), + } as unknown as AgentConfigDoc; + + expect(toAgentConfigPublic(doc)).toEqual({ + name: 'triage', + enabled: true, + mode: 'auto-apply', + allowedFields: ['priority'], + dailyActionCap: 12, + flagThreshold: 'medium', + workingHours: '09-17', + updatedBy: 'admin-1', + updatedAt: '2026-01-01T00:00:00.000Z', + }); + }); + + it('toTriageSuggestionPublic maps a suggestion document to the public DTO', () => { + const doc = { + _id: new mongoose.Types.ObjectId(), + workOrderId: new mongoose.Types.ObjectId(), + runId: new mongoose.Types.ObjectId(), + summary: 'Needs dispatch', + suggestedPriority: 'high', + flagForDispatcher: true, + applied: false, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T01:00:00Z'), + } as unknown as TriageSuggestionDoc; + + expect(toTriageSuggestionPublic(doc)).toEqual({ + id: doc._id.toString(), + workOrderId: doc.workOrderId.toString(), + runId: doc.runId.toString(), + summary: 'Needs dispatch', + suggestedPriority: 'high', + flagForDispatcher: true, + applied: false, + createdAt: '2026-01-01T00:00:00.000Z', + }); + }); + + it('toAgentRunPublic maps a run with all optional fields set', () => { + const userId = new mongoose.Types.ObjectId(); + const doc = { + _id: new mongoose.Types.ObjectId(), + sessionId: null, + userId, + mode: 'autonomous', + agentName: 'triage', + status: 'complete', + model: 'gpt-4o-mini', + inputTokens: 100, + outputTokens: 20, + errorCode: 'AI_UNAVAILABLE', + finishedAt: new Date('2026-01-01T02:00:00Z'), + leaseUntil: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T02:00:00Z'), + } as unknown as AgentRunDoc; + + expect(toAgentRunPublic(doc)).toEqual({ + id: doc._id.toString(), + mode: 'autonomous', + actorId: userId.toString(), + agentName: 'triage', + status: 'complete', + model: 'gpt-4o-mini', + inputTokens: 100, + outputTokens: 20, + startedAt: '2026-01-01T00:00:00.000Z', + finishedAt: '2026-01-01T02:00:00.000Z', + errorCode: 'AI_UNAVAILABLE', + }); + }); + + it('toAgentRunPublic maps a run with no optional fields set', () => { + const doc = { + _id: new mongoose.Types.ObjectId(), + sessionId: null, + userId: null, + mode: 'copilot', + status: 'running', + model: 'gpt-4o-mini', + inputTokens: 0, + outputTokens: 0, + finishedAt: null, + leaseUntil: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + } as unknown as AgentRunDoc; + + expect(toAgentRunPublic(doc)).toEqual({ + id: doc._id.toString(), + mode: 'copilot', + actorId: null, + status: 'running', + model: 'gpt-4o-mini', + inputTokens: 0, + outputTokens: 0, + startedAt: '2026-01-01T00:00:00.000Z', + finishedAt: null, + }); + }); +}); diff --git a/backend/tests/policy.unit.test.ts b/backend/tests/policy.unit.test.ts new file mode 100644 index 0000000..c8937e3 --- /dev/null +++ b/backend/tests/policy.unit.test.ts @@ -0,0 +1,147 @@ +import './ai-helpers.js'; +import { afterEach, describe, expect, it } from 'vitest'; +import { env } from '../src/config/env.js'; +import { + assertAIEnabled, + assertBudget, + billSpend, + compactWorkOrder, + isWorkingHours, + serializeResult, +} from '../src/agent/policy.js'; +import { agentRepo } from '../src/repositories/agent.repo.js'; +import { HttpError } from '../src/utils/http-error.js'; + +function setAIEnabled(value: boolean): void { + Object.defineProperty(env, 'AI_ENABLED', { value, configurable: true, writable: true }); +} + +afterEach(() => { + setAIEnabled(true); +}); + +describe('policy.assertAIEnabled', () => { + it('passes when AI is enabled', () => { + setAIEnabled(true); + expect(() => assertAIEnabled()).not.toThrow(); + }); + + it('throws 503 AI_UNAVAILABLE when AI is disabled', () => { + setAIEnabled(false); + try { + assertAIEnabled(); + expect.unreachable(); + } catch (err) { + expect(err).toBeInstanceOf(HttpError); + expect(err).toMatchObject({ status: 503, code: 'AI_UNAVAILABLE' }); + } + }); +}); + +describe('policy.isWorkingHours', () => { + it('accepts the wildcard spec', () => { + expect(isWorkingHours('*', new Date('2026-01-01T00:00:00Z'))).toBe(true); + }); + + it('checks the current UTC hour inclusively', () => { + expect(isWorkingHours('09-17', new Date('2026-01-01T09:00:00Z'))).toBe(true); + expect(isWorkingHours('09-17', new Date('2026-01-01T10:00:00Z'))).toBe(true); + expect(isWorkingHours('09-17', new Date('2026-01-01T17:00:00Z'))).toBe(true); + expect(isWorkingHours('09-17', new Date('2026-01-01T20:00:00Z'))).toBe(false); + expect(isWorkingHours('09-17', new Date('2026-01-01T08:00:00Z'))).toBe(false); + }); + + it('rejects invalid specs', () => { + expect(isWorkingHours('', new Date('2026-01-01T10:00:00Z'))).toBe(false); + expect(isWorkingHours(' ', new Date('2026-01-01T10:00:00Z'))).toBe(false); + expect(isWorkingHours('bogus', new Date('2026-01-01T10:00:00Z'))).toBe(false); + expect(isWorkingHours('9-17-20', new Date('2026-01-01T10:00:00Z'))).toBe(false); + expect(isWorkingHours('24-25', new Date('2026-01-01T10:00:00Z'))).toBe(false); + expect(isWorkingHours('5-2', new Date('2026-01-01T10:00:00Z'))).toBe(false); + expect(isWorkingHours('9.5-10', new Date('2026-01-01T10:00:00Z'))).toBe(false); + }); +}); + +describe('policy.serializeResult', () => { + it('serializes plain values', () => { + expect(serializeResult({ a: 1, b: ['x'] })).toBe('{"a":1,"b":["x"]}'); + expect(serializeResult(null)).toBe('null'); + expect(serializeResult(undefined)).toBe('null'); + expect(serializeResult('plain')).toBe('"plain"'); + }); + + it('truncates results longer than 8000 characters', () => { + const out = serializeResult({ blob: 'y'.repeat(9000) }); + expect(out.length).toBe(8000 + '…[truncated]'.length); + expect(out.endsWith('…[truncated]')).toBe(true); + expect(out.startsWith('{"blob":"')).toBe(true); + expect(out.length).toBeLessThan(8500); + }); + + it('returns a placeholder for unserializable values', () => { + const circular: Record = {}; + circular.self = circular; + expect(serializeResult(circular)).toBe('[Unserializable value]'); + }); +}); + +describe('policy.compactWorkOrder', () => { + const base = { + id: 'wo-1', + title: 'Fix the pipe', + description: 'x'.repeat(400), + priority: 'medium' as const, + status: 'pending' as const, + owner: { id: 'u1', name: 'N', email: 'n@example.com' }, + version: 3, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T01:00:00.000Z', + }; + + it('truncates long descriptions to 300 characters', () => { + const out = compactWorkOrder(base); + expect(out.description).toBe('x'.repeat(300) + '…'); + expect(out).toMatchObject({ id: 'wo-1', title: 'Fix the pipe', priority: 'medium', status: 'pending', version: 3 }); + }); + + it('keeps null descriptions as-is', () => { + const out = compactWorkOrder({ ...base, description: null }); + expect(out.description).toBeNull(); + }); + + it('keeps short descriptions untouched', () => { + const out = compactWorkOrder({ ...base, description: 'short' }); + expect(out.description).toBe('short'); + }); +}); + +describe('policy.assertBudget', () => { + it('resolves when spend is below the daily limits', async () => { + await expect(assertBudget('budget-ok-user')).resolves.toBeUndefined(); + }); + + it('throws 429 AI_BUDGET_EXCEEDED when the user spend is exhausted', async () => { + await agentRepo.chargeSpend('user:budget-spent-user', env.AI_DAILY_SPEND_USD); + await expect(assertBudget('budget-spent-user')).rejects.toMatchObject({ + status: 429, + code: 'AI_BUDGET_EXCEEDED', + }); + }); + + it('throws 429 AI_BUDGET_EXCEEDED when the global spend is exhausted', async () => { + await agentRepo.chargeSpend('global', env.AI_GLOBAL_DAILY_SPEND_USD); + await expect(assertBudget('budget-any-user')).rejects.toMatchObject({ + status: 429, + code: 'AI_BUDGET_EXCEEDED', + }); + }); +}); + +describe('policy.billSpend', () => { + it('charges the user and global spend keys', async () => { + await billSpend('bill-user', 1_000_000, 0); + const cost = (1_000_000 / 1e6) * env.AI_PRICE_PER_1M_INPUT + (0 / 1e6) * env.AI_PRICE_PER_1M_OUTPUT; + expect(await agentRepo.getSpend('user:bill-user')).toBeCloseTo(cost, 6); + expect(await agentRepo.getSpend('global')).toBeCloseTo(cost, 6); + }); +}); diff --git a/backend/tests/provider.unit.test.ts b/backend/tests/provider.unit.test.ts new file mode 100644 index 0000000..fb27655 --- /dev/null +++ b/backend/tests/provider.unit.test.ts @@ -0,0 +1,248 @@ +import './ai-helpers.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { chatComplete, chatStream, ProviderError } from '../src/agent/provider.js'; + +// Exercises the REAL provider HTTP client with a stubbed global fetch. + +type FetchResponse = { + ok: boolean; + status: number; + text: () => Promise; + json: () => Promise; + body?: ReadableStream; +}; + +function jsonResponse( + body: unknown, + options: { ok?: boolean; status?: number; text?: string } = {}, +): FetchResponse { + return { + ok: options.ok ?? true, + status: options.status ?? 200, + text: vi.fn().mockResolvedValue(options.text ?? JSON.stringify(body)), + json: vi.fn().mockResolvedValue(body), + }; +} + +function sseResponse(...chunks: string[]): FetchResponse { + const encoder = new TextEncoder(); + return { + ok: true, + status: 200, + text: vi.fn(), + json: vi.fn(), + body: new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)); + controller.close(); + }, + }), + }; +} + +const sse = (obj: unknown): string => `data: ${JSON.stringify(obj)}\n\n`; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('provider.chatComplete', () => { + it('returns content, tool calls and usage on the happy path', async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + choices: [ + { + message: { + content: 'Hello', + tool_calls: [{ id: 't1', type: 'function', function: { name: 'get_profile', arguments: '{}' } }], + }, + }, + ], + usage: { prompt_tokens: 12, completion_tokens: 5 }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await chatComplete( + [{ role: 'user', content: 'hi' }], + [{ type: 'function', function: { name: 'get_profile', description: 'd', parameters: {} } }], + { maxTokens: 100 }, + ); + + expect(result).toEqual({ + content: 'Hello', + tool_calls: [{ id: 't1', type: 'function', function: { name: 'get_profile', arguments: '{}' } }], + inputTokens: 12, + outputTokens: 5, + }); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://llm.example.com/v1/chat/completions'); + expect(init.method).toBe('POST'); + expect(init.headers).toEqual({ 'Content-Type': 'application/json', Authorization: 'Bearer test-key' }); + const body = JSON.parse(String(init.body)) as Record; + expect(body).toMatchObject({ + model: 'gpt-4o-mini', + stream: false, + max_tokens: 100, + tool_choice: 'auto', + }); + expect(body.tools).toHaveLength(1); + expect(body.messages).toEqual([{ role: 'user', content: 'hi' }]); + }); + + it('omits tools when none are provided', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ choices: [{ message: { content: '' } }] })); + vi.stubGlobal('fetch', fetchMock); + await chatComplete([{ role: 'user', content: 'hi' }], []); + const body = JSON.parse(String((fetchMock.mock.calls[0] as [string, RequestInit])[1].body)) as Record; + expect(body.tools).toBeUndefined(); + }); + + it('maps non-2xx responses to a bad_status ProviderError', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse(null, { ok: false, status: 503, text: 'Overloaded' }))); + await expect(chatComplete([{ role: 'user', content: 'hi' }], [])).rejects.toMatchObject({ + name: 'ProviderError', + code: 'bad_status', + message: 'bad_status 503: Overloaded', + }); + }); + + it('maps a JSON parse failure to an invalid_response ProviderError', async () => { + const res = jsonResponse(null); + res.json = vi.fn().mockRejectedValue(new SyntaxError('Unexpected token')); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(res)); + await expect(chatComplete([{ role: 'user', content: 'hi' }], [])).rejects.toMatchObject({ + name: 'ProviderError', + code: 'invalid_response', + }); + }); + + it('maps a fetch rejection to a network ProviderError', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED'))); + await expect(chatComplete([{ role: 'user', content: 'hi' }], [])).rejects.toMatchObject({ + name: 'ProviderError', + code: 'network', + }); + }); + + it('maps a TimeoutError to a timeout ProviderError', async () => { + const timeout = Object.assign(new Error('The operation was aborted due to timeout'), { name: 'TimeoutError' }); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(timeout)); + await expect(chatComplete([{ role: 'user', content: 'hi' }], [])).rejects.toMatchObject({ + name: 'ProviderError', + code: 'timeout', + }); + }); + + it('rethrows AbortError as-is', async () => { + const abort = Object.assign(new Error('aborted'), { name: 'AbortError' }); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(abort)); + await expect(chatComplete([{ role: 'user', content: 'hi' }], [])).rejects.toBe(abort); + }); +}); + +describe('provider.chatStream', () => { + it('assembles content deltas across line-buffered chunks and reports usage', async () => { + const deltas: string[] = []; + // First enqueue carries no newline, so the line is completed by the second enqueue. + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + sseResponse( + 'data: {"choices":[{"delta":{"content":"Hel', // no newline: continues mid-line + `lo"}}]}\n\n${sse({ choices: [{ delta: { content: ' world' } }] })}${ + sse({ usage: { prompt_tokens: 9, completion_tokens: 4 } }) + }data: [DONE]\n\n`, + ), + ), + ); + + const result = await chatStream([{ role: 'user', content: 'hi' }], [], { onToken: (delta) => deltas.push(delta) }); + + expect(result).toEqual({ content: 'Hello world', tool_calls: [], inputTokens: 9, outputTokens: 4 }); + expect(deltas).toEqual(['Hello', ' world']); + }); + +it('assembles tool-call deltas by index with arguments split across chunks', async () => { + // Build raw SSE payloads as strings: nested object literals containing + // single-quoted strings that end in "}" confuse esbuild's TS type parser. + const chunk = (payload: string): string => `data: ${payload}\n\n`; + const toolCallDelta = ( + index: number, + id: string | null, + name: string | null, + argumentsJson: string | null, + ): string => { + const parts = [`{"index":${index}`]; + if (id) parts.push(`"id":${JSON.stringify(id)}`); + const fn = [ + ...(name ? [`"name":${JSON.stringify(name)}`] : []), + ...(argumentsJson !== null ? [`"arguments":${JSON.stringify(argumentsJson)}`] : []), + ]; + parts.push(`"function":{${fn.join(',')}}`); + return chunk(`{"choices":[{"delta":{"tool_calls":[${parts.join(',')}}]}}]}`); + }; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + sseResponse( + toolCallDelta(0, 'call_a', 'search', ''), + toolCallDelta(0, null, null, '{"q":"'), + toolCallDelta(0, null, null, 'needle"}'), + toolCallDelta(1, 'call_b', 'read', '{}'), + sse({ usage: { prompt_tokens: 11, completion_tokens: 6 } }), + 'data: [DONE]\n\n', + ), + ), + ); + + const result = await chatStream([{ role: 'user', content: 'hi' }], []); + + expect(result.tool_calls).toEqual([ + { id: 'call_a', type: 'function', function: { name: 'search', arguments: '{"q":"needle"}' } }, + { id: 'call_b', type: 'function', function: { name: 'read', arguments: '{}' } }, + ]); + expect(result.inputTokens).toBe(11); + expect(result.outputTokens).toBe(6); + expect(result.content).toBe(''); + }); + + it('rejects streams that exceed the 1MB content cap', async () => { + const big = 'x'.repeat(1024 * 1024 + 1); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(sseResponse(sse({ choices: [{ delta: { content: big } }] }), 'data: [DONE]\n\n')), + ); + await expect(chatStream([{ role: 'user', content: 'hi' }], [])).rejects.toMatchObject({ + name: 'ProviderError', + code: 'size', + }); + }); + + it('rejects malformed SSE JSON payloads', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(sseResponse('data: definitely-not-json\n\n'))); + await expect(chatStream([{ role: 'user', content: 'hi' }], [])).rejects.toMatchObject({ + name: 'ProviderError', + code: 'invalid_response', + }); + }); + + it('maps a missing response body to a network ProviderError', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200, text: vi.fn(), json: vi.fn() })); + await expect(chatStream([{ role: 'user', content: 'hi' }], [])).rejects.toMatchObject({ + name: 'ProviderError', + code: 'network', + }); + }); + + it('is an instance of ProviderError for typed failures', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse(null, { ok: false, status: 500, text: 'boom' }))); + try { + await chatStream([{ role: 'user', content: 'hi' }], []); + expect.unreachable(); + } catch (err) { + expect(err).toBeInstanceOf(ProviderError); + expect((err as ProviderError).code).toBe('bad_status'); + } + }); +}); diff --git a/backend/tests/triage.worker.test.ts b/backend/tests/triage.worker.test.ts index 5a96806..9f656bc 100644 --- a/backend/tests/triage.worker.test.ts +++ b/backend/tests/triage.worker.test.ts @@ -1,5 +1,6 @@ import { providerResult } from './ai-helpers.js'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Types } from 'mongoose'; import { ensureTriageConfig, isWorkingHours, runTriage } from '../src/agent/triage.js'; import { agentRepo } from '../src/repositories/agent.repo.js'; import { userRepo } from '../src/repositories/user.repo.js'; @@ -114,6 +115,51 @@ describe('triage agent', () => { expect(runs[0]).toMatchObject({ status: 'error', errorCode: 'AI_UNAVAILABLE' }); }); + it('skips triage when outside the configured working hours', async () => { + const { wo } = await makeWorkOrder(); + await ensureTriageConfig(); + const hour = new Date().getUTCHours(); + const excluding = (hour + 2) % 24; + await agentRepo.updateAgentConfig('triage', { workingHours: `${excluding}-${excluding}` }, 'admin'); + const outcome = await runTriage(wo.id); + expect(outcome).toBe('skipped'); + expect(providerMock.chatComplete).not.toHaveBeenCalled(); + }); + + it('skips triage when the daily action cap is already reached', async () => { + const { wo } = await makeWorkOrder(); + await ensureTriageConfig(); + await agentRepo.updateAgentConfig('triage', { dailyActionCap: 1 }, 'admin'); + await agentRepo.createSuggestion({ + workOrderId: wo.id, + runId: new Types.ObjectId().toString(), + summary: 'Already flagged', + suggestedPriority: 'low', + flagForDispatcher: false, + applied: false, + }); + const outcome = await runTriage(wo.id); + expect(outcome).toBe('skipped'); + expect(providerMock.chatComplete).not.toHaveBeenCalled(); + }); + + it('skips triage when the agent daily spend budget is exhausted', async () => { + const { wo } = await makeWorkOrder(); + await agentRepo.chargeSpend('agent:triage', 100); + const outcome = await runTriage(wo.id); + expect(outcome).toBe('skipped'); + expect(providerMock.chatComplete).not.toHaveBeenCalled(); + }); + + it('returns retry when the provider fails transiently and records AI_UNAVAILABLE', async () => { + const { wo } = await makeWorkOrder(); + providerMock.chatComplete.mockRejectedValue(new providerMock.ProviderError('upstream down', 'network')); + const outcome = await runTriage(wo.id); + expect(outcome).toBe('retry'); + const runs = (await agentRepo.listAdminRuns(1, 10)).items.filter((run) => run.mode === 'autonomous'); + expect(runs[0]).toMatchObject({ status: 'error', errorCode: 'AI_UNAVAILABLE' }); + }); + it('claims outbox events with a lease and recovers expired leases', async () => { const now = new Date('2026-01-01T00:00:00Z'); await agentRepo.enqueueOutbox({ type: 'work_order.created', payloadRef: 'wo-lease-1' });