FieldOps/scripts/report-smoke.ts
Pedro Gomes fdfa936461 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
2026-05-30 12:51:14 +01:00

171 lines
6.4 KiB
TypeScript

/**
* 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());