feat: implement problem resolution (009)
Populates the five real problem-management stubs (investigation, root-causes, solutions, verification, resolutions -- problems is confirmed dead/unwired scaffold and stays untouched) with doc04's sequential workflow engine: - investigation: version-row-per-attempt (never overwritten), with a customer-safe read path that always strips internalNotes. - root-causes/solutions/verification: a strict existence chain (investigation -> root cause -> solution -> approval -> implementation -> verification), each step resolve-or-409 on its own precondition, matching doc06's schema field-for-field with no invented columns. - resolutions: gated on a successfully verified solution (no stored solutionId FK, per doc06 -- resolved via a join at write time), moving the ticket to RESOLUTION_PENDING_CUSTOMER; explicit customer confirmation and a durable auto-close sweep (the previously-unregistered CLEANUP queue stub, mirroring 008's breach-detection job) both resolve it from there. - reopen (ticketing/tickets): two real, separately-audited transitions (RESOLVED|CLOSED -> REOPENED -> IN_PROGRESS), touching no prior problem-resolution record and no SLARun -- closes the loop 008's own spec.md left open. Verification-failure escalation reuses 003/007's existing HUMAN_ESCALATION transition directly rather than adding an eleventh trigger type to 008's already-shipped escalation rules. Customer-facing confirm-resolution/reopen needed a body-shape variant of 002's inbound trust boundary that didn't previously exist: fastify.authenticateProductIntegration hard-required a full ticket-creation-shaped body. Extracted the shared token/scope/replay verification into verifyIntegrationIdentity and added a narrower authenticateProductIntegrationIdentity decorator + identityOnlyRequestSchema on top of it -- purely additive, ticket creation's own behavior is unchanged. Also fixes a real test-data-hygiene bug surfaced by running this feature's suite alongside 008's: a wildcard-scoped HierarchyNode and an intentionally-global SLAPolicy in 008's own test fixtures were silently affecting other test files' tickets sharing the same live Postgres. Verified against throwaway Docker Postgres/Redis: typecheck, lint, architecture-check all clean; full regression (tests/unit + tests/integration together, 172 tests) passes except the 2 pre-existing MinIO-dependent attachment failures, unrelated to this feature. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
aaa51ef475
commit
16daf8d32d
@@ -0,0 +1,550 @@
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user