import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { buildApp } from '@/app'; import { prismaClient } from '@/infrastructure/database'; import { FastifyInstance } from 'fastify'; import { loginAs, authHeader } from '../helpers/auth'; import { encryptCredential, generateCredentialSecret, issueIntegrationToken, } from '@/modules/catalog/products'; /** * Covers specs/007-orchestration-assignment/quickstart.md Scenarios 1, 3, 4, 5 against a real * Postgres/Redis — one ticket's lifecycle through escalation, automatic assignment, history, * manual reassignment, and re-escalation. */ describe('Orchestration and assignment — full flow (User Stories 1, 3, 4, 5)', () => { let app: FastifyInstance; let adminToken: string; const externalProductId = `TEST_ORCH_PROD_${Date.now()}`; const skillTag = `orch_skill_${Date.now()}`; let productId: string; let teamId: string; let agentAId: string; let agentBId: string; let secret: string; let ticketId: string; beforeAll(async () => { app = await buildApp(); adminToken = await loginAs(app, 'ADMIN'); const product = await prismaClient.product.create({ data: { externalProductId, name: 'Orchestration Test Product', status: 'active' }, }); productId = product.id; secret = generateCredentialSecret(); await prismaClient.productIntegration.create({ data: { productId, credentialRef: encryptCredential(secret), authMechanism: 'signed_token', allowedScope: { tenantIds: ['tenant-1'] }, status: 'active', rateLimitPerMinute: 1000, rateLimitPerUserPerMinute: 1000, }, }); const team = await app.inject({ method: 'POST', url: '/admin/teams', headers: authHeader(adminToken), payload: { name: `Orch Team ${Date.now()}` }, }); teamId = team.json().data.id; const agentA = await app.inject({ method: 'POST', url: `/admin/teams/${teamId}/agents`, headers: authHeader(adminToken), payload: { name: 'Orch Agent A' }, }); agentAId = agentA.json().data.id; await app.inject({ method: 'PUT', url: `/admin/agents/${agentAId}/skills/${skillTag}`, headers: authHeader(adminToken), payload: { level: 3 }, }); const agentB = await app.inject({ method: 'POST', url: `/admin/teams/${teamId}/agents`, headers: authHeader(adminToken), payload: { name: 'Orch Agent B' }, }); agentBId = agentB.json().data.id; await app.inject({ method: 'PUT', url: `/admin/agents/${agentBId}/skills/${skillTag}`, headers: authHeader(adminToken), payload: { level: 3 }, }); await app.inject({ method: 'POST', url: '/admin/hierarchy-nodes', headers: authHeader(adminToken), payload: { name: 'Orch Node', order: 0, productScope: [externalProductId], skills: [skillTag], assignmentStrategy: 'ROUND_ROBIN', }, }); const token = issueIntegrationToken(secret, { externalProductId, tenantId: 'tenant-1', userId: 'user-1', }); const created = await app.inject({ method: 'POST', url: '/v1/support/requests', headers: { authorization: `Bearer ${token}` }, payload: { productId: externalProductId, tenantId: 'tenant-1', userId: 'user-1', source: 'test', problem: 'Needs a human, unrelated to AI diagnosis in this test.', }, }); ticketId = created.json().data.ticketId; }); afterAll(async () => { await prismaClient.assignmentHistory.deleteMany({ where: { ticketId } }); await prismaClient.assignment.deleteMany({ where: { ticketId } }); await prismaClient.hierarchyNode.deleteMany({ where: { name: 'Orch Node' } }); await prismaClient.agentSkill.deleteMany({ where: { agentId: { in: [agentAId, agentBId] } } }); await prismaClient.agent.deleteMany({ where: { teamId } }); await prismaClient.team.deleteMany({ where: { id: teamId } }); await prismaClient.ticketMessage.deleteMany({ where: { ticketId } }); // A wildcard (non-product-scoped) SLA policy from another concurrently-running suite (e.g. // sla-escalation-flow.test.ts) can match this ticket too, leaving a real sla_run row that // would otherwise RESTRICT this delete. await prismaClient.sLARun.deleteMany({ where: { ticketId } }); await prismaClient.ticket.deleteMany({ where: { id: ticketId } }); await prismaClient.problem.deleteMany({ where: { productId } }); await prismaClient.productIntegration.deleteMany({ where: { productId } }); await prismaClient.product.deleteMany({ where: { id: productId } }); await app.close(); }); it('Scenario 1: escalation triggers automatic resolution and assignment', async () => { const ticket = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId } }); const escalate = await app.inject({ method: 'PATCH', url: `/tickets/${ticketId}/status`, headers: authHeader(adminToken), payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version }, }); expect(escalate.statusCode).toBe(200); // The event handler runs synchronously within the same process (in-memory EventEmitter), // so by the time inject() resolves, publish's listeners have already been invoked — no // polling needed, matching this codebase's existing event-bus behavior (005's own // handleTicketStatusChanged is exercised the same way). const current = await app.inject({ method: 'GET', url: `/tickets/${ticketId}/assignment` }); expect(current.statusCode).toBe(200); expect([agentAId, agentBId]).toContain(current.json().data.agentId); expect(current.json().data.strategy).toBe('ROUND_ROBIN'); const updatedTicket = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId } }); expect(updatedTicket.status).toBe('IN_PROGRESS'); }); it('Scenario 4: a manual reassignment overrides the automatic one', async () => { const before = await app.inject({ method: 'GET', url: `/tickets/${ticketId}/assignment` }); const originalAgentId = before.json().data.agentId; const otherAgentId = originalAgentId === agentAId ? agentBId : agentAId; const manual = await app.inject({ method: 'POST', url: `/admin/tickets/${ticketId}/assignment`, headers: authHeader(adminToken), payload: { agentId: otherAgentId, reason: 'Manual override for test' }, }); expect(manual.statusCode).toBe(200); expect(manual.json().data.agentId).toBe(otherAgentId); expect(manual.json().data.strategy).toBe('MANUAL'); const current = await app.inject({ method: 'GET', url: `/tickets/${ticketId}/assignment` }); expect(current.json().data.agentId).toBe(otherAgentId); const notFound = await app.inject({ method: 'POST', url: `/admin/tickets/${ticketId}/assignment`, headers: authHeader(adminToken), payload: { agentId: 'nonexistent-agent-id' }, }); expect(notFound.statusCode).toBe(404); }); it('Scenario 3: assignment history preserves every prior decision', async () => { const history = await app.inject({ method: 'GET', url: `/tickets/${ticketId}/assignment-history`, }); expect(history.statusCode).toBe(200); const rows: { action: string; strategy: string }[] = history.json().data; expect(rows.length).toBeGreaterThanOrEqual(2); // the automatic assignment + the manual one expect(rows.some((r) => r.action === 'assigned')).toBe(true); expect(rows.some((r) => r.action === 'reassigned')).toBe(true); }); it('Scenario 5: re-escalation resolves a fresh eligible set and reassigns', async () => { const beforeReEscalate = await prismaClient.assignment.findFirst({ where: { ticketId, isCurrent: true }, }); const ticket = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId } }); await app.inject({ method: 'PATCH', url: `/tickets/${ticketId}/status`, headers: authHeader(adminToken), payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version }, }); const afterReEscalate = await app.inject({ method: 'GET', url: `/tickets/${ticketId}/assignment`, }); expect(afterReEscalate.statusCode).toBe(200); const history = await app.inject({ method: 'GET', url: `/tickets/${ticketId}/assignment-history`, }); const rows: { agentId: string | null }[] = history.json().data; // The pre-re-escalation assignment must still be present in history, whatever the new one is. expect(rows.some((r) => r.agentId === beforeReEscalate?.agentId)).toBe(true); }); });