import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { buildApp } from '@/app'; import { prismaClient } from '@/infrastructure/database'; import { FastifyInstance } from 'fastify'; import { resolutionsService } from '@/modules/problem-management/resolutions'; import { ticketsService } from '@/modules/ticketing/tickets'; import { encryptCredential, generateCredentialSecret, issueIntegrationToken, } from '@/modules/catalog/products'; /** * Covers specs/009-problem-resolution/quickstart.md Scenarios 1-6 against a real Postgres/Redis * — the full doc04 workflow: investigation through root cause, solution, implementation, * verification (both outcomes), resolution, customer confirmation, durable auto-close, and * reopen (customer and agent). */ describe('Problem resolution — full flow (User Stories 1-6)', () => { let app: FastifyInstance; const externalProductId = `TEST_PR_PROD_${Date.now()}`; const skillTag = `pr_skill_${Date.now()}`; let productId: string; let secret: string; let teamId: string; let agentId: string; const createdTicketIds: string[] = []; async function createTicket(): Promise<{ ticketId: string; problemId: 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 resolution ${Date.now()}-${Math.random()}`, }, }); const ticketId = created.json().data.ticketId as string; createdTicketIds.push(ticketId); const ticket = await ticketsService.getById(ticketId); return { ticketId, problemId: ticket.problemId }; } async function tokenForCurrentRequest(): Promise { return issueIntegrationToken(secret, { externalProductId, tenantId: 'tenant-1', userId: 'user-1', }); } function identityPayload() { return { productId: externalProductId, tenantId: 'tenant-1', userId: 'user-1' }; } async function escalateAndAssign(ticketId: string): Promise { const ticket = await ticketsService.getById(ticketId); await app.inject({ method: 'PATCH', url: `/tickets/${ticketId}/status`, payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticket.version }, }); } async function fullyResolve(problemId: string, ticketId: string): Promise { await escalateAndAssign(ticketId); await app.inject({ method: 'POST', url: `/admin/problems/${problemId}/investigations`, payload: { investigator: 'agent-1', findings: { note: 'checked logs' } }, }); await app.inject({ method: 'POST', url: `/admin/problems/${problemId}/root-causes`, payload: { type: 'technical', description: 'a bug' }, }); const solutionRes = await app.inject({ method: 'POST', url: `/admin/problems/${problemId}/solutions`, payload: { proposed: 'apply fix' }, }); const solutionId = solutionRes.json().data.id; await app.inject({ method: 'PATCH', url: `/admin/solutions/${solutionId}/approve`, }); await app.inject({ method: 'POST', url: `/admin/solutions/${solutionId}/implementation`, payload: { implementedBy: 'agent-1' }, }); await app.inject({ method: 'POST', url: `/admin/solutions/${solutionId}/verification`, payload: { method: 'agent_confirmation', result: 'success' }, }); await app.inject({ method: 'POST', url: `/admin/tickets/${ticketId}/resolution`, payload: { outcome: 'fixed', resolvedBy: 'agent-1' }, }); } beforeAll(async () => { app = await buildApp(); const product = await prismaClient.product.create({ data: { externalProductId, name: 'Problem Resolution 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: `PR Team ${Date.now()}` }, }); teamId = team.json().data.id; const agent = await app.inject({ method: 'POST', url: `/admin/teams/${teamId}/agents`, payload: { name: 'PR Agent' }, }); agentId = agent.json().data.id; await app.inject({ method: 'PUT', url: `/admin/agents/${agentId}/skills/${skillTag}`, payload: { level: 3 }, }); await app.inject({ method: 'POST', url: '/admin/hierarchy-nodes', payload: { name: 'PR Node', order: 0, productScope: [externalProductId], skills: [skillTag], assignmentStrategy: 'ROUND_ROBIN', }, }); }); afterAll(async () => { const ticketFilter = { ticketId: { in: createdTicketIds } }; const problemIds = ( await prismaClient.ticket.findMany({ where: { id: { in: createdTicketIds } }, select: { problemId: true }, }) ).map((t) => t.problemId); const problemFilter = { problemId: { in: problemIds } }; await prismaClient.resolution.deleteMany({ where: ticketFilter }); await prismaClient.solutionVerification.deleteMany({ where: { solution: problemFilter }, }); await prismaClient.solutionImplementation.deleteMany({ where: { solution: problemFilter }, }); await prismaClient.solution.deleteMany({ where: problemFilter }); await prismaClient.rootCause.deleteMany({ where: problemFilter }); await prismaClient.investigation.deleteMany({ where: problemFilter }); await prismaClient.escalationEvent.deleteMany({ where: ticketFilter }); await prismaClient.sLARun.deleteMany({ where: ticketFilter }); await prismaClient.assignmentHistory.deleteMany({ where: ticketFilter }); await prismaClient.assignment.deleteMany({ where: ticketFilter }); await prismaClient.hierarchyNode.deleteMany({ where: { name: 'PR Node' } }); await prismaClient.agentSkill.deleteMany({ where: { agentId } }); await prismaClient.agent.deleteMany({ where: { teamId } }); await prismaClient.team.deleteMany({ where: { id: teamId } }); await prismaClient.ticketMessage.deleteMany({ where: ticketFilter }); await prismaClient.ticket.deleteMany({ where: { id: { in: createdTicketIds } } }); await prismaClient.problem.deleteMany({ where: { id: { in: problemIds } } }); await prismaClient.productIntegration.deleteMany({ where: { productId } }); await prismaClient.product.deleteMany({ where: { id: productId } }); await app.close(); }); it('Scenario 1: structured investigation, preserved across attempts', async () => { const { problemId } = await createTicket(); const record = await app.inject({ method: 'POST', url: `/admin/problems/${problemId}/investigations`, payload: { investigator: 'agent-1', findings: { checked: 'logs' }, evidence: { logId: 'abc' }, internalNotes: 'suspect race condition', }, }); expect(record.statusCode).toBe(201); const agentRead = await app.inject({ method: 'GET', url: `/admin/problems/${problemId}/investigations`, }); expect(agentRead.json().data[0].internalNotes).toBe('suspect race condition'); const customerRead = await app.inject({ method: 'GET', url: `/problems/${problemId}/investigations`, }); expect(customerRead.json().data[0].internalNotes).toBeUndefined(); const second = await app.inject({ method: 'POST', url: `/admin/problems/${problemId}/investigations`, payload: { investigator: 'agent-1', findings: { checked: 'more logs' } }, }); expect(second.statusCode).toBe(201); const both = await app.inject({ method: 'GET', url: `/admin/problems/${problemId}/investigations`, }); expect(both.json().data.length).toBe(2); }); it('Scenario 2: root cause requires an investigation on file', async () => { const { problemId } = await createTicket(); const beforeInvestigation = await app.inject({ method: 'POST', url: `/admin/problems/${problemId}/root-causes`, payload: { type: 'technical', description: 'x' }, }); expect(beforeInvestigation.statusCode).toBe(409); await app.inject({ method: 'POST', url: `/admin/problems/${problemId}/investigations`, payload: { investigator: 'agent-1', findings: {} }, }); const afterInvestigation = await app.inject({ method: 'POST', url: `/admin/problems/${problemId}/root-causes`, payload: { type: 'technical', description: 'a real cause' }, }); expect(afterInvestigation.statusCode).toBe(201); const invalidType = await app.inject({ method: 'POST', url: `/admin/problems/${problemId}/root-causes`, payload: { type: 'not_a_real_type', description: 'x' }, }); expect(invalidType.statusCode).toBe(400); }); it('Scenario 3: solution proposed, approved, implemented as distinct states', async () => { const { problemId } = await createTicket(); const beforeRootCause = await app.inject({ method: 'POST', url: `/admin/problems/${problemId}/solutions`, payload: { proposed: 'x' }, }); expect(beforeRootCause.statusCode).toBe(409); await app.inject({ method: 'POST', url: `/admin/problems/${problemId}/investigations`, payload: { investigator: 'agent-1', findings: {} }, }); await app.inject({ method: 'POST', url: `/admin/problems/${problemId}/root-causes`, payload: { type: 'technical', description: 'x' }, }); const proposed = await app.inject({ method: 'POST', url: `/admin/problems/${problemId}/solutions`, payload: { proposed: 'apply the fix' }, }); expect(proposed.statusCode).toBe(201); expect(proposed.json().data.approved).toBe(false); const solutionId = proposed.json().data.id; const implBeforeApproval = await app.inject({ method: 'POST', url: `/admin/solutions/${solutionId}/implementation`, payload: { implementedBy: 'agent-1' }, }); expect(implBeforeApproval.statusCode).toBe(409); await app.inject({ method: 'PATCH', url: `/admin/solutions/${solutionId}/approve` }); const impl = await app.inject({ method: 'POST', url: `/admin/solutions/${solutionId}/implementation`, payload: { implementedBy: 'agent-1' }, }); expect(impl.statusCode).toBe(201); const secondImpl = await app.inject({ method: 'POST', url: `/admin/solutions/${solutionId}/implementation`, payload: { implementedBy: 'agent-1' }, }); expect(secondImpl.statusCode).toBe(409); }); it('Scenario 4: verification, and what happens on failure', async () => { const { problemId } = await createTicket(); await app.inject({ method: 'POST', url: `/admin/problems/${problemId}/investigations`, payload: { investigator: 'agent-1', findings: {} }, }); await app.inject({ method: 'POST', url: `/admin/problems/${problemId}/root-causes`, payload: { type: 'technical', description: 'x' }, }); const proposed = await app.inject({ method: 'POST', url: `/admin/problems/${problemId}/solutions`, payload: { proposed: 'fix' }, }); const solutionId = proposed.json().data.id; await app.inject({ method: 'PATCH', url: `/admin/solutions/${solutionId}/approve` }); await app.inject({ method: 'POST', url: `/admin/solutions/${solutionId}/implementation`, payload: { implementedBy: 'agent-1' }, }); const success = await app.inject({ method: 'POST', url: `/admin/solutions/${solutionId}/verification`, payload: { method: 'agent_confirmation', result: 'success' }, }); expect(success.statusCode).toBe(201); // Failure path: a second problem/solution/implementation. const { ticketId: ticket2, problemId: problem2 } = await createTicket(); await app.inject({ method: 'POST', url: `/admin/problems/${problem2}/investigations`, payload: { investigator: 'agent-1', findings: {} }, }); await app.inject({ method: 'POST', url: `/admin/problems/${problem2}/root-causes`, payload: { type: 'technical', description: 'x' }, }); const proposed2 = await app.inject({ method: 'POST', url: `/admin/problems/${problem2}/solutions`, payload: { proposed: 'a wrong fix' }, }); const solution2Id = proposed2.json().data.id; await app.inject({ method: 'PATCH', url: `/admin/solutions/${solution2Id}/approve` }); await app.inject({ method: 'POST', url: `/admin/solutions/${solution2Id}/implementation`, payload: { implementedBy: 'agent-1' }, }); const failed = await app.inject({ method: 'POST', url: `/admin/solutions/${solution2Id}/verification`, payload: { method: 'agent_confirmation', result: 'failed' }, }); expect(failed.statusCode).toBe(201); // No resolution can be recorded — problem2 has no successful verification. const rejectedResolution = await app.inject({ method: 'POST', url: `/admin/tickets/${ticket2}/resolution`, payload: { outcome: 'x', resolvedBy: 'agent-1' }, }); expect(rejectedResolution.statusCode).toBe(409); // Re-investigate path: a fresh Investigation row for the same problem. const reInvestigate = await app.inject({ method: 'POST', url: `/admin/problems/${problem2}/investigations`, payload: { investigator: 'agent-2', findings: { retried: true } }, }); expect(reInvestigate.statusCode).toBe(201); const allInvestigations = await app.inject({ method: 'GET', url: `/admin/problems/${problem2}/investigations`, }); expect(allInvestigations.json().data.length).toBe(2); // Escalate path: transition to HUMAN_ESCALATION, confirm 007 auto-assigns. const ticketBeforeEscalate = await ticketsService.getById(ticket2); const escalate = await app.inject({ method: 'PATCH', url: `/tickets/${ticket2}/status`, payload: { status: 'HUMAN_ESCALATION', expectedVersion: ticketBeforeEscalate.version }, }); expect(escalate.statusCode).toBe(200); const assignment = await app.inject({ method: 'GET', url: `/tickets/${ticket2}/assignment`, }); expect(assignment.statusCode).toBe(200); expect(assignment.json().data.agentId).toBe(agentId); }); it('Scenario 5: resolution, customer confirmation, and durable auto-close', async () => { const { ticketId, problemId } = await createTicket(); await escalateAndAssign(ticketId); const rejected = await app.inject({ method: 'POST', url: `/admin/tickets/${ticketId}/resolution`, payload: { outcome: 'x', resolvedBy: 'agent-1' }, }); expect(rejected.statusCode).toBe(409); await app.inject({ method: 'POST', url: `/admin/problems/${problemId}/investigations`, payload: { investigator: 'agent-1', findings: {} }, }); await app.inject({ method: 'POST', url: `/admin/problems/${problemId}/root-causes`, payload: { type: 'technical', description: 'x' }, }); const proposed = await app.inject({ method: 'POST', url: `/admin/problems/${problemId}/solutions`, payload: { proposed: 'fix' }, }); const solutionId = proposed.json().data.id; await app.inject({ method: 'PATCH', url: `/admin/solutions/${solutionId}/approve` }); await app.inject({ method: 'POST', url: `/admin/solutions/${solutionId}/implementation`, payload: { implementedBy: 'agent-1' }, }); await app.inject({ method: 'POST', url: `/admin/solutions/${solutionId}/verification`, payload: { method: 'agent_confirmation', result: 'success' }, }); const resolved = await app.inject({ method: 'POST', url: `/admin/tickets/${ticketId}/resolution`, payload: { outcome: 'fixed', resolvedBy: 'agent-1' }, }); expect(resolved.statusCode).toBe(201); const pendingTicket = await ticketsService.getById(ticketId); expect(pendingTicket.status).toBe('RESOLUTION_PENDING_CUSTOMER'); const token = await tokenForCurrentRequest(); const confirm = await app.inject({ method: 'POST', url: `/v1/support/tickets/${ticketId}/confirm-resolution`, headers: { authorization: `Bearer ${token}` }, payload: identityPayload(), }); expect(confirm.statusCode).toBe(200); const confirmedTicket = await ticketsService.getById(ticketId); expect(confirmedTicket.status).toBe('RESOLVED'); // Auto-close path: a second ticket, aged past the configured window, resolved by the sweep. const { ticketId: ticket2, problemId: problem2 } = await createTicket(); await fullyResolve(problem2, ticket2); await prismaClient.ticket.update({ where: { id: ticket2 }, data: { updatedAt: new Date(Date.now() - 73 * 60 * 60 * 1000) }, // > default 72h }); await resolutionsService.runAutoCloseSweep(); const autoClosedTicket = await ticketsService.getById(ticket2); expect(autoClosedTicket.status).toBe('RESOLVED'); }); it('Scenario 6: reopen — customer and agent, leaving prior records untouched', async () => { const { ticketId, problemId } = await createTicket(); await fullyResolve(problemId, ticketId); const token = await tokenForCurrentRequest(); await app.inject({ method: 'POST', url: `/v1/support/tickets/${ticketId}/confirm-resolution`, headers: { authorization: `Bearer ${token}` }, payload: identityPayload(), }); const resolutionBefore = await prismaClient.resolution.findUniqueOrThrow({ where: { ticketId }, }); const reopenToken = await tokenForCurrentRequest(); const reopen = await app.inject({ method: 'POST', url: `/v1/support/tickets/${ticketId}/reopen`, headers: { authorization: `Bearer ${reopenToken}` }, payload: identityPayload(), }); expect(reopen.statusCode).toBe(200); expect(reopen.json().data.status).toBe('IN_PROGRESS'); const resolutionAfter = await prismaClient.resolution.findUniqueOrThrow({ where: { ticketId }, }); expect(resolutionAfter).toEqual(resolutionBefore); // Agent reopen of a CLOSED ticket. const { ticketId: ticket2, problemId: problem2 } = await createTicket(); await fullyResolve(problem2, ticket2); const confirmToken = await tokenForCurrentRequest(); await app.inject({ method: 'POST', url: `/v1/support/tickets/${ticket2}/confirm-resolution`, headers: { authorization: `Bearer ${confirmToken}` }, payload: identityPayload(), }); const resolvedTicket = await ticketsService.getById(ticket2); await ticketsService.updateStatus(ticket2, 'CLOSED', resolvedTicket.version, 'system'); const agentReopen = await app.inject({ method: 'POST', url: `/admin/tickets/${ticket2}/reopen`, }); expect(agentReopen.statusCode).toBe(200); expect(agentReopen.json().data.status).toBe('IN_PROGRESS'); }); });