295 lines
10 KiB
TypeScript
295 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 });
|
||
|
|
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);
|
||
|
|
});
|
||
|
|
});
|