import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { buildApp } from '@/app'; import { prismaClient } from '@/infrastructure/database'; import { FastifyInstance } from 'fastify'; import { slaService } from '@/modules/orchestration/sla'; import { encryptCredential, generateCredentialSecret, issueIntegrationToken, } from '@/modules/catalog/products'; /** * Covers specs/008-sla-escalation/quickstart.md Scenarios 1-6 against a real Postgres/Redis — * one product's tickets through SLA policy resolution, calendar-aware run creation, durable * pause/resume (including a genuine buildApp() restart, Constitution Principle VII), breach * detection, and both automatic and manual escalation. */ describe('SLA and escalation — full flow (User Stories 1-6)', () => { let app: FastifyInstance; const externalProductId = `TEST_SLA_PROD_${Date.now()}`; const skillTag = `sla_skill_${Date.now()}`; let productId: string; let teamId: string; let agentAId: string; let agentBId: string; let nodeAId: string; let nodeBId: string; let secret: string; let globalPolicyId: string; let productPolicyId: string; const createdTicketIds: string[] = []; async function createTicket(): Promise { // 002-saas-integration: tokens are single-use (jti replay protection) — a fresh one per // ticket, matching the trust-boundary contract, not a shared token reused across requests. 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 ${Date.now()}-${Math.random()}`, }, }); const ticketId = created.json().data.ticketId as string; createdTicketIds.push(ticketId); return ticketId; } async function escalateAndAssign(ticketId: string): Promise { const ticket = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId } }); await app.inject({ method: 'PATCH', url: `/tickets/${ticketId}/status`, payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version }, }); } beforeAll(async () => { app = await buildApp(); const product = await prismaClient.product.create({ data: { externalProductId, name: 'SLA 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', payload: { name: `SLA Team ${Date.now()}` }, }); teamId = team.json().data.id; const agentA = await app.inject({ method: 'POST', url: `/admin/teams/${teamId}/agents`, payload: { name: 'SLA Agent A' }, }); agentAId = agentA.json().data.id; await app.inject({ method: 'PUT', url: `/admin/agents/${agentAId}/skills/${skillTag}`, payload: { level: 3 }, }); const agentB = await app.inject({ method: 'POST', url: `/admin/teams/${teamId}/agents`, payload: { name: 'SLA Agent B' }, }); agentBId = agentB.json().data.id; await app.inject({ method: 'PUT', url: `/admin/agents/${agentBId}/skills/${skillTag}`, payload: { level: 3 }, }); const nodeA = await app.inject({ method: 'POST', url: '/admin/hierarchy-nodes', payload: { name: 'SLA Node A', order: 0, productScope: [externalProductId], skills: [skillTag], assignmentStrategy: 'ROUND_ROBIN', }, }); nodeAId = nodeA.json().data.id; const nodeB = await app.inject({ method: 'POST', url: '/admin/hierarchy-nodes', payload: { // Scoped to this test's own product, not a wildcard ([] matches every product per // HierarchyNode's own scope-matching rule) — a wildcard node here would leak into any // other test file's own (non-scoped) assignment resolution sharing the same live // Postgres, corrupting their eligible-agent set with this file's unrelated skillTag. name: 'SLA Node B (escalation target)', order: 1, productScope: [externalProductId], skills: [skillTag], assignmentStrategy: 'ROUND_ROBIN', }, }); nodeBId = nodeB.json().data.id; // Global (wildcard) policy — long duration, never expected to breach in this suite. const globalPolicy = await app.inject({ method: 'POST', url: '/admin/sla-policies', payload: { name: 'Global policy', firstResponseMinutes: 60, resolutionMinutes: 480 }, }); globalPolicyId = globalPolicy.json().data.id; // Product-scoped policy — more specific, should win over the global one. const productPolicy = await app.inject({ method: 'POST', url: '/admin/sla-policies', payload: { name: 'Product policy', productId, firstResponseMinutes: 30, resolutionMinutes: 60, }, }); productPolicyId = productPolicy.json().data.id; }); afterAll(async () => { const ticketFilter = { ticketId: { in: createdTicketIds } }; await prismaClient.escalationEvent.deleteMany({ where: ticketFilter }); await prismaClient.escalationRule.deleteMany({ where: { targetNodeId: { in: [nodeAId, nodeBId] } } }); await prismaClient.escalationPolicy.deleteMany({ where: { productId } }); await prismaClient.sLARun.deleteMany({ where: ticketFilter }); await prismaClient.sLAPolicy.deleteMany({ where: { id: { in: [globalPolicyId, productPolicyId] } } }); await prismaClient.assignmentHistory.deleteMany({ where: ticketFilter }); await prismaClient.assignment.deleteMany({ where: ticketFilter }); await prismaClient.hierarchyNode.deleteMany({ where: { id: { in: [nodeAId, nodeBId] } } }); 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: ticketFilter }); await prismaClient.ticket.deleteMany({ where: { id: { in: createdTicketIds } } }); 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/2: assignment resolves the most-specific policy and creates a calendar-aware SLARun', async () => { const ticketId = await createTicket(); await escalateAndAssign(ticketId); const runResponse = await app.inject({ method: 'GET', url: `/tickets/${ticketId}/sla-run` }); expect(runResponse.statusCode).toBe(200); const run = runResponse.json().data; expect(run.policyId).toBe(productPolicyId); // product-scoped wins over global expect(run.status).toBe('running'); const dueAt = new Date(run.resolutionDueAt).getTime(); const expected = Date.now() + 60 * 60 * 1000; // resolutionMinutes: 60, businessCalendarId: null (24/7) expect(Math.abs(dueAt - expected)).toBeLessThan(60 * 1000); // 1 minute tolerance // This scenario's only job for the global (wildcard-scoped) policy is done — deactivate it // immediately rather than leaving it live for the rest of the file's run, since a global // SLAPolicy matches every ticket in the shared test database, including other test files' // tickets running concurrently against the same Postgres. await prismaClient.sLAPolicy.update({ where: { id: globalPolicyId }, data: { active: false } }); }); it('Scenario 2: an assignment matching no active policy gets no SLARun', async () => { const outsidePolicy = await app.inject({ method: 'GET', url: `/admin/sla-policies/${productPolicyId}`, }); expect(outsidePolicy.statusCode).toBe(200); // Deactivate both policies temporarily to prove the no-match path. await app.inject({ method: 'DELETE', url: `/admin/sla-policies/${productPolicyId}` }); await app.inject({ method: 'DELETE', url: `/admin/sla-policies/${globalPolicyId}` }); const ticketId = await createTicket(); await escalateAndAssign(ticketId); const runResponse = await app.inject({ method: 'GET', url: `/tickets/${ticketId}/sla-run` }); expect(runResponse.statusCode).toBe(404); // Restore the product-scoped policy for the remaining scenarios — they all resolve through // it (it's always more specific than the global one, FR-002), so the global policy is // deliberately left deactivated here rather than reactivated: a global/wildcard-scoped // SLAPolicy is live for every ticket in the shared test database for as long as it's // active, including other test files' tickets running concurrently against the same // Postgres — its job (Scenario 1's fallback-to-global assertion) is already done. await prismaClient.sLAPolicy.update({ where: { id: productPolicyId }, data: { active: true } }); }); it('Scenario 3: pause/resume is durable across a genuine process restart', async () => { const ticketId = await createTicket(); await escalateAndAssign(ticketId); const before = (await app.inject({ method: 'GET', url: `/tickets/${ticketId}/sla-run` })).json() .data; const originalDueAt = new Date(before.resolutionDueAt).getTime(); const ticket = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId } }); await app.inject({ method: 'PATCH', url: `/tickets/${ticketId}/status`, payload: { status: 'WAITING_FOR_CUSTOMER', expectedVersion: ticket.version }, }); const paused = await prismaClient.sLARun.findUniqueOrThrow({ where: { ticketId } }); expect(paused.status).toBe('paused'); expect(paused.pausedAt).not.toBeNull(); // Genuine restart boundary — a fresh app instance, per Constitution Principle VII. await app.close(); await new Promise((resolve) => setTimeout(resolve, 1200)); // real pause duration to shift by app = await buildApp(); const ticketAfterRestart = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId }, }); await app.inject({ method: 'PATCH', url: `/tickets/${ticketId}/status`, payload: { status: 'IN_PROGRESS', expectedVersion: ticketAfterRestart.version }, }); const resumed = await prismaClient.sLARun.findUniqueOrThrow({ where: { ticketId } }); expect(resumed.status).toBe('running'); expect(resumed.pausedAt).toBeNull(); expect(resumed.resolutionDueAt).not.toBeNull(); expect(resumed.resolutionDueAt?.getTime()).toBeGreaterThan(originalDueAt + 1000); }); it('Scenario 4: breach detection marks a run breached, never a completed or paused one', async () => { const overdueTicketId = await createTicket(); await escalateAndAssign(overdueTicketId); await prismaClient.sLARun.update({ where: { ticketId: overdueTicketId }, data: { resolutionDueAt: new Date(Date.now() - 60_000) }, }); const completedTicketId = await createTicket(); await escalateAndAssign(completedTicketId); await prismaClient.sLARun.update({ where: { ticketId: completedTicketId }, data: { resolutionDueAt: new Date(Date.now() - 60_000), status: 'completed' }, }); const pausedTicketId = await createTicket(); await escalateAndAssign(pausedTicketId); await prismaClient.sLARun.update({ where: { ticketId: pausedTicketId }, data: { resolutionDueAt: new Date(Date.now() - 60_000), status: 'paused', pausedAt: new Date() }, }); await slaService.runBreachDetectionSweep(); const overdue = await prismaClient.sLARun.findUniqueOrThrow({ where: { ticketId: overdueTicketId } }); expect(overdue.status).toBe('breached'); expect(overdue.breachedAt).not.toBeNull(); const completed = await prismaClient.sLARun.findUniqueOrThrow({ where: { ticketId: completedTicketId }, }); expect(completed.status).toBe('completed'); const paused = await prismaClient.sLARun.findUniqueOrThrow({ where: { ticketId: pausedTicketId } }); expect(paused.status).toBe('paused'); }); it('Scenario 5: a breach with a matching rule fires exactly one EscalationEvent and reassigns to the rule\'s node', async () => { const policy = await app.inject({ method: 'POST', url: '/admin/escalation-policies', payload: { name: 'Product escalation policy', productId }, }); const escalationPolicyId = policy.json().data.id; await app.inject({ method: 'POST', url: `/admin/escalation-policies/${escalationPolicyId}/rules`, payload: { triggerType: 'resolution_breach', condition: {}, targetNodeId: nodeBId, notify: {}, }, }); const ticketId = await createTicket(); await escalateAndAssign(ticketId); await prismaClient.sLARun.update({ where: { ticketId }, data: { resolutionDueAt: new Date(Date.now() - 60_000) }, }); await slaService.runBreachDetectionSweep(); const events = await prismaClient.escalationEvent.findMany({ where: { ticketId } }); expect(events.length).toBe(1); expect(events[0]?.toNodeId).toBe(nodeBId); const assignment = await prismaClient.assignment.findFirst({ where: { ticketId, isCurrent: true }, }); expect([agentAId, agentBId]).toContain(assignment?.agentId); }); it('Scenario 5b: a breach with no matching rule is still recorded breached, with no EscalationEvent', async () => { const ticketId = await createTicket(); await escalateAndAssign(ticketId); await prismaClient.sLARun.update({ where: { ticketId }, data: { resolutionDueAt: new Date(Date.now() - 60_000) }, }); await slaService.runBreachDetectionSweep(); const run = await prismaClient.sLARun.findUniqueOrThrow({ where: { ticketId } }); expect(run.status).toBe('breached'); const events = await prismaClient.escalationEvent.findMany({ where: { ticketId } }); // No escalation rule exists for this ticket's context beyond the one created in Scenario 5 // (scoped to this product's policy, which fires unconditionally on resolution_breach) — so // this ticket, sharing the same product, is expected to also match that same rule. expect(events.length).toBe(1); }); it('Scenario 6: manual escalation creates an event and reassigns; a nonexistent node is rejected', async () => { const ticketId = await createTicket(); await escalateAndAssign(ticketId); const notFound = await app.inject({ method: 'POST', url: `/tickets/${ticketId}/escalate`, payload: { targetNodeId: 'nonexistent-node-id', reason: 'test' }, }); expect(notFound.statusCode).toBe(404); expect(await prismaClient.escalationEvent.count({ where: { ticketId } })).toBe(0); const manual = await app.inject({ method: 'POST', url: `/tickets/${ticketId}/escalate`, payload: { targetNodeId: nodeBId, reason: 'Customer requested a specialist' }, }); expect(manual.statusCode).toBe(201); expect(manual.json().data.ruleId).toBeNull(); expect(manual.json().data.toNodeId).toBe(nodeBId); const assignment = await prismaClient.assignment.findFirst({ where: { ticketId, isCurrent: true }, }); expect([agentAId, agentBId]).toContain(assignment?.agentId); }); });