Files
support_backend/tests/integration/agent-ticket-queue.test.ts
T
saqib mirandClaude Sonnet 5 3bdccc901f test(011-agent-ticket-queue): harden afterAll against wildcard SLA policy contamination
Same class of cross-file test-isolation gap already fixed in
orchestration-flow.test.ts and sla-escalation-flow.test.ts (010's own
regression work): a wildcard (non-product-scoped) SLA policy from
another suite can match this file's own tickets too, leaving a real
sla_run row that RESTRICTs the ticket delete. Also cleaned up several
orphaned wildcard SLA policies that had accumulated in the shared
throwaway test database from earlier runs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 17:33:52 +05:30

299 lines
10 KiB
TypeScript

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify';
import bcrypt from 'bcryptjs';
import { loginAs, authHeader } from '../helpers/auth';
import {
encryptCredential,
generateCredentialSecret,
issueIntegrationToken,
} from '@/modules/catalog/products';
/**
* Covers specs/011-agent-ticket-queue/quickstart.md Scenarios 1-3 against a real Postgres/
* Redis — linking a User to an Agent roster row (and its rejection rules), an agent listing
* their own currently-assigned tickets, and the admin equivalent for an explicit agent.
*/
describe('Agent ticket queue (User Stories 1-2)', () => {
let app: FastifyInstance;
let adminToken: string;
const suffix = Date.now();
const externalProductId = `TEST_ATQ_PROD_${suffix}`;
const skillTag = `atq_skill_${suffix}`;
const password = 'Agent-Queue-Test-1!';
let productId: string;
let teamId: string;
let agentXId: string;
let agentYId: string;
let secret: string;
const createdUserIds: string[] = [];
const createdTicketIds: string[] = [];
async function createUser(email: string, role: 'ADMIN' | 'AGENT'): Promise<string> {
const user = await prismaClient.user.create({
data: { email, name: email, role, passwordHash: await bcrypt.hash(password, 10) },
});
createdUserIds.push(user.id);
return user.id;
}
async function loginAsUser(email: string): Promise<string> {
const res = await app.inject({
method: 'POST',
url: '/auth/login',
payload: { email, password },
});
return res.json().data.token as string;
}
async function createTicket(): Promise<string> {
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 assignTo(ticketId: string, agentId: string): Promise<void> {
const ticket = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId } });
if (ticket.status === 'NEW') {
await app.inject({
method: 'PATCH',
url: `/tickets/${ticketId}/status`,
headers: authHeader(adminToken),
payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version },
});
}
await app.inject({
method: 'POST',
url: `/admin/tickets/${ticketId}/assignment`,
headers: authHeader(adminToken),
payload: { agentId, reason: 'test setup', strategy: 'MANUAL' },
});
}
beforeAll(async () => {
app = await buildApp();
adminToken = await loginAs(app, 'ADMIN');
const product = await prismaClient.product.create({
data: { externalProductId, name: 'Agent Ticket Queue 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: `ATQ Team ${suffix}` },
});
teamId = team.json().data.id;
const agentX = await app.inject({
method: 'POST',
url: `/admin/teams/${teamId}/agents`,
headers: authHeader(adminToken),
payload: { name: 'ATQ Agent X' },
});
agentXId = agentX.json().data.id;
await app.inject({
method: 'PUT',
url: `/admin/agents/${agentXId}/skills/${skillTag}`,
headers: authHeader(adminToken),
payload: { level: 3 },
});
const agentY = await app.inject({
method: 'POST',
url: `/admin/teams/${teamId}/agents`,
headers: authHeader(adminToken),
payload: { name: 'ATQ Agent Y' },
});
agentYId = agentY.json().data.id;
await app.inject({
method: 'PUT',
url: `/admin/agents/${agentYId}/skills/${skillTag}`,
headers: authHeader(adminToken),
payload: { level: 3 },
});
await app.inject({
method: 'POST',
url: '/admin/hierarchy-nodes',
headers: authHeader(adminToken),
payload: {
name: 'ATQ Node',
order: 0,
productScope: [externalProductId],
skills: [skillTag],
assignmentStrategy: 'ROUND_ROBIN',
},
});
});
afterAll(async () => {
const ticketFilter = { ticketId: { in: createdTicketIds } };
await prismaClient.assignmentHistory.deleteMany({ where: ticketFilter });
await prismaClient.assignment.deleteMany({ where: ticketFilter });
await prismaClient.hierarchyNode.deleteMany({ where: { name: 'ATQ Node' } });
await prismaClient.agentSkill.deleteMany({ where: { agentId: { in: [agentXId, agentYId] } } });
await prismaClient.ticketMessage.deleteMany({ where: ticketFilter });
// A wildcard (non-product-scoped) SLA policy from another concurrently-running suite (e.g.
// sla-escalation-flow.test.ts's own "Global policy") can match these tickets too, leaving a
// real sla_run row that would otherwise RESTRICT this delete.
await prismaClient.sLARun.deleteMany({ where: ticketFilter });
await prismaClient.ticket.deleteMany({ where: { id: { in: createdTicketIds } } });
await prismaClient.problem.deleteMany({ where: { productId } });
await prismaClient.agent.deleteMany({ where: { teamId } });
await prismaClient.team.deleteMany({ where: { id: teamId } });
await prismaClient.productIntegration.deleteMany({ where: { productId } });
await prismaClient.product.deleteMany({ where: { id: productId } });
await prismaClient.user.deleteMany({ where: { id: { in: createdUserIds } } });
await app.close();
});
it('User Story 1: linking succeeds, rejects a non-AGENT role, and rejects a duplicate link', async () => {
const userXId = await createUser(`atq-agent-x-${suffix}@supporthub.test`, 'AGENT');
const userYId = await createUser(`atq-agent-y-${suffix}@supporthub.test`, 'AGENT');
const adminRoleUserId = await createUser(`atq-admin-role-${suffix}@supporthub.test`, 'ADMIN');
const link = await app.inject({
method: 'PATCH',
url: `/admin/agents/${agentXId}`,
headers: authHeader(adminToken),
payload: { userId: userXId },
});
expect(link.statusCode).toBe(200);
expect(link.json().data.userId).toBe(userXId);
const nonAgentRole = await app.inject({
method: 'PATCH',
url: `/admin/agents/${agentYId}`,
headers: authHeader(adminToken),
payload: { userId: adminRoleUserId },
});
expect(nonAgentRole.statusCode).toBe(400);
const duplicateLink = await app.inject({
method: 'PATCH',
url: `/admin/agents/${agentYId}`,
headers: authHeader(adminToken),
payload: { userId: userXId },
});
expect(duplicateLink.statusCode).toBe(409);
const linkY = await app.inject({
method: 'PATCH',
url: `/admin/agents/${agentYId}`,
headers: authHeader(adminToken),
payload: { userId: userYId },
});
expect(linkY.statusCode).toBe(200);
});
it('User Story 2: an agent sees exactly their own current assignments, live', async () => {
const agentXToken = await loginAsUser(`atq-agent-x-${suffix}@supporthub.test`);
const agentYToken = await loginAsUser(`atq-agent-y-${suffix}@supporthub.test`);
const ticket1 = await createTicket();
const ticket2 = await createTicket();
const ticket3 = await createTicket();
await assignTo(ticket1, agentXId);
await assignTo(ticket2, agentXId);
await assignTo(ticket3, agentYId);
const xList = await app.inject({
method: 'GET',
url: '/agents/me/tickets',
headers: authHeader(agentXToken),
});
expect(xList.statusCode).toBe(200);
const xIds = xList.json().data.map((t: { id: string }) => t.id);
expect(xIds.sort()).toEqual([ticket1, ticket2].sort());
const firstEntry = xList.json().data[0];
expect(firstEntry).toHaveProperty('code');
expect(firstEntry).toHaveProperty('product.externalProductId', externalProductId);
expect(firstEntry).toHaveProperty('customer.externalUserId', 'user-1');
// Reassign ticket1 away from X — the list must reflect live state, not a snapshot.
await assignTo(ticket1, agentYId);
const xListAfter = await app.inject({
method: 'GET',
url: '/agents/me/tickets',
headers: authHeader(agentXToken),
});
expect(xListAfter.json().data.map((t: { id: string }) => t.id)).toEqual([ticket2]);
const yList = await app.inject({
method: 'GET',
url: '/agents/me/tickets',
headers: authHeader(agentYToken),
});
expect(
yList
.json()
.data.map((t: { id: string }) => t.id)
.sort(),
).toEqual([ticket1, ticket3].sort());
});
it('User Story 2: a session with no linked agent is rejected distinctly from an empty list', async () => {
await createUser(`atq-unlinked-${suffix}@supporthub.test`, 'AGENT');
const unlinkedToken = await loginAsUser(`atq-unlinked-${suffix}@supporthub.test`);
const res = await app.inject({
method: 'GET',
url: '/agents/me/tickets',
headers: authHeader(unlinkedToken),
});
expect(res.statusCode).toBe(404);
});
it('User Story 2: the admin route returns the same shape for an explicit agent, and rejects a non-admin', async () => {
const agentYToken = await loginAsUser(`atq-agent-y-${suffix}@supporthub.test`);
const asAdmin = await app.inject({
method: 'GET',
url: `/admin/agents/${agentYId}/tickets`,
headers: authHeader(adminToken),
});
expect(asAdmin.statusCode).toBe(200);
expect(Array.isArray(asAdmin.json().data)).toBe(true);
const asNonAdmin = await app.inject({
method: 'GET',
url: `/admin/agents/${agentYId}/tickets`,
headers: authHeader(agentYToken),
});
expect(asNonAdmin.statusCode).toBe(403);
});
});