Skip to content

test(Repo): Suíte de testes unitários, de integração e e2e + CI - #127

Open
risixdzn wants to merge 21 commits into
developfrom
claude/feat/tests
Open

risixdzn wants to merge 21 commits into
developfrom
claude/feat/tests

Conversation

@risixdzn

Copy link
Copy Markdown
Member

O que mudou

Implementa o épico de testes: stack definida, suítes para todos os apps/pacotes, e2e com Playwright e workflow de CI. Convenções em TESTING.md (referenciado no AGENTS.md); e2e documentado em e2e/README.md.

Fundação

  • Vitest alinhado via catalog: em todo o monorepo; example.spec.ts removido; scripts test, test:unit, test:ci, test:integration, test:e2e, lint:ci na raiz.
  • buildApp() extraído de server.ts (que virou só o entrypoint com listen()); os dois setErrorHandler concorrentes viraram um só.
  • Harness de integração com Testcontainers (MySQL 8 + Redis), truncate/flush entre testes, factories e helpers de sessão por role.

Cobertura

Suíte Testes
Unit (server) 447
Integração (server, MySQL/Redis reais) 338
permissions / schemas / constants / mail 313 / 201 / 20 / 19
workers / web / admin 10 / 164 / 20
E2E (Playwright) 23 (+8 fixme)
  • Varredura de RBAC: toda rota do buildApp() precisa estar na allowlist pública ou ter autenticação + requirePermission(); cada rota protegida é exercitada com cada role de roleAbilities (403 vs. passa), sem sessão (401) e com token forjado (401).
  • E2E hermético: MySQL/Redis dedicados via docker compose (nunca o banco de dev), seed via API, storageState por role e Turnstile stubado no browser e na API de e2e (sem mudar código de produção).

CI (.github/workflows/ci.yml): lint, types, unit + cobertura (thresholds por workspace, mais rígidos para @fixr/permissions e middlewares de auth/RBAC, resumo comentado no PR), integração (Docker) e e2e (PRs para main, pushes em develop/main, com upload do relatório/traces em falha). deploy.yml não foi alterado (guardrail de infra); a relação deploy × CI está documentada no TESTING.md.

Merge de develop: inclui as API keys. O registro das rotas/tag foi movido para app.ts; as API keys ganharam testes unitários, de integração, web e e2e.

Bugs encontrados pelos testes e corrigidos

  • Escrita entre tenants: qualquer empresa com devices:update alterava/excluía models de outra empresa (e do catálogo compartilhado).
  • Login de clientes (não-funcionários) sempre retornava 500 (company: NULL rejeitado pelos schemas).
  • Erros viravam 500: envelopes de erro passavam pelos schemas de resposta literais das docs; qualquer erro diferente do documentado para o status virava FST_ERR_FAILED_ERROR_SERIALIZATION.
  • Presign de uploads de OS e de models sempre falhava (500); só avatar funcionava.
  • authenticate transformava todo AppError em 401 (ex.: conta não-funcionário deveria ser 403).
  • Web: o interceptor de refresh 401 estava registrado no axios global (nunca rodava) e podia entrar em loop; /service-orders/new era liberado com serviceOrders:read; role guest entrava em loop infinito de redirect; o Turnstile acusava falha 15s após a última tecla; /auth/login retornava 500 logo após a verificação de e-mail; a primeira página após o refresh silencioso de sessão retornava 500.
  • API keys: a validade mínima de expiresAt era congelada no boot do servidor (aceitava datas passadas).
  • Menores: stack trace vazava em 500 em produção; /auth/register respondia HTTP 500 com corpo 501; createAbility() com role desconhecida agora cai em guest; worker de e-mail com shutdown gracioso e erro claro para job desconhecido; @fixr/env agora referencia o typescript-config do workspace; hook de pre-push usa lint:ci (o --elide-lines quebrava fora de terminal).

Lacunas mapeadas (não alteradas, decisões de produto/segurança)

  • Cadeia de migrations quebrada num banco vazio: a 0012 renomeia uploads.purpose, que nenhuma migration anterior cria. Os testes usam drizzle-kit push; a cadeia precisa ser regenerada.
  • Enumeração de contas: login e reset de senha diferenciam "e-mail inexistente" de "senha errada".
  • Logout no web só limpa cookies (o refresh token continua válido); reset de senha não derruba sessões; OAuth do Google sem state (CSRF).
  • Uploads aceitam qualquer MIME; avatar aceita qualquer URL.
  • Nenhuma role tem account:delete; clientes têm a ability de guest (não leem GET /account); o painel admin não verifica role (qualquer usuário Clerk cria empresas).
  • Telas de OS usam @fixr/mock (formulário só faz console.log), status do web não batem com o enum da API, não há endpoint de mudança de status nem UI de catálogo (fluxos e2e ficaram fixme).
  • API keys: lookup em cache por 60s (rebaixar/remover funcionário leva até 1 min para refletir; revogação é imediata); o preset "1 ano" pede exatamente o limite de 365 dias (clock adiantado no cliente gera api_key_expiration_too_far).

Issue relacionada

Closes #95
Closes #102, closes #103, closes #104, closes #105, closes #106, closes #107, closes #108, closes #109, closes #110, closes #111, closes #112, closes #113, closes #114, closes #115, closes #116, closes #117, closes #118, closes #119, closes #120, closes #121, closes #122

Como testar

bun install
bun run lint:ci && bun run check-types:ci
bun run test:ci              # unit + cobertura (sem Docker)
bun run test:integration     # precisa de Docker
bun run test:e2e             # precisa de Docker; primeira vez: cd e2e && bunx playwright install chromium

Depois do merge, marcar Lint, Types, Unit tests (e idealmente Integration tests (server)) como checks obrigatórios na branch protection de develop/main.

Checklist

  • bun run check-types passa
  • bun run lint passa (lint:ci)
  • Testes adicionados/atualizados quando relevante
  • Mensagens de commit seguem a convenção action(Scope): message

🤖 Generated with Claude Code

https://claude.ai/code/session_01JtJ1gJpgdDNQ2xkvhGc5H6


Generated by Claude Code

Pin vitest in the root catalog for every workspace, drop the root
example.spec.ts placeholder, add root test scripts and document the
conventions in TESTING.md (linked from AGENTS.md). Fix @fixr/env's
typescript-config dependency so its tsconfig resolves.

Refs #102

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JtJ1gJpgdDNQ2xkvhGc5H6
server.ts built Fastify at module scope and called listen() on import.
buildApp() now builds the instance without binding a port (docs and
logger configurable); server.ts is only the entrypoint. The JWT helper
no longer imports the server singleton, and the two competing error
handlers are merged into one (the ZodError one was dead code).

Refs #103

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JtJ1gJpgdDNQ2xkvhGc5H6
Vitest projects for unit and integration, MySQL + Redis containers in
globalSetup, truncate/flush between tests, deterministic factories and
auth helpers that sign real sessions per role.

Refs #104

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JtJ1gJpgdDNQ2xkvhGc5H6
RBAC role x permission matrix with orphan-permission guard, Zod schema
specs for every @fixr/schemas file and constants helpers.
createAbility() now fails closed to guest for unknown roles.

Refs #105 #106

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JtJ1gJpgdDNQ2xkvhGc5H6
authenticate/authenticateEmployee now rethrow AppError unchanged (it
matched isFastifyError and every domain error became a 401), and
withErrorHandler no longer returns stack traces in production.

Refs #107 #108

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JtJ1gJpgdDNQ2xkvhGc5H6
Auth, tokens, credentials, account, companies, employees, catalog,
uploads and service-orders services, with repositories, mail, R2 and
Google mocked. Known security gaps (login/reset enumeration, missing
OAuth state, any upload MIME) are pinned with explicit comments.

Fix: patch/delete/image operations on models now require the model to
belong to the caller's company (any company with devices:update could
modify another tenant's models or the shared catalog).

Refs #109 #110 #111 #112

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JtJ1gJpgdDNQ2xkvhGc5H6
Full login/verify/refresh/sign-out and password-reset flows against
real MySQL and Redis. Bugs found and fixed along the way:

- Client (non-employee) accounts could not log in: the JWT/account
  queries return company = NULL, which the schemas rejected (500).
- Error envelopes were serialized through the routes' literal response
  schemas, so any error with a status the route documents under another
  code (e.g. a 400 validation error, a 401 missing session) became a
  500 FST_ERR_FAILED_ERROR_SERIALIZATION. Errors now bypass them.
- POST /auth/register answered HTTP 500 with a 501 body; it now throws
  AUTH_NOT_IMPLEMENTED.

Refs #113

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JtJ1gJpgdDNQ2xkvhGc5H6
- RBAC sweep: every route from buildApp() must be allowlisted as public
  or carry an auth middleware plus requirePermission(); every guarded
  route is exercised with every role from roleAbilities (403 vs pass),
  without a session (401) and with a forged token (401).
- Companies/employees, catalog, service orders, uploads, cache
  invalidation and fail-open behaviour against real MySQL/Redis.

Fix: the presign route documented a single literal success code, so
service-order and model image presigns always failed with a 500
response serialization error; only avatars worked.

Refs #114 #115

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JtJ1gJpgdDNQ2xkvhGc5H6
Vitest config for apps/workers and packages/mail; specs for the job
processor, worker wiring and listeners, every React Email template and
the queue helper (retry/backoff contract).

The worker now rejects unknown job names with a clear error instead of
a TypeError, and closes gracefully on SIGTERM/SIGINT so in-flight jobs
finish before the process exits.

Refs #116

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JtJ1gJpgdDNQ2xkvhGc5H6
jsdom environment, MSW server with onUnhandledRequest=error and specs
for route matching, formatters, parsers, data-table helpers, the API
service and the auth client/interceptors.

Fixes found by the new tests:
- The 401 -> refresh response interceptor was registered on the global
  axios instance, not on the exported client, so it never ran; it now
  also retries a request at most once (no refresh loop).
- Route rules matched by prefix and the first rule won, so pages like
  /service-orders/new were gated by serviceOrders:read instead of
  serviceOrders:create. Exact matches now take precedence.

Refs #117

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JtJ1gJpgdDNQ2xkvhGc5H6
Session/avatar/media-query/scroll hooks and the sidebar store, the
@fixr/permissions React bindings (<Can>, useAbility), the login,
forgot-password and new-employee forms (validation, success and API
error paths through MSW) and the service order table.

Refs #118

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JtJ1gJpgdDNQ2xkvhGc5H6
Own jsdom/MSW setup and a Clerk test double (separate from apps/web's
JWT helpers). Covers the Clerk middleware route protection, lib/utils
and the create-company form (bearer token, validation, API errors).

Refs #119

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JtJ1gJpgdDNQ2xkvhGc5H6
New e2e workspace: dedicated MySQL/Redis via docker compose (never the
dev DB), API + Next dev servers, per-run reset/seed (employees seeded
through the API), storageState per role and hermetic Turnstile stubs.
Covers login (success, wrong password, unverified), email verification,
protected-route redirect, logout, password reset and silent refresh.

Fix: the Turnstile wrapper re-armed its 15s load timeout on every parent
render, so it reported a security-check failure (and dropped the token)
15s after the user's last keystroke. The sign-out icon button now has an
accessible name.

Refs #120

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JtJ1gJpgdDNQ2xkvhGc5H6
Employee creation through the UI, one access test per role derived from
roleAbilities (UI redirect + API 403), cross-tenant URL/API access,
API-down resilience and navigation smoke. Service order and catalog
flows are marked fixme: those screens still use mock data / don't exist.

Fix: a role without companies:read (guest) was redirected from home to
home forever (ERR_TOO_MANY_REDIRECTS); it now lands on the public
support page. Covered by a middleware unit spec as well.

Refs #121

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JtJ1gJpgdDNQ2xkvhGc5H6
Parallel lint/types/unit jobs, a Docker-backed integration job and an
e2e job (PRs to main, pushes, manual) that uploads the Playwright report
and traces on failure. Unit coverage (V8) is enforced per workspace with
low starting thresholds and stricter ones for @fixr/permissions and the
server auth/RBAC middlewares; a summary is posted on the PR.

deploy.yml is left untouched (infra guardrail); TESTING.md documents
the required checks and how deploys relate to CI.

Refs #122

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JtJ1gJpgdDNQ2xkvhGc5H6
bun run lint passes --elide-lines, which only works in a terminal, so
the hook failed in any non-interactive shell before checking anything.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JtJ1gJpgdDNQ2xkvhGc5H6
Keeps this branch's buildApp() split: the API keys route and its OpenAPI
tag move from server.ts into app.ts, and the test/e2e envs gain
API_KEY_SECRET. Keeps this branch's vitest config (develop's esbuild
workaround targeted the same unresolved typescript-config extends that
the @fixr/env dependency fix already solves).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JtJ1gJpgdDNQ2xkvhGc5H6
z.date().min(new Date()) froze "now" when the module loaded, so a
long-running server accepted expirations already in the past.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JtJ1gJpgdDNQ2xkvhGc5H6
- CookieDialog wrote document.cookie during render, so /auth/login
  returned 500 whenever the verified/deleted dialog should open.
- When the middleware refreshed an expired session it only set the new
  cookies on the response; the server components of that same request
  still saw no session and getSession() threw (500). The refreshed
  cookies are now forwarded to the request too.

The e2e tests now assert the pages render, not just the URL.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JtJ1gJpgdDNQ2xkvhGc5H6
Schemas, key helpers, both new middlewares (scope narrowing, revoked,
expired, usage throttling), the service (no scope escalation, TTL cap,
ownership), real-DB flows (revocation beats the Redis lookup cache,
keys can't mint keys or reach /account, cross-tenant), the RBAC sweep
for authenticateEmployeeOrApiKey, the web form/secret reveal and an e2e
create -> use -> revoke flow. The secret copy button gets an accessible
name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JtJ1gJpgdDNQ2xkvhGc5H6
@github-actions

Copy link
Copy Markdown

Unit test coverage

Workspace Lines Branches Functions Statements
apps/server 70.0% 75.9% 76.2% 69.0%
apps/workers 90.3% 77.8% 90.9% 90.9%
apps/web 42.2% 36.1% 38.6% 42.3%
apps/admin 50.0% 48.4% 40.0% 50.5%
packages/permissions 100.0% 100.0% 100.0% 100.0%
packages/schemas 100.0% 97.3% 100.0% 100.0%
packages/constants 85.7% 100.0% 100.0% 85.7%
packages/mail 92.6% 66.7% 100.0% 92.7%

Thresholds are enforced per workspace in each vitest.config.ts (see TESTING.md).

@risixdzn risixdzn self-assigned this Sep 24, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants