MAI CALL - v0.3
O que o supervisor encontra agora:
Na fila de manutenção (:3001), novo botão "Relatório de turno" no header.
Página /maintenance/report com:
Atalhos Manhã / Tarde / Noite / Hoje + seletor de dia + Personalizado (date-time livre)
Label sempre visível com a janela ativa ("Turno da Manhã — 30/05 06:00 → 14:00")
6 cartões de métricas: pedidos, resolvidos, em aberto, tempo médio de resposta, tempo médio de resolução, pior resposta
Tabela por posto e resumo por área
Lista "Em aberto à hora do relatório" (ou "Nada em aberto. ✓")
Botão Imprimir → PDF via browser; CSS @media print limpa botões/nav
Verificações verdes:
report-smoke.ts — 17/17 (totals, responseMs, resolutionMs, byWorkstation, byArea, stillOpen, window edge cases)
E2E MAI CALL happy-path — 1/1 (dados de seed extra não interferem)
TypeScript — limpo nos pacotes tocados (@repo/api, @repo/admin-web)
Seed cria 6 pedidos de exemplo: relatório "Hoje" nunca começa vazio
+
Resumo da revisão do v0.3
Conformidade com o plano: alta. Shape de output exato, ctx.db (tenant-scoped), requireRole, helper de turnos com o caso da noite, seed com 6 pedidos, UI completa + impressão. Tudo no sítio.
Dois defeitos reais que escaparam ao typecheck e ao E2E — corrigidos:
# Problema Correção
🔴 1 Fetch storm no modo "Hoje" (default): computeWindow recalculava to = new Date() a cada render → nova query key → loop de fetch contínuo. useMemo([windowState]) estabiliza a janela em report-view.tsx:101. Reclicar "Hoje" refresca. Também limpei estado morto (customFrom/customTo).
🔴 2 Smoke não cumpria o AC: re-implementava a agregação à mão em vez de chamar a procedure, e não testava to <= from → BAD_REQUEST (exigido pelo AC do Passo 1). Reescrito report-smoke.ts no padrão createCallerFactory — agora exercita a procedure real: agregação, BAD_REQUEST (to≤from e >31d), janela futura vazia, e FORBIDDEN para operador.
Verificações finais (todas verdes):
tsc --noEmit admin-web — limpo
report-smoke.ts — 22/22 (agora contra a procedure real)
E2E MAI CALL — 1 passed
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Report smoke test — exercises the real maintenanceRequest.report procedure
|
||||
* (aggregation + Zod validation + role) via a tRPC caller, the same pattern as
|
||||
* maintenance-smoke.ts.
|
||||
* Run: pnpm tsx scripts/report-smoke.ts
|
||||
* Requires: Docker Postgres running + pnpm db:seed already done.
|
||||
*/
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { config as loadEnv } from 'dotenv';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
loadEnv({ path: path.resolve(here, '../.env') });
|
||||
|
||||
import { prisma } from '../packages/db/src/index.js';
|
||||
import { appRouter, createTRPCContext } from '../packages/api/src/index.js';
|
||||
import { createCallerFactory } from '../packages/api/src/trpc.js';
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(label: string) {
|
||||
console.log(` ✓ ${label}`);
|
||||
passed++;
|
||||
}
|
||||
|
||||
function fail(label: string, detail?: string) {
|
||||
console.error(` ✗ ${label}${detail ? ` — ${detail}` : ''}`);
|
||||
failed++;
|
||||
}
|
||||
|
||||
function assert(condition: boolean, label: string, detail?: string) {
|
||||
if (condition) ok(label);
|
||||
else fail(label, detail);
|
||||
}
|
||||
|
||||
async function makeCaller(email: string) {
|
||||
const user = await prisma.user.findFirst({ where: { email } });
|
||||
if (!user) throw new Error(`User ${email} not found — run pnpm db:seed`);
|
||||
const ctx = await createTRPCContext({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
role: user.role as 'ADMIN' | 'OPERATOR',
|
||||
tenantId: user.tenantId,
|
||||
},
|
||||
headers: new Headers(),
|
||||
});
|
||||
return createCallerFactory(appRouter)(ctx);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('\nReport smoke — running assertions against the real procedure…\n');
|
||||
|
||||
const admin = await makeCaller('admin@demo.local');
|
||||
|
||||
// Wide window covering all seeded "today" requests.
|
||||
const from = new Date();
|
||||
from.setHours(0, 0, 0, 0);
|
||||
const to = new Date();
|
||||
|
||||
const report = await admin.maintenanceRequest.report({ from, to });
|
||||
|
||||
// 1. Totals — seed creates 3 RESOLVED + 1 CLAIMED + 2 OPEN = 6
|
||||
assert(report.totals.created >= 6, `created ≥6 (got ${report.totals.created})`);
|
||||
assert(report.totals.open >= 2, `open ≥2 (got ${report.totals.open})`);
|
||||
assert(report.totals.claimed >= 1, `claimed ≥1 (got ${report.totals.claimed})`);
|
||||
assert(report.totals.resolved >= 3, `resolved ≥3 (got ${report.totals.resolved})`);
|
||||
assert(
|
||||
report.totals.open + report.totals.claimed + report.totals.resolved ===
|
||||
report.totals.created,
|
||||
'totals add up to created',
|
||||
`${report.totals.open}+${report.totals.claimed}+${report.totals.resolved} != ${report.totals.created}`,
|
||||
);
|
||||
|
||||
// 2. responseMs — only rows with claimedAt (3 resolved + 1 claimed = 4)
|
||||
assert(report.responseMs.count >= 4, `responseMs.count ≥4 (got ${report.responseMs.count})`);
|
||||
assert(
|
||||
report.responseMs.avg !== null && report.responseMs.avg > 0,
|
||||
`responseMs.avg positive (${report.responseMs.avg})`,
|
||||
);
|
||||
assert(
|
||||
report.responseMs.max !== null && report.responseMs.max >= report.responseMs.avg!,
|
||||
`responseMs.max ≥ avg (${report.responseMs.max} ≥ ${report.responseMs.avg})`,
|
||||
);
|
||||
|
||||
// 3. resolutionMs — only resolved rows (3)
|
||||
assert(report.resolutionMs.count >= 3, `resolutionMs.count ≥3 (got ${report.resolutionMs.count})`);
|
||||
assert(
|
||||
report.resolutionMs.avg !== null && report.resolutionMs.avg > 0,
|
||||
`resolutionMs.avg positive (${report.resolutionMs.avg})`,
|
||||
);
|
||||
|
||||
// 4. resolution takes longer than response on average (it includes it)
|
||||
if (report.responseMs.avg !== null && report.resolutionMs.avg !== null) {
|
||||
assert(
|
||||
report.resolutionMs.avg > report.responseMs.avg,
|
||||
`resolutionMs.avg (${report.resolutionMs.avg}) > responseMs.avg (${report.responseMs.avg})`,
|
||||
);
|
||||
}
|
||||
|
||||
// 5. byWorkstation — counts add up, sorted desc
|
||||
const wTotal = report.byWorkstation.reduce((s, w) => s + w.count, 0);
|
||||
assert(wTotal === report.totals.created, 'byWorkstation counts add up to created');
|
||||
const wSortedDesc = report.byWorkstation.every(
|
||||
(w, i) => i === 0 || report.byWorkstation[i - 1]!.count >= w.count,
|
||||
);
|
||||
assert(wSortedDesc, 'byWorkstation sorted by count desc');
|
||||
|
||||
// 6. byArea — counts add up
|
||||
const aTotal = report.byArea.reduce((s, a) => s + a.count, 0);
|
||||
assert(aTotal === report.totals.created, 'byArea counts add up to created');
|
||||
|
||||
// 7. stillOpen = OPEN + CLAIMED, never RESOLVED
|
||||
assert(
|
||||
report.stillOpen.length === report.totals.open + report.totals.claimed,
|
||||
`stillOpen length == OPEN+CLAIMED (${report.stillOpen.length} == ${report.totals.open + report.totals.claimed})`,
|
||||
);
|
||||
assert(
|
||||
report.stillOpen.every((r) => r.status === 'OPEN' || r.status === 'CLAIMED'),
|
||||
'stillOpen contains no RESOLVED rows',
|
||||
);
|
||||
|
||||
// 8. Validation: to <= from → BAD_REQUEST
|
||||
try {
|
||||
await admin.maintenanceRequest.report({ from: to, to: from });
|
||||
fail('to <= from → BAD_REQUEST', 'no error thrown');
|
||||
} catch (err: unknown) {
|
||||
const code = (err as { code?: string }).code;
|
||||
assert(code === 'BAD_REQUEST', 'to <= from → BAD_REQUEST', `got code=${code}`);
|
||||
}
|
||||
|
||||
// 9. Validation: window > 31 days → BAD_REQUEST
|
||||
try {
|
||||
const farFrom = new Date(to.getTime() - 40 * 24 * 60 * 60 * 1000);
|
||||
await admin.maintenanceRequest.report({ from: farFrom, to });
|
||||
fail('window > 31d → BAD_REQUEST', 'no error thrown');
|
||||
} catch (err: unknown) {
|
||||
const code = (err as { code?: string }).code;
|
||||
assert(code === 'BAD_REQUEST', 'window > 31d → BAD_REQUEST', `got code=${code}`);
|
||||
}
|
||||
|
||||
// 10. Future window → empty report
|
||||
const fFrom = new Date(to.getTime() + 24 * 60 * 60 * 1000);
|
||||
const fTo = new Date(to.getTime() + 48 * 60 * 60 * 1000);
|
||||
const empty = await admin.maintenanceRequest.report({ from: fFrom, to: fTo });
|
||||
assert(empty.totals.created === 0, 'future window → 0 created');
|
||||
assert(empty.responseMs.avg === null, 'future window → responseMs.avg null');
|
||||
assert(empty.stillOpen.length === 0, 'future window → stillOpen empty');
|
||||
|
||||
// 11. Role: an OPERATOR may not call report (requireRole)
|
||||
const op = await makeCaller('op1@demo.local');
|
||||
try {
|
||||
await op.maintenanceRequest.report({ from, to });
|
||||
fail('operator → FORBIDDEN', 'no error thrown');
|
||||
} catch (err: unknown) {
|
||||
const code = (err as { code?: string }).code;
|
||||
assert(code === 'FORBIDDEN', 'operator → FORBIDDEN', `got code=${code}`);
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed.\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error('Report smoke failed:', err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
Reference in New Issue
Block a user