@evertonferreira96/agents 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/agents/api.md +45 -0
- package/.claude/agents/app.md +41 -0
- package/.claude/agents/architect.md +49 -0
- package/.claude/agents/infra.md +27 -0
- package/.claude/agents/product.md +58 -0
- package/.claude/agents/quick.md +33 -0
- package/.claude/agents/security.md +39 -0
- package/.claude/agents/ui.md +54 -0
- package/.claude/agents/verifier.md +56 -0
- package/.claude/agents/web.md +41 -0
- package/.claude/hooks/config-update-check.mjs +14 -0
- package/.claude/hooks/context-budget.mjs +51 -0
- package/.claude/hooks/graphify-update.sh +16 -0
- package/.claude/hooks/guard-bash.sh +64 -0
- package/.claude/hooks/orchestrator-guard.sh +49 -0
- package/.claude/hooks/post-compact.sh +26 -0
- package/.claude/hooks/readonly-guard.sh +54 -0
- package/.claude/hooks/stop-verify.sh +52 -0
- package/.claude/rules/api.md +21 -0
- package/.claude/rules/db.md +9 -0
- package/.claude/rules/delivery-diagram.md +16 -0
- package/.claude/rules/git-workflow.md +34 -0
- package/.claude/rules/graphify.md +3 -0
- package/.claude/rules/mobile.md +22 -0
- package/.claude/rules/tickets.md +9 -0
- package/.claude/rules/ui.md +14 -0
- package/.claude/rules/web.md +27 -0
- package/.claude/settings.json +114 -0
- package/.claude/skills/animate/RECIPES.md +324 -0
- package/.claude/skills/animate/SKILL.md +208 -0
- package/.claude/skills/animate/agents/openai.yaml +5 -0
- package/.claude/skills/animate-expo/RECIPES.md +385 -0
- package/.claude/skills/animate-expo/SKILL.md +264 -0
- package/.claude/skills/animate-expo/agents/openai.yaml +5 -0
- package/.claude/skills/animation-performance/SKILL.md +223 -0
- package/.claude/skills/animation-performance/agents/openai.yaml +5 -0
- package/.claude/skills/apple-design/SKILL.md +291 -0
- package/.claude/skills/apple-design/agents/openai.yaml +5 -0
- package/.claude/skills/arch-api/SKILL.md +291 -0
- package/.claude/skills/arch-api/agents/openai.yaml +3 -0
- package/.claude/skills/arch-api/references/common.md +174 -0
- package/.claude/skills/arch-api/references/contract.md +289 -0
- package/.claude/skills/arch-api/references/domain.md +301 -0
- package/.claude/skills/arch-api/references/endpoints.md +184 -0
- package/.claude/skills/arch-api/references/review.md +228 -0
- package/.claude/skills/arch-api/references/security.md +151 -0
- package/.claude/skills/arch-app/SKILL.md +278 -0
- package/.claude/skills/arch-app/agents/openai.yaml +3 -0
- package/.claude/skills/arch-app/references/navigation-session.md +257 -0
- package/.claude/skills/arch-app/references/review.md +155 -0
- package/.claude/skills/arch-app/references/screens.md +214 -0
- package/.claude/skills/arch-app/references/services.md +301 -0
- package/.claude/skills/arch-app/references/ui-mobile.md +124 -0
- package/.claude/skills/arch-db/SKILL.md +181 -0
- package/.claude/skills/arch-db/agents/openai.yaml +3 -0
- package/.claude/skills/arch-db/references/indexes-queries.md +251 -0
- package/.claude/skills/arch-db/references/migrations.md +191 -0
- package/.claude/skills/arch-db/references/modeling.md +214 -0
- package/.claude/skills/arch-db/references/mysql.md +262 -0
- package/.claude/skills/arch-db/references/pooling-ops.md +112 -0
- package/.claude/skills/arch-db/references/review.md +143 -0
- package/.claude/skills/arch-db/references/transactions-locks.md +141 -0
- package/.claude/skills/arch-web/SKILL.md +307 -0
- package/.claude/skills/arch-web/agents/openai.yaml +3 -0
- package/.claude/skills/arch-web/references/auth-session-env.md +165 -0
- package/.claude/skills/arch-web/references/blocks.md +198 -0
- package/.claude/skills/arch-web/references/cache.md +124 -0
- package/.claude/skills/arch-web/references/review.md +142 -0
- package/.claude/skills/arch-web/references/services.md +297 -0
- package/.claude/skills/arch-web/references/tests.md +159 -0
- package/.claude/skills/arch-web/references/ui-forms.md +143 -0
- package/.claude/skills/break-ui/CATALOG.md +135 -0
- package/.claude/skills/break-ui/SKILL.md +176 -0
- package/.claude/skills/break-ui/agents/openai.yaml +5 -0
- package/.claude/skills/brief/SKILL.md +91 -0
- package/.claude/skills/brief/agents/openai.yaml +3 -0
- package/.claude/skills/diagnosing-bugs/SKILL.md +138 -0
- package/.claude/skills/diagnosing-bugs/agents/openai.yaml +3 -0
- package/.claude/skills/diagnosing-bugs/scripts/hitl-loop.template.sh +44 -0
- package/.claude/skills/emil-design-eng/SKILL.md +675 -0
- package/.claude/skills/emil-design-eng/agents/openai.yaml +5 -0
- package/.claude/skills/find-animation-opportunities/SKILL.md +141 -0
- package/.claude/skills/find-animation-opportunities/agents/openai.yaml +5 -0
- package/.claude/skills/graphify/.graphify_version +1 -0
- package/.claude/skills/graphify/SKILL.md +742 -0
- package/.claude/skills/graphify/agents/openai.yaml +3 -0
- package/.claude/skills/graphify/references/add-watch.md +56 -0
- package/.claude/skills/graphify/references/exports.md +91 -0
- package/.claude/skills/graphify/references/extraction-spec.md +70 -0
- package/.claude/skills/graphify/references/github-and-merge.md +46 -0
- package/.claude/skills/graphify/references/hooks.md +33 -0
- package/.claude/skills/graphify/references/query.md +317 -0
- package/.claude/skills/graphify/references/transcribe.md +52 -0
- package/.claude/skills/graphify/references/update.md +221 -0
- package/.claude/skills/grill-me/SKILL.md +7 -0
- package/.claude/skills/grill-me/agents/openai.yaml +5 -0
- package/.claude/skills/grill-with-docs/SKILL.md +64 -0
- package/.claude/skills/grill-with-docs/agents/openai.yaml +5 -0
- package/.claude/skills/grilling/SKILL.md +30 -0
- package/.claude/skills/grilling/agents/openai.yaml +3 -0
- package/.claude/skills/handoff/SKILL.md +28 -0
- package/.claude/skills/handoff/agents/openai.yaml +5 -0
- package/.claude/skills/impeccable/SKILL.md +89 -0
- package/.claude/skills/impeccable/agents/openai.yaml +5 -0
- package/.claude/skills/impeccable/reference/adapt.md +318 -0
- package/.claude/skills/impeccable/reference/adapt.native.md +58 -0
- package/.claude/skills/impeccable/reference/android.md +46 -0
- package/.claude/skills/impeccable/reference/animate.md +89 -0
- package/.claude/skills/impeccable/reference/audit.md +137 -0
- package/.claude/skills/impeccable/reference/audit.native.md +139 -0
- package/.claude/skills/impeccable/reference/bolder.md +33 -0
- package/.claude/skills/impeccable/reference/clarify.md +94 -0
- package/.claude/skills/impeccable/reference/colorize.md +86 -0
- package/.claude/skills/impeccable/reference/component-review.md +63 -0
- package/.claude/skills/impeccable/reference/craft-floor.md +44 -0
- package/.claude/skills/impeccable/reference/craft.md +5 -0
- package/.claude/skills/impeccable/reference/critique.md +809 -0
- package/.claude/skills/impeccable/reference/degraded/asset-producer.md +42 -0
- package/.claude/skills/impeccable/reference/degraded/documenter.md +24 -0
- package/.claude/skills/impeccable/reference/degraded/finish-reviewer.md +38 -0
- package/.claude/skills/impeccable/reference/degraded/manual-edit-applier.md +92 -0
- package/.claude/skills/impeccable/reference/delight.md +70 -0
- package/.claude/skills/impeccable/reference/distill.md +111 -0
- package/.claude/skills/impeccable/reference/doctor.md +54 -0
- package/.claude/skills/impeccable/reference/document.md +416 -0
- package/.claude/skills/impeccable/reference/extract.md +69 -0
- package/.claude/skills/impeccable/reference/generate.md +101 -0
- package/.claude/skills/impeccable/reference/harden.md +345 -0
- package/.claude/skills/impeccable/reference/hooks.md +113 -0
- package/.claude/skills/impeccable/reference/init.md +131 -0
- package/.claude/skills/impeccable/reference/ios.md +51 -0
- package/.claude/skills/impeccable/reference/layout.md +84 -0
- package/.claude/skills/impeccable/reference/live-setup.md +104 -0
- package/.claude/skills/impeccable/reference/live.md +325 -0
- package/.claude/skills/impeccable/reference/mode-operate.md +21 -0
- package/.claude/skills/impeccable/reference/mode-persuade.md +19 -0
- package/.claude/skills/impeccable/reference/mode-read.md +21 -0
- package/.claude/skills/impeccable/reference/new-work.md +154 -0
- package/.claude/skills/impeccable/reference/onboard.md +234 -0
- package/.claude/skills/impeccable/reference/operate.md +61 -0
- package/.claude/skills/impeccable/reference/optimize.md +258 -0
- package/.claude/skills/impeccable/reference/overdrive.md +127 -0
- package/.claude/skills/impeccable/reference/polish.md +105 -0
- package/.claude/skills/impeccable/reference/quieter.md +99 -0
- package/.claude/skills/impeccable/reference/region-map.md +26 -0
- package/.claude/skills/impeccable/reference/routing.md +24 -0
- package/.claude/skills/impeccable/reference/shape.md +59 -0
- package/.claude/skills/impeccable/reference/typeset.md +80 -0
- package/.claude/skills/impeccable/reference/visualize.md +47 -0
- package/.claude/skills/impeccable/scripts/VERSION +1 -0
- package/.claude/skills/impeccable/scripts/command-metadata.json +98 -0
- package/.claude/skills/impeccable/scripts/data/font-index-failures.json +121 -0
- package/.claude/skills/impeccable/scripts/data/font-index.json +1 -0
- package/.claude/skills/impeccable/scripts/impeccable +206 -0
- package/.claude/skills/impeccable/scripts/impeccable.cmd +214 -0
- package/.claude/skills/impeccable/scripts/live-browser-dom.js +281 -0
- package/.claude/skills/impeccable/scripts/live-browser-ignores.js +242 -0
- package/.claude/skills/impeccable/scripts/live-browser-session.js +148 -0
- package/.claude/skills/impeccable/scripts/live-browser.js +13532 -0
- package/.claude/skills/impeccable/scripts/modern-screenshot.umd.js +14 -0
- package/.claude/skills/improve-animations/AUDIT.md +115 -0
- package/.claude/skills/improve-animations/PLAN-TEMPLATE.md +73 -0
- package/.claude/skills/improve-animations/SKILL.md +110 -0
- package/.claude/skills/improve-animations/agents/openai.yaml +5 -0
- package/.claude/skills/mobile-native/SKILL.md +311 -0
- package/.claude/skills/mobile-native/agents/openai.yaml +3 -0
- package/.claude/skills/orchestrate/SKILL.md +181 -0
- package/.claude/skills/orchestrate/agents/openai.yaml +3 -0
- package/.claude/skills/pick-ui-library/SKILL.md +85 -0
- package/.claude/skills/pick-ui-library/agents/openai.yaml +5 -0
- package/.claude/skills/ponytail/SKILL.md +52 -0
- package/.claude/skills/ponytail/agents/openai.yaml +3 -0
- package/.claude/skills/ponytail-audit/SKILL.md +103 -0
- package/.claude/skills/ponytail-audit/agents/openai.yaml +3 -0
- package/.claude/skills/ponytail-debt/SKILL.md +44 -0
- package/.claude/skills/ponytail-debt/agents/openai.yaml +3 -0
- package/.claude/skills/ponytail-review/SKILL.md +101 -0
- package/.claude/skills/ponytail-review/agents/openai.yaml +3 -0
- package/.claude/skills/postgres-best-practices/SKILL.md +42 -0
- package/.claude/skills/postgres-best-practices/agents/openai.yaml +3 -0
- package/.claude/skills/postgres-best-practices/references/backup-restore.md +372 -0
- package/.claude/skills/postgres-best-practices/references/bulk-loading.md +315 -0
- package/.claude/skills/postgres-best-practices/references/connection-pooling.md +310 -0
- package/.claude/skills/postgres-best-practices/references/hot-standby.md +221 -0
- package/.claude/skills/postgres-best-practices/references/indexing.md +318 -0
- package/.claude/skills/postgres-best-practices/references/logical-replication.md +610 -0
- package/.claude/skills/postgres-best-practices/references/major-version-upgrades.md +334 -0
- package/.claude/skills/postgres-best-practices/references/performance-diagnostics.md +553 -0
- package/.claude/skills/postgres-best-practices/references/query-optimization.md +294 -0
- package/.claude/skills/postgres-best-practices/references/query-patterns.md +566 -0
- package/.claude/skills/postgres-best-practices/references/schema-design.md +362 -0
- package/.claude/skills/postgres-best-practices/references/security-roles.md +489 -0
- package/.claude/skills/postgres-best-practices/references/transaction-isolation.md +344 -0
- package/.claude/skills/project-docs/SKILL.md +330 -0
- package/.claude/skills/project-docs/agents/openai.yaml +3 -0
- package/.claude/skills/prototype/PICKER.md +197 -0
- package/.claude/skills/prototype/SKILL.md +98 -0
- package/.claude/skills/prototype/agents/openai.yaml +5 -0
- package/.claude/skills/retro/SKILL.md +43 -0
- package/.claude/skills/retro/agents/openai.yaml +5 -0
- package/.claude/skills/review/SKILL.md +74 -0
- package/.claude/skills/review/agents/openai.yaml +3 -0
- package/.claude/skills/review/references/smells.md +23 -0
- package/.claude/skills/review-animations/SKILL.md +120 -0
- package/.claude/skills/review-animations/STANDARDS.md +187 -0
- package/.claude/skills/review-animations/agents/openai.yaml +5 -0
- package/.claude/skills/tdd/SKILL.md +38 -0
- package/.claude/skills/tdd/agents/openai.yaml +3 -0
- package/.claude/skills/tdd/mocking.md +59 -0
- package/.claude/skills/tdd/tests.md +77 -0
- package/.claude/skills/triage/SKILL.md +58 -0
- package/.claude/skills/triage/agents/openai.yaml +3 -0
- package/.claude/skills/ui-ux-pro-max/SKILL.md +378 -0
- package/.claude/skills/ui-ux-pro-max/agents/openai.yaml +5 -0
- package/.claude/skills/ui-ux-pro-max/data/charts.csv +26 -0
- package/.claude/skills/ui-ux-pro-max/data/colors.csv +97 -0
- package/.claude/skills/ui-ux-pro-max/data/icons.csv +101 -0
- package/.claude/skills/ui-ux-pro-max/data/landing.csv +31 -0
- package/.claude/skills/ui-ux-pro-max/data/products.csv +97 -0
- package/.claude/skills/ui-ux-pro-max/data/react-performance.csv +45 -0
- package/.claude/skills/ui-ux-pro-max/data/stacks/astro.csv +54 -0
- package/.claude/skills/ui-ux-pro-max/data/stacks/flutter.csv +53 -0
- package/.claude/skills/ui-ux-pro-max/data/stacks/html-tailwind.csv +56 -0
- package/.claude/skills/ui-ux-pro-max/data/stacks/jetpack-compose.csv +53 -0
- package/.claude/skills/ui-ux-pro-max/data/stacks/nextjs.csv +53 -0
- package/.claude/skills/ui-ux-pro-max/data/stacks/nuxt-ui.csv +51 -0
- package/.claude/skills/ui-ux-pro-max/data/stacks/nuxtjs.csv +59 -0
- package/.claude/skills/ui-ux-pro-max/data/stacks/react-native.csv +52 -0
- package/.claude/skills/ui-ux-pro-max/data/stacks/react.csv +54 -0
- package/.claude/skills/ui-ux-pro-max/data/stacks/shadcn.csv +61 -0
- package/.claude/skills/ui-ux-pro-max/data/stacks/svelte.csv +54 -0
- package/.claude/skills/ui-ux-pro-max/data/stacks/swiftui.csv +51 -0
- package/.claude/skills/ui-ux-pro-max/data/stacks/vue.csv +50 -0
- package/.claude/skills/ui-ux-pro-max/data/styles.csv +68 -0
- package/.claude/skills/ui-ux-pro-max/data/typography.csv +58 -0
- package/.claude/skills/ui-ux-pro-max/data/ui-reasoning.csv +101 -0
- package/.claude/skills/ui-ux-pro-max/data/ux-guidelines.csv +100 -0
- package/.claude/skills/ui-ux-pro-max/data/web-interface.csv +31 -0
- package/.claude/skills/ui-ux-pro-max/scripts/core.py +253 -0
- package/.claude/skills/ui-ux-pro-max/scripts/design_system.py +1067 -0
- package/.claude/skills/ui-ux-pro-max/scripts/search.py +114 -0
- package/.claude/skills/writing-for-agents/SKILL-MECHANICS.md +22 -0
- package/.claude/skills/writing-for-agents/SKILL.md +81 -0
- package/.claude/skills/writing-for-agents/agents/openai.yaml +3 -0
- package/.worktreeinclude +3 -0
- package/AGENTS.md +44 -0
- package/README.md +14 -2
- package/VERSION +1 -0
- package/bin/agents.mjs +45 -0
- package/docs/adr/README.md +31 -0
- package/docs/brief-template.md +3 -0
- package/docs/plans/README.md +73 -0
- package/package.json +46 -4
- package/scripts/bootstrap.mjs +188 -0
- package/scripts/check-catalog.mjs +43 -0
- package/scripts/skills-update.mjs +51 -0
- package/skills-lock.json +269 -0
- package/templates/CLAUDE.project.md +41 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
# Services e Server Actions
|
|
2
|
+
|
|
3
|
+
Mecânica da camada `src/modules/<feature>` que a SKILL resume em "Caminhos" e nas regras 1 a 11: anatomia da feature, service de mutação e de leitura, `Action.Result`, fachada `shared/api`, mapper de falha, permissões, revalidate, schemas, entities, listagem, Route Handler e upload. Exemplos com `invoice`/`invoices`; troque pelos nomes do repo.
|
|
4
|
+
|
|
5
|
+
## Anatomia da feature
|
|
6
|
+
|
|
7
|
+
| Arquivo | Contém | Quem importa |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| `index.ts` | leituras `server-only`, schemas de URL, tipos, constantes, mappers puros | servidor (página, bloco, outra feature) |
|
|
10
|
+
| `<feature>.constants.ts` | listas `as const`, limites, regex, whitelist de sort (`satisfies readonly Invoice.Model.ListSort[]`) | todos, inclusive ilhas e `schemas/` |
|
|
11
|
+
| `<feature>.permissions.ts` | `INVOICE_PERMISSIONS` | services, `<rota>.utils.ts` |
|
|
12
|
+
| `<feature>.revalidate.ts` | `revalidateInvoice(options?)` com `import 'server-only'` | services `'use server'` |
|
|
13
|
+
| `<feature>.url-parsers.ts` | parsers de URL das ilhas (sem Zod) | ilhas |
|
|
14
|
+
| `entities/` | `namespace Invoice { Model; DTO }` | todos (`import type`) |
|
|
15
|
+
| `schemas/` | Zod de URL e de escrita | services, página (via `Block.querySchema`) |
|
|
16
|
+
| `mappers/` | regra pura (`<assunto>.mapper.ts`) e falha (`<feature>-failure.mapper.ts`) | services, blocos (só os puros) |
|
|
17
|
+
| `services/` | só `*.service.ts` e seus testes | página, blocos, `<rota>.utils.ts`, outros services, ilhas (só `'use server'`, pelo arquivo) |
|
|
18
|
+
|
|
19
|
+
Feature é um domínio, não uma tela. A feature nasce com `services` + `entities`; `schemas`, `mappers`, `permissions`, `revalidate` entram com a primeira necessidade real.
|
|
20
|
+
|
|
21
|
+
## Service de mutação (a Server Action)
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
'use server'
|
|
25
|
+
|
|
26
|
+
import { authorize } from '@/modules/auth/services/authorize.service'
|
|
27
|
+
import { Action } from '@/shared/action'
|
|
28
|
+
import { type Api, api, upstream } from '@/shared/api'
|
|
29
|
+
import { validationFailure } from '@/shared/api/failure'
|
|
30
|
+
import type { Invoice } from '../entities/invoice.entities'
|
|
31
|
+
import { invoiceFailure } from '../mappers/invoice-failure.mapper'
|
|
32
|
+
import { INVOICE_PERMISSIONS } from '../invoice.permissions'
|
|
33
|
+
import { revalidateInvoice } from '../invoice.revalidate'
|
|
34
|
+
import { createInvoiceSchema, invoiceScopeSchema } from '../schemas/invoice.schemas'
|
|
35
|
+
|
|
36
|
+
/** `POST /invoices`: nova fatura. 422 `invoice.items_invalid` volta com os itens em `fieldErrors`. */
|
|
37
|
+
export async function createInvoice(
|
|
38
|
+
_: Action.Result<Invoice.Model.Detail>,
|
|
39
|
+
input: unknown,
|
|
40
|
+
): Promise<Action.Result<Invoice.Model.Detail>> {
|
|
41
|
+
const scope = invoiceScopeSchema.safeParse(input) // só o recorte de que a permissão depende
|
|
42
|
+
if (!scope.success) return validationFailure(scope.error)
|
|
43
|
+
const access = await authorize(INVOICE_PERMISSIONS.create, { tenantId: scope.data.tenantId })
|
|
44
|
+
if (!access.ok) return access.result
|
|
45
|
+
|
|
46
|
+
const parsed = createInvoiceSchema.safeParse(input)
|
|
47
|
+
if (!parsed.success) return validationFailure(parsed.error)
|
|
48
|
+
const { tenantId, ...body } = parsed.data
|
|
49
|
+
|
|
50
|
+
const result = upstream<Api.<Provider>.CreateInvoiceBodyResponse, Api.Common.Error>(
|
|
51
|
+
await api.<Provider>.methods.createInvoice({
|
|
52
|
+
...(await api.<Provider>.session({ tenantId })),
|
|
53
|
+
body,
|
|
54
|
+
cache: 'no-store',
|
|
55
|
+
}),
|
|
56
|
+
)
|
|
57
|
+
if (!result.ok) return invoiceFailure.write(result.status, result.error, 'invoices.create')
|
|
58
|
+
|
|
59
|
+
revalidateInvoice({ list: true })
|
|
60
|
+
return Action.result.ok(result.body.data)
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
- Precisa do estado atual para decidir (versão, transição permitida): componha um service `server-only` de leitura com `cache: 'no-store'`.
|
|
65
|
+
- Regra conferida no web antes de chamar a API devolve `domainFailure('<domínio>.<motivo>', token)`.
|
|
66
|
+
- O JSDoc cita o endpoint e os `code` de 409/422 que a operação devolve.
|
|
67
|
+
|
|
68
|
+
| Situação | O que muda |
|
|
69
|
+
|---|---|
|
|
70
|
+
| Criar ou cobrar, e a API exige `Idempotency-Key` | a ilha gera `idempotencyKey` (uuid) ao abrir o formulário; o schema valida; o service a tira do body e a envia no header da chamada (`headers: { ...session.headers, 'Idempotency-Key': key }`), nunca no body (o body da API é `.strict()`) |
|
|
71
|
+
| Lote | `z.array(z.uuid()).min(1).max(<teto>)`, permissão própria, resultado parcial em `data` |
|
|
72
|
+
| Operação longa (import, export grande) | A API dispara um job; a tela acompanha o status, fora da Server Action |
|
|
73
|
+
| Escrita vista por outra área | A invalidação cobre os padrões de rota das duas áreas |
|
|
74
|
+
|
|
75
|
+
## Service de leitura
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
import 'server-only'
|
|
79
|
+
import { z } from 'zod'
|
|
80
|
+
import { getMe } from '@/modules/auth/services/get-me.service'
|
|
81
|
+
import { Action } from '@/shared/action'
|
|
82
|
+
import { type Api, api, upstream } from '@/shared/api'
|
|
83
|
+
import { apiFailure } from '@/shared/api/failure'
|
|
84
|
+
import { requestMemo } from '@/shared/cache/request-memo'
|
|
85
|
+
import type { Invoice } from '../entities/invoice.entities'
|
|
86
|
+
import { invoiceFailure } from '../mappers/invoice-failure.mapper'
|
|
87
|
+
|
|
88
|
+
/** `GET /invoices/:invoiceId`. Memoizado por request: página, blocos e `authorizeInvoice` leem uma vez. */
|
|
89
|
+
export const getInvoice = requestMemo(async (invoiceId: string): Promise<Action.Result<Invoice.Model.Detail>> => {
|
|
90
|
+
if (!z.uuid().safeParse(invoiceId).success) return invoiceFailure.read(404, undefined, 'invoices.read')
|
|
91
|
+
const me = await getMe() // sessão conferida; o alcance quem decide é a API
|
|
92
|
+
if (me.status !== 'success') return me.status === 'error' ? me : apiFailure(401)
|
|
93
|
+
|
|
94
|
+
const result = upstream<Invoice.DTO.DetailResponse, Api.Common.Error>(
|
|
95
|
+
await api.<Provider>.methods.getInvoiceById({
|
|
96
|
+
...(await api.<Provider>.session()),
|
|
97
|
+
path: { invoiceId },
|
|
98
|
+
cache: 'no-store', // ou ...cached(perfil, tags): cache.md
|
|
99
|
+
}),
|
|
100
|
+
)
|
|
101
|
+
if (!result.ok) return invoiceFailure.read(result.status, result.error, 'invoices.read')
|
|
102
|
+
return Action.result.ok(result.body.data)
|
|
103
|
+
})
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
- Id da URL inválido vira 404 sem ida à API.
|
|
107
|
+
- Alcance que só a API resolve (papel no recurso): o service confere a sessão e deixa a API recusar (403 vira `authorization.forbidden`, 404 vira `<recurso>.not_found`). Permissão que o perfil já traz: `authorize(keys, scope)` antes da chamada, como na listagem.
|
|
108
|
+
- Leitura com recorte: memoize pela string canônica (ver Listagem).
|
|
109
|
+
- Nome diz o caso de uso: `getInvoiceSummary(id)`, não `getInvoice(id, { mode })`.
|
|
110
|
+
|
|
111
|
+
## `Action.Result`
|
|
112
|
+
|
|
113
|
+
`Action.Result<T>` (`shared/action`) é `idle` · `success { data, message }` · `error { message, error: { code, fieldErrors?, details? } }`, montado por `Action.result.idle()/ok(data, message?)/fail(code, message, fieldErrors?, details?)`.
|
|
114
|
+
|
|
115
|
+
- A UI decide por `status`, nunca pela presença de `data`; `idle` é o estado inicial do hook da ilha.
|
|
116
|
+
- `details` leva só o que a tela precisa para reagir (campos faltando, contagem), vindo do envelope de erro da API.
|
|
117
|
+
- `fieldErrors` usa o caminho do campo com ponto (`items.0.quantity`); `_` é o erro sem campo.
|
|
118
|
+
|
|
119
|
+
## `shared/api`: a fachada do client gerado
|
|
120
|
+
|
|
121
|
+
| Peça | Papel |
|
|
122
|
+
|---|---|
|
|
123
|
+
| `api.<Provider>.methods` | SDK gerado; o `operationId` do contrato vira o nome do método |
|
|
124
|
+
| `api.<Provider>.types` / `Api.<Provider>.*` | tipos gerados, base das entities |
|
|
125
|
+
| `api.<Provider>.session(options?)` | config autenticada: bearer pelo `auth` do client, headers do navegador e header de contexto do tenant |
|
|
126
|
+
| `api.<Provider>.anonymous(client?)` | config das rotas públicas (login, refresh); o `proxy.ts` passa o contexto do request dele |
|
|
127
|
+
| `<provider>/config.ts` | `baseUrl` = origem do env + prefixo da API; `fetch: instrumentedFetch` |
|
|
128
|
+
| `<provider>/client-context.ts` | repassa `user-agent` e `x-forwarded-for` do navegador (tamanho limitado), para auditoria e rate limit da API verem o usuário, não o servidor Next |
|
|
129
|
+
| `upstream<Body, Error>(result)` | reafirma o tipo do contrato e discrimina por `response.ok` |
|
|
130
|
+
| `Api.Common.Error` | envelope de erro da API: `statusCode`, `message`, `code?`, `errors?`, `traceId?` (e `details` quando a API manda) |
|
|
131
|
+
|
|
132
|
+
- O porquê de `upstream` decidir por `response.ok` (regra 6 da SKILL): um 204 devolve `data: {}` e um 4xx de corpo ilegível devolve `error: {}`.
|
|
133
|
+
- Correção de tipo do client gerado mora em `upstream`/entities, num lugar só; o teste de `upstream` avisa quando a correção deixa de ser necessária.
|
|
134
|
+
- Sem retry de 401 no transporte; o motivo está em [`auth-session-env.md`](auth-session-env.md).
|
|
135
|
+
- Contrato: `contracts/<provider>.openapi.json` é a cópia do `openapi.json` da API (`openapi:sync`, contrato da API em `arch-api`); `generated/` sai de `openapi:generate` e é commitado; `openapi:check` falha com drift.
|
|
136
|
+
|
|
137
|
+
## Falha: `shared/api/failure.ts` e o mapper da feature
|
|
138
|
+
|
|
139
|
+
`apiFailure(status, overrides, { operation, error }, codes)` decide, nesta ordem:
|
|
140
|
+
|
|
141
|
+
1. 401 é sempre `auth.session_expired`;
|
|
142
|
+
2. `codes`: o `error.code` da API casado com o significado na operação; quando casa, os `details` seguem no `Action.Result`;
|
|
143
|
+
3. `overrides`: o que 400/403/404/409/422 significam nesta operação;
|
|
144
|
+
4. o vocabulário comum.
|
|
145
|
+
|
|
146
|
+
| status | `code` comum |
|
|
147
|
+
|---|---|
|
|
148
|
+
| 400, 422 | `request.invalid` |
|
|
149
|
+
| 401 | `auth.session_expired` |
|
|
150
|
+
| 403 | `authorization.forbidden` |
|
|
151
|
+
| 404 | `resource.not_found` |
|
|
152
|
+
| 409 | `resource.conflict` |
|
|
153
|
+
| 429 | `request.rate_limited` |
|
|
154
|
+
| 5xx | `upstream.unavailable` |
|
|
155
|
+
| outro | `upstream.failed` |
|
|
156
|
+
|
|
157
|
+
`apiFailure` loga `operation`, `status`, `code` escolhido, `code` e `message` da API em `warn`; a tela recebe a mensagem traduzida do token. `validationFailure(zodError)` devolve `validation.failed` com `fieldErrors` traduzidos por tipo de issue (a mensagem padrão do Zod nunca chega à tela; `refine` com mensagem em forma de token é traduzido).
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
// mappers/invoice-failure.mapper.ts
|
|
161
|
+
import 'server-only'
|
|
162
|
+
|
|
163
|
+
const tokens = i18nTokens.invoices.errors
|
|
164
|
+
const CODES: FailureCodes = {
|
|
165
|
+
'invoice.stale': { code: 'invoice.stale', message: tokens.stale },
|
|
166
|
+
'invoice.items_invalid': { code: 'invoice.items_invalid', message: tokens.itemsInvalid },
|
|
167
|
+
}
|
|
168
|
+
const notFound = { code: 'invoice.not_found', message: tokens.notFound } as const
|
|
169
|
+
|
|
170
|
+
export const invoiceFailure = {
|
|
171
|
+
read: (status: number, error: unknown, operation: string) =>
|
|
172
|
+
apiFailure(status, { 404: notFound }, { operation, error }, CODES),
|
|
173
|
+
write: (status: number, error: unknown, operation: string) =>
|
|
174
|
+
apiFailure(status, { 404: notFound, 409: { code: 'invoice.stale', message: tokens.stale } }, { operation, error }, CODES),
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
- Duas formas convivem no repo de referência: objeto por operação (`invoiceFailure.write(status, error, operation)`, como acima, quando 404/409 mudam de sentido por operação) e função única (`invoiceFailure(operation, status, error?)`, que chama `apiFailure` com os mesmos `overrides` e `CODES`). Siga a forma do mapper da feature; feature nova escolhe a que cobre os significados dela.
|
|
179
|
+
- Um `CODES` por feature, com todo `code` de 409/422 que a API documenta para ela.
|
|
180
|
+
- 422 com `details` por campo: um `withFieldErrors(result)` no mapper converte `details` em `fieldErrors` mantendo `code` e `details`; o formulário marca cada campo.
|
|
181
|
+
|
|
182
|
+
## Permissões e autorização
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
// invoice.permissions.ts
|
|
186
|
+
const OVERRIDES = ['invoices:manage', '<admin>:manage'] as const
|
|
187
|
+
export const INVOICE_PERMISSIONS = {
|
|
188
|
+
read: ['invoices:read', ...OVERRIDES],
|
|
189
|
+
create: ['invoices:create', ...OVERRIDES],
|
|
190
|
+
} as const satisfies Record<string, readonly Permission[]>
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
- Cada lista vale por "qualquer uma"; quem passa em todas entra em cada lista.
|
|
194
|
+
- `authorize(keys, scope?)` (helper do módulo de auth do repo) falha com `auth.session_expired` sem sessão e `authorization.forbidden` sem permissão; no sucesso devolve o perfil (`me`).
|
|
195
|
+
- Decisão de apresentação (mostrar botão, aba) usa a regra pura (`authorization.canAny(me, keys, scope)` de `shared/auth`) em `<rota>.utils.ts`; esconder não substitui o `authorize` no service.
|
|
196
|
+
- Permissão por recurso (o perfil não traz o papel no recurso): `services/authorize-<recurso>.service.ts` (`server-only`) lê o recurso memoizado, aceita as permissões do recurso **ou** as do perfil no tenant e devolve o perfil e o recurso no sucesso; a regra pura vai para `mappers/<recurso>-access.mapper.ts`. Na Server Action, o id do recurso é o recorte de escopo da regra 5.
|
|
197
|
+
|
|
198
|
+
## Revalidate
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
// invoice.revalidate.ts
|
|
202
|
+
import 'server-only'
|
|
203
|
+
import { revalidatePath } from 'next/cache'
|
|
204
|
+
import { invalidate } from '@/shared/cache/invalidate'
|
|
205
|
+
import { CacheTags } from '@/shared/cache/tags'
|
|
206
|
+
import { INVOICE_ROUTE_PATTERNS } from '@/shared/settings/routes'
|
|
207
|
+
|
|
208
|
+
export function revalidateInvoice(options: { list?: boolean } = {}) {
|
|
209
|
+
revalidatePath(INVOICE_ROUTE_PATTERNS.detail, 'page') // padrão da rota: todas as faturas de uma vez
|
|
210
|
+
if (options.list) revalidatePath(INVOICE_ROUTE_PATTERNS.list, 'page')
|
|
211
|
+
invalidate.now(CacheTags.invoiceSummary())
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
O papel de cada chamada e o modo de invalidação: [`cache.md`](cache.md).
|
|
216
|
+
|
|
217
|
+
## Schemas
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
// URL: lido pela página; .catch() faz link antigo degradar para o padrão
|
|
221
|
+
export const invoicesListQuerySchema = z.object({
|
|
222
|
+
search: listingParams.search,
|
|
223
|
+
status: listingParams.oneOf(INVOICE_STATUSES),
|
|
224
|
+
sort: sortSchema(INVOICE_LIST_SORTS).optional().catch(undefined),
|
|
225
|
+
page: listingParams.page,
|
|
226
|
+
pageSize: listingParams.pageSize,
|
|
227
|
+
})
|
|
228
|
+
export type InvoicesListQuery = z.infer<typeof invoicesListQuerySchema>
|
|
229
|
+
|
|
230
|
+
// Escrita: recorte de escopo + resto, .strict() contra mass assignment
|
|
231
|
+
export const invoiceScopeSchema = z.object({ tenantId: z.uuid() })
|
|
232
|
+
export const createInvoiceSchema = invoiceScopeSchema
|
|
233
|
+
.extend({ customerId: z.uuid(), notes: z.string().trim().max(INVOICE_NOTES_MAX).optional() })
|
|
234
|
+
.strict()
|
|
235
|
+
export type CreateInvoiceInput = z.input<typeof createInvoiceSchema>
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
- Helpers de URL (`listingParams`, `sortSchema`) vêm de `shared/query`; limites e listas, de `<feature>.constants.ts`.
|
|
239
|
+
- O schema traduz para o formato do contrato (`''` vira `null`, data do calendário vira ISO).
|
|
240
|
+
- `z.input` tipa o que a ilha envia; `z.infer`, o que o service usa.
|
|
241
|
+
- Input em `FormData` chega string: número com `z.coerce.number()`, checkbox com `z.literal('on').optional().transform(Boolean)` (`z.coerce.boolean()` faz `'false'` virar `true`).
|
|
242
|
+
|
|
243
|
+
## Entities
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
import type { Api } from '@/shared/api'
|
|
247
|
+
import type { Page } from '@/shared/query/listing'
|
|
248
|
+
|
|
249
|
+
export namespace Invoice {
|
|
250
|
+
export namespace Model {
|
|
251
|
+
export type Status = Api.<Provider>.InvoiceStatus
|
|
252
|
+
export type Summary = Api.<Provider>.InvoiceSummary
|
|
253
|
+
export type Listing = Page<Summary>
|
|
254
|
+
export type Detail = Api.<Provider>.GetInvoiceByIdBodyResponse['data']
|
|
255
|
+
}
|
|
256
|
+
export namespace DTO {
|
|
257
|
+
export type ListQuery = Api.<Provider>.ListInvoicesQueryParamRequest
|
|
258
|
+
export type ListResponse = Api.<Provider>.ListInvoicesBodyResponse
|
|
259
|
+
export type DetailResponse = Api.<Provider>.GetInvoiceByIdBodyResponse
|
|
260
|
+
export type CreateBody = Api.<Provider>.CreateInvoiceBodyRequest
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Tipo que o service monta por conta própria (composição de duas leituras) mora em `Model` com JSDoc.
|
|
266
|
+
|
|
267
|
+
## Listagem
|
|
268
|
+
|
|
269
|
+
```ts
|
|
270
|
+
const fetchInvoices = requestMemo(async (key: string): Promise<Action.Result<Invoice.Model.Listing>> => {
|
|
271
|
+
const { tenantId, ...query } = JSON.parse(key) as InvoicesListQuery & { tenantId: string }
|
|
272
|
+
const access = await authorize(INVOICE_PERMISSIONS.read, { tenantId })
|
|
273
|
+
if (!access.ok) return access.result
|
|
274
|
+
const result = upstream<Invoice.DTO.ListResponse, Api.Common.Error>(
|
|
275
|
+
await api.<Provider>.methods.listInvoices({ ...(await api.<Provider>.session({ tenantId })), query, cache: 'no-store' }),
|
|
276
|
+
)
|
|
277
|
+
if (!result.ok) return invoiceFailure.read(result.status, result.error, 'invoices.list')
|
|
278
|
+
return Action.result.ok(toPage(result.body))
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
export function listInvoices(tenantId: string, input: Partial<InvoicesListQuery>) {
|
|
282
|
+
return fetchInvoices(requestMemoKey({ tenantId, ...invoicesListQuerySchema.parse(input) }))
|
|
283
|
+
}
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
- O service revalida o recorte: a URL é do usuário, mesmo depois do parse da página.
|
|
287
|
+
- `requestMemoKey` monta a chave canônica (chaves ordenadas, vazio fora), e dois blocos com o mesmo recorte dividem uma ida à API.
|
|
288
|
+
- `toPage` normaliza o envelope paginado da API para `Page<T>`.
|
|
289
|
+
- O lado da página está em [`blocks.md`](blocks.md); o da ilha de filtro, em [`ui-forms.md`](ui-forms.md).
|
|
290
|
+
|
|
291
|
+
## Route Handler
|
|
292
|
+
|
|
293
|
+
Server Action é o caminho da mutação da UI. Route Handler entra para webhook ou callback externo, endpoint consumido por outro sistema, download/streaming/export, upload grande com progresso, ou protocolo que Server Action não atende.
|
|
294
|
+
|
|
295
|
+
Webhook, nesta ordem: corpo cru (`request.text()`) → assinatura conferida (401 se falha) → payload pelo Zod → service `server-only` → `invalidate.background(CacheTags.*)` (ninguém espera a própria escrita) → log em `after()` → `Response.json`. Route Handler chama services `server-only`, nunca um `'use server'`; o que lê para o navegador autentica e autoriza como qualquer leitura.
|
|
296
|
+
|
|
297
|
+
**Upload:** arquivo pequeno de formulário vai por Server Action com `serverActions.bodySizeLimit` no `next.config` acima do limite de produto (o multipart soma bytes); grande, múltiplo, com progresso ou retomada vai por Route Handler ou URL assinada do storage. Tipo e tamanho validados no servidor; `accept` do `<input>` é conveniência.
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# Testes
|
|
2
|
+
|
|
3
|
+
O que testar em cada camada, onde o teste mora e como mockar a fronteira. Runner, ambiente e setup são os do repo (`vitest.config.*`, `vitest.setup.ts`); esta reference não prescreve setup novo.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Onde mora
|
|
8
|
+
|
|
9
|
+
- Ao lado do código que testa: `create-invoice.service.test.ts` ao lado do service, `invoice.schemas.test.ts` ao lado do schema, `invoices-table-block.test.tsx` ao lado do bloco.
|
|
10
|
+
- Um arquivo pode cobrir vários services da mesma família (`invoice-mutations.service.test.ts`).
|
|
11
|
+
- Fixtures, fábricas e helpers compartilhados em `src/test/`.
|
|
12
|
+
- Os guards (`scripts/check-*.mjs`) fazem parte do gate: estrutura errada falha no `guard`, não na revisão.
|
|
13
|
+
|
|
14
|
+
## Por camada
|
|
15
|
+
|
|
16
|
+
| Camada | Teste | Cobre |
|
|
17
|
+
|---|---|---|
|
|
18
|
+
| `mappers/<assunto>.mapper.ts` | unit, sem React/Next | regra pura, casos de borda |
|
|
19
|
+
| `mappers/<feature>-failure.mapper.ts` | unit | `code` por `error.code` da API, `overrides` por status, `withFieldErrors` |
|
|
20
|
+
| `schemas/` | unit | válido, inválido, normalização, `.catch()` da URL, `.strict()` |
|
|
21
|
+
| `blocks/<rota>.utils.ts` | unit | cada estado do resolver, permissões, links |
|
|
22
|
+
| Service `'use server'` | unit com a fronteira mockada | validação, forbidden, falha upstream com `code`, sucesso, invalidação |
|
|
23
|
+
| Service `server-only` | unit com a fronteira mockada | 404 por id inválido, sessão, falha mapeada, `cached` vs `no-store` |
|
|
24
|
+
| Bloco server | RSC chamado como função | os três `status`, fallback degradável |
|
|
25
|
+
| Ilha | Testing Library | interação, `fieldErrors` no campo, pendência, a11y |
|
|
26
|
+
| `shared/` | unit | `upstream`, `apiFailure`, `requestMemoKey`, logger (redaction) |
|
|
27
|
+
| Catálogo de páginas | contrato, se o repo tem | toda rota do catálogo tem `page.tsx` e key em `pages.ts` |
|
|
28
|
+
| Bug | o nível mais baixo que reproduz | regressão |
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Service `'use server'`
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
vi.mock('@/shared/api', async (importOriginal) => ({
|
|
36
|
+
...(await importOriginal<typeof import('@/shared/api')>()),
|
|
37
|
+
api: { <Provider>: { session: vi.fn(async () => ({})), methods: { createInvoice: vi.fn() } } },
|
|
38
|
+
}))
|
|
39
|
+
vi.mock('@/modules/auth/services/authorize.service', () => ({ authorize: vi.fn() }))
|
|
40
|
+
vi.mock('next/cache', () => ({ revalidatePath: vi.fn(), updateTag: vi.fn(), revalidateTag: vi.fn() }))
|
|
41
|
+
|
|
42
|
+
const idle = Action.result.idle<Invoice.Model.Detail>()
|
|
43
|
+
const generated = (status: number, body: unknown) =>
|
|
44
|
+
status < 400
|
|
45
|
+
? { data: body, response: new Response(null, { status }) }
|
|
46
|
+
: { error: body, response: new Response(null, { status }) }
|
|
47
|
+
|
|
48
|
+
it('recorte inválido: validation.failed sem autorizar nem chamar a API', async () => {
|
|
49
|
+
const result = await createInvoice(idle, { tenantId: 'x' })
|
|
50
|
+
expect(result.status === 'error' && result.error.code).toBe('validation.failed')
|
|
51
|
+
expect(authorize).not.toHaveBeenCalled()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('sem permissão: authorization.forbidden antes de validar o body', async () => {
|
|
55
|
+
vi.mocked(authorize).mockResolvedValue({ ok: false, result: Action.result.fail('authorization.forbidden', 'forbidden') })
|
|
56
|
+
const result = await createInvoice(idle, { tenantId: TENANT, customerId: 'inválido' })
|
|
57
|
+
expect(result.status === 'error' && result.error.code).toBe('authorization.forbidden')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('422 da API vira o code da operação, com details', async () => {
|
|
61
|
+
vi.mocked(authorize).mockResolvedValue({ ok: true, me })
|
|
62
|
+
vi.mocked(api.<Provider>.methods.createInvoice).mockResolvedValue(
|
|
63
|
+
generated(422, { statusCode: 422, code: 'invoice.items_invalid', message: 'raw', details: { items: [0] } }),
|
|
64
|
+
)
|
|
65
|
+
const result = await createInvoice(idle, validInput)
|
|
66
|
+
expect(result.status === 'error' && result.error).toMatchObject({ code: 'invoice.items_invalid', details: { items: [0] } })
|
|
67
|
+
expect(result.message).not.toContain('raw') // mensagem crua da API só no log
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('sucesso invalida as telas e as tags', async () => {
|
|
71
|
+
vi.mocked(authorize).mockResolvedValue({ ok: true, me })
|
|
72
|
+
vi.mocked(api.<Provider>.methods.createInvoice).mockResolvedValue(generated(201, { data: invoice }))
|
|
73
|
+
const result = await createInvoice(idle, validInput)
|
|
74
|
+
expect(result).toMatchObject({ status: 'success', data: invoice })
|
|
75
|
+
expect(revalidatePath).toHaveBeenCalledWith(INVOICE_ROUTE_PATTERNS.list, 'page')
|
|
76
|
+
expect(updateTag).toHaveBeenCalledWith(CacheTags.invoiceSummary())
|
|
77
|
+
})
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
- O mock atinge a fronteira (`shared/api`, o service de autorização, `next/cache`); o service roda de verdade.
|
|
81
|
+
- Afirme por `code` e `status`, nunca pela mensagem traduzida.
|
|
82
|
+
- Teste a tag declarada e as tags invalidadas, não se o framework cacheou.
|
|
83
|
+
- `notFound()`, `redirect()`, `forbidden()` lançam por design; o mock de `next/navigation` lança e o teste espera o lançamento.
|
|
84
|
+
- Tradução no teste (se o repo tem i18n): siga o setup do repo (mock de `getTranslation` ou locale carregado); a asserção continua por `code`.
|
|
85
|
+
|
|
86
|
+
## Schema de URL
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
it('coage e limita o que vem da URL', () => {
|
|
90
|
+
expect(invoicesListQuerySchema.parse({ page: '-3', pageSize: '99999', status: 'inexistente' }))
|
|
91
|
+
.toMatchObject({ page: 1, status: undefined }) // .catch() em ação: link antigo degrada
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('body com campo extra é recusado', () => {
|
|
95
|
+
expect(createInvoiceSchema.safeParse({ ...validInput, admin: true }).success).toBe(false)
|
|
96
|
+
})
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Bloco server: chame como função
|
|
100
|
+
|
|
101
|
+
`render()` da Testing Library não lida com componente que retorna Promise. Chame, `await`, renderize o resultado.
|
|
102
|
+
|
|
103
|
+
```tsx
|
|
104
|
+
vi.mock('@/modules/invoice', () => ({ getInvoiceSummary: vi.fn() }))
|
|
105
|
+
|
|
106
|
+
it('mostra o erro na moldura quando a leitura falha', async () => {
|
|
107
|
+
vi.mocked(getInvoiceSummary).mockResolvedValue(Action.result.fail('upstream.unavailable', 'indisponível'))
|
|
108
|
+
render(await InvoiceSummaryComponent({ invoiceId: INVOICE }))
|
|
109
|
+
expect(screen.getByRole('region', { name: /resumo/i })).toBeInTheDocument()
|
|
110
|
+
expect(screen.getByText('indisponível')).toBeInTheDocument()
|
|
111
|
+
})
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
| Funciona | Vai para E2E |
|
|
115
|
+
|---|---|
|
|
116
|
+
| bloco que retorna elementos e ilhas, os três `status`, fallback | RSC async aninhado (vira Promise no meio da árvore) |
|
|
117
|
+
| conferir a chamada ao service | streaming real de `<Suspense>`, ordem de chegada |
|
|
118
|
+
|
|
119
|
+
## Ilha
|
|
120
|
+
|
|
121
|
+
- Localize pelo que o usuário vê: `getByRole` com `name`; test id só quando não há papel.
|
|
122
|
+
- Mocke a Server Action importada pelo arquivo e o provider de feedback.
|
|
123
|
+
- Cubra: submit com valores válidos chama a action com o objeto certo; `fieldErrors` aparecem no campo e não em toast; erro sem campo vira toast/`formError`; botão pendente.
|
|
124
|
+
- `axe` nos componentes do DS e em ilhas com formulário, se o repo usa.
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## E2E (se o repo tiver)
|
|
129
|
+
|
|
130
|
+
Sem Playwright ou equivalente configurado, registre a lacuna e não crie setup novo dentro da tarefa. Mock de rede (MSW) idem: só se o repo já usa; o padrão é mockar `shared/api`. Com setup:
|
|
131
|
+
|
|
132
|
+
| Mudança | E2E |
|
|
133
|
+
|---|---|
|
|
134
|
+
| Fluxo com carregamento em etapas (moldura antes, blocos em streaming) | afirme a ordem observável, nunca o esqueleto |
|
|
135
|
+
| Boundary nova ou movida, `prefetch={true}` | sim |
|
|
136
|
+
| Mutação com otimista e reconciliação | sim, no fluxo principal |
|
|
137
|
+
| Regra, schema, invalidação de tag, composição de bloco | não: unit |
|
|
138
|
+
|
|
139
|
+
- Nome acessível vem do locale importado no teste, não de string repetida no spec.
|
|
140
|
+
- Rotas visitadas podem continuar montadas e ocultas: `filter({ visible: true })` ou escopo de container.
|
|
141
|
+
- Suíte que escreve: um worker, backend semeado, servidor subido do zero (leitura cacheada sobrevive ao re-seed).
|
|
142
|
+
- Sem permissão: o controle some (E2E) **e** a chamada direta à Server Action falha (unit).
|
|
143
|
+
|
|
144
|
+
Load test e relatórios (k6, Allure): só se o repo já tem; mantenha alvos e labels existentes.
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## Checklist
|
|
149
|
+
|
|
150
|
+
```txt
|
|
151
|
+
[ ] mapper puro e failure mapper com teste ao lado
|
|
152
|
+
[ ] schema: válido, inválido, .catch() da URL, .strict() do body
|
|
153
|
+
[ ] Server Action nova: os cinco casos da linha `'use server'` em "Por camada"
|
|
154
|
+
[ ] leitura nova: id inválido, sessão, falha mapeada
|
|
155
|
+
[ ] <rota>.utils.ts: cada estado do resolver
|
|
156
|
+
[ ] bloco degradável: fallback com o erro do Action.Result
|
|
157
|
+
[ ] ilha com formulário: fieldErrors no campo
|
|
158
|
+
[ ] guard, lint, check-types e test verdes
|
|
159
|
+
```
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# UI e formulários
|
|
2
|
+
|
|
3
|
+
O lado client que a SKILL resume nas regras 8, 9, 18 e 19: o que a ilha importa, a receita de formulário com `useServerAction`, feedback único, pendência e otimista, ação destrutiva, ação que desmonta a própria ilha, busca e filtro, componentes do DS, tokens e texto em ilha. Estética e escolhas visuais: `/ui`; o README do DS do repo vence esta reference.
|
|
4
|
+
|
|
5
|
+
**Feedback não se duplica.** Cada resultado tem um único sinal: o item que mudou, o status inline, o campo com erro ou um toast. Nunca dois.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## O que a ilha importa
|
|
10
|
+
|
|
11
|
+
Além das regras 8 e 9 da SKILL (Server Action pelo arquivo; vocabulário sem Zod):
|
|
12
|
+
|
|
13
|
+
| Importa | Fica no servidor |
|
|
14
|
+
|---|---|
|
|
15
|
+
| tipos de `entities/` por `import type` (somem do bundle) | `shared/api`, `shared/environment`, o logger do servidor, qualquer `server-only` |
|
|
16
|
+
| `useServerAction` de `shared/feedback` | o objeto de tokens de i18n completo (a ilha usa a fatia) |
|
|
17
|
+
| componentes do DS pelo index (`@/shared/ui/components/<nome>`) | arquivo interno de componente (`.../atoms/...`) |
|
|
18
|
+
| blueprint pelo arquivo | barril de blueprint |
|
|
19
|
+
|
|
20
|
+
## Formulário: React Hook Form + `useServerAction`
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
'use client'
|
|
24
|
+
|
|
25
|
+
import { useForm } from 'react-hook-form'
|
|
26
|
+
import { INVOICE_NOTES_MAX } from '@/modules/invoice/invoice.constants'
|
|
27
|
+
import { createInvoice } from '@/modules/invoice/services/create-invoice.service'
|
|
28
|
+
import { useServerAction } from '@/shared/feedback'
|
|
29
|
+
|
|
30
|
+
type Values = { tenantId: string; customerId: string; notes: string }
|
|
31
|
+
|
|
32
|
+
export const CreateInvoiceForm: FC<{ tenantId: string; onDone: () => void }> = ({ tenantId, onDone }) => {
|
|
33
|
+
const { t } = useTranslation(['invoices', 'common'])
|
|
34
|
+
const form = useForm<Values>({ defaultValues: { tenantId, customerId: '', notes: '' } })
|
|
35
|
+
const create = useServerAction(createInvoice, {
|
|
36
|
+
closeLabel: t(commonTokens.actions.close),
|
|
37
|
+
successMessage: t(invoicesTokens.create.success),
|
|
38
|
+
onSuccess: () => { form.reset(); onDone() },
|
|
39
|
+
setError: (field, error) => form.setError(field as keyof Values, error),
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<form onSubmit={form.handleSubmit((values) => create.run(values))} noValidate>
|
|
44
|
+
<TextField.Field
|
|
45
|
+
label={t(invoicesTokens.create.notes)}
|
|
46
|
+
error={form.formState.errors.notes?.message}
|
|
47
|
+
{...form.register('notes', { maxLength: { value: INVOICE_NOTES_MAX, message: t(validationTokens.tooLong, { max: INVOICE_NOTES_MAX }) } })}
|
|
48
|
+
/>
|
|
49
|
+
<Button.Root type="submit" disabled={create.pending}>{t(invoicesTokens.create.submit)}</Button.Root>
|
|
50
|
+
</form>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
- O client valida com as **regras nativas** do React Hook Form (`required`, `maxLength`, `pattern` com as constantes da feature); o Zod fica no service, que é a validação que conta.
|
|
56
|
+
- `useServerAction(action, { closeLabel, successMessage?, onSuccess?, onError?, setError? })` devolve `{ state, run, pending, fieldErrors, formError }`: `run(input)` dispara em transição com o objeto validado; `pending` vem do `useActionState`.
|
|
57
|
+
- Sucesso: toast se `successMessage` (sem ele, o sucesso é silencioso porque o dado novo já aparece) + `onSuccess` (fechar dialog, resetar).
|
|
58
|
+
- Erro **com** `fieldErrors`: cada campo volta ao formulário por `setError`, sem toast. Erro **sem** `fieldErrors`: toast com a mensagem já traduzida pelo service (ou `formError` dentro do dialog).
|
|
59
|
+
- Formulário em dialog: `FormDialog` do DS.
|
|
60
|
+
- Rascunho sobrevive à rejeição: confira no navegador que um `status: 'error'` não limpa o que o usuário digitou.
|
|
61
|
+
- Ação que muda a tarefa do usuário (cadastro concluído) troca o formulário pela confirmação, em vez de toast.
|
|
62
|
+
|
|
63
|
+
## Ação destrutiva
|
|
64
|
+
|
|
65
|
+
- Confirmação com `AlertDialog` do DS: ícone + rótulo + consequência, `tone="danger"`, `pending` e `error={action.formError}` vindos do hook; o botão de confirmar é dono da pendência.
|
|
66
|
+
- `onSuccess` fecha o diálogo; navegue só depois de `status: 'success'`.
|
|
67
|
+
- Sem `redirect()` no service quando o chamador precisa fechar diálogo ou dar toast: retorne sucesso e navegue no client.
|
|
68
|
+
- Texto do diálogo que depende de contagem guarda o valor na hora de abrir (depois do sucesso a lista encolhe e o texto mudaria fechando).
|
|
69
|
+
|
|
70
|
+
## Ação que desmonta a própria ilha
|
|
71
|
+
|
|
72
|
+
Quando o sucesso remove a linha, o item ou o botão que disparou a ação (encerrar, remover da lista, moderar e sair da fila), um `useServerAction` no botão desmontaria antes de anunciar o resultado e fechar o diálogo. Duas saídas:
|
|
73
|
+
|
|
74
|
+
1. **Provider de ações que fica montado:** a ilha `<bloco>-actions.client.tsx` envolve a lista, guarda o alvo e os `AlertDialog`, e chama `useServerAction`; cada linha só abre a confirmação pelo contexto (`askRemove({ id, name })`).
|
|
75
|
+
2. **Hook de ação destacada** (`useDetachedAction` na referência): o aviso vai para o provider de feedback global, que sobrevive à ilha.
|
|
76
|
+
|
|
77
|
+
Fora desse caso, `useServerAction` na própria ilha.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Pendência e otimista
|
|
82
|
+
|
|
83
|
+
| Situação | Use | Regra |
|
|
84
|
+
|---|---|---|
|
|
85
|
+
| Mutação que quase nunca falha (favoritar, alternar) | `useOptimistic` | atualiza já, reverte no erro; dentro da mesma transição do `run` |
|
|
86
|
+
| Sem encaixe otimista (filtro, ordenação, navegação) | `useTransition` + `data-pending` | o nó pendente marca `data-pending`; ancestrais reagem por CSS (`group-has-data-pending:`) |
|
|
87
|
+
| Erro que só o servidor encontra | `useServerAction` | erro no campo com `aria-invalid` + `role="alert"` |
|
|
88
|
+
| Seleção que vive na URL | `useOptimistic(draft)` + `router.replace` em `startTransition` | a URL é a fonte; o rascunho mantém o controle responsivo |
|
|
89
|
+
|
|
90
|
+
- Todo clique que inicia escrita ou navegação mostra a pendência **no que foi clicado**, sem mexer no layout (indicador de tamanho fixo).
|
|
91
|
+
- Mais de uma fonte de pendência na mesma subárvore: nomeie o sinal (`data-filtering`, `data-saving`).
|
|
92
|
+
- Lista com criar, editar e excluir: um reducer otimista por lista, com nome do domínio, em vez de pendências espalhadas.
|
|
93
|
+
- Controle deriva do valor otimista; moldura ao redor, do valor confirmado.
|
|
94
|
+
- Falha esperada é inline, na linha de status; toast é para falha sem lugar no layout.
|
|
95
|
+
|
|
96
|
+
## Toast
|
|
97
|
+
|
|
98
|
+
| Caso | Toast? |
|
|
99
|
+
|---|---|
|
|
100
|
+
| UI otimista ou dado revalidado já mostra o resultado | só no erro |
|
|
101
|
+
| Efeito sem resultado visível (e-mail enviado, link copiado, export iniciado) | sim, no sucesso |
|
|
102
|
+
| Navegação | não: a troca de página é o feedback |
|
|
103
|
+
| Dentro do service ou em render server | nunca: o service retorna `Action.Result`, o toast é do hook |
|
|
104
|
+
|
|
105
|
+
## Busca e filtro
|
|
106
|
+
|
|
107
|
+
- O recorte vive na URL; filtro não é motivo para `'use client'` na página, só o controle é ilha.
|
|
108
|
+
- Default vem da URL (o estado de URL do repo: nuqs ou `URLSearchParams`); o `<form>` de busca vive na moldura, fora da boundary do `searchParams`, com `key={params.toString()}`.
|
|
109
|
+
- Submit em transição: `startTransition(() => router.replace(href, { scroll: false }))`; `replace`, não `push`; trocar filtro reseta a página.
|
|
110
|
+
- Resultado atual esmaece (`data-pending` + `transition-opacity`); o `key` do `<Suspense>` da lista evita mostrar dado velho como novo.
|
|
111
|
+
- Filtros em diálogo entram por `<bloco>.loader.tsx` (`next/dynamic`), montados só quando abrem.
|
|
112
|
+
- Seleção de linhas é estado local; "selecionar todos" envia o **filtro**, não os ids.
|
|
113
|
+
- Cinco estados: carregando · vazio · sem resultado de filtro (texto diferente de vazio) · erro · sem permissão.
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Componentes do DS
|
|
118
|
+
|
|
119
|
+
- Componente de `shared/ui/components/<nome>/` é um **namespace de átomos**: `index.tsx` (só agrupa e reexporta, nunca `'use client'`) + `props.ts` (`data-tag`) + `atoms/<grupo>/<parte>.tsx` + `hooks/`. O consumidor compõe (`<Chip.Root>`, `<PageHeader.Title>`) importando do index.
|
|
120
|
+
- `className` mescla com `cn()`; variantes por CVA (forma do componente na tabela Nomes da SKILL).
|
|
121
|
+
- `'use client'` só no átomo que chama hook, cria handler ou importa biblioteca client; átomo que só repassa props fica sem diretiva (a proporção de arquivos client entra no orçamento de bundle).
|
|
122
|
+
- Peça pré-composta (`TextField.Field`, `AlertDialog.Dialog`, `FormDialog`) só existe com repetição real; o resto é composição no consumidor.
|
|
123
|
+
- Campo que o DS já resolve (escolha com busca, dinheiro, nota) vem do DS; o consumidor não monta à mão.
|
|
124
|
+
- Componente do DS não conhece feature: não importa Server Action. Quando coordena uma mutação (otimista, pendência), recebe a prop `action`/`<x>Action` e o bloco passa a Server Action; `onChange`/`onClick` é callback comum.
|
|
125
|
+
- Norma, anatomia do átomo e checklist: README do DS do repo.
|
|
126
|
+
|
|
127
|
+
## Tokens semânticos
|
|
128
|
+
|
|
129
|
+
- A regra 18 da SKILL vale para todo `className`, inclusive valor arbitrário (`bg-[#…]`); o guard de cor crua confere.
|
|
130
|
+
- Par de contraste vem do DS (`bg-accent` com `text-content-onaccent`); não sobrescreva localmente.
|
|
131
|
+
- Valor dinâmico de runtime entra como custom property no `style` e a classe lê a variável; valor estático é classe.
|
|
132
|
+
|
|
133
|
+
## Texto em ilha (se o repo tem i18n)
|
|
134
|
+
|
|
135
|
+
- A regra 19 da SKILL vale também para `aria-label`, placeholder e nome acessível.
|
|
136
|
+
- Por que a fatia (`token-slices/<ns>`) em todo código alcançável de `'use client'`: o objeto completo não sofre tree-shaking por propriedade e pesa em toda rota. `import type` do tipo do token é livre.
|
|
137
|
+
- Ilhas com texto ficam sob `<I18nScope namespaces={…}>`, e a página passa só os namespaces dela.
|
|
138
|
+
- Erro da API chega traduzido pelo service; a ilha não traduz mensagem de API.
|
|
139
|
+
- `Intl` com locale explícito (helpers de formatação do i18n do repo).
|
|
140
|
+
|
|
141
|
+
## Estado global client
|
|
142
|
+
|
|
143
|
+
Provider `'use client'` só para estado efêmero de UI (menu aberto, drawer, rascunho otimista), com `children` server. Dado do servidor nunca entra em contexto client; store global só para estado de fato compartilhado.
|