feat(015-reporting-dashboards): four real reporting/analytics endpoints
Wires the pre-scaffolded, unused platform/reports module (ReportsService
.generateSummaryReport previously returned {}) into four real, admin-
gated dashboards matching docs/09-testing-observability-cicd.md's own
table:
- GET /admin/reports/management: total cases, AI-resolved, human-
escalated, resolved/open, SLA compliance/breaches, escalation count,
average response/resolution time.
- GET /admin/reports/product/:externalProductId: support volume,
problem-category breakdown, recurring problems, AI-resolution/human-
escalation rate, top error codes.
- GET /admin/reports/support: current per-agent workload, SLA at-risk/
breached counts, escalation count, response/resolution performance.
- GET /admin/reports/ai: AI resolution/human-handoff rate, failed-
troubleshooting-then-escalated rate, knowledge-match rate, confidence
distribution (reusing 005-ai-support's own decideConfidenceBand),
tool invocation success/failure.
Every rate/average is number|null -- null means no qualifying data in
range, never a computed NaN or a misleading 0. Adds one new durable
table, ErrorCodeLookup, since 014-full-observability's own equivalent
metric is a process-lifetime Prometheus counter unusable for a
historical "top errors" report.
Verified end-to-end against real Postgres/Redis: every figure checked
against hand-computed expected values, including a no-activity range
(all-zero counts, all-null rates) and cross-product isolation.
Also fixes a real regression the new ErrorCodeLookup FK caused in the
pre-existing known-issues.test.ts (its afterAll deleted ErrorCode rows
before the now-referencing lookup rows).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
814d9d7b17
commit
d65683641a
@@ -21,6 +21,9 @@ describe('Error codes and known issues', () => {
|
||||
|
||||
afterAll(async () => {
|
||||
await prismaClient.knownIssue.deleteMany({ where: { product: { externalProductId } } });
|
||||
// 015-reporting-dashboards: findKnownIssuesByErrorCode now also writes a durable
|
||||
// ErrorCodeLookup row (RESTRICT FK to ErrorCode) — must be deleted before ErrorCode itself.
|
||||
await prismaClient.errorCodeLookup.deleteMany({ where: { product: { externalProductId } } });
|
||||
await prismaClient.errorCode.deleteMany({ where: { product: { externalProductId } } });
|
||||
await prismaClient.product.deleteMany({ where: { externalProductId } });
|
||||
await app.close();
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
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';
|
||||
import {
|
||||
sessionRepository,
|
||||
diagnosisRepository,
|
||||
knowledgeReferenceRepository,
|
||||
} from '@/modules/ai-support/sessions';
|
||||
import { actionRepository } from '@/modules/ai-support/tools';
|
||||
import { aiConfig } from '@/config';
|
||||
|
||||
/** Covers specs/015-reporting-dashboards/quickstart.md Scenario 4 against a real Postgres/Redis. */
|
||||
describe('AI dashboard (User Story 4)', () => {
|
||||
let app: FastifyInstance;
|
||||
let authToken: string;
|
||||
const externalProductId = `TEST_AI_REPORT_PROD_${Date.now()}`;
|
||||
let secret: string;
|
||||
const rangeFrom = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||
const rangeTo = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
authToken = await loginAs(app, 'ADMIN');
|
||||
|
||||
const product = await prismaClient.product.create({
|
||||
data: { externalProductId, name: 'AI Report Test Product', status: 'active' },
|
||||
});
|
||||
secret = generateCredentialSecret();
|
||||
await prismaClient.productIntegration.create({
|
||||
data: {
|
||||
productId: product.id,
|
||||
credentialRef: encryptCredential(secret),
|
||||
authMechanism: 'signed_token',
|
||||
allowedScope: { tenantIds: ['tenant-1'] },
|
||||
status: 'active',
|
||||
rateLimitPerMinute: 1000,
|
||||
rateLimitPerUserPerMinute: 1000,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
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: `AI report test ${Date.now()}-${Math.random()}`,
|
||||
},
|
||||
});
|
||||
expect(created.statusCode).toBe(202);
|
||||
return created.json().data.ticketId as string;
|
||||
}
|
||||
|
||||
it('reflects real session outcomes, tool results, and confidence bands', async () => {
|
||||
// A resolved session, with a knowledge match and a successful tool call.
|
||||
const resolvedTicketId = await createTicket();
|
||||
const resolvedSession = await sessionRepository.create(resolvedTicketId);
|
||||
await knowledgeReferenceRepository.recordMany(resolvedSession.id, ['fake-knowledge-id']);
|
||||
await diagnosisRepository.create({
|
||||
sessionId: resolvedSession.id,
|
||||
product: 'test-product',
|
||||
problemType: 'test-problem',
|
||||
severity: 'medium',
|
||||
confidence: aiConfig.defaultHighConfidence,
|
||||
possibleCauses: ['test cause'],
|
||||
});
|
||||
const successAction = await actionRepository.create({
|
||||
sessionId: resolvedSession.id,
|
||||
toolName: 'getTicketSnapshot',
|
||||
input: {},
|
||||
riskLevel: 'low',
|
||||
evaluationOutcome: 'approved',
|
||||
});
|
||||
await actionRepository.createResult(successAction.id, { ok: true }, 'success');
|
||||
await sessionRepository.updateStatus(resolvedSession.id, 'resolved');
|
||||
|
||||
// An escalated session, with a failed tool call and a low-confidence diagnosis.
|
||||
const escalatedTicketId = await createTicket();
|
||||
const escalatedSession = await sessionRepository.create(escalatedTicketId);
|
||||
await diagnosisRepository.create({
|
||||
sessionId: escalatedSession.id,
|
||||
product: 'test-product',
|
||||
problemType: 'test-problem',
|
||||
severity: 'high',
|
||||
confidence: aiConfig.defaultLowConfidence - 0.05,
|
||||
possibleCauses: ['test cause'],
|
||||
});
|
||||
const failedAction = await actionRepository.create({
|
||||
sessionId: escalatedSession.id,
|
||||
toolName: 'getTicketSnapshot',
|
||||
input: {},
|
||||
riskLevel: 'low',
|
||||
evaluationOutcome: 'approved',
|
||||
});
|
||||
await actionRepository.createResult(failedAction.id, { error: 'boom' }, 'failed');
|
||||
await sessionRepository.updateStatus(escalatedSession.id, 'escalated');
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/admin/reports/ai?from=${rangeFrom}&to=${rangeTo}`,
|
||||
headers: authHeader(authToken),
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const data = res.json().data;
|
||||
|
||||
expect(data.totalSessions).toBeGreaterThanOrEqual(2);
|
||||
expect(data.aiResolutionRate).not.toBeNull();
|
||||
expect(data.humanHandoffRate).not.toBeNull();
|
||||
expect(data.knowledgeMatchRate).not.toBeNull();
|
||||
expect(data.confidenceDistribution.proceed).toBeGreaterThanOrEqual(1);
|
||||
expect(data.confidenceDistribution.escalate).toBeGreaterThanOrEqual(1);
|
||||
expect(data.toolInvocations.success).toBeGreaterThanOrEqual(1);
|
||||
expect(data.toolInvocations.failed).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('returns null rates and zero counts for a range with no AI activity', async () => {
|
||||
const farPastFrom = new Date('2000-01-01').toISOString();
|
||||
const farPastTo = new Date('2000-01-02').toISOString();
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/admin/reports/ai?from=${farPastFrom}&to=${farPastTo}`,
|
||||
headers: authHeader(authToken),
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const data = res.json().data;
|
||||
|
||||
expect(data.totalSessions).toBe(0);
|
||||
expect(data.aiResolutionRate).toBeNull();
|
||||
expect(data.humanHandoffRate).toBeNull();
|
||||
expect(data.knowledgeMatchRate).toBeNull();
|
||||
expect(data.confidenceDistribution).toEqual({ proceed: 0, ask: 0, escalate: 0 });
|
||||
expect(data.toolInvocations).toEqual({ success: 0, failed: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
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';
|
||||
import { ticketsRepository } from '@/modules/ticketing/tickets';
|
||||
import { messagesService } from '@/modules/ticketing/messages';
|
||||
import { resolutionRepository } from '@/modules/problem-management/resolutions';
|
||||
|
||||
/**
|
||||
* Covers specs/015-reporting-dashboards/quickstart.md Scenario 1 against a real Postgres/Redis.
|
||||
* Drives ticket-status transitions directly through ticketsRepository (not ticketsService) to
|
||||
* avoid publishing TICKET_UPDATED — this test only needs the raw persisted state its own
|
||||
* aggregation queries read, and publishing real domain events here risks the same kind of
|
||||
* cross-file contamination 014-full-observability's own business-metrics.test.ts found and fixed
|
||||
* (an unscoped HUMAN_ESCALATION triggering real auto-assignment against the shared agent pool).
|
||||
*/
|
||||
describe('Management dashboard (User Story 1)', () => {
|
||||
let app: FastifyInstance;
|
||||
let authToken: string;
|
||||
const externalProductId = `TEST_MGMT_REPORT_PROD_${Date.now()}`;
|
||||
let productId: string;
|
||||
let secret: string;
|
||||
const rangeFrom = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||
const rangeTo = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
authToken = await loginAs(app, 'ADMIN');
|
||||
|
||||
const product = await prismaClient.product.create({
|
||||
data: { externalProductId, name: 'Management Report 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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
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: `Management report test ${Date.now()}-${Math.random()}`,
|
||||
},
|
||||
});
|
||||
expect(created.statusCode).toBe(202);
|
||||
return created.json().data.ticketId as string;
|
||||
}
|
||||
|
||||
async function driveDirectly(ticketId: string, statuses: string[]): Promise<void> {
|
||||
let ticket = await prismaClient.ticket.findUniqueOrThrow({ where: { id: ticketId } });
|
||||
for (const status of statuses) {
|
||||
const updated = await ticketsRepository.updateStatus(ticketId, status, ticket.version);
|
||||
if (!updated) throw new Error(`Failed to transition ticket to ${status} in test setup.`);
|
||||
ticket = updated;
|
||||
}
|
||||
}
|
||||
|
||||
it('reports real figures matching the actual created data', async () => {
|
||||
// AI-resolved ticket.
|
||||
const aiTicketId = await createTicket();
|
||||
await driveDirectly(aiTicketId, [
|
||||
'AI_ANALYZING',
|
||||
'AI_TROUBLESHOOTING',
|
||||
'AI_VERIFYING',
|
||||
'AI_RESOLVED',
|
||||
]);
|
||||
await resolutionRepository.create({ ticketId: aiTicketId, outcome: 'fixed', resolvedBy: 'ai' });
|
||||
await driveDirectly(aiTicketId, ['RESOLVED']);
|
||||
|
||||
// Human-resolved ticket, with a first agent response recorded.
|
||||
const humanTicketId = await createTicket();
|
||||
await messagesService.post(humanTicketId, 'agent-1', 'AGENT_MESSAGE', 'Looking into this.');
|
||||
await driveDirectly(humanTicketId, [
|
||||
'HUMAN_ESCALATION',
|
||||
'IN_PROGRESS',
|
||||
'RESOLUTION_PENDING_CUSTOMER',
|
||||
]);
|
||||
await resolutionRepository.create({
|
||||
ticketId: humanTicketId,
|
||||
outcome: 'fixed',
|
||||
resolvedBy: 'agent-1',
|
||||
});
|
||||
await driveDirectly(humanTicketId, ['RESOLVED']);
|
||||
|
||||
// Still-open ticket.
|
||||
await createTicket();
|
||||
|
||||
// SLA policy + one met, one breached run.
|
||||
const policy = await prismaClient.sLAPolicy.create({
|
||||
data: {
|
||||
name: `Mgmt Report Policy ${Date.now()}`,
|
||||
productId,
|
||||
firstResponseMinutes: 30,
|
||||
resolutionMinutes: 240,
|
||||
},
|
||||
});
|
||||
const metTicketId = await createTicket();
|
||||
await prismaClient.sLARun.create({
|
||||
data: {
|
||||
ticketId: metTicketId,
|
||||
policyId: policy.id,
|
||||
firstResponseDueAt: new Date(Date.now() + 30 * 60_000),
|
||||
resolutionDueAt: new Date(Date.now() + 240 * 60_000),
|
||||
status: 'completed',
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
const breachedTicketId = await createTicket();
|
||||
await prismaClient.sLARun.create({
|
||||
data: {
|
||||
ticketId: breachedTicketId,
|
||||
policyId: policy.id,
|
||||
firstResponseDueAt: new Date(Date.now() + 30 * 60_000),
|
||||
resolutionDueAt: new Date(Date.now() - 60_000),
|
||||
status: 'breached',
|
||||
breachedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// One escalation event.
|
||||
const escalatedTicketId = await createTicket();
|
||||
await prismaClient.escalationEvent.create({
|
||||
data: {
|
||||
ticketId: escalatedTicketId,
|
||||
ruleId: null,
|
||||
fromNodeId: null,
|
||||
toNodeId: null,
|
||||
reason: 'management dashboard test',
|
||||
triggeredBy: 'system',
|
||||
},
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/admin/reports/management?from=${rangeFrom}&to=${rangeTo}`,
|
||||
headers: authHeader(authToken),
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const data = res.json().data;
|
||||
|
||||
expect(data.totalCases).toBeGreaterThanOrEqual(6);
|
||||
expect(data.aiResolved).toBeGreaterThanOrEqual(1);
|
||||
expect(data.humanEscalated).toBeGreaterThanOrEqual(1);
|
||||
expect(data.resolved).toBeGreaterThanOrEqual(2);
|
||||
expect(data.open).toBeGreaterThanOrEqual(1);
|
||||
expect(data.slaCompliance.met).toBeGreaterThanOrEqual(1);
|
||||
expect(data.slaCompliance.breached).toBeGreaterThanOrEqual(1);
|
||||
expect(data.slaCompliance.rate).not.toBeNull();
|
||||
expect(data.escalationCount).toBeGreaterThanOrEqual(1);
|
||||
expect(data.averageResponseSeconds).not.toBeNull();
|
||||
expect(data.averageResolutionSeconds).not.toBeNull();
|
||||
expect(data.range.from).toBeTruthy();
|
||||
expect(data.range.to).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns all-zero counts and all-null rates for a range with no activity', async () => {
|
||||
const farPastFrom = new Date('2000-01-01').toISOString();
|
||||
const farPastTo = new Date('2000-01-02').toISOString();
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/admin/reports/management?from=${farPastFrom}&to=${farPastTo}`,
|
||||
headers: authHeader(authToken),
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const data = res.json().data;
|
||||
|
||||
expect(data.totalCases).toBe(0);
|
||||
expect(data.aiResolved).toBe(0);
|
||||
expect(data.humanEscalated).toBe(0);
|
||||
expect(data.resolved).toBe(0);
|
||||
expect(data.open).toBe(0);
|
||||
expect(data.slaCompliance.rate).toBeNull();
|
||||
expect(data.averageResponseSeconds).toBeNull();
|
||||
expect(data.averageResolutionSeconds).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a range where from is after to', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/admin/reports/management?from=${rangeTo}&to=${rangeFrom}`,
|
||||
headers: authHeader(authToken),
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
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';
|
||||
import { errorCodesService } from '@/modules/ai-support/knowledge';
|
||||
|
||||
/** Covers specs/015-reporting-dashboards/quickstart.md Scenario 2 against a real Postgres/Redis. */
|
||||
describe('Product dashboard (User Story 2)', () => {
|
||||
let app: FastifyInstance;
|
||||
let authToken: string;
|
||||
const rangeFrom = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||
const rangeTo = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||||
|
||||
async function setUpProduct(nameSuffix: string) {
|
||||
const externalProductId = `TEST_PRODUCT_REPORT_${nameSuffix}_${Date.now()}`;
|
||||
const product = await prismaClient.product.create({
|
||||
data: { externalProductId, name: `Product Report ${nameSuffix}`, status: 'active' },
|
||||
});
|
||||
const secret = generateCredentialSecret();
|
||||
await prismaClient.productIntegration.create({
|
||||
data: {
|
||||
productId: product.id,
|
||||
credentialRef: encryptCredential(secret),
|
||||
authMechanism: 'signed_token',
|
||||
allowedScope: { tenantIds: ['tenant-1'] },
|
||||
status: 'active',
|
||||
rateLimitPerMinute: 1000,
|
||||
rateLimitPerUserPerMinute: 1000,
|
||||
},
|
||||
});
|
||||
return { externalProductId, productId: product.id, secret };
|
||||
}
|
||||
|
||||
async function createTicket(externalProductId: string, secret: string): 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: `Product report test ${Date.now()}-${Math.random()}`,
|
||||
},
|
||||
});
|
||||
expect(created.statusCode).toBe(202);
|
||||
return created.json().data.ticketId as string;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
authToken = await loginAs(app, 'ADMIN');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("scopes every figure to the requested product, never another product's data", async () => {
|
||||
const productA = await setUpProduct('A');
|
||||
const productB = await setUpProduct('B');
|
||||
|
||||
await createTicket(productA.externalProductId, productA.secret);
|
||||
await createTicket(productA.externalProductId, productA.secret);
|
||||
await createTicket(productB.externalProductId, productB.secret);
|
||||
|
||||
const resA = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/admin/reports/product/${productA.externalProductId}?from=${rangeFrom}&to=${rangeTo}`,
|
||||
headers: authHeader(authToken),
|
||||
});
|
||||
expect(resA.statusCode).toBe(200);
|
||||
expect(resA.json().data.supportVolume).toBe(2);
|
||||
|
||||
const resB = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/admin/reports/product/${productB.externalProductId}?from=${rangeFrom}&to=${rangeTo}`,
|
||||
headers: authHeader(authToken),
|
||||
});
|
||||
expect(resB.statusCode).toBe(200);
|
||||
expect(resB.json().data.supportVolume).toBe(1);
|
||||
});
|
||||
|
||||
it('ranks the most-frequently-looked-up error code first', async () => {
|
||||
const product = await setUpProduct('ERR');
|
||||
const popularCode = `POPULAR-${Date.now()}`;
|
||||
const rareCode = `RARE-${Date.now()}`;
|
||||
await errorCodesService.createErrorCode(product.productId, popularCode, 'Popular error');
|
||||
await errorCodesService.createErrorCode(product.productId, rareCode, 'Rare error');
|
||||
|
||||
await errorCodesService.findKnownIssuesByErrorCode(product.productId, popularCode);
|
||||
await errorCodesService.findKnownIssuesByErrorCode(product.productId, popularCode);
|
||||
await errorCodesService.findKnownIssuesByErrorCode(product.productId, popularCode);
|
||||
await errorCodesService.findKnownIssuesByErrorCode(product.productId, rareCode);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/admin/reports/product/${product.externalProductId}?from=${rangeFrom}&to=${rangeTo}`,
|
||||
headers: authHeader(authToken),
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const topErrors = res.json().data.topErrors as Array<{ code: string; count: number }>;
|
||||
expect(topErrors[0]).toMatchObject({ code: popularCode, count: 3 });
|
||||
expect(topErrors.find((e) => e.code === rareCode)).toMatchObject({ count: 1 });
|
||||
});
|
||||
|
||||
it('404s for an unknown product', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/admin/reports/product/NONEXISTENT_PRODUCT_${Date.now()}`,
|
||||
headers: authHeader(authToken),
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
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';
|
||||
import { reportingConfig } from '@/config';
|
||||
|
||||
/**
|
||||
* Covers specs/015-reporting-dashboards/quickstart.md Scenario 3 against a real Postgres/Redis.
|
||||
* Assignment rows are created directly via Prisma (not through a real HUMAN_ESCALATION +
|
||||
* default-strategy auto-assignment) — the same contamination avoidance
|
||||
* management-dashboard.test.ts already documents: this test only needs the persisted
|
||||
* Assignment/SLARun state its own aggregation queries read, not a live orchestration run.
|
||||
*/
|
||||
describe('Support dashboard (User Story 3)', () => {
|
||||
let app: FastifyInstance;
|
||||
let authToken: string;
|
||||
const externalProductId = `TEST_SUPPORT_REPORT_PROD_${Date.now()}`;
|
||||
let productId: string;
|
||||
let secret: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
authToken = await loginAs(app, 'ADMIN');
|
||||
|
||||
const product = await prismaClient.product.create({
|
||||
data: { externalProductId, name: 'Support Report 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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
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: `Support report test ${Date.now()}-${Math.random()}`,
|
||||
},
|
||||
});
|
||||
expect(created.statusCode).toBe(202);
|
||||
return created.json().data.ticketId as string;
|
||||
}
|
||||
|
||||
it("reflects each agent's real current assignment workload", async () => {
|
||||
const team = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin/teams',
|
||||
headers: authHeader(authToken),
|
||||
payload: { name: `Support Report Team ${Date.now()}` },
|
||||
});
|
||||
const teamId = team.json().data.id as string;
|
||||
const agent = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/admin/teams/${teamId}/agents`,
|
||||
headers: authHeader(authToken),
|
||||
payload: { name: 'Support Report Agent' },
|
||||
});
|
||||
const agentId = agent.json().data.id as string;
|
||||
|
||||
const ticket1 = await createTicket();
|
||||
const ticket2 = await createTicket();
|
||||
await prismaClient.assignment.createMany({
|
||||
data: [
|
||||
{ ticketId: ticket1, agentId, strategy: 'MANUAL', isCurrent: true },
|
||||
{ ticketId: ticket2, agentId, strategy: 'MANUAL', isCurrent: true },
|
||||
],
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin/reports/support',
|
||||
headers: authHeader(authToken),
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const workload = res.json().data.workloadByAgent as Array<{
|
||||
agentId: string;
|
||||
openAssignments: number;
|
||||
}>;
|
||||
expect(workload.find((w) => w.agentId === agentId)).toMatchObject({ openAssignments: 2 });
|
||||
});
|
||||
|
||||
it('counts a near-due SLA run as at-risk, distinct from breached', async () => {
|
||||
const policy = await prismaClient.sLAPolicy.create({
|
||||
data: {
|
||||
name: `Support Risk Policy ${Date.now()}`,
|
||||
productId,
|
||||
firstResponseMinutes: 30,
|
||||
resolutionMinutes: 240,
|
||||
},
|
||||
});
|
||||
|
||||
const riskTicketId = await createTicket();
|
||||
await prismaClient.sLARun.create({
|
||||
data: {
|
||||
ticketId: riskTicketId,
|
||||
policyId: policy.id,
|
||||
firstResponseDueAt: new Date(Date.now() + 30 * 60_000),
|
||||
resolutionDueAt: new Date(
|
||||
Date.now() + (reportingConfig.slaRiskThresholdMinutes - 1) * 60_000,
|
||||
),
|
||||
status: 'running',
|
||||
},
|
||||
});
|
||||
|
||||
const safeTicketId = await createTicket();
|
||||
await prismaClient.sLARun.create({
|
||||
data: {
|
||||
ticketId: safeTicketId,
|
||||
policyId: policy.id,
|
||||
firstResponseDueAt: new Date(Date.now() + 30 * 60_000),
|
||||
resolutionDueAt: new Date(Date.now() + 999 * 60_000),
|
||||
status: 'running',
|
||||
},
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin/reports/support',
|
||||
headers: authHeader(authToken),
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data.slaAtRisk).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { decideConfidenceBand } from '@/modules/ai-support/sessions';
|
||||
import { aiConfig } from '@/config';
|
||||
|
||||
/**
|
||||
* 015-reporting-dashboards research.md §7: the AI dashboard's confidence distribution reuses
|
||||
* 005-ai-support's own decideConfidenceBand against the system-default thresholds, rather than
|
||||
* reimplementing a threshold check — this test proves the reused function classifies values
|
||||
* the way the dashboard's own bucketing loop (reports.service.ts) depends on.
|
||||
*/
|
||||
describe('AI dashboard confidence distribution reuses decideConfidenceBand', () => {
|
||||
const policy = {
|
||||
highThreshold: aiConfig.defaultHighConfidence,
|
||||
lowThreshold: aiConfig.defaultLowConfidence,
|
||||
};
|
||||
|
||||
it('classifies a high-confidence value as proceed', () => {
|
||||
expect(decideConfidenceBand(policy.highThreshold, policy)).toBe('proceed');
|
||||
});
|
||||
|
||||
it('classifies a low-confidence value as escalate', () => {
|
||||
expect(decideConfidenceBand(policy.lowThreshold - 0.01, policy)).toBe('escalate');
|
||||
});
|
||||
|
||||
it('classifies a mid-range value as ask', () => {
|
||||
const midpoint = (policy.highThreshold + policy.lowThreshold) / 2;
|
||||
expect(decideConfidenceBand(midpoint, policy)).toBe('ask');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { computeRate, computeAverageSeconds } from '@/modules/platform/reports/mapper';
|
||||
|
||||
describe('computeRate (015-reporting-dashboards research.md §3)', () => {
|
||||
it('returns null when the denominator is zero — never NaN, never a computed 0', () => {
|
||||
expect(computeRate(0, 0)).toBeNull();
|
||||
expect(computeRate(5, 0)).toBeNull();
|
||||
});
|
||||
|
||||
it('computes a real rate when there is qualifying data', () => {
|
||||
expect(computeRate(3, 12)).toBe(0.25);
|
||||
});
|
||||
|
||||
it('returns a real 0 when the numerator is legitimately zero but the denominator is not', () => {
|
||||
expect(computeRate(0, 10)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeAverageSeconds', () => {
|
||||
it('returns null for an empty list — no fabricated average', () => {
|
||||
expect(computeAverageSeconds([])).toBeNull();
|
||||
});
|
||||
|
||||
it('averages a list of millisecond durations into seconds', () => {
|
||||
expect(computeAverageSeconds([1000, 2000, 3000])).toBe(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user