Files
support_backend/tests/integration/orchestration-flow.test.ts
T
saqib mirandClaude Sonnet 5 77928c4878 feat: implement orchestration and assignment (007) — routing, strategies, history
Phase 7 of the roadmap. On a ticket's automatic transition to
HUMAN_ESCALATION, routing resolves 006's hierarchy nodes and
capability-eligibility lookup directly (never a second matching
algorithm), and a pluggable strategy (ROUND_ROBIN, concurrency-safe via
atomic Redis INCR; LEAST_LOADED; SKILL_BASED) selects exactly one
eligible agent, persisted as a version-row-per-period Assignment plus an
append-only AssignmentHistory event log. MANUAL/DIRECT are never
auto-selected — only an explicit admin-supplied agentId reaches them.
On success the ticket moves HUMAN_ESCALATION -> IN_PROGRESS through
003's existing state machine. A ticket's "required skill" comes from
its most recent AI diagnosis's problemType (005) when one exists,
unioned with any matching hierarchy node's skills (006); when neither
exists, there's no skill constraint (every active agent eligible),
never zero.

Found and fixed two real, latent bugs in the shared event-bus
infrastructure while building this feature's own tests: (1)
EventBus.publish was built on EventEmitter.emit(), which never awaits
async listeners, so a caller had no guarantee any subscriber (005's
AI-session-ending hook, now also this feature's orchestration hook) had
actually finished — rewritten to track subscribers directly and await
them via Promise.all, same per-handler error isolation as before. (2)
registerDomainEventHandlers() was only called from server.ts's
production startup path, never from buildApp() — meaning every
integration test in this codebase had zero domain-event subscribers
registered at all. Now called (idempotently) from buildApp() itself,
since domain-event wiring is synchronous application behavior, not a
background-worker concern like the queue.

Adds 8 unit tests (each strategy's pure selection/tie-break logic), a
dedicated round-robin concurrency test verifying no two concurrent
selections collide under real parallel load, and 2 integration test
files covering all five user stories. Full regression (every
pre-existing 002-006 integration test plus every new 007 test) run
together against real Postgres/Redis/MinIO: 124 passed, 9 skipped
(005's AI-key-gated tests, unrelated), 0 failed.

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

211 lines
7.8 KiB
TypeScript

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify';
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;
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();
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',
payload: { name: `Orch Team ${Date.now()}` },
});
teamId = team.json().data.id;
const agentA = await app.inject({
method: 'POST',
url: `/admin/teams/${teamId}/agents`,
payload: { name: 'Orch 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: 'Orch Agent B' },
});
agentBId = agentB.json().data.id;
await app.inject({
method: 'PUT',
url: `/admin/agents/${agentBId}/skills/${skillTag}`,
payload: { level: 3 },
});
await app.inject({
method: 'POST',
url: '/admin/hierarchy-nodes',
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 } });
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`,
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`,
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`,
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`,
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);
});
});