Files
support_backend/tests/integration/sla-escalation-flow.test.ts
T
saqib mirandClaude Sonnet 5 9357f03e1d feat: implement SLA and escalation (008)
Populates platform/business-calendars, orchestration/sla, and
orchestration/escalation (all thin stubs until now) with the real engine:

- business-calendars: a luxon-based day-by-day calendar walk
  (addBusinessMinutes/isWithinWorkingHours) excluding non-working hours,
  weekends, and holidays — replacing the naive createdAt+hours stub FR-004
  explicitly forbids.
- sla: most-specific SLAPolicy resolution (product/category/problemType/
  priority, wildcard-or-exact-match, specificity-count + updatedAt
  tiebreak), SLARun creation on the first real publish of the
  long-unused TICKET_ASSIGNED domain event, durable pause/resume via an
  absolute-timestamp shift (no in-memory state, verified across a real
  buildApp() restart), and a repeatable BullMQ breach-detection sweep
  (src/jobs/sla, itself a previously-unregistered stub) that is directly
  callable for tests, not only reachable through a running worker.
- escalation: EscalationPolicy/Rule CRUD (all 10 doc05 trigger types
  storable, only resolution_breach/first_response_breach evaluated),
  breach-triggered and manual escalation both funnel through one EscalationEvent
  + scoped re-assignment path. AssignmentEngine (007) gains
  assignToSpecificNode — a new, explicitly node-scoped entry point,
  since escalation must never let 007's general resolution re-derive a
  different node than the one a rule or a caller targeted.

Two small pre-existing scaffold gaps were closed along the way:
CategoriesRepository had no findById, and TICKET_ASSIGNED/SLA_BREACHED/
ESCALATION_TRIGGERED were defined since earlier phases but never
published by any code.

Verified against throwaway Docker Postgres/Redis (typecheck, lint,
architecture-check all clean; 148/150 relevant tests pass — the 2
failures are pre-existing, MinIO-dependent, and unrelated to this
feature).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 13:02:05 +05:30

385 lines
14 KiB
TypeScript

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<string> {
// 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<void> {
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: {
name: 'SLA Node B (escalation target)',
order: 1,
productScope: [],
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
});
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 both policies for the remaining scenarios.
await prismaClient.sLAPolicy.update({ where: { id: productPolicyId }, data: { active: true } });
await prismaClient.sLAPolicy.update({ where: { id: globalPolicyId }, 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!.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);
});
});