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>
211 lines
7.8 KiB
TypeScript
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);
|
|
});
|
|
});
|