mirror of
https://github.com/vee1e/workorder-desk.git
synced 2026-09-01 09:50:13 +00:00
test(backend): agent runtime, copilot API, and triage/worker suites with mocked provider
Also fix two bugs the tests surfaced in the merged AI implementation: - agent-tool-call model made approval.summary/expiresAt conditionally required only when an approval subdocument is present, so blocked/error tool calls persist without a fake approval - agent.repo.createRun coerced empty-string session/user ids to null so autonomous triage runs (no session/user) no longer fail ObjectId casting
This commit is contained in:
parent
93e828bcb1
commit
7a84996f51
7 changed files with 583 additions and 10 deletions
145
backend/tests/triage.worker.test.ts
Normal file
145
backend/tests/triage.worker.test.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { providerResult } from './ai-helpers.js';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
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';
|
||||
import { workOrderRepo } from '../src/repositories/work-order.repo.js';
|
||||
import { OutboxEvent } from '../src/models/outbox-event.model.js';
|
||||
import { TriageSuggestion } from '../src/models/triage-suggestion.model.js';
|
||||
import { WorkOrder } from '../src/models/work-order.model.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: 'triage-test-model',
|
||||
ProviderError,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../src/agent/provider.js', () => providerMock);
|
||||
|
||||
const proposal = (overrides?: Record<string, unknown>): string =>
|
||||
JSON.stringify({ summary: 'Needs attention', suggestedPriority: 'high', flagForDispatcher: false, ...overrides });
|
||||
|
||||
async function makeWorkOrder(options: { aiEnabled?: boolean; priority?: 'low' | 'medium' | 'high' } = {}): Promise<{
|
||||
user: { id: string };
|
||||
wo: { id: string };
|
||||
}> {
|
||||
const user = await userRepo.createUser({
|
||||
email: `owner-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`,
|
||||
passwordHash: 'test-hash',
|
||||
name: 'Owner',
|
||||
});
|
||||
if (options.aiEnabled === false) await userRepo.updateAiEnabled(user._id.toString(), false);
|
||||
const wo = await workOrderRepo.create({
|
||||
ownerId: user._id.toString(),
|
||||
title: 'Triage me',
|
||||
priority: options.priority ?? 'medium',
|
||||
status: 'pending',
|
||||
});
|
||||
return { user: { id: user._id.toString() }, wo: { id: wo._id.toString() } };
|
||||
}
|
||||
|
||||
describe('triage agent', () => {
|
||||
beforeEach(() => {
|
||||
providerMock.chatStream.mockReset();
|
||||
providerMock.chatComplete.mockReset();
|
||||
});
|
||||
|
||||
it('creates the triage config singleton', async () => {
|
||||
expect(await agentRepo.getAgentConfig('triage')).toBeNull();
|
||||
await ensureTriageConfig();
|
||||
const config = await agentRepo.getAgentConfig('triage');
|
||||
expect(config).not.toBeNull();
|
||||
expect(config?.enabled).toBe(true);
|
||||
await ensureTriageConfig();
|
||||
expect((await agentRepo.getAgentConfig('triage'))?._id.toString()).toBe(config?._id.toString());
|
||||
});
|
||||
|
||||
it('triages a work order and records a suggestion without applying it', async () => {
|
||||
const { wo } = await makeWorkOrder();
|
||||
providerMock.chatComplete.mockResolvedValue(
|
||||
providerResult({ content: proposal(), inputTokens: 100, outputTokens: 20 }),
|
||||
);
|
||||
const outcome = await runTriage(wo.id);
|
||||
expect(outcome).toBe('done');
|
||||
const suggestions = await TriageSuggestion.find({ workOrderId: wo.id });
|
||||
expect(suggestions).toHaveLength(1);
|
||||
expect(suggestions[0]).toMatchObject({ applied: false, suggestedPriority: 'high', flagForDispatcher: false });
|
||||
const runs = (await agentRepo.listAdminRuns(1, 10)).items.filter((run) => run.mode === 'autonomous');
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0]).toMatchObject({ status: 'complete', agentName: 'triage' });
|
||||
const calls = await agentRepo.listToolCallsForRun(runs[0]!._id.toString());
|
||||
expect(calls[0]).toMatchObject({ tool: 'triage_propose', outcome: 'executed' });
|
||||
});
|
||||
|
||||
it('auto-applies the suggested priority in auto-apply mode', async () => {
|
||||
const { wo } = await makeWorkOrder({ priority: 'low' });
|
||||
await ensureTriageConfig();
|
||||
await agentRepo.updateAgentConfig('triage', { mode: 'auto-apply' }, 'admin');
|
||||
providerMock.chatComplete.mockResolvedValue(providerResult({ content: proposal({ suggestedPriority: 'high' }) }));
|
||||
const outcome = await runTriage(wo.id);
|
||||
expect(outcome).toBe('done');
|
||||
const fresh = await WorkOrder.findById(wo.id);
|
||||
expect(fresh?.priority).toBe('high');
|
||||
const suggestions = await TriageSuggestion.find({ workOrderId: wo.id });
|
||||
expect(suggestions[0]?.applied).toBe(true);
|
||||
});
|
||||
|
||||
it('skips triage when the owner has AI disabled', async () => {
|
||||
const { wo } = await makeWorkOrder({ aiEnabled: false });
|
||||
const outcome = await runTriage(wo.id);
|
||||
expect(outcome).toBe('skipped');
|
||||
expect(providerMock.chatComplete).not.toHaveBeenCalled();
|
||||
expect(await TriageSuggestion.countDocuments()).toBe(0);
|
||||
});
|
||||
|
||||
it('fails after exhausting attempts when the provider returns invalid JSON', async () => {
|
||||
const { wo } = await makeWorkOrder();
|
||||
providerMock.chatComplete.mockResolvedValue(providerResult({ content: 'definitely not json' }));
|
||||
const outcome = await runTriage(wo.id);
|
||||
expect(outcome).toBe('failed');
|
||||
expect(providerMock.chatComplete).toHaveBeenCalledTimes(3);
|
||||
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' });
|
||||
await agentRepo.enqueueOutbox({ type: 'work_order.created', payloadRef: 'wo-lease-2' });
|
||||
const first = await agentRepo.claimOutboxEvent(now, 15_000);
|
||||
expect(first).not.toBeNull();
|
||||
expect(first?.status).toBe('processing');
|
||||
expect(first?.attempts).toBe(1);
|
||||
// a second event is still pending → claimed next
|
||||
const second = await agentRepo.claimOutboxEvent(new Date(now.getTime() + 1000), 15_000);
|
||||
expect(second).not.toBeNull();
|
||||
expect(second?.payloadRef).not.toBe(first?.payloadRef);
|
||||
// both are now processing with live leases → nothing to claim
|
||||
const none = await agentRepo.claimOutboxEvent(new Date(now.getTime() + 2000), 15_000);
|
||||
expect(none).toBeNull();
|
||||
// crash recovery: expired lease → the event is claimable again
|
||||
await OutboxEvent.updateMany({}, { $set: { leasedUntil: new Date(now.getTime() - 1000) } });
|
||||
const recovered = await agentRepo.claimOutboxEvent(new Date(now.getTime() + 3000), 15_000);
|
||||
expect(recovered).not.toBeNull();
|
||||
expect(recovered?._id.toString()).toBe(first?._id.toString());
|
||||
});
|
||||
|
||||
it('evaluates working-hours specs', () => {
|
||||
expect(isWorkingHours('*', new Date('2026-01-01T10: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-01T20:00:00Z'))).toBe(false);
|
||||
expect(isWorkingHours('bogus', new Date('2026-01-01T10:00:00Z'))).toBe(false);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue