Files
support_backend/tests/integration/orchestration-flow.test.ts
T
saqib mirandClaude Sonnet 5 40687f68fa feat(010-identity-auth): real staff login, session verification, and role gating
Replaces the no-op fastify.authenticate stub and the never-implemented
identity/auth login with real bcrypt password verification, JWT session
issuance/verification (reusing the existing JWT_SECRET), and a Redis-backed
revocation denylist for logout. Adds requireRole('ADMIN') to admin-only
configuration writes across 002-009 that previously relied on a decorator
that never actually checked anything. Adds self-identity (GET /auth/me,
re-validated against live account state) and admin-provisioned accounts
(POST /admin/users).

Making the auth check genuinely reject invalid/missing tokens exposed that
~18 pre-existing integration test files called already-gated routes with no
Authorization header (safe against the old no-op stub, broken against a real
one) — fixed via a shared tests/helpers/auth.ts (loginAs/authHeader) and a
file-by-file pass, plus two related SLA-run cleanup races exposed once admin
setup calls in those files' own beforeAll blocks started actually succeeding.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 12:45:37 +05:30

228 lines
8.6 KiB
TypeScript

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);
});
});