diff --git a/apps/admin-frontend/.agent.md b/apps/admin-frontend/.agent.md new file mode 100644 index 0000000..252041e --- /dev/null +++ b/apps/admin-frontend/.agent.md @@ -0,0 +1,113 @@ +# Senior React & TypeScript Developer + +You are a Staff-level React and TypeScript developer specializing in **clean, type-safe React** for the Agenza admin-frontend SaaS application. You write code that adheres to strict architectural principles, Clean Architecture layering, and modern React best practices. + +## Scope + +This agent handles: + +- **React/TypeScript code generation and review** for features within `apps/admin-frontend/` +- **Architecture compliance** — ensuring feature isolation (ADR 009), dependency inversion, and layer boundaries +- **Type safety** — strict TypeScript config enforcement, no `any`, proper discriminated unions +- **Testing strategy** — TDD workflows, fake repositories, MSW mocking, React Testing Library patterns + +When the user asks about features, infrastructure, or cross-cutting concerns, propose solutions grounded in the project's ADRs (`docs/adr/`). + +## Non-Scope + +- Backend API implementation (refer to `backend/AGENTS.md`) +- DevOps, CI/CD, deployment tooling +- Package/dependency upgrades (ask the user first) + +## Principles + +### Type Safety First + +- Never use `any`. Use strict TypeScript types, discriminated unions for state, and narrow types properly. +- Leverage `unknown` for dynamic data; validate at runtime (especially for externally-supplied arrays and numeric IDs). +- When a domain entity's input comes from the generated API, validate at runtime in the domain's `create()` factory, not just at the type level. + +### Modern React (v18/v19) + +- Functional components and hooks only; no class components or `React.FC`. +- Props are explicit interfaces/types. +- Prefer `useCallback` and `useMemo` judiciously — only when avoiding re-renders solves a real problem (expensive computation, reference stability for deps). +- Never use array index as list key when items can reorder or be deleted. + +### Clean Architecture & Separation of Concerns + +- **Layering:** Each feature's `domain/` → `application/` → `infrastructure/`/`presentation/` layers are hermetic. Dependencies point inward only. +- **Presentation layer** never imports infrastructure directly; all errors are `AppError` before leaving infrastructure. +- **Hooks/Controllers** are thin wrappers around use cases; complex multi-workflow logic is split into focused hooks, not monolithic controllers. +- **Composition root** is `app/main.tsx`; only place that constructs `AppContainer`. + +### Componentization + +- A page component is a shell that wires a controller hook's view models into UI components — nothing else. +- Extract a component/hook on first distinct concern; promote to `shared/` only on _second_ identical use. +- Use `src/components/ui/*` (shadcn/ui) as-is; extend only when a real need surfaces. +- Reference implementations: `TagsPage` (behavior & design), `TagForm` (form structure), `AdminLayout` (page shell). + +### Testing + +- Use case tests → hand-written fake repositories +- Infrastructure tests → MSW handlers (real `HttpClient` code path) +- Presentation tests → fake `AppContainer` from `src/test/fixtures/createFakeAppContainer.ts` +- Every HTTP call needs a registered MSW handler; `onUnhandledRequest: 'error'` +- Add `jest-axe` accessibility checks to new/changed forms and routed pages + +### Comments — Minimal by Default + +- No comment unless a senior reviewer would get it wrong without it. +- Security/tenant-isolation defaults, concurrency guards, React/Radix/RHF/Zod quirks, or lint suppression only. +- Architectural rationale belongs in `docs/adr/`; reference it in one clause (`see docs/adr/0006`), never restate. + +### Configuration Compliance + +- `erasableSyntaxOnly: true` — no constructor parameter property shorthand; explicit field + `this.x = x`. +- `exactOptionalPropertyTypes: true` — always guard optional fields; never assign `maybeUndefined` directly. +- `noUncheckedIndexedAccess: true` — index access returns `T | undefined`; always guard. +- `strict: true` — no `any`. +- ESLint rules for layer/feature isolation must not be disabled (`no-restricted-imports`). + +## Workflow + +1. **Read context first:** If the user mentions a feature, file, or domain, understand the existing code & ADRs before proposing changes. +2. **Type-safe design:** Propose types upfront, validate at runtime where necessary. +3. **Incremental changes:** Prefer small, testable changes; explain architectural decisions with ADR references when non-obvious. +4. **Test coverage:** After code changes, confirm tests pass (`npm run test:coverage --workspace=apps/admin-frontend`). +5. **No speculative code:** Don't add variants, props, or styling "just in case." Add only what's needed now. + +## Key Files to Reference + +- `.AGENTS.md` — repo-wide rules and non-negotiable constraints +- `docs/adr/` — architectural decisions and their rationale +- `docs/STATUS.md` — current feature state and blockers +- `docs/DOMAIN.md` — domain entity definitions +- `agent-skills/agenza-frontend-feature` — design language, component inventory, mobile checklist +- `.skills/admin-tdd-conventions/SKILL.md` — testing patterns & MSW usage +- `src/test/fixtures/createFakeAppContainer.ts` — fake container for presentation tests + +## Communication Style + +- **Professional, concise, direct:** Address the immediate request; omit unrelated details. +- **Educate via reference:** When a decision seems surprising, reference the relevant ADR or doc; don't re-explain. +- **Show, don't tell:** Provide working code/tests; minimal preamble. +- **Language:** All user-facing text (labels, messages, aria-labels) is Brazilian Portuguese (pt-BR). + +## Invocation Triggers + +Use this agent when: + +- Building a new feature vertical or page component +- Refactoring existing React/TypeScript code for type safety or architectural compliance +- Writing or debugging tests (especially MSW mocking, fake containers, presentation/use-case layers) +- Reviewing code for adherence to Clean Architecture, layer isolation, or TypeScript strictness +- Questions about why a constraint or pattern exists — refer to ADRs + +**Example prompts:** + +- "Add a new `ClientsPage` following the Tags reference pattern" +- "Review this controller hook for over-complexity; should I split it?" +- "Why does the component forward a `ref` to the DOM? When is that required?" +- "Add MSW handlers for these endpoints and wire them into the test" diff --git a/apps/admin-frontend/AGENTS.md b/apps/admin-frontend/AGENTS.md index 609c580..83668f7 100644 --- a/apps/admin-frontend/AGENTS.md +++ b/apps/admin-frontend/AGENTS.md @@ -231,7 +231,6 @@ run the repo-wide governance checks from - React Router 8 - oidc-client-ts (Auth Code + PKCE) - Vitest + React Testing Library + MSW -- Husky + lint-staged ## Design language @@ -254,8 +253,12 @@ page). The short version: Tailwind palette classes. Tokens are what make dark mode work; raw classes silently break it. - A list of records is a `Table` (`src/components/ui/table.tsx`), not - stacked `Card`s. A create/edit form always opens in a `Dialog` - modal, never inline or as its own route. + stacked `Card`s. Create/edit forms open in a `Dialog` by default. + Categories maps the nested routes `/categories/new` and + `/categories/:id/edit` to the same editor `Dialog` over the still-mounted + `/categories` list. The dialog reuses one form and one controller hook for + creation and editing. Its table uses compact, record-labelled icon actions + on smartphones and text actions from `sm` upward (docs/adr/012). - Build pages from `src/components/ui/` (shadcn/ui) and the shared composites in `shared/presentation/components/` (`PageHeader`, `StatusMessage`, `TextField`/`TextAreaField`, `CenteredScreen`, diff --git a/apps/admin-frontend/docs/API.md b/apps/admin-frontend/docs/API.md index 265955d..57cab82 100644 --- a/apps/admin-frontend/docs/API.md +++ b/apps/admin-frontend/docs/API.md @@ -196,15 +196,16 @@ claim. Routes are versioned (`Asp.Versioning.Mvc`, docs/adr/0005) — omitting the segment falls back to v1, but the frontend always sends it explicitly. -| Method | Path | Success | -| -------- | ------------------------- | ----------------------------- | -| `GET` | `/api/v1/categories` | `200` — `CategoryDto[]` | -| `POST` | `/api/v1/categories` | `201` — created `CategoryDto` | -| `PUT` | `/api/v1/categories/{id}` | `200` — updated `CategoryDto` | -| `DELETE` | `/api/v1/categories/{id}` | `204` — no body | - -`GET` accepts an optional `search` query param (case-insensitive name -match), e.g. `GET /api/v1/categories?search=massa`. +| Method | Path | Success | +| -------- | ------------------------- | ----------------------------------------------------------------------- | +| `GET` | `/api/v1/categories` | `200` — `CategoryDto[]` | +| `GET` | `/api/v1/categories/{id}` | `200` — `CategoryDto`, `404` if not found (tenant-scoped, docs/adr/013) | +| `POST` | `/api/v1/categories` | `201` — created `CategoryDto` | +| `PUT` | `/api/v1/categories/{id}` | `200` — updated `CategoryDto` | +| `DELETE` | `/api/v1/categories/{id}` | `204` — no body | + +`GET` (collection) accepts an optional `search` query param +(case-insensitive name match), e.g. `GET /api/v1/categories?search=massa`. `DELETE` fails with `409` (`Category.InUse`) if the category is still referenced by one or more Services. diff --git a/apps/admin-frontend/docs/DECISIONS.md b/apps/admin-frontend/docs/DECISIONS.md index 844e968..5040203 100644 --- a/apps/admin-frontend/docs/DECISIONS.md +++ b/apps/admin-frontend/docs/DECISIONS.md @@ -241,19 +241,24 @@ each. a wide table usable at 375px — don't add a second scroll wrapper around it. -### Form pattern: always a `Dialog` modal - -**Decision:** A create/edit form always opens in a `Dialog` -(`src/components/ui/dialog.tsx`) over the list. Never inline in the -page, never its own route. -**Reason:** The project owner chose this explicitly over a dedicated -page/route, for one consistent pattern across every feature vertical — -simpler to build and to maintain than deciding per-vertical. -**Impact:** The list stays mounted and visible behind the dialog (no -navigation, no lost scroll position). `TagsPage` is the reference: a -single `Dialog` instance whose content switches between create/edit -based on which record (if any) triggered it, rather than one dialog per -row. +### Form pattern: one URL-driven `Dialog` editor for Categories + +**Decision:** A create/edit form opens in a `Dialog` +(`src/components/ui/dialog.tsx`) over the list by default. Categories maps +the nested routes `/categories/new` and `/categories/:id/edit` to the same +`CategoryEditorDialog`, which renders the same `CategoryForm` for both +operations. +**Reason:** Both workflows edit the same single-field shape. Separate route +components, pages, and hooks duplicated lifecycle and error-handling logic +without adding a distinct interaction. The URLs still provide direct +navigation and predictable browser-history behavior while the list remains +mounted. +**Impact:** `TagsPage` remains the reference for the modal CRUD pattern. +Categories follows docs/adr/012 for the routed-modal shape; per +docs/adr/013, `useCategoryEditor` resolves an edit id by calling +`GET /api/v1/categories/{id}` directly (tenant-scoped) instead of reading +the list's state through outlet context, and the list refetches when +navigation returns to `/categories`. ### Destructive-action confirmation: `AlertDialog`, not `window.confirm` diff --git a/apps/admin-frontend/docs/STATUS.md b/apps/admin-frontend/docs/STATUS.md index a01a48f..38de335 100644 --- a/apps/admin-frontend/docs/STATUS.md +++ b/apps/admin-frontend/docs/STATUS.md @@ -19,18 +19,18 @@ what's blocked, and what order to build things in. ## Infrastructure -| Piece | Status | Notes | -| -------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------- | -| TypeScript strict config | `done` | | -| ESLint + Prettier | `done` | | -| Vitest + RTL + MSW | `done` | | -| Husky + lint-staged | `done` | | -| `HttpClient` interface + `AuthenticatedHttpClient` | `done` | Single per-request session read (token + tenant id together); converts every failure to `AppError` | -| MSW handlers (auth) | `stub` | Auth uses OIDC not REST — no handlers needed | -| MSW handlers (Tags/Categories/Services) | `done` | `tagHandlers.ts`/`categoryHandlers.ts`/`serviceHandlers.ts` | -| MSW handlers (remaining REST features) | `stub` | Add per-feature as specs arrive (Clients, Appointments, Inbox, Settings) | -| shadcn/ui design system (`src/components/ui/`) | `done` | Radix-based, stock "Nova"/neutral theme, unmodified; see ADR 005 | -| `ThemeProvider` / `useTheme` / `ThemeToggle` | `done` | Light/dark, defaults to OS preference, persists an override | +| Piece | Status | Notes | +| -------------------------------------------------- | --------- | -------------------------------------------------------------------------------------------------- | +| TypeScript strict config | `done` | | +| ESLint + Prettier | `done` | | +| Vitest + RTL + MSW | `done` | | +| Local Git hooks | `removed` | Quality gates run explicitly during development and in required CI checks | +| `HttpClient` interface + `AuthenticatedHttpClient` | `done` | Single per-request session read (token + tenant id together); converts every failure to `AppError` | +| MSW handlers (auth) | `stub` | Auth uses OIDC not REST — no handlers needed | +| MSW handlers (Tags/Categories/Services) | `done` | `tagHandlers.ts`/`categoryHandlers.ts`/`serviceHandlers.ts` | +| MSW handlers (remaining REST features) | `stub` | Add per-feature as specs arrive (Clients, Appointments, Inbox, Settings) | +| shadcn/ui design system (`src/components/ui/`) | `done` | Radix-based, stock "Nova"/neutral theme, unmodified; see ADR 005 | +| `ThemeProvider` / `useTheme` / `ThemeToggle` | `done` | Light/dark, defaults to OS preference, persists an override | --- @@ -106,7 +106,7 @@ create/edit form's pickers, both already built. | Use cases (List, Create, Update, Delete) | `done` | | | `ApiCategoryRepository` + `categoryMapper` | `done` | | | `useCategories` hook | `done` | | -| `CategoriesPage` + nav entry | `done` | Table list, dialog create/edit form, delete with confirm | +| Categories responsive list/editor + nav entry | `done` | Mobile-ready table; shared URL-driven create/edit modal; delete with confirm | | Backend (services-service `/api/v1/categories`) | `done` | Search/filter added; see docs/adr/0012 for the latest validation/handler shape | **Dependency:** none. Referenced by Services (optional `categoryId`). diff --git a/apps/admin-frontend/docs/adr/001-clean-architecture-layers.md b/apps/admin-frontend/docs/adr/001-clean-architecture-layers.md index bd7019b..4d6c30e 100644 --- a/apps/admin-frontend/docs/adr/001-clean-architecture-layers.md +++ b/apps/admin-frontend/docs/adr/001-clean-architecture-layers.md @@ -16,7 +16,7 @@ dependency rule: `domain` ← `application` ← `infrastructure` + ESLint `no-restricted-imports` rules prevent `domain/` and `application/` from importing React, react-router, or any outer-layer module. Violations -fail the pre-commit hook and CI. +fail the explicit lint command and CI. ## Consequences diff --git a/apps/admin-frontend/docs/adr/012-routed-category-editor.md b/apps/admin-frontend/docs/adr/012-routed-category-editor.md new file mode 100644 index 0000000..fca38b2 --- /dev/null +++ b/apps/admin-frontend/docs/adr/012-routed-category-editor.md @@ -0,0 +1,66 @@ +# ADR 012 — Categories uses one URL-driven modal editor + +**Status:** Accepted. The outlet-context sharing described below is +superseded by docs/adr/013 - the routed modal editor itself (one Dialog, +two nested routes) is still accurate. + +## Decision + +Categories separates its collection, creation, and editing workflows into +three routes: + +- `/categories` renders the searchable table and delete confirmation; +- `/categories/new` is a nested route that opens the editor in create mode; +- `/categories/:id/edit` is a nested route that opens the same editor in + edit mode. + +`CategoriesListPage` is the route component and composes its controller +hook, responsive table, outlet, and delete confirmation. +Both child routes render `CategoryEditorDialog`. It composes +`useCategoryEditor` with the same `CategoryForm` for creation and editing. +The route parameter selects the operation, title, submit label, initial +values, and mutation. + +The list remains a `Table`. On smartphones, record actions use labelled +icons with larger touch targets and the category name can wrap; from the +`sm` breakpoint upward, the action text is also visible. + +## Rationale + +Creation and editing operate on the same single-field form. Keeping both in +one modal preserves list/search context and avoids parallel components and +hooks for nearly identical workflows. Their nested URLs still provide +direct navigation and browser back/forward behavior without unmounting the +list page. + +The parent passes its single `useCategories` source through outlet context. +The editor therefore creates, resolves, and updates against the visible +tenant-scoped source without an independent collection request. + +The backend exposes collection listing but no `GET /categories/{id}`. +The editor therefore resolves the requested id from the authenticated +tenant's category collection. A missing id renders a curated not-found +state; it never falls back to data from another tenant. + +## Consequences + +- `CategoriesListPage` is a composition shell; state machines and use-case + access remain in focused hooks. +- `useCategoriesListPage` instantiates `useCategories` once and provides it + to the nested editor through outlet context. +- `useCategoryEditor` owns the shared submit, structured-error, + loading/not-found, and return-navigation state. +- Closing or submitting either editor mode navigates to `/categories` + while keeping the current list/search state. Browser back closes it too. +- There are no operation-specific creation route or editing page + components. +- There is no redundant list route wrapper. +- Route-level regression tests cover shared modal creation/editing, + direct `/categories/new` access, browser history, preserved list state, + not-found/loading errors, deletion refresh, responsive action semantics, + structured field errors, security, and accessibility. + +No architecture guard is added because choosing a routed editor or modal +is a product interaction decision, not a generalizable import or +filesystem invariant. The regression tests run through the existing +frontend coverage command in CI. diff --git a/apps/admin-frontend/docs/adr/013-category-editor-fetches-by-id.md b/apps/admin-frontend/docs/adr/013-category-editor-fetches-by-id.md new file mode 100644 index 0000000..db86a4d --- /dev/null +++ b/apps/admin-frontend/docs/adr/013-category-editor-fetches-by-id.md @@ -0,0 +1,64 @@ +# ADR 013 — Category editor fetches by id directly, no outlet context + +**Status:** Accepted + +## Decision + +- `useCategoryEditor` no longer reads react-router's `useOutletContext`. In + edit mode it fetches its own category directly through a new + `GET /api/v1/categories/{id}` endpoint + (`GetCategoryByIdQuery`/`GetCategoryByIdQueryHandler`, reusing the + already tenant-scoped `ICategoryRepository.GetByIdAsync` that + `Update`/`Delete` already relied on). Create/update still call the + `catalog` facade directly, same as before - they're just no longer + routed through the list's shared state. +- `CategoriesListPage` no longer builds or passes an `editorContext` + through ``; it renders a plain ``. +- `useCategoriesListPage` refetches the category list whenever navigation + returns from a nested editor route (`/categories/new` or + `/categories/:id/edit`) back to the bare `/categories` route - + unconditionally, whether the editor closed via cancel or a successful + save. + +## Rationale + +docs/adr/012 shared list state through outlet context specifically +because no `GET /categories/{id}` endpoint existed - the editor had no +other way to resolve an id without depending on the full collection the +list had already loaded. That constraint no longer holds: +`GetByIdAsync` already existed on the repository, so exposing it as its +own tenant-scoped query and endpoint was a small, additive slice, not a +new capability. + +`useOutletContext()` is also untyped at the router level - the generic +cast has no runtime guarantee that an ancestor route actually supplied a +value; a route tree change could silently turn it into `undefined` at +runtime while the type system still says otherwise. Fetching directly +removes that coupling instead of trading it for a differently-shaped +implicit channel. + +Refetching the list unconditionally on return-to-list is simpler and more +robust than threading a "did this actually mutate anything" flag through +navigation state, at the cost of one avoidable `GET` when the editor is +only cancelled. Categories is a small, infrequently-changing collection - +that cost was judged worth the simplicity. + +## Consequences + +- `CategoriesEditorContext` (the outlet-context payload type) no longer + exists; `useCategoriesListPage.types.ts` only describes the list's own + view model. +- The editor's not-found state is now a real 404 + (`AppError.code === 'notFound'`) instead of "id absent from an + already-loaded collection." Behaviorally equivalent: the lookup is + still tenant-scoped, so an id belonging to another tenant still renders + the curated not-found state, never another tenant's data. +- `useCategoryEditor` and `useCategoriesListPage` no longer need each + other to be tested or reasoned about - the editor doesn't require a + full route tree wrapping it in `` to exist. +- One extra `GET /api/v1/categories` fires whenever the editor route + closes back to the list, including on cancel. Accepted per Rationale + above. +- Route-level regression tests (`CategoriesRoutes.test.tsx`) stub + `catalog.getCategory` directly for edit-mode load/not-found/retry + scenarios instead of shaping `catalog.listCategories` to produce them. diff --git a/apps/admin-frontend/docs/adr/014-catalog-result-errors-and-global-net.md b/apps/admin-frontend/docs/adr/014-catalog-result-errors-and-global-net.md new file mode 100644 index 0000000..261746e --- /dev/null +++ b/apps/admin-frontend/docs/adr/014-catalog-result-errors-and-global-net.md @@ -0,0 +1,116 @@ +# ADR 014 — Catalog is throw-free end to end; one global net catches the rest + +**Status:** Accepted. The "Auth is out of scope" note below is superseded +by docs/adr/015 - Auth now follows the same Result convention too. The +Catalog decision and the global-net decision below are otherwise still +accurate. + +## Decision + +Within `features/catalog/` (Categories and Tags) and the shared hooks they +depend on, no business or presentation code throws for an expected +outcome, and no business or presentation code catches an exception as its +error-handling mechanism: + +- `Category.create()`/`Tag.create()` return `Result` + instead of throwing. +- `mapCategoryDtoToDomain`/`mapTagDtoToDomain` convert that failure into a + curated `AppError` right there (`malformedResponseError()`, + `shared/infrastructure/http/malformedResponseError.ts`), so every + `ApiCategoryRepository`/`ApiTagRepository` method stays + `Result` end to end. Composition uses `flatMapResult`/ + `combineResults` (`shared/application/Result.ts`) instead of nested + `Result`s. +- `useAsync` (`shared/presentation/hooks/useAsync.ts`) takes + `() => Promise>` instead of `() => Promise`, and branches + on `result.success` internally instead of `try/catch`. +- `useDeleteConfirmation` takes `onDelete: (item: T) => Promise>` instead of a throwing `Promise`. +- Every Catalog hook that used to convert a `Result.Failure` into a throw + purely to route it through a `catch` block (`useCategoryEditor`, + `useCategoriesListPage`, `useTags`, `useTagEditor`) now branches on the + `Result` directly. + +Separately, `main.tsx` now wires a global error-capture net: + +- `shared/application/ErrorReporter.ts` (port) + + `shared/infrastructure/observability/ConsoleErrorReporter.ts` (default + adapter) — swap the adapter for a real backend (Sentry, Application + Insights, a custom endpoint) later; nothing else in the app depends on + the concrete class. +- `createRoot(root, { onCaughtError, onUncaughtError })` (React 19) + centralizes reporting for everything any error boundary + (`ErrorBoundary`, the router's `errorElement`) catches or misses, in one + place instead of duplicated per boundary. `ErrorBoundary.componentDidCatch` + no longer logs on its own. +- `window.addEventListener('unhandledrejection' | 'error', ...)` covers + the two surfaces no React error boundary can reach: a promise an async + event handler never awaited, and a synchronous throw outside React's + render/effect cycle. + +## Rationale + +Two failure categories were being handled by the same mechanism +(`try`/`catch`/`throw`), which made it impossible to tell, from a hook's +code alone, whether a given catch handled something _expected_ (a 409 +conflict, a 404) or something _unexpected_ (a bug). `flatMapResult` +tracing a Result through `Category.create()`/`mapCategoryDtoToDomain` +surfaced exactly this ambiguity: a malformed backend response could throw +from inside a repository call that every caller assumed, by its +`Result`-returning contract, could not throw (see docs/adr/013's +follow-up discussion). Separating the two - `Result` for anything a +caller is expected to branch on, exceptions reserved for what a global +net catches - removes that ambiguity, mirrors the backend's own +Result/DomainResult discipline (docs/adr/0012, docs/adr/0014 in the +backend repo), and was validated end-to-end on Catalog specifically +because catching the malformed-response gap there is what motivated this +change in the first place. + +The global net exists because eliminating throws from Catalog's own code +doesn't eliminate every way an error can reach the browser - a genuine +bug (a `TypeError`, a contract violation elsewhere in the app) can still +throw, and until now nothing outside a single `try/catch` block observed +that centrally. React 19's `onCaughtError`/`onUncaughtError` were chosen +over per-boundary logging specifically because this app already has two +independent boundaries (`ErrorBoundary`, `RouteErrorElement`) - a +root-level hook reports both without either boundary needing to know +about reporting at all. + +`ApiError`/`ApiProblemDetails` parsing inside `AuthenticatedHttpClient`/ +`parseApiResponse.ts` (and `decodeCategoryDto`/`decodeTagDto`'s own +malformed-payload throw) are unaffected - they're private implementation +detail fully contained within one function, never crossing the +`HttpClient` interface's public, always-`Result` surface. "No throw in +business/interface logic" is about code a caller has to reason about, not +every internal statement of an infrastructure adapter. + +**Auth (`features/auth/`) is explicitly out of scope for this pass.** +`Session`/`User`/`Tenant` domain entities, `CallbackPage`/`LoginPage`, and +`OidcAuthRepository` still throw - that's a separate, deliberate follow-up +given the extra scrutiny authentication/session code warrants +(AGENTS.md's question policy). `AuthProvider.tsx`'s own `useAsync` call +site is the one adapter point where Auth's still-throwing +`getCurrentSession()` meets `useAsync`'s new Result-based contract; that +conversion happens locally in `AuthProvider.tsx`, not by changing Auth's +own architecture. + +## Consequences + +- `agent-skills/agenza-frontend-feature`'s "frontend's own, + already-established exception-and-catch convention" (describing domain + entity factories) now applies to Auth only - Catalog's domain entities + return `Result`. The skill is updated to say so explicitly. +- A new Catalog feature vertical (Services, when built) should follow + Catalog's Result-all-the-way shape, not Auth's throwing one. +- `useDeleteConfirmation`'s `fallbackMessage` parameter is gone - it + existed to cover a non-`Error` thrown value, which no longer reaches it; + `toUiError` already curates anything that isn't a well-formed `AppError`. +- A handful of existing tests exercised the old "any thrown `Error`'s raw + `.message` renders" behavior via a force-cast fixture + (`new Error(...) as unknown as AppError`) - fixed to construct a real + `AppError`, matching what `mapErrorToAppError` actually produces and + the codebase's existing "never render a raw non-`AppError` message" + rule everywhere else. +- Reporting is not yet wired to a real backend - `ConsoleErrorReporter` is + a placeholder. Choosing a vendor/endpoint is a follow-up, not blocked by + this ADR. diff --git a/apps/admin-frontend/docs/adr/015-auth-result-errors.md b/apps/admin-frontend/docs/adr/015-auth-result-errors.md new file mode 100644 index 0000000..20424f3 --- /dev/null +++ b/apps/admin-frontend/docs/adr/015-auth-result-errors.md @@ -0,0 +1,86 @@ +# ADR 015 — Auth is throw-free end to end too + +**Status:** Accepted + +## Decision + +`features/auth/` now follows the same Result convention docs/adr/014 +established for Catalog - no throw for an expected outcome, no +try/catch as the primary error-handling mechanism in business or +presentation code: + +- `Session.create()`/`User.create()`/`Tenant.create()` return + `Result` instead of throwing. +- `mapOidcUserToSession` (`features/auth/infrastructure/`) composes them + via early-return `Result` branching into + `Result` - `SessionMappingError` is the + union of `MissingTenantClaimError`, the new `MissingExpiryClaimError`, + and the three domain validation errors above. +- `AuthRepository.initiateLogin`/`handleCallback`/`logout` return + `Result` instead of throwing. The `try/catch` around + `oidc-client-ts`'s own throwing calls (`signinRedirect`, + `signinRedirectCallback`, `signinSilent`) stays inside + `OidcAuthRepository` - a contained infrastructure-adapter boundary, the + same category docs/adr/014 already carved out for + `AuthenticatedHttpClient`/`parseApiResponse.ts`. +- `AuthRepository.getCurrentSession` is unchanged: + `Promise`, never a `Result`. It already never threw in + the sense that mattered to callers - `null` already means "no usable + session" whether that's no session, a failed renewal, or (now) a + malformed cached user. Wrapping an always-successful call in `Result` + would add a type parameter with no failure variant ever produced. +- `InitiateLogin`/`HandleAuthCallback`/`Logout` (use cases) mirror the + repository's Result-returning methods. `GetCurrentSession` is + unchanged for the same reason as above. +- `AuthProvider`'s `login`/`completeLogin`/`logout` callbacks return + `Result` instead of throwing; `LoginPage`/`CallbackPage` branch on the + `Result` directly instead of `try/catch`. + +## Rationale + +Converting `mapOidcUserToSession` surfaced a real gap the same way +`categoryMapper.ts` did during the Catalog pass: `getCurrentSession()`'s +cached-user path called it with **no try/catch at all** - only the +silent-renewal path had one. A cached user whose profile no longer maps +to a valid session (e.g. `tenant_id` claim dropped by a client +misconfiguration) would throw uncaught, land in `useAsync`'s +`initialError` state, and `AuthProvider`'s state derivation - which only +checked `loading` vs `tenantContext !== null` - would silently treat that +as `unauthenticated` and redirect to `/login`. Not a security hole (fails +closed, no cross-tenant leak), but a real bug: a genuine error was +indistinguishable from "never logged in." Fixed by giving +`getCurrentSession()` the same broad try/catch `renewSession()` already +had (extracted into a shared `clearAndReturnNull()` helper), so every +failure mode - missing user, malformed cached profile, storage read +failure, failed renewal, identity mismatch - now converges on the same +explicit, intentional "clear and require a full login" path instead of +some going through an accidental exception. + +The silent-renewal identity check (user/tenant claims must not change +across a renewal, or the renewed token is discarded) is unchanged in +substance - only its surrounding control flow moved from throw-based to +Result-based. + +## Consequences + +- `agent-skills/agenza-frontend-feature` no longer describes Auth as an + exception to Catalog's Result convention - both features follow the + same shape now. Updated to say so. +- Test fixtures across the suite that build a `Tenant`/`User`/`Session` + for a known-valid case (most of the suite touches auth in some way) + import from a new `src/test/fixtures/authEntityFixtures.ts` instead of + the real entities directly - it re-exports `{ create }` wrappers that + unwrap the `Result`, so call sites read identically to before + (`Tenant.create('tenant-123')`) without threading `unwrapResult(...)` + through dozens of call sites. The entities' own `*.test.ts` files import + the real classes directly, since they specifically assert on both the + success and failure `Result` shapes. +- `AuthFlowErrorCode` gained `AUTH_LOGOUT_FAILED` (logout previously had + no error handling at all - `userManager.removeUser()`/ + `signoutRedirect()` could throw uncaught). +- `AuthProvider`'s `logout` always clears local session state + (`mutate(() => null)`) regardless of whether the returned `Result` + succeeded - the local session is meaningfully gone once `removeUser()` + succeeds even if ending the identity provider's own session afterward + fails, and the app must never keep believing the user is still signed + in. diff --git a/apps/admin-frontend/e2e/categories-mobile.spec.ts b/apps/admin-frontend/e2e/categories-mobile.spec.ts new file mode 100644 index 0000000..5d1834f --- /dev/null +++ b/apps/admin-frontend/e2e/categories-mobile.spec.ts @@ -0,0 +1,109 @@ +import { expect, test } from '@playwright/test' +import { injectAuthenticatedSession } from './support/session' + +interface CategoryRecord { + id: string + name: string +} + +interface CategoryWriteBody { + name: string +} + +test.describe('categories on smartphones', () => { + test.beforeEach(async ({ page }) => { + await injectAuthenticatedSession(page) + + let categories: CategoryRecord[] = [{ id: 'category-1', name: 'Massagens terapêuticas' }] + + await page.route( + url => url.pathname.startsWith('/api/v1/categories'), + async route => { + const request = route.request() + + if (request.method() === 'GET') { + const segments = new URL(request.url()).pathname.split('/') + const categoryId = segments.at(-1) === 'categories' ? null : segments.at(-1) + + if (categoryId === null) { + await route.fulfill({ json: categories }) + return + } + + const category = categories.find(candidate => candidate.id === categoryId) + if (category === undefined) { + await route.fulfill({ status: 404 }) + return + } + + await route.fulfill({ json: category }) + return + } + + if (request.method() === 'POST') { + const body = request.postDataJSON() as CategoryWriteBody + const created = { id: 'category-2', name: body.name } + categories = [...categories, created] + await route.fulfill({ status: 201, json: created }) + return + } + + if (request.method() === 'PUT') { + const categoryId = request.url().split('/').at(-1) + const body = request.postDataJSON() as CategoryWriteBody + const updated = { id: categoryId ?? '', name: body.name } + categories = categories.map(category => (category.id === categoryId ? updated : category)) + await route.fulfill({ json: updated }) + return + } + + await route.continue() + }, + ) + }) + + test('keeps the list usable and creates and edits through one modal at 375px', async ({ + page, + }) => { + await page.setViewportSize({ width: 375, height: 812 }) + await page.goto('/categories') + + await expect(page.getByText('Massagens terapêuticas')).toBeVisible() + await expect( + page.getByRole('button', { name: 'Editar categoria Massagens terapêuticas' }), + ).toBeVisible() + await expect( + page.getByRole('button', { name: 'Excluir categoria Massagens terapêuticas' }), + ).toBeVisible() + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375) + + await page.getByRole('button', { name: /editar categoria/i }).click() + await expect(page).toHaveURL(/\/categories\/category-1\/edit$/) + const dialog = page.getByRole('dialog') + await expect(dialog.getByRole('heading', { name: 'Editar categoria' })).toBeVisible() + await expect(dialog.getByLabel('Nome')).toHaveValue(/Massagens terap/) + await dialog.getByLabel('Nome').fill('Massagens relaxantes') + await dialog.getByRole('button', { name: /salvar altera/i }).click() + + await expect(page).toHaveURL(/\/categories$/) + await expect(dialog).toBeHidden() + await expect(page.getByText('Massagens relaxantes')).toBeVisible() + + await page.getByRole('button', { name: 'Nova categoria' }).click() + await expect(page).toHaveURL(/\/categories\/new$/) + await expect(dialog).toBeVisible() + + await page.goBack() + await expect(page).toHaveURL(/\/categories$/) + await expect(dialog).toBeHidden() + + await page.getByRole('button', { name: 'Nova categoria' }).click() + await expect(page).toHaveURL(/\/categories\/new$/) + await dialog.getByLabel('Nome').fill('Estética') + await dialog.getByRole('button', { name: 'Criar categoria' }).click() + + await expect(page).toHaveURL(/\/categories$/) + await expect(dialog).toBeHidden() + await expect(page.getByText('Estética')).toBeVisible() + }) +}) diff --git a/apps/admin-frontend/src/app/composition/container.test.ts b/apps/admin-frontend/src/app/composition/container.test.ts index fe6d880..f4b9342 100644 --- a/apps/admin-frontend/src/app/composition/container.test.ts +++ b/apps/admin-frontend/src/app/composition/container.test.ts @@ -1,15 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { createAppContainer } from '@/app/composition/container' import { InMemorySessionEventBus } from '@/shared/infrastructure/InMemorySessionEventBus' -import { - InitiateLogin, - HandleAuthCallback, - GetCurrentSession, - Logout, - toTenantContext, -} from '@/features/auth' -import { Tenant, User } from '@/test/fixtures/authEntityFixtures' -import { ListTags } from '@/features/catalog' +import { InitiateLogin, HandleAuthCallback, GetCurrentSession, Logout } from '@/features/auth' function stubOidcEnv(): void { vi.stubEnv('VITE_OIDC_AUTHORITY', 'https://identity.example.com') @@ -39,10 +31,10 @@ describe('createAppContainer', () => { expect(container.auth.sessionEvents).toBeInstanceOf(InMemorySessionEventBus) }) - it('wires the catalog facade from concrete use cases', () => { + it('wires the catalog facade to the concrete repository', () => { const container = createAppContainer() - expect(container.catalog.listTags).toBeInstanceOf(ListTags) + expect(container.catalog.listTags.execute).toBeTypeOf('function') }) it('does not expose a repository or an HttpClient on the container', () => { @@ -60,14 +52,11 @@ describe('createAppContainer', () => { const listener = vi.fn() container.auth.sessionEvents.subscribe(listener) - const tenant = Tenant.create('tenant-1') - const tenantContext = toTenantContext(User.create({ id: 'user-1', tenant })) - // No stored OIDC session in this test environment, so this call has no // access token - it goes through the private httpClient built with this // exact sessionEvents instance as its notifier, proving they're wired // together end to end rather than just both present on the container. - await container.catalog.listTags.execute(tenantContext, {}).catch(() => undefined) + await container.catalog.listTags.execute({}) expect(listener).toHaveBeenCalledTimes(1) }) diff --git a/apps/admin-frontend/src/app/composition/container.ts b/apps/admin-frontend/src/app/composition/container.ts index 7a360ea..92119c8 100644 --- a/apps/admin-frontend/src/app/composition/container.ts +++ b/apps/admin-frontend/src/app/composition/container.ts @@ -15,22 +15,8 @@ import { import { ApiTagRepository, ApiCategoryRepository, - ApiServiceRepository, type TagRepository, type CategoryRepository, - type ServiceRepository, - ListTags, - CreateTag, - UpdateTag, - DeleteTag, - ListCategories, - CreateCategory, - UpdateCategory, - DeleteCategory, - ListServices, - CreateService, - UpdateService, - DeleteService, } from '@/features/catalog' // Each entry is the *shape* of a use case (Pick), not the @@ -45,20 +31,20 @@ export interface AuthFacade { sessionEvents: SessionEventBus } -/** Tags, Categories, and Services collaborate in the same business context. */ +/** Tags and Categories collaborate in the same business context. Each entry's + * execute signature mirrors the matching repository method directly - there's + * no orchestration between the facade and the repository, so no intermediate + * use-case class is worth the indirection. */ export interface CatalogFacade { - listTags: Pick - createTag: Pick - updateTag: Pick - deleteTag: Pick - listCategories: Pick - createCategory: Pick - updateCategory: Pick - deleteCategory: Pick - listServices: Pick - createService: Pick - updateService: Pick - deleteService: Pick + listTags: { execute: TagRepository['listAll'] } + createTag: { execute: TagRepository['create'] } + updateTag: { execute: TagRepository['update'] } + deleteTag: { execute: TagRepository['delete'] } + listCategories: { execute: CategoryRepository['listAll'] } + getCategory: { execute: CategoryRepository['getById'] } + createCategory: { execute: CategoryRepository['create'] } + updateCategory: { execute: CategoryRepository['update'] } + deleteCategory: { execute: CategoryRepository['delete'] } } // What presentation is allowed to see - grouped facades, never a raw @@ -95,7 +81,6 @@ export function createAppContainer(): AppContainer { const tagRepository: TagRepository = new ApiTagRepository(httpClient) const categoryRepository: CategoryRepository = new ApiCategoryRepository(httpClient) - const serviceRepository: ServiceRepository = new ApiServiceRepository(httpClient) return { auth: { @@ -106,18 +91,15 @@ export function createAppContainer(): AppContainer { sessionEvents, }, catalog: { - listTags: new ListTags(tagRepository), - createTag: new CreateTag(tagRepository), - updateTag: new UpdateTag(tagRepository), - deleteTag: new DeleteTag(tagRepository), - listCategories: new ListCategories(categoryRepository), - createCategory: new CreateCategory(categoryRepository), - updateCategory: new UpdateCategory(categoryRepository), - deleteCategory: new DeleteCategory(categoryRepository), - listServices: new ListServices(serviceRepository), - createService: new CreateService(serviceRepository), - updateService: new UpdateService(serviceRepository), - deleteService: new DeleteService(serviceRepository), + listTags: { execute: options => tagRepository.listAll(options) }, + createTag: { execute: input => tagRepository.create(input) }, + updateTag: { execute: (id, input) => tagRepository.update(id, input) }, + deleteTag: { execute: id => tagRepository.delete(id) }, + listCategories: { execute: options => categoryRepository.listAll(options) }, + getCategory: { execute: id => categoryRepository.getById(id) }, + createCategory: { execute: input => categoryRepository.create(input) }, + updateCategory: { execute: (id, input) => categoryRepository.update(id, input) }, + deleteCategory: { execute: id => categoryRepository.delete(id) }, }, } } diff --git a/apps/admin-frontend/src/app/layouts/AdminLayout.test.tsx b/apps/admin-frontend/src/app/layouts/AdminLayout.test.tsx index 12f7029..f03a720 100644 --- a/apps/admin-frontend/src/app/layouts/AdminLayout.test.tsx +++ b/apps/admin-frontend/src/app/layouts/AdminLayout.test.tsx @@ -9,8 +9,8 @@ import { Tenant, User } from '@/test/fixtures/authEntityFixtures' import { ThemeProvider } from '@/shared/presentation/providers/ThemeProvider' import type { AppContainer } from '@/app/composition/container' import { createFakeAppContainer } from '@/test/fixtures/createFakeAppContainer' -import { success } from '@/shared/application/Result' import { vi } from 'vitest' +import { success } from '@/shared/application/Result' function buildTenantContext(): TenantContext { const tenant = Tenant.create('tenant-123') diff --git a/apps/admin-frontend/src/app/main.tsx b/apps/admin-frontend/src/app/main.tsx index 85d04e1..acbd718 100644 --- a/apps/admin-frontend/src/app/main.tsx +++ b/apps/admin-frontend/src/app/main.tsx @@ -4,6 +4,7 @@ import '../index.css' import { App } from './App.tsx' import { AppProviders } from './providers/AppProviders.tsx' import { ErrorBoundary } from '@/shared/presentation/components/ErrorBoundary.tsx' +import { ConsoleErrorReporter } from '@/shared/infrastructure/observability/ConsoleErrorReporter' import { createAppContainer } from './composition/container.ts' const rootElement = document.getElementById('root') @@ -17,7 +18,39 @@ if (!rootElement) { // whatever container it's given into context (docs/adr/008). const container = createAppContainer() -createRoot(rootElement).render( +// Swap this instance for a real backend (Sentry, Application Insights, a +// custom endpoint) when one is chosen - every capture surface below only +// depends on the ErrorReporter port (docs/adr/014). +const errorReporter = new ConsoleErrorReporter() + +// Two capture surfaces ErrorBoundary/RouteErrorElement structurally can't +// reach: a rejected promise never awaited (an async event handler) and a +// synchronous throw outside React's render/effect cycle. +window.addEventListener('unhandledrejection', event => { + errorReporter.report(event.reason, { source: 'window.unhandledrejection' }) +}) +window.addEventListener('error', event => { + errorReporter.report(event.error ?? event.message, { source: 'window.error' }) +}) + +createRoot(rootElement, { + // React 19: fires for every error a boundary catches/misses, regardless + // of which of possibly several boundaries (ErrorBoundary, the router's + // errorElement) handles it - one reporting call instead of one per + // boundary implementation. + onCaughtError: (error, errorInfo) => { + errorReporter.report(error, { + source: 'react.onCaughtError', + extra: { componentStack: errorInfo.componentStack }, + }) + }, + onUncaughtError: (error, errorInfo) => { + errorReporter.report(error, { + source: 'react.onUncaughtError', + extra: { componentStack: errorInfo.componentStack }, + }) + }, +}).render( diff --git a/apps/admin-frontend/src/app/pages/ServicesPage/ServicesPage.tsx b/apps/admin-frontend/src/app/pages/ServicesPage/ServicesPage.tsx new file mode 100644 index 0000000..da142f2 --- /dev/null +++ b/apps/admin-frontend/src/app/pages/ServicesPage/ServicesPage.tsx @@ -0,0 +1,7 @@ +import type { JSX } from 'react' +import { Sparkles } from 'lucide-react' +import { PlaceholderPage } from '@/shared/presentation/components/PlaceholderPage' + +export function ServicesPage(): JSX.Element { + return +} diff --git a/apps/admin-frontend/src/app/routes/router.tsx b/apps/admin-frontend/src/app/routes/router.tsx index 4b4927f..2a4fe90 100644 --- a/apps/admin-frontend/src/app/routes/router.tsx +++ b/apps/admin-frontend/src/app/routes/router.tsx @@ -17,9 +17,7 @@ const AppointmentsPage = lazy(() => })), ) const ServicesPage = lazy(() => - import('@/features/catalog/presentation/services/ServicesPage').then(m => ({ - default: m.ServicesPage, - })), + import('@/app/pages/ServicesPage/ServicesPage').then(m => ({ default: m.ServicesPage })), ) const ClientsPage = lazy(() => import('@/app/pages/ClientsPage/ClientsPage').then(m => ({ default: m.ClientsPage })), @@ -30,12 +28,22 @@ const InboxPage = lazy(() => const SettingsPage = lazy(() => import('@/app/pages/SettingsPage/SettingsPage').then(m => ({ default: m.SettingsPage })), ) +const CategoriesListPage = lazy(() => + import('@/features/catalog/presentation/categories/pages/CategoriesListPage/CategoriesListPage').then( + m => ({ default: m.CategoriesListPage }), + ), +) +const CategoryEditorDialog = lazy(() => + import('@/features/catalog/presentation/categories/pages/CategoryEditorDialog/CategoryEditorDialog').then( + m => ({ default: m.CategoryEditorDialog }), + ), +) const TagsPage = lazy(() => import('@/features/catalog/presentation/tags/TagsPage').then(m => ({ default: m.TagsPage })), ) -const CategoriesPage = lazy(() => - import('@/features/catalog/presentation/categories/CategoriesPage').then(m => ({ - default: m.CategoriesPage, +const TagEditorDialog = lazy(() => + import('@/features/catalog/presentation/tags/pages/TagEditorDialog').then(m => ({ + default: m.TagEditorDialog, })), ) @@ -69,11 +77,25 @@ export const router = createBrowserRouter([ { index: true, element: }, { path: 'dashboard', element: withSuspense() }, { path: 'appointments', element: withSuspense() }, - { path: 'services', element: withSuspense() }, - { path: 'categories', element: withSuspense() }, + { + path: 'categories', + element: withSuspense(), + children: [ + { path: 'new', element: withSuspense() }, + { path: ':id/edit', element: withSuspense() }, + ], + }, { path: 'clients', element: withSuspense() }, { path: 'inbox', element: withSuspense() }, - { path: 'tags', element: withSuspense() }, + { + path: 'tags', + element: withSuspense(), + children: [ + { path: 'new', element: withSuspense() }, + { path: ':id/edit', element: withSuspense() }, + ], + }, + { path: 'services', element: withSuspense() }, { path: 'settings', element: withSuspense() }, ], }, diff --git a/apps/admin-frontend/src/features/catalog/application/repositories/CategoryRepository.ts b/apps/admin-frontend/src/features/catalog/application/repositories/CategoryRepository.ts index 15285e5..871bb20 100644 --- a/apps/admin-frontend/src/features/catalog/application/repositories/CategoryRepository.ts +++ b/apps/admin-frontend/src/features/catalog/application/repositories/CategoryRepository.ts @@ -1,5 +1,6 @@ import type { Category } from '@/features/catalog/domain/entities/Category' -import type { TenantContext } from '@/features/auth' +import type { AppError } from '@/shared/application/AppError' +import type { Result } from '@/shared/application/Result' export interface CreateCategoryInput { name: string @@ -14,8 +15,9 @@ export interface ListAllCategoriesOptions { } export interface CategoryRepository { - listAll(tenantContext: TenantContext, options?: ListAllCategoriesOptions): Promise - create(tenantContext: TenantContext, input: CreateCategoryInput): Promise - update(tenantContext: TenantContext, id: string, input: UpdateCategoryInput): Promise - delete(tenantContext: TenantContext, id: string): Promise + listAll(options?: ListAllCategoriesOptions): Promise> + getById(id: string): Promise> + create(input: CreateCategoryInput): Promise> + update(id: string, input: UpdateCategoryInput): Promise> + delete(id: string): Promise> } diff --git a/apps/admin-frontend/src/features/catalog/application/repositories/ServiceRepository.ts b/apps/admin-frontend/src/features/catalog/application/repositories/ServiceRepository.ts deleted file mode 100644 index 8650bd0..0000000 --- a/apps/admin-frontend/src/features/catalog/application/repositories/ServiceRepository.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { Service } from '@/features/catalog/domain/entities/Service' -import type { TenantContext } from '@/features/auth' - -export interface CreateServiceInput { - name: string - description?: string | null - durationMinutes: number - minDurationMinutes: number - maxDurationMinutes: number - price: number - maxDiscountPercentage: number - categoryId?: string | null - tagIds?: readonly string[] -} - -export interface UpdateServiceInput { - name: string - description?: string | null - durationMinutes: number - minDurationMinutes: number - maxDurationMinutes: number - price: number - maxDiscountPercentage: number - categoryId?: string | null - tagIds?: readonly string[] -} - -export interface ListAllServicesOptions { - page?: number - pageSize?: number - search?: string - categoryId?: string - tagId?: string -} - -/** The paginated shape `ServiceRepository.listAll` resolves to (docs/API.md `PagedResult`). */ -export interface PagedServices { - services: readonly Service[] - totalCount: number - page: number - pageSize: number -} - -export interface ServiceRepository { - listAll(tenantContext: TenantContext, options?: ListAllServicesOptions): Promise - create(tenantContext: TenantContext, input: CreateServiceInput): Promise - update(tenantContext: TenantContext, id: string, input: UpdateServiceInput): Promise - delete(tenantContext: TenantContext, id: string): Promise -} diff --git a/apps/admin-frontend/src/features/catalog/application/repositories/TagRepository.ts b/apps/admin-frontend/src/features/catalog/application/repositories/TagRepository.ts index 3ba958a..1ddc89d 100644 --- a/apps/admin-frontend/src/features/catalog/application/repositories/TagRepository.ts +++ b/apps/admin-frontend/src/features/catalog/application/repositories/TagRepository.ts @@ -1,5 +1,6 @@ import type { Tag } from '@/features/catalog/domain/entities/Tag' -import type { TenantContext } from '@/features/auth' +import type { AppError } from '@/shared/application/AppError' +import type { Result } from '@/shared/application/Result' export interface CreateTagInput { name: string @@ -18,8 +19,8 @@ export interface ListAllTagsOptions { } export interface TagRepository { - listAll(tenantContext: TenantContext, options?: ListAllTagsOptions): Promise - create(tenantContext: TenantContext, input: CreateTagInput): Promise - update(tenantContext: TenantContext, id: string, input: UpdateTagInput): Promise - delete(tenantContext: TenantContext, id: string): Promise + listAll(options?: ListAllTagsOptions): Promise> + create(input: CreateTagInput): Promise> + update(id: string, input: UpdateTagInput): Promise> + delete(id: string): Promise> } diff --git a/apps/admin-frontend/src/features/catalog/application/test-helpers/createFakeCategoryRepository.ts b/apps/admin-frontend/src/features/catalog/application/test-helpers/createFakeCategoryRepository.ts index 6497f30..dc635a2 100644 --- a/apps/admin-frontend/src/features/catalog/application/test-helpers/createFakeCategoryRepository.ts +++ b/apps/admin-frontend/src/features/catalog/application/test-helpers/createFakeCategoryRepository.ts @@ -1,13 +1,22 @@ import type { CategoryRepository } from '@/features/catalog/application/repositories/CategoryRepository' +import { AppError } from '@/shared/application/AppError' +import { failure, success } from '@/shared/application/Result' + +const NOT_IMPLEMENTED = new AppError({ + code: 'unexpected', + message: 'not implemented in this fake', + retryable: false, +}) export function createFakeCategoryRepository( overrides: Partial = {}, ): CategoryRepository { return { - listAll: () => Promise.resolve([]), - create: () => Promise.reject(new Error('not implemented in this fake')), - update: () => Promise.reject(new Error('not implemented in this fake')), - delete: () => Promise.resolve(), + listAll: () => Promise.resolve(success([])), + getById: () => Promise.resolve(failure(NOT_IMPLEMENTED)), + create: () => Promise.resolve(failure(NOT_IMPLEMENTED)), + update: () => Promise.resolve(failure(NOT_IMPLEMENTED)), + delete: () => Promise.resolve(success(undefined)), ...overrides, } } diff --git a/apps/admin-frontend/src/features/catalog/application/test-helpers/createFakeServiceRepository.ts b/apps/admin-frontend/src/features/catalog/application/test-helpers/createFakeServiceRepository.ts deleted file mode 100644 index 835bc7e..0000000 --- a/apps/admin-frontend/src/features/catalog/application/test-helpers/createFakeServiceRepository.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { ServiceRepository } from '@/features/catalog/application/repositories/ServiceRepository' - -export function createFakeServiceRepository( - overrides: Partial = {}, -): ServiceRepository { - return { - listAll: () => Promise.resolve({ services: [], totalCount: 0, page: 1, pageSize: 20 }), - create: () => Promise.reject(new Error('not implemented in this fake')), - update: () => Promise.reject(new Error('not implemented in this fake')), - delete: () => Promise.resolve(), - ...overrides, - } -} diff --git a/apps/admin-frontend/src/features/catalog/application/test-helpers/createFakeTagRepository.ts b/apps/admin-frontend/src/features/catalog/application/test-helpers/createFakeTagRepository.ts index 0a9d630..7258d1d 100644 --- a/apps/admin-frontend/src/features/catalog/application/test-helpers/createFakeTagRepository.ts +++ b/apps/admin-frontend/src/features/catalog/application/test-helpers/createFakeTagRepository.ts @@ -1,11 +1,19 @@ import type { TagRepository } from '@/features/catalog/application/repositories/TagRepository' +import { AppError } from '@/shared/application/AppError' +import { failure, success } from '@/shared/application/Result' + +const NOT_IMPLEMENTED = new AppError({ + code: 'unexpected', + message: 'not implemented in this fake', + retryable: false, +}) export function createFakeTagRepository(overrides: Partial = {}): TagRepository { return { - listAll: () => Promise.resolve([]), - create: () => Promise.reject(new Error('not implemented in this fake')), - update: () => Promise.reject(new Error('not implemented in this fake')), - delete: () => Promise.resolve(), + listAll: () => Promise.resolve(success([])), + create: () => Promise.resolve(failure(NOT_IMPLEMENTED)), + update: () => Promise.resolve(failure(NOT_IMPLEMENTED)), + delete: () => Promise.resolve(success(undefined)), ...overrides, } } diff --git a/apps/admin-frontend/src/features/catalog/application/use-cases/categories/CreateCategory.test.ts b/apps/admin-frontend/src/features/catalog/application/use-cases/categories/CreateCategory.test.ts deleted file mode 100644 index f045348..0000000 --- a/apps/admin-frontend/src/features/catalog/application/use-cases/categories/CreateCategory.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { CreateCategory } from '@/features/catalog/application/use-cases/categories/CreateCategory' -import { createFakeCategoryRepository } from '@/features/catalog/application/test-helpers/createFakeCategoryRepository' -import { Category } from '@/features/catalog/domain/entities/Category' -import { Tenant } from '@/test/fixtures/authEntityFixtures' -import { User } from '@/test/fixtures/authEntityFixtures' -import type { TenantContext } from '@/features/auth' - -function buildTenantContext(): TenantContext { - const tenant = Tenant.create('tenant-123') - return { tenant, user: User.create({ id: 'user-1', tenant }) } -} - -describe('CreateCategory', () => { - it('delegates to the category repository with the tenant context and input', async () => { - const tenantContext = buildTenantContext() - const created = Category.create({ id: 'category-1', name: 'Massagens' }) - const createSpy = vi.fn(() => Promise.resolve(created)) - const categoryRepository = createFakeCategoryRepository({ create: createSpy }) - const input = { name: 'Massagens' } - - const result = await new CreateCategory(categoryRepository).execute(tenantContext, input) - - expect(createSpy).toHaveBeenCalledExactlyOnceWith(tenantContext, input) - expect(result).toBe(created) - }) -}) diff --git a/apps/admin-frontend/src/features/catalog/application/use-cases/categories/CreateCategory.ts b/apps/admin-frontend/src/features/catalog/application/use-cases/categories/CreateCategory.ts deleted file mode 100644 index 553a5bd..0000000 --- a/apps/admin-frontend/src/features/catalog/application/use-cases/categories/CreateCategory.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Category } from '@/features/catalog/domain/entities/Category' -import type { - CategoryRepository, - CreateCategoryInput, -} from '@/features/catalog/application/repositories/CategoryRepository' -import type { TenantContext } from '@/features/auth' - -export class CreateCategory { - private readonly categoryRepository: CategoryRepository - - constructor(categoryRepository: CategoryRepository) { - this.categoryRepository = categoryRepository - } - - execute(tenantContext: TenantContext, input: CreateCategoryInput): Promise { - return this.categoryRepository.create(tenantContext, input) - } -} diff --git a/apps/admin-frontend/src/features/catalog/application/use-cases/categories/DeleteCategory.test.ts b/apps/admin-frontend/src/features/catalog/application/use-cases/categories/DeleteCategory.test.ts deleted file mode 100644 index 662fcdf..0000000 --- a/apps/admin-frontend/src/features/catalog/application/use-cases/categories/DeleteCategory.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { DeleteCategory } from '@/features/catalog/application/use-cases/categories/DeleteCategory' -import { createFakeCategoryRepository } from '@/features/catalog/application/test-helpers/createFakeCategoryRepository' -import { Tenant } from '@/test/fixtures/authEntityFixtures' -import { User } from '@/test/fixtures/authEntityFixtures' -import type { TenantContext } from '@/features/auth' - -function buildTenantContext(): TenantContext { - const tenant = Tenant.create('tenant-123') - return { tenant, user: User.create({ id: 'user-1', tenant }) } -} - -describe('DeleteCategory', () => { - it('delegates to the category repository with the tenant context and id', async () => { - const tenantContext = buildTenantContext() - const deleteSpy = vi.fn(() => Promise.resolve()) - const categoryRepository = createFakeCategoryRepository({ delete: deleteSpy }) - - await new DeleteCategory(categoryRepository).execute(tenantContext, 'category-1') - - expect(deleteSpy).toHaveBeenCalledExactlyOnceWith(tenantContext, 'category-1') - }) -}) diff --git a/apps/admin-frontend/src/features/catalog/application/use-cases/categories/DeleteCategory.ts b/apps/admin-frontend/src/features/catalog/application/use-cases/categories/DeleteCategory.ts deleted file mode 100644 index dbad96f..0000000 --- a/apps/admin-frontend/src/features/catalog/application/use-cases/categories/DeleteCategory.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { CategoryRepository } from '@/features/catalog/application/repositories/CategoryRepository' -import type { TenantContext } from '@/features/auth' - -export class DeleteCategory { - private readonly categoryRepository: CategoryRepository - - constructor(categoryRepository: CategoryRepository) { - this.categoryRepository = categoryRepository - } - - execute(tenantContext: TenantContext, id: string): Promise { - return this.categoryRepository.delete(tenantContext, id) - } -} diff --git a/apps/admin-frontend/src/features/catalog/application/use-cases/categories/ListCategories.test.ts b/apps/admin-frontend/src/features/catalog/application/use-cases/categories/ListCategories.test.ts deleted file mode 100644 index 0258b9e..0000000 --- a/apps/admin-frontend/src/features/catalog/application/use-cases/categories/ListCategories.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { ListCategories } from '@/features/catalog/application/use-cases/categories/ListCategories' -import { createFakeCategoryRepository } from '@/features/catalog/application/test-helpers/createFakeCategoryRepository' -import { Category } from '@/features/catalog/domain/entities/Category' -import { Tenant } from '@/test/fixtures/authEntityFixtures' -import { User } from '@/test/fixtures/authEntityFixtures' -import type { TenantContext } from '@/features/auth' - -function buildTenantContext(): TenantContext { - const tenant = Tenant.create('tenant-123') - return { tenant, user: User.create({ id: 'user-1', tenant }) } -} - -describe('ListCategories', () => { - it('delegates to the category repository with the given tenant context', async () => { - const tenantContext = buildTenantContext() - const category = Category.create({ id: 'category-1', name: 'Massagens' }) - const listAllSpy = vi.fn(() => Promise.resolve([category])) - const categoryRepository = createFakeCategoryRepository({ listAll: listAllSpy }) - - const result = await new ListCategories(categoryRepository).execute(tenantContext) - - expect(listAllSpy).toHaveBeenCalledExactlyOnceWith(tenantContext, undefined) - expect(result).toEqual([category]) - }) -}) diff --git a/apps/admin-frontend/src/features/catalog/application/use-cases/categories/ListCategories.ts b/apps/admin-frontend/src/features/catalog/application/use-cases/categories/ListCategories.ts deleted file mode 100644 index b98ad69..0000000 --- a/apps/admin-frontend/src/features/catalog/application/use-cases/categories/ListCategories.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Category } from '@/features/catalog/domain/entities/Category' -import type { - CategoryRepository, - ListAllCategoriesOptions, -} from '@/features/catalog/application/repositories/CategoryRepository' -import type { TenantContext } from '@/features/auth' - -export class ListCategories { - private readonly categoryRepository: CategoryRepository - - constructor(categoryRepository: CategoryRepository) { - this.categoryRepository = categoryRepository - } - - execute(tenantContext: TenantContext, options?: ListAllCategoriesOptions): Promise { - return this.categoryRepository.listAll(tenantContext, options) - } -} diff --git a/apps/admin-frontend/src/features/catalog/application/use-cases/categories/UpdateCategory.test.ts b/apps/admin-frontend/src/features/catalog/application/use-cases/categories/UpdateCategory.test.ts deleted file mode 100644 index 471a922..0000000 --- a/apps/admin-frontend/src/features/catalog/application/use-cases/categories/UpdateCategory.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { UpdateCategory } from '@/features/catalog/application/use-cases/categories/UpdateCategory' -import { createFakeCategoryRepository } from '@/features/catalog/application/test-helpers/createFakeCategoryRepository' -import { Category } from '@/features/catalog/domain/entities/Category' -import { Tenant } from '@/test/fixtures/authEntityFixtures' -import { User } from '@/test/fixtures/authEntityFixtures' -import type { TenantContext } from '@/features/auth' - -function buildTenantContext(): TenantContext { - const tenant = Tenant.create('tenant-123') - return { tenant, user: User.create({ id: 'user-1', tenant }) } -} - -describe('UpdateCategory', () => { - it('delegates to the category repository with the tenant context, id, and input', async () => { - const tenantContext = buildTenantContext() - const updated = Category.create({ id: 'category-1', name: 'Renamed' }) - const updateSpy = vi.fn(() => Promise.resolve(updated)) - const categoryRepository = createFakeCategoryRepository({ update: updateSpy }) - const input = { name: 'Renamed' } - - const result = await new UpdateCategory(categoryRepository).execute( - tenantContext, - 'category-1', - input, - ) - - expect(updateSpy).toHaveBeenCalledExactlyOnceWith(tenantContext, 'category-1', input) - expect(result).toBe(updated) - }) -}) diff --git a/apps/admin-frontend/src/features/catalog/application/use-cases/categories/UpdateCategory.ts b/apps/admin-frontend/src/features/catalog/application/use-cases/categories/UpdateCategory.ts deleted file mode 100644 index b2e3de1..0000000 --- a/apps/admin-frontend/src/features/catalog/application/use-cases/categories/UpdateCategory.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Category } from '@/features/catalog/domain/entities/Category' -import type { - CategoryRepository, - UpdateCategoryInput, -} from '@/features/catalog/application/repositories/CategoryRepository' -import type { TenantContext } from '@/features/auth' - -export class UpdateCategory { - private readonly categoryRepository: CategoryRepository - - constructor(categoryRepository: CategoryRepository) { - this.categoryRepository = categoryRepository - } - - execute(tenantContext: TenantContext, id: string, input: UpdateCategoryInput): Promise { - return this.categoryRepository.update(tenantContext, id, input) - } -} diff --git a/apps/admin-frontend/src/features/catalog/application/use-cases/services/CreateService.test.ts b/apps/admin-frontend/src/features/catalog/application/use-cases/services/CreateService.test.ts deleted file mode 100644 index 12969c4..0000000 --- a/apps/admin-frontend/src/features/catalog/application/use-cases/services/CreateService.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { CreateService } from '@/features/catalog/application/use-cases/services/CreateService' -import { createFakeServiceRepository } from '@/features/catalog/application/test-helpers/createFakeServiceRepository' -import { Service } from '@/features/catalog/domain/entities/Service' -import { Tenant } from '@/test/fixtures/authEntityFixtures' -import { User } from '@/test/fixtures/authEntityFixtures' -import type { TenantContext } from '@/features/auth' -import type { CreateServiceInput } from '@/features/catalog/application/repositories/ServiceRepository' - -function buildTenantContext(): TenantContext { - const tenant = Tenant.create('tenant-123') - return { tenant, user: User.create({ id: 'user-1', tenant }) } -} - -describe('CreateService', () => { - it('delegates to the service repository with the tenant context and input', async () => { - const tenantContext = buildTenantContext() - const created = Service.create({ - id: 'service-1', - code: 1001, - name: 'Massagem relaxante', - durationMinutes: 60, - minDurationMinutes: 30, - maxDurationMinutes: 90, - price: 150, - maxDiscountPercentage: 10, - tags: [], - }) - const createSpy = vi.fn(() => Promise.resolve(created)) - const serviceRepository = createFakeServiceRepository({ create: createSpy }) - const input: CreateServiceInput = { - name: 'Massagem relaxante', - durationMinutes: 60, - minDurationMinutes: 30, - maxDurationMinutes: 90, - price: 150, - maxDiscountPercentage: 10, - } - - const result = await new CreateService(serviceRepository).execute(tenantContext, input) - - expect(createSpy).toHaveBeenCalledExactlyOnceWith(tenantContext, input) - expect(result).toBe(created) - }) -}) diff --git a/apps/admin-frontend/src/features/catalog/application/use-cases/services/CreateService.ts b/apps/admin-frontend/src/features/catalog/application/use-cases/services/CreateService.ts deleted file mode 100644 index 92ac917..0000000 --- a/apps/admin-frontend/src/features/catalog/application/use-cases/services/CreateService.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Service } from '@/features/catalog/domain/entities/Service' -import type { - CreateServiceInput, - ServiceRepository, -} from '@/features/catalog/application/repositories/ServiceRepository' -import type { TenantContext } from '@/features/auth' - -export class CreateService { - private readonly serviceRepository: ServiceRepository - - constructor(serviceRepository: ServiceRepository) { - this.serviceRepository = serviceRepository - } - - execute(tenantContext: TenantContext, input: CreateServiceInput): Promise { - return this.serviceRepository.create(tenantContext, input) - } -} diff --git a/apps/admin-frontend/src/features/catalog/application/use-cases/services/DeleteService.test.ts b/apps/admin-frontend/src/features/catalog/application/use-cases/services/DeleteService.test.ts deleted file mode 100644 index bf140d1..0000000 --- a/apps/admin-frontend/src/features/catalog/application/use-cases/services/DeleteService.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { DeleteService } from '@/features/catalog/application/use-cases/services/DeleteService' -import { createFakeServiceRepository } from '@/features/catalog/application/test-helpers/createFakeServiceRepository' -import { Tenant } from '@/test/fixtures/authEntityFixtures' -import { User } from '@/test/fixtures/authEntityFixtures' -import type { TenantContext } from '@/features/auth' - -function buildTenantContext(): TenantContext { - const tenant = Tenant.create('tenant-123') - return { tenant, user: User.create({ id: 'user-1', tenant }) } -} - -describe('DeleteService', () => { - it('delegates to the service repository with the tenant context and id', async () => { - const tenantContext = buildTenantContext() - const deleteSpy = vi.fn(() => Promise.resolve()) - const serviceRepository = createFakeServiceRepository({ delete: deleteSpy }) - - await new DeleteService(serviceRepository).execute(tenantContext, 'service-1') - - expect(deleteSpy).toHaveBeenCalledExactlyOnceWith(tenantContext, 'service-1') - }) -}) diff --git a/apps/admin-frontend/src/features/catalog/application/use-cases/services/DeleteService.ts b/apps/admin-frontend/src/features/catalog/application/use-cases/services/DeleteService.ts deleted file mode 100644 index 811845d..0000000 --- a/apps/admin-frontend/src/features/catalog/application/use-cases/services/DeleteService.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { ServiceRepository } from '@/features/catalog/application/repositories/ServiceRepository' -import type { TenantContext } from '@/features/auth' - -export class DeleteService { - private readonly serviceRepository: ServiceRepository - - constructor(serviceRepository: ServiceRepository) { - this.serviceRepository = serviceRepository - } - - execute(tenantContext: TenantContext, id: string): Promise { - return this.serviceRepository.delete(tenantContext, id) - } -} diff --git a/apps/admin-frontend/src/features/catalog/application/use-cases/services/ListServices.test.ts b/apps/admin-frontend/src/features/catalog/application/use-cases/services/ListServices.test.ts deleted file mode 100644 index f66c5ad..0000000 --- a/apps/admin-frontend/src/features/catalog/application/use-cases/services/ListServices.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { ListServices } from '@/features/catalog/application/use-cases/services/ListServices' -import { createFakeServiceRepository } from '@/features/catalog/application/test-helpers/createFakeServiceRepository' -import { Service } from '@/features/catalog/domain/entities/Service' -import { Tenant } from '@/test/fixtures/authEntityFixtures' -import { User } from '@/test/fixtures/authEntityFixtures' -import type { TenantContext } from '@/features/auth' - -function buildTenantContext(): TenantContext { - const tenant = Tenant.create('tenant-123') - return { tenant, user: User.create({ id: 'user-1', tenant }) } -} - -function buildService(): Service { - return Service.create({ - id: 'service-1', - code: 1001, - name: 'Massagem relaxante', - durationMinutes: 60, - minDurationMinutes: 30, - maxDurationMinutes: 90, - price: 150, - maxDiscountPercentage: 10, - tags: [], - }) -} - -describe('ListServices', () => { - it('delegates to the service repository with the given tenant context and options', async () => { - const tenantContext = buildTenantContext() - const service = buildService() - const pagedResult = { services: [service], totalCount: 1, page: 1, pageSize: 20 } - const listAllSpy = vi.fn(() => Promise.resolve(pagedResult)) - const serviceRepository = createFakeServiceRepository({ listAll: listAllSpy }) - - const result = await new ListServices(serviceRepository).execute(tenantContext, { - page: 1, - pageSize: 20, - }) - - expect(listAllSpy).toHaveBeenCalledExactlyOnceWith(tenantContext, { page: 1, pageSize: 20 }) - expect(result).toEqual(pagedResult) - }) -}) diff --git a/apps/admin-frontend/src/features/catalog/application/use-cases/services/ListServices.ts b/apps/admin-frontend/src/features/catalog/application/use-cases/services/ListServices.ts deleted file mode 100644 index 1552aa9..0000000 --- a/apps/admin-frontend/src/features/catalog/application/use-cases/services/ListServices.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { - ListAllServicesOptions, - PagedServices, - ServiceRepository, -} from '@/features/catalog/application/repositories/ServiceRepository' -import type { TenantContext } from '@/features/auth' - -export class ListServices { - private readonly serviceRepository: ServiceRepository - - constructor(serviceRepository: ServiceRepository) { - this.serviceRepository = serviceRepository - } - - execute(tenantContext: TenantContext, options?: ListAllServicesOptions): Promise { - return this.serviceRepository.listAll(tenantContext, options) - } -} diff --git a/apps/admin-frontend/src/features/catalog/application/use-cases/services/UpdateService.test.ts b/apps/admin-frontend/src/features/catalog/application/use-cases/services/UpdateService.test.ts deleted file mode 100644 index 07301f5..0000000 --- a/apps/admin-frontend/src/features/catalog/application/use-cases/services/UpdateService.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { UpdateService } from '@/features/catalog/application/use-cases/services/UpdateService' -import { createFakeServiceRepository } from '@/features/catalog/application/test-helpers/createFakeServiceRepository' -import { Service } from '@/features/catalog/domain/entities/Service' -import { Tenant } from '@/test/fixtures/authEntityFixtures' -import { User } from '@/test/fixtures/authEntityFixtures' -import type { TenantContext } from '@/features/auth' -import type { UpdateServiceInput } from '@/features/catalog/application/repositories/ServiceRepository' - -function buildTenantContext(): TenantContext { - const tenant = Tenant.create('tenant-123') - return { tenant, user: User.create({ id: 'user-1', tenant }) } -} - -describe('UpdateService', () => { - it('delegates to the service repository with the tenant context, id, and input', async () => { - const tenantContext = buildTenantContext() - const updated = Service.create({ - id: 'service-1', - code: 1001, - name: 'Renamed', - durationMinutes: 45, - minDurationMinutes: 30, - maxDurationMinutes: 60, - price: 200, - maxDiscountPercentage: 5, - tags: [], - }) - const updateSpy = vi.fn(() => Promise.resolve(updated)) - const serviceRepository = createFakeServiceRepository({ update: updateSpy }) - const input: UpdateServiceInput = { - name: 'Renamed', - durationMinutes: 45, - minDurationMinutes: 30, - maxDurationMinutes: 60, - price: 200, - maxDiscountPercentage: 5, - } - - const result = await new UpdateService(serviceRepository).execute( - tenantContext, - 'service-1', - input, - ) - - expect(updateSpy).toHaveBeenCalledExactlyOnceWith(tenantContext, 'service-1', input) - expect(result).toBe(updated) - }) -}) diff --git a/apps/admin-frontend/src/features/catalog/application/use-cases/services/UpdateService.ts b/apps/admin-frontend/src/features/catalog/application/use-cases/services/UpdateService.ts deleted file mode 100644 index 5a2f21f..0000000 --- a/apps/admin-frontend/src/features/catalog/application/use-cases/services/UpdateService.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Service } from '@/features/catalog/domain/entities/Service' -import type { - ServiceRepository, - UpdateServiceInput, -} from '@/features/catalog/application/repositories/ServiceRepository' -import type { TenantContext } from '@/features/auth' - -export class UpdateService { - private readonly serviceRepository: ServiceRepository - - constructor(serviceRepository: ServiceRepository) { - this.serviceRepository = serviceRepository - } - - execute(tenantContext: TenantContext, id: string, input: UpdateServiceInput): Promise { - return this.serviceRepository.update(tenantContext, id, input) - } -} diff --git a/apps/admin-frontend/src/features/catalog/application/use-cases/tags/CreateTag.test.ts b/apps/admin-frontend/src/features/catalog/application/use-cases/tags/CreateTag.test.ts deleted file mode 100644 index 1eef8d6..0000000 --- a/apps/admin-frontend/src/features/catalog/application/use-cases/tags/CreateTag.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { CreateTag } from '@/features/catalog/application/use-cases/tags/CreateTag' -import { createFakeTagRepository } from '@/features/catalog/application/test-helpers/createFakeTagRepository' -import { Tag } from '@/features/catalog/domain/entities/Tag' -import { Tenant } from '@/test/fixtures/authEntityFixtures' -import { User } from '@/test/fixtures/authEntityFixtures' -import type { TenantContext } from '@/features/auth' - -function buildTenantContext(): TenantContext { - const tenant = Tenant.create('tenant-123') - return { tenant, user: User.create({ id: 'user-1', tenant }) } -} - -describe('CreateTag', () => { - it('delegates to the tag repository with the tenant context and input', async () => { - const tenantContext = buildTenantContext() - const created = Tag.create({ id: 'tag-1', name: 'VIP', color: '#0d9488' }) - const createSpy = vi.fn(() => Promise.resolve(created)) - const tagRepository = createFakeTagRepository({ create: createSpy }) - const input = { name: 'VIP', color: '#0d9488' } - - const result = await new CreateTag(tagRepository).execute(tenantContext, input) - - expect(createSpy).toHaveBeenCalledExactlyOnceWith(tenantContext, input) - expect(result).toBe(created) - }) -}) diff --git a/apps/admin-frontend/src/features/catalog/application/use-cases/tags/CreateTag.ts b/apps/admin-frontend/src/features/catalog/application/use-cases/tags/CreateTag.ts deleted file mode 100644 index 79e79d5..0000000 --- a/apps/admin-frontend/src/features/catalog/application/use-cases/tags/CreateTag.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Tag } from '@/features/catalog/domain/entities/Tag' -import type { - CreateTagInput, - TagRepository, -} from '@/features/catalog/application/repositories/TagRepository' -import type { TenantContext } from '@/features/auth' - -export class CreateTag { - private readonly tagRepository: TagRepository - - constructor(tagRepository: TagRepository) { - this.tagRepository = tagRepository - } - - execute(tenantContext: TenantContext, input: CreateTagInput): Promise { - return this.tagRepository.create(tenantContext, input) - } -} diff --git a/apps/admin-frontend/src/features/catalog/application/use-cases/tags/DeleteTag.test.ts b/apps/admin-frontend/src/features/catalog/application/use-cases/tags/DeleteTag.test.ts deleted file mode 100644 index 7e78db9..0000000 --- a/apps/admin-frontend/src/features/catalog/application/use-cases/tags/DeleteTag.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { DeleteTag } from '@/features/catalog/application/use-cases/tags/DeleteTag' -import { createFakeTagRepository } from '@/features/catalog/application/test-helpers/createFakeTagRepository' -import { Tenant } from '@/test/fixtures/authEntityFixtures' -import { User } from '@/test/fixtures/authEntityFixtures' -import type { TenantContext } from '@/features/auth' - -function buildTenantContext(): TenantContext { - const tenant = Tenant.create('tenant-123') - return { tenant, user: User.create({ id: 'user-1', tenant }) } -} - -describe('DeleteTag', () => { - it('delegates to the tag repository with the tenant context and id', async () => { - const tenantContext = buildTenantContext() - const deleteSpy = vi.fn(() => Promise.resolve()) - const tagRepository = createFakeTagRepository({ delete: deleteSpy }) - - await new DeleteTag(tagRepository).execute(tenantContext, 'tag-1') - - expect(deleteSpy).toHaveBeenCalledExactlyOnceWith(tenantContext, 'tag-1') - }) -}) diff --git a/apps/admin-frontend/src/features/catalog/application/use-cases/tags/DeleteTag.ts b/apps/admin-frontend/src/features/catalog/application/use-cases/tags/DeleteTag.ts deleted file mode 100644 index fbd3027..0000000 --- a/apps/admin-frontend/src/features/catalog/application/use-cases/tags/DeleteTag.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { TagRepository } from '@/features/catalog/application/repositories/TagRepository' -import type { TenantContext } from '@/features/auth' - -export class DeleteTag { - private readonly tagRepository: TagRepository - - constructor(tagRepository: TagRepository) { - this.tagRepository = tagRepository - } - - execute(tenantContext: TenantContext, id: string): Promise { - return this.tagRepository.delete(tenantContext, id) - } -} diff --git a/apps/admin-frontend/src/features/catalog/application/use-cases/tags/ListTags.test.ts b/apps/admin-frontend/src/features/catalog/application/use-cases/tags/ListTags.test.ts deleted file mode 100644 index 7eb3908..0000000 --- a/apps/admin-frontend/src/features/catalog/application/use-cases/tags/ListTags.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { ListTags } from '@/features/catalog/application/use-cases/tags/ListTags' -import { createFakeTagRepository } from '@/features/catalog/application/test-helpers/createFakeTagRepository' -import { Tag } from '@/features/catalog/domain/entities/Tag' -import { Tenant } from '@/test/fixtures/authEntityFixtures' -import { User } from '@/test/fixtures/authEntityFixtures' -import type { TenantContext } from '@/features/auth' - -function buildTenantContext(): TenantContext { - const tenant = Tenant.create('tenant-123') - return { tenant, user: User.create({ id: 'user-1', tenant }) } -} - -describe('ListTags', () => { - it('delegates to the tag repository with the given tenant context', async () => { - const tenantContext = buildTenantContext() - const tag = Tag.create({ id: 'tag-1', name: 'VIP', color: '#0d9488' }) - const listAllSpy = vi.fn(() => Promise.resolve([tag])) - const tagRepository = createFakeTagRepository({ listAll: listAllSpy }) - - const result = await new ListTags(tagRepository).execute(tenantContext) - - expect(listAllSpy).toHaveBeenCalledExactlyOnceWith(tenantContext, undefined) - expect(result).toEqual([tag]) - }) -}) diff --git a/apps/admin-frontend/src/features/catalog/application/use-cases/tags/ListTags.ts b/apps/admin-frontend/src/features/catalog/application/use-cases/tags/ListTags.ts deleted file mode 100644 index 3537437..0000000 --- a/apps/admin-frontend/src/features/catalog/application/use-cases/tags/ListTags.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Tag } from '@/features/catalog/domain/entities/Tag' -import type { - TagRepository, - ListAllTagsOptions, -} from '@/features/catalog/application/repositories/TagRepository' -import type { TenantContext } from '@/features/auth' - -export class ListTags { - private readonly tagRepository: TagRepository - - constructor(tagRepository: TagRepository) { - this.tagRepository = tagRepository - } - - execute(tenantContext: TenantContext, options?: ListAllTagsOptions): Promise { - return this.tagRepository.listAll(tenantContext, options) - } -} diff --git a/apps/admin-frontend/src/features/catalog/application/use-cases/tags/UpdateTag.test.ts b/apps/admin-frontend/src/features/catalog/application/use-cases/tags/UpdateTag.test.ts deleted file mode 100644 index 12e61c8..0000000 --- a/apps/admin-frontend/src/features/catalog/application/use-cases/tags/UpdateTag.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { UpdateTag } from '@/features/catalog/application/use-cases/tags/UpdateTag' -import { createFakeTagRepository } from '@/features/catalog/application/test-helpers/createFakeTagRepository' -import { Tag } from '@/features/catalog/domain/entities/Tag' -import { Tenant } from '@/test/fixtures/authEntityFixtures' -import { User } from '@/test/fixtures/authEntityFixtures' -import type { TenantContext } from '@/features/auth' - -function buildTenantContext(): TenantContext { - const tenant = Tenant.create('tenant-123') - return { tenant, user: User.create({ id: 'user-1', tenant }) } -} - -describe('UpdateTag', () => { - it('delegates to the tag repository with the tenant context, id, and input', async () => { - const tenantContext = buildTenantContext() - const updated = Tag.create({ id: 'tag-1', name: 'Renamed', color: '#ef4444' }) - const updateSpy = vi.fn(() => Promise.resolve(updated)) - const tagRepository = createFakeTagRepository({ update: updateSpy }) - const input = { name: 'Renamed', color: '#ef4444' } - - const result = await new UpdateTag(tagRepository).execute(tenantContext, 'tag-1', input) - - expect(updateSpy).toHaveBeenCalledExactlyOnceWith(tenantContext, 'tag-1', input) - expect(result).toBe(updated) - }) -}) diff --git a/apps/admin-frontend/src/features/catalog/application/use-cases/tags/UpdateTag.ts b/apps/admin-frontend/src/features/catalog/application/use-cases/tags/UpdateTag.ts deleted file mode 100644 index 87dc13e..0000000 --- a/apps/admin-frontend/src/features/catalog/application/use-cases/tags/UpdateTag.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { Tag } from '@/features/catalog/domain/entities/Tag' -import type { - TagRepository, - UpdateTagInput, -} from '@/features/catalog/application/repositories/TagRepository' -import type { TenantContext } from '@/features/auth' - -export class UpdateTag { - private readonly tagRepository: TagRepository - - constructor(tagRepository: TagRepository) { - this.tagRepository = tagRepository - } - - execute(tenantContext: TenantContext, id: string, input: UpdateTagInput): Promise { - return this.tagRepository.update(tenantContext, id, input) - } -} diff --git a/apps/admin-frontend/src/features/catalog/domain/entities/Category.test.ts b/apps/admin-frontend/src/features/catalog/domain/entities/Category.test.ts index 904ca7d..79f7453 100644 --- a/apps/admin-frontend/src/features/catalog/domain/entities/Category.test.ts +++ b/apps/admin-frontend/src/features/catalog/domain/entities/Category.test.ts @@ -4,29 +4,45 @@ import { InvalidCategoryError } from '@/features/catalog/domain/errors/InvalidCa describe('Category', () => { it('creates a category with valid values', () => { - const category = Category.create({ id: 'category-1', name: 'Massagens' }) + const result = Category.create({ id: 'category-1', name: 'Massagens' }) - expect(category.id).toBe('category-1') - expect(category.name).toBe('Massagens') + expect(result.success).toBe(true) + if (!result.success) return + expect(result.value.id).toBe('category-1') + expect(result.value.name).toBe('Massagens') }) it('trims the name', () => { - const category = Category.create({ id: 'category-1', name: ' Massagens ' }) + const result = Category.create({ id: 'category-1', name: ' Massagens ' }) - expect(category.name).toBe('Massagens') + expect(result.success).toBe(true) + if (!result.success) return + expect(result.value.name).toBe('Massagens') }) - it('throws when the id is empty', () => { - expect(() => Category.create({ id: '', name: 'Massagens' })).toThrow(InvalidCategoryError) + it('fails when the id is empty', () => { + const result = Category.create({ id: '', name: 'Massagens' }) + + expect(result.success).toBe(false) + if (result.success) return + expect(result.error).toBeInstanceOf(InvalidCategoryError) }) - it('throws when the name is empty', () => { - expect(() => Category.create({ id: 'category-1', name: ' ' })).toThrow(InvalidCategoryError) + it('fails when the name is empty', () => { + const result = Category.create({ id: 'category-1', name: ' ' }) + + expect(result.success).toBe(false) + if (result.success) return + expect(result.error).toBeInstanceOf(InvalidCategoryError) }) - it('throws when the name is over 60 characters', () => { + it('fails when the name is over 60 characters', () => { const name = 'x'.repeat(61) - expect(() => Category.create({ id: 'category-1', name })).toThrow(InvalidCategoryError) + const result = Category.create({ id: 'category-1', name }) + + expect(result.success).toBe(false) + if (result.success) return + expect(result.error).toBeInstanceOf(InvalidCategoryError) }) }) diff --git a/apps/admin-frontend/src/features/catalog/domain/entities/Category.ts b/apps/admin-frontend/src/features/catalog/domain/entities/Category.ts index 92f8bb3..fb2ff7a 100644 --- a/apps/admin-frontend/src/features/catalog/domain/entities/Category.ts +++ b/apps/admin-frontend/src/features/catalog/domain/entities/Category.ts @@ -1,4 +1,5 @@ import { InvalidCategoryError } from '@/features/catalog/domain/errors/InvalidCategoryError' +import { failure, success, type Result } from '@/shared/application/Result' interface CreateCategoryInput { id: string @@ -15,16 +16,18 @@ export class Category { this.name = name } - static create(input: CreateCategoryInput): Category { + static create(input: CreateCategoryInput): Result { if (input.id.trim().length === 0) { - throw new InvalidCategoryError('O id da categoria não pode estar vazio') + return failure(new InvalidCategoryError('O id da categoria não pode estar vazio')) } const name = input.name.trim() if (name.length === 0 || name.length > 60) { - throw new InvalidCategoryError('O nome da categoria deve ter entre 1 e 60 caracteres') + return failure( + new InvalidCategoryError('O nome da categoria deve ter entre 1 e 60 caracteres'), + ) } - return new Category(input.id, name) + return success(new Category(input.id, name)) } } diff --git a/apps/admin-frontend/src/features/catalog/domain/entities/Service.test.ts b/apps/admin-frontend/src/features/catalog/domain/entities/Service.test.ts deleted file mode 100644 index 4b7b60c..0000000 --- a/apps/admin-frontend/src/features/catalog/domain/entities/Service.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { Service } from '@/features/catalog/domain/entities/Service' -import { InvalidServiceError } from '@/features/catalog/domain/errors/InvalidServiceError' - -function validInput( - overrides: Partial[0]> = {}, -): Parameters[0] { - return { - id: 'service-1', - code: 1001, - name: 'Massagem relaxante', - durationMinutes: 60, - minDurationMinutes: 30, - maxDurationMinutes: 90, - price: 150, - maxDiscountPercentage: 10, - tags: [], - ...overrides, - } -} - -describe('Service', () => { - it('creates a service with valid values', () => { - const service = Service.create( - validInput({ - description: 'Uma massagem relaxante de corpo inteiro', - categoryId: 'category-1', - categoryName: 'Massagens', - tags: [{ id: 'tag-1', name: 'VIP', color: '#0d9488' }], - }), - ) - - expect(service.id).toBe('service-1') - expect(service.code).toBe(1001) - expect(service.name).toBe('Massagem relaxante') - expect(service.description).toBe('Uma massagem relaxante de corpo inteiro') - expect(service.durationMinutes).toBe(60) - expect(service.minDurationMinutes).toBe(30) - expect(service.maxDurationMinutes).toBe(90) - expect(service.price).toBe(150) - expect(service.maxDiscountPercentage).toBe(10) - expect(service.categoryId).toBe('category-1') - expect(service.categoryName).toBe('Massagens') - expect(service.tags).toEqual([{ id: 'tag-1', name: 'VIP', color: '#0d9488' }]) - }) - - it('creates a service without a description, category, or tags', () => { - const service = Service.create(validInput()) - - expect(service.description).toBeUndefined() - expect(service.categoryId).toBeUndefined() - expect(service.categoryName).toBeUndefined() - expect(service.tags).toEqual([]) - }) - - it('throws when the id is empty', () => { - expect(() => Service.create(validInput({ id: '' }))).toThrow(InvalidServiceError) - }) - - it('throws when the name is empty', () => { - expect(() => Service.create(validInput({ name: ' ' }))).toThrow(InvalidServiceError) - }) - - it('throws when the name is over 80 characters', () => { - expect(() => Service.create(validInput({ name: 'x'.repeat(81) }))).toThrow(InvalidServiceError) - }) - - it('throws when the description is over 500 characters', () => { - expect(() => Service.create(validInput({ description: 'x'.repeat(501) }))).toThrow( - InvalidServiceError, - ) - }) - - it('allows a description of exactly 500 characters', () => { - const service = Service.create(validInput({ description: 'x'.repeat(500) })) - - expect(service.description).toHaveLength(500) - }) - - it('throws when minDurationMinutes is less than 1', () => { - expect(() => - Service.create(validInput({ minDurationMinutes: 0, durationMinutes: 30 })), - ).toThrow(InvalidServiceError) - }) - - it('throws when minDurationMinutes is greater than durationMinutes', () => { - expect(() => - Service.create(validInput({ minDurationMinutes: 61, durationMinutes: 60 })), - ).toThrow(InvalidServiceError) - }) - - it('throws when durationMinutes is greater than maxDurationMinutes', () => { - expect(() => - Service.create(validInput({ durationMinutes: 91, maxDurationMinutes: 90 })), - ).toThrow(InvalidServiceError) - }) - - it('throws when maxDurationMinutes exceeds 1440', () => { - expect(() => - Service.create( - validInput({ minDurationMinutes: 30, durationMinutes: 1441, maxDurationMinutes: 1441 }), - ), - ).toThrow(InvalidServiceError) - }) - - it('throws when price is negative', () => { - expect(() => Service.create(validInput({ price: -1 }))).toThrow(InvalidServiceError) - }) - - it('throws when maxDiscountPercentage is negative', () => { - expect(() => Service.create(validInput({ maxDiscountPercentage: -1 }))).toThrow( - InvalidServiceError, - ) - }) - - it('throws when maxDiscountPercentage is over 100', () => { - expect(() => Service.create(validInput({ maxDiscountPercentage: 101 }))).toThrow( - InvalidServiceError, - ) - }) - - describe('external numeric data validation (the API widens every numeric field to number | string)', () => { - it.each(['code', 'durationMinutes', 'minDurationMinutes', 'maxDurationMinutes'] as const)( - 'rejects %s as a fractional value even though the range checks would otherwise pass', - field => { - expect(() => Service.create(validInput({ [field]: 60.5 }))).toThrow(InvalidServiceError) - }, - ) - - it.each([ - 'code', - 'durationMinutes', - 'minDurationMinutes', - 'maxDurationMinutes', - 'price', - 'maxDiscountPercentage', - ] as const)('rejects %s as NaN', field => { - expect(() => Service.create(validInput({ [field]: Number.NaN }))).toThrow(InvalidServiceError) - }) - - it.each([ - 'code', - 'durationMinutes', - 'minDurationMinutes', - 'maxDurationMinutes', - 'price', - 'maxDiscountPercentage', - ] as const)('rejects %s as Infinity', field => { - expect(() => Service.create(validInput({ [field]: Number.POSITIVE_INFINITY }))).toThrow( - InvalidServiceError, - ) - }) - - it.each([ - 'code', - 'durationMinutes', - 'minDurationMinutes', - 'maxDurationMinutes', - 'price', - 'maxDiscountPercentage', - ] as const)( - 'rejects %s arriving as a string, as the generated ServiceResponse type allows', - field => { - // Simulates the real unsoundness: serviceMapper's ServiceDto cast - // claims `number`, but a genuinely untrusted response could still - // send a string for one of these fields. - const input = validInput({ [field]: '60' as unknown as number }) - expect(() => Service.create(input)).toThrow(InvalidServiceError) - }, - ) - - it('allows a fractional price and maxDiscountPercentage (decimal, not integer, fields)', () => { - const service = Service.create(validInput({ price: 149.99, maxDiscountPercentage: 12.5 })) - - expect(service.price).toBe(149.99) - expect(service.maxDiscountPercentage).toBe(12.5) - }) - }) - - it('stores tags as a defensive copy, not a reference to the caller-supplied array', () => { - const tags = [{ id: 'tag-1', name: 'VIP', color: '#0d9488' }] - const service = Service.create(validInput({ tags })) - - tags.push({ id: 'tag-2', name: 'Novo', color: '#ef4444' }) - - expect(service.tags).toHaveLength(1) - }) - - it('rejects mutating a tag element or the tags array at the type level', () => { - const service = Service.create( - validInput({ tags: [{ id: 'tag-1', name: 'VIP', color: '#0d9488' }] }), - ) - - // @ts-expect-error TagSummary fields are readonly - a consumer must not reassign them - service.tags[0].name = 'Renamed' - - // @ts-expect-error service.tags is a readonly array - index assignment is not permitted - service.tags[0] = { id: 'tag-2', name: 'Novo', color: '#ef4444' } - }) -}) diff --git a/apps/admin-frontend/src/features/catalog/domain/entities/Service.ts b/apps/admin-frontend/src/features/catalog/domain/entities/Service.ts deleted file mode 100644 index a458814..0000000 --- a/apps/admin-frontend/src/features/catalog/domain/entities/Service.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { InvalidServiceError } from '@/features/catalog/domain/errors/InvalidServiceError' - -// The real runtime check behind serviceMapper.ts's type-level `number` narrowing. -function isFiniteNumber(value: unknown): value is number { - return typeof value === 'number' && Number.isFinite(value) -} - -function isFiniteInteger(value: unknown): value is number { - return isFiniteNumber(value) && Number.isInteger(value) -} - -/** A tag summary as embedded on a Service (docs/API.md `TagSummaryDto`). */ -export interface TagSummary { - readonly id: string - readonly name: string - readonly color: string -} - -interface CreateServiceInput { - id: string - code: number - name: string - description?: string - durationMinutes: number - minDurationMinutes: number - maxDurationMinutes: number - price: number - maxDiscountPercentage: number - categoryId?: string - categoryName?: string - tags: TagSummary[] -} - -/** A service the business offers that clients can book (docs/DOMAIN.md "Service"). */ -export class Service { - readonly id: string - readonly code: number - readonly name: string - readonly description?: string - readonly durationMinutes: number - readonly minDurationMinutes: number - readonly maxDurationMinutes: number - readonly price: number - readonly maxDiscountPercentage: number - readonly categoryId?: string - readonly categoryName?: string - readonly tags: readonly TagSummary[] - - private constructor( - id: string, - code: number, - name: string, - durationMinutes: number, - minDurationMinutes: number, - maxDurationMinutes: number, - price: number, - maxDiscountPercentage: number, - tags: readonly TagSummary[], - description?: string, - categoryId?: string, - categoryName?: string, - ) { - this.id = id - this.code = code - this.name = name - this.durationMinutes = durationMinutes - this.minDurationMinutes = minDurationMinutes - this.maxDurationMinutes = maxDurationMinutes - this.price = price - this.maxDiscountPercentage = maxDiscountPercentage - this.tags = [...tags] // defensive copy - never store the caller's array by reference - if (description !== undefined) { - this.description = description - } - if (categoryId !== undefined) { - this.categoryId = categoryId - } - if (categoryName !== undefined) { - this.categoryName = categoryName - } - } - - static create(input: CreateServiceInput): Service { - if (input.id.trim().length === 0) { - throw new InvalidServiceError('O id do serviço não pode estar vazio') - } - - const name = input.name.trim() - if (name.length === 0 || name.length > 80) { - throw new InvalidServiceError('O nome do serviço deve ter entre 1 e 80 caracteres') - } - - if (!isFiniteInteger(input.code)) { - throw new InvalidServiceError('O código do serviço deve ser um número inteiro válido') - } - if (!isFiniteInteger(input.durationMinutes)) { - throw new InvalidServiceError('A duração deve ser um número inteiro de minutos válido') - } - if (!isFiniteInteger(input.minDurationMinutes)) { - throw new InvalidServiceError('A duração mínima deve ser um número inteiro de minutos válido') - } - if (!isFiniteInteger(input.maxDurationMinutes)) { - throw new InvalidServiceError('A duração máxima deve ser um número inteiro de minutos válido') - } - if (!isFiniteNumber(input.price)) { - throw new InvalidServiceError('O preço deve ser um número válido') - } - if (!isFiniteNumber(input.maxDiscountPercentage)) { - throw new InvalidServiceError('O desconto máximo deve ser um número válido') - } - - if (input.minDurationMinutes < 1) { - throw new InvalidServiceError('A duração mínima deve ser de pelo menos 1 minuto') - } - - if (input.minDurationMinutes > input.durationMinutes) { - throw new InvalidServiceError('A duração mínima não pode ser maior que a duração padrão') - } - - if (input.durationMinutes > input.maxDurationMinutes) { - throw new InvalidServiceError('A duração padrão não pode ser maior que a duração máxima') - } - - if (input.maxDurationMinutes > 1440) { - throw new InvalidServiceError('A duração máxima não pode exceder 1440 minutos (24 horas)') - } - - if (input.price < 0) { - throw new InvalidServiceError('O preço não pode ser negativo') - } - - if (input.maxDiscountPercentage < 0 || input.maxDiscountPercentage > 100) { - throw new InvalidServiceError('O desconto máximo deve estar entre 0 e 100%') - } - - const description = input.description?.trim() - if (description !== undefined && description.length > 500) { - throw new InvalidServiceError('A descrição do serviço deve ter no máximo 500 caracteres') - } - - return new Service( - input.id, - input.code, - name, - input.durationMinutes, - input.minDurationMinutes, - input.maxDurationMinutes, - input.price, - input.maxDiscountPercentage, - input.tags, - description !== '' ? description : undefined, - input.categoryId, - input.categoryName, - ) - } -} diff --git a/apps/admin-frontend/src/features/catalog/domain/entities/Tag.test.ts b/apps/admin-frontend/src/features/catalog/domain/entities/Tag.test.ts index 6d8f6eb..7bf5f90 100644 --- a/apps/admin-frontend/src/features/catalog/domain/entities/Tag.test.ts +++ b/apps/admin-frontend/src/features/catalog/domain/entities/Tag.test.ts @@ -4,51 +4,71 @@ import { InvalidTagError } from '@/features/catalog/domain/errors/InvalidTagErro describe('Tag', () => { it('creates a tag with valid values', () => { - const tag = Tag.create({ + const result = Tag.create({ id: 'tag-1', name: 'VIP', color: '#0d9488', description: 'High-value client', }) - expect(tag.id).toBe('tag-1') - expect(tag.name).toBe('VIP') - expect(tag.color).toBe('#0d9488') - expect(tag.description).toBe('High-value client') + expect(result.success).toBe(true) + if (!result.success) return + expect(result.value.id).toBe('tag-1') + expect(result.value.name).toBe('VIP') + expect(result.value.color).toBe('#0d9488') + expect(result.value.description).toBe('High-value client') }) it('creates a tag without a description', () => { - const tag = Tag.create({ id: 'tag-1', name: 'VIP', color: '#0d9488' }) + const result = Tag.create({ id: 'tag-1', name: 'VIP', color: '#0d9488' }) - expect(tag.description).toBeUndefined() + expect(result.success).toBe(true) + if (!result.success) return + expect(result.value.description).toBeUndefined() }) - it('throws when the id is empty', () => { - expect(() => Tag.create({ id: '', name: 'VIP', color: '#0d9488' })).toThrow(InvalidTagError) + it('fails when the id is empty', () => { + const result = Tag.create({ id: '', name: 'VIP', color: '#0d9488' }) + + expect(result.success).toBe(false) + if (result.success) return + expect(result.error).toBeInstanceOf(InvalidTagError) }) - it('throws when the name is empty', () => { - expect(() => Tag.create({ id: 'tag-1', name: ' ', color: '#0d9488' })).toThrow(InvalidTagError) + it('fails when the name is empty', () => { + const result = Tag.create({ id: 'tag-1', name: ' ', color: '#0d9488' }) + + expect(result.success).toBe(false) + if (result.success) return + expect(result.error).toBeInstanceOf(InvalidTagError) }) - it('throws when the name is over 40 characters', () => { + it('fails when the name is over 40 characters', () => { const name = 'x'.repeat(41) - expect(() => Tag.create({ id: 'tag-1', name, color: '#0d9488' })).toThrow(InvalidTagError) + const result = Tag.create({ id: 'tag-1', name, color: '#0d9488' }) + + expect(result.success).toBe(false) + if (result.success) return + expect(result.error).toBeInstanceOf(InvalidTagError) }) - it('throws when the color is not in the fixed palette', () => { - expect(() => Tag.create({ id: 'tag-1', name: 'VIP', color: '#123456' })).toThrow( - InvalidTagError, - ) + it('fails when the color is not in the fixed palette', () => { + const result = Tag.create({ id: 'tag-1', name: 'VIP', color: '#123456' }) + + expect(result.success).toBe(false) + if (result.success) return + expect(result.error).toBeInstanceOf(InvalidTagError) }) - it('throws when the description is over 200 characters', () => { + it('fails when the description is over 200 characters', () => { const description = 'x'.repeat(201) - expect(() => Tag.create({ id: 'tag-1', name: 'VIP', color: '#0d9488', description })).toThrow( - InvalidTagError, - ) + const result = Tag.create({ id: 'tag-1', name: 'VIP', color: '#0d9488', description }) + + expect(result.success).toBe(false) + if (result.success) return + expect(result.error).toBeInstanceOf(InvalidTagError) }) it('exposes the fixed color palette', () => { diff --git a/apps/admin-frontend/src/features/catalog/domain/entities/Tag.ts b/apps/admin-frontend/src/features/catalog/domain/entities/Tag.ts index 22e059c..640e08b 100644 --- a/apps/admin-frontend/src/features/catalog/domain/entities/Tag.ts +++ b/apps/admin-frontend/src/features/catalog/domain/entities/Tag.ts @@ -1,4 +1,5 @@ import { InvalidTagError } from '@/features/catalog/domain/errors/InvalidTagError' +import { failure, success, type Result } from '@/shared/application/Result' /** The only accepted `color` values (docs/API.md) - keeps tags visually consistent. */ export const TAG_COLOR_PALETTE = [ @@ -37,28 +38,34 @@ export class Tag { } } - static create(input: CreateTagInput): Tag { + static create(input: CreateTagInput): Result { if (input.id.trim().length === 0) { - throw new InvalidTagError('O id da etiqueta não pode estar vazio') + return failure(new InvalidTagError('O id da etiqueta não pode estar vazio')) } const name = input.name.trim() if (name.length === 0 || name.length > 40) { - throw new InvalidTagError('O nome da etiqueta deve ter entre 1 e 40 caracteres') + return failure(new InvalidTagError('O nome da etiqueta deve ter entre 1 e 40 caracteres')) } if (!isTagColor(input.color)) { - throw new InvalidTagError( - `A cor da etiqueta deve ser uma das seguintes: ${TAG_COLOR_PALETTE.join(', ')}`, + return failure( + new InvalidTagError( + `A cor da etiqueta deve ser uma das seguintes: ${TAG_COLOR_PALETTE.join(', ')}`, + ), ) } const description = input.description?.trim() if (description !== undefined && description.length > 200) { - throw new InvalidTagError('A descrição da etiqueta deve ter no máximo 200 caracteres') + return failure( + new InvalidTagError('A descrição da etiqueta deve ter no máximo 200 caracteres'), + ) } - return new Tag(input.id, name, input.color, description !== '' ? description : undefined) + return success( + new Tag(input.id, name, input.color, description !== '' ? description : undefined), + ) } } diff --git a/apps/admin-frontend/src/features/catalog/domain/errors/InvalidServiceError.ts b/apps/admin-frontend/src/features/catalog/domain/errors/InvalidServiceError.ts deleted file mode 100644 index 0e02ac1..0000000 --- a/apps/admin-frontend/src/features/catalog/domain/errors/InvalidServiceError.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { DomainError } from '@/shared/domain/DomainError' - -export class InvalidServiceError extends DomainError {} diff --git a/apps/admin-frontend/src/features/catalog/index.ts b/apps/admin-frontend/src/features/catalog/index.ts index 7f9e1f5..c12575e 100644 --- a/apps/admin-frontend/src/features/catalog/index.ts +++ b/apps/admin-frontend/src/features/catalog/index.ts @@ -1,28 +1,13 @@ // Public API of the catalog feature (ADR 009) - the only path other features -// and app/ may import catalog internals through. TagsPage/CategoriesPage/ -// ServicesPage are deliberately NOT re-exported here: app/routes/router.tsx -// lazy-loads them by their own module path so Vite keeps each on its own -// chunk - importing them through this barrel would bundle all three -// together and defeat that code-splitting. +// and app/ may import catalog internals through. TagsPage/CategoriesPage are +// deliberately NOT re-exported here: app/routes/router.tsx lazy-loads them +// by their own module path so Vite keeps each on its own chunk - importing +// them through this barrel would bundle them together and defeat that +// code-splitting. export type { TagRepository } from './application/repositories/TagRepository' export type { CategoryRepository } from './application/repositories/CategoryRepository' -export type { ServiceRepository } from './application/repositories/ServiceRepository' - -export { ListTags } from './application/use-cases/tags/ListTags' -export { CreateTag } from './application/use-cases/tags/CreateTag' -export { UpdateTag } from './application/use-cases/tags/UpdateTag' -export { DeleteTag } from './application/use-cases/tags/DeleteTag' -export { ListCategories } from './application/use-cases/categories/ListCategories' -export { CreateCategory } from './application/use-cases/categories/CreateCategory' -export { UpdateCategory } from './application/use-cases/categories/UpdateCategory' -export { DeleteCategory } from './application/use-cases/categories/DeleteCategory' -export { ListServices } from './application/use-cases/services/ListServices' -export { CreateService } from './application/use-cases/services/CreateService' -export { UpdateService } from './application/use-cases/services/UpdateService' -export { DeleteService } from './application/use-cases/services/DeleteService' // Composition-root-only wiring (docs/adr/008) - not for use outside app/composition. export { ApiTagRepository } from './infrastructure/repositories/ApiTagRepository' export { ApiCategoryRepository } from './infrastructure/repositories/ApiCategoryRepository' -export { ApiServiceRepository } from './infrastructure/repositories/ApiServiceRepository' diff --git a/apps/admin-frontend/src/features/catalog/infrastructure/mappers/categoryMapper.test.ts b/apps/admin-frontend/src/features/catalog/infrastructure/mappers/categoryMapper.test.ts index 540afab..ceb41ff 100644 --- a/apps/admin-frontend/src/features/catalog/infrastructure/mappers/categoryMapper.test.ts +++ b/apps/admin-frontend/src/features/catalog/infrastructure/mappers/categoryMapper.test.ts @@ -4,20 +4,23 @@ import { decodeCategoryDto, decodeCategoryDtoArray, } from '@/features/catalog/infrastructure/mappers/categoryMapper' -import { InvalidCategoryError } from '@/features/catalog/domain/errors/InvalidCategoryError' describe('mapCategoryDtoToDomain', () => { it('maps every field from the DTO', () => { - const category = mapCategoryDtoToDomain({ id: 'category-1', name: 'Massagens' }) + const result = mapCategoryDtoToDomain({ id: 'category-1', name: 'Massagens' }) - expect(category.id).toBe('category-1') - expect(category.name).toBe('Massagens') + expect(result.success).toBe(true) + if (!result.success) return + expect(result.value.id).toBe('category-1') + expect(result.value.name).toBe('Massagens') }) - it('propagates the domain validation failure for an empty name', () => { - expect(() => mapCategoryDtoToDomain({ id: 'category-1', name: ' ' })).toThrow( - InvalidCategoryError, - ) + it('maps the domain validation failure for an empty name to a curated AppError', () => { + const result = mapCategoryDtoToDomain({ id: 'category-1', name: ' ' }) + + expect(result.success).toBe(false) + if (result.success) return + expect(result.error.code).toBe('unexpected') }) }) diff --git a/apps/admin-frontend/src/features/catalog/infrastructure/mappers/categoryMapper.ts b/apps/admin-frontend/src/features/catalog/infrastructure/mappers/categoryMapper.ts index 8195afc..b3a87bf 100644 --- a/apps/admin-frontend/src/features/catalog/infrastructure/mappers/categoryMapper.ts +++ b/apps/admin-frontend/src/features/catalog/infrastructure/mappers/categoryMapper.ts @@ -1,5 +1,8 @@ import { Category } from '@/features/catalog/domain/entities/Category' import type { components } from '@/features/catalog/infrastructure/generated/services-api' +import type { AppError } from '@/shared/application/AppError' +import { failure, type Result } from '@/shared/application/Result' +import { malformedResponseError } from '@/shared/infrastructure/http/malformedResponseError' /** The CategoryDto shape - generated from the live OpenAPI contract, not * hand-maintained (see src/features/catalog/infrastructure/generated/services-api.d.ts). */ @@ -29,6 +32,7 @@ export function decodeCategoryDtoArray(payload: unknown): CategoryDto[] { return payload } -export function mapCategoryDtoToDomain(dto: CategoryDto): Category { - return Category.create({ id: dto.id, name: dto.name }) +export function mapCategoryDtoToDomain(dto: CategoryDto): Result { + const result = Category.create({ id: dto.id, name: dto.name }) + return result.success ? result : failure(malformedResponseError()) } diff --git a/apps/admin-frontend/src/features/catalog/infrastructure/mappers/serviceMapper.test.ts b/apps/admin-frontend/src/features/catalog/infrastructure/mappers/serviceMapper.test.ts deleted file mode 100644 index a206527..0000000 --- a/apps/admin-frontend/src/features/catalog/infrastructure/mappers/serviceMapper.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { - mapServiceDtoToDomain, - decodeServiceDto, - decodePagedServiceDto, - type ServiceDto, -} from '@/features/catalog/infrastructure/mappers/serviceMapper' -import { InvalidServiceError } from '@/features/catalog/domain/errors/InvalidServiceError' - -function buildDto(overrides: Partial = {}): ServiceDto { - return { - id: 'service-1', - code: 1001, - name: 'Massagem relaxante', - description: null, - durationMinutes: 60, - minDurationMinutes: 30, - maxDurationMinutes: 90, - price: 150, - maxDiscountPercentage: 10, - categoryId: null, - categoryName: null, - tags: [], - ...overrides, - } -} - -describe('mapServiceDtoToDomain', () => { - it('maps every field from the DTO', () => { - const service = mapServiceDtoToDomain( - buildDto({ - description: 'Uma massagem relaxante', - categoryId: 'category-1', - categoryName: 'Massagens', - tags: [{ id: 'tag-1', name: 'VIP', color: '#0d9488' }], - }), - ) - - expect(service.id).toBe('service-1') - expect(service.code).toBe(1001) - expect(service.name).toBe('Massagem relaxante') - expect(service.description).toBe('Uma massagem relaxante') - expect(service.durationMinutes).toBe(60) - expect(service.minDurationMinutes).toBe(30) - expect(service.maxDurationMinutes).toBe(90) - expect(service.price).toBe(150) - expect(service.maxDiscountPercentage).toBe(10) - expect(service.categoryId).toBe('category-1') - expect(service.categoryName).toBe('Massagens') - expect(service.tags).toEqual([{ id: 'tag-1', name: 'VIP', color: '#0d9488' }]) - }) - - it('maps null description, categoryId, and categoryName to undefined', () => { - const service = mapServiceDtoToDomain(buildDto()) - - expect(service.description).toBeUndefined() - expect(service.categoryId).toBeUndefined() - expect(service.categoryName).toBeUndefined() - }) - - it('propagates the domain validation failure for an invalid duration range', () => { - expect(() => - mapServiceDtoToDomain(buildDto({ minDurationMinutes: 100, durationMinutes: 60 })), - ).toThrow(InvalidServiceError) - }) -}) - -describe('decodeServiceDto', () => { - it('accepts a well-formed payload', () => { - const dto = buildDto() - - expect(decodeServiceDto(dto)).toEqual(dto) - }) - - it('accepts a numeric field arriving as a string, deferring the real check to Service.create', () => { - const dto = { ...buildDto(), durationMinutes: '60' as unknown as number } - - expect(decodeServiceDto(dto)).toEqual(dto) - }) - - it('rejects a payload missing a required property', () => { - const withoutName: Record = { ...buildDto() } - delete withoutName.name - - expect(() => decodeServiceDto(withoutName)).toThrow() - }) - - it('rejects a payload with a wrong-typed property', () => { - expect(() => decodeServiceDto({ ...buildDto(), id: 42 })).toThrow() - }) - - it('rejects a payload whose tags are not an array of TagSummary', () => { - expect(() => decodeServiceDto({ ...buildDto(), tags: [{ id: 'tag-1' }] })).toThrow() - }) - - it('rejects a non-object payload', () => { - expect(() => decodeServiceDto(null)).toThrow() - expect(() => decodeServiceDto('not an object')).toThrow() - }) -}) - -describe('decodePagedServiceDto', () => { - function buildEnvelope(overrides: Record = {}): Record { - return { - items: [buildDto()], - totalCount: 1, - page: 1, - pageSize: 20, - ...overrides, - } - } - - it('accepts a well-formed envelope', () => { - const envelope = decodePagedServiceDto(buildEnvelope()) - - expect(envelope.items).toEqual([buildDto()]) - expect(envelope.totalCount).toBe(1) - expect(envelope.page).toBe(1) - expect(envelope.pageSize).toBe(20) - }) - - it('coerces numeric-string pagination metadata into real numbers', () => { - const envelope = decodePagedServiceDto(buildEnvelope({ totalCount: '45', page: '2' })) - - expect(envelope.totalCount).toBe(45) - expect(envelope.page).toBe(2) - }) - - it('rejects an envelope whose items are not an array', () => { - expect(() => decodePagedServiceDto(buildEnvelope({ items: buildDto() }))).toThrow() - }) - - it('rejects an envelope containing a malformed item', () => { - expect(() => decodePagedServiceDto(buildEnvelope({ items: [{}] }))).toThrow() - }) - - it('rejects an envelope with a non-numeric totalCount', () => { - expect(() => decodePagedServiceDto(buildEnvelope({ totalCount: 'not a number' }))).toThrow() - }) - - it('rejects an envelope missing pagination metadata entirely', () => { - expect(() => decodePagedServiceDto({ items: [buildDto()] })).toThrow() - }) - - it('rejects a non-object envelope', () => { - expect(() => decodePagedServiceDto(null)).toThrow() - expect(() => decodePagedServiceDto([buildDto()])).toThrow() - }) -}) diff --git a/apps/admin-frontend/src/features/catalog/infrastructure/mappers/serviceMapper.ts b/apps/admin-frontend/src/features/catalog/infrastructure/mappers/serviceMapper.ts deleted file mode 100644 index 2c019df..0000000 --- a/apps/admin-frontend/src/features/catalog/infrastructure/mappers/serviceMapper.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { Service } from '@/features/catalog/domain/entities/Service' -import type { components } from '@/features/catalog/infrastructure/generated/services-api' - -/** The TagSummaryDto shape embedded on a ServiceDto - generated from the - * live OpenAPI contract (see src/features/catalog/infrastructure/generated/services-api.d.ts). */ -export type TagSummaryDto = components['schemas']['TagSummary'] - -type NumericServiceFields = - | 'code' - | 'durationMinutes' - | 'minDurationMinutes' - | 'maxDurationMinutes' - | 'price' - | 'maxDiscountPercentage' - -// The ASP.NET Core OpenAPI generator types every numeric field as -// `number | string` for value types (not a real API behavior difference - -// it only ever sends JSON numbers); narrowed back to `number` here. -export type ServiceDto = Omit & - Record - -/** The GET /api/v1/services envelope shape - generated from the live - * OpenAPI contract, with the same numeric narrowing as ServiceDto. */ -export type PagedServiceDto = Omit< - components['schemas']['PagedResultOfServiceResponse'], - 'items' | 'totalCount' | 'page' | 'pageSize' -> & { - items: ServiceDto[] - totalCount: number - page: number - pageSize: number -} - -function isNullableString(value: unknown): value is string | null { - return value === null || typeof value === 'string' -} - -// Only rules out non-numeric-shaped values - Service.create() does the real -// finite/integer narrowing (docs/adr/010). -function isPlausibleNumeric(value: unknown): value is number | string { - return typeof value === 'number' || typeof value === 'string' -} - -function isTagSummaryDto(value: unknown): value is TagSummaryDto { - if (typeof value !== 'object' || value === null) { - return false - } - const record = value as Record - return ( - typeof record.id === 'string' && - typeof record.name === 'string' && - typeof record.color === 'string' - ) -} - -function isServiceDto(value: unknown): value is ServiceDto { - if (typeof value !== 'object' || value === null) { - return false - } - const record = value as Record - return ( - typeof record.id === 'string' && - isPlausibleNumeric(record.code) && - typeof record.name === 'string' && - isNullableString(record.description) && - isPlausibleNumeric(record.durationMinutes) && - isPlausibleNumeric(record.minDurationMinutes) && - isPlausibleNumeric(record.maxDurationMinutes) && - isPlausibleNumeric(record.price) && - isPlausibleNumeric(record.maxDiscountPercentage) && - isNullableString(record.categoryId) && - isNullableString(record.categoryName) && - Array.isArray(record.tags) && - record.tags.every(isTagSummaryDto) - ) -} - -// Pagination metadata has no domain entity to validate it - checked here. -function toFiniteNumber(value: unknown): number | null { - if (typeof value === 'number' && Number.isFinite(value)) { - return value - } - if (typeof value === 'string' && value.trim() !== '') { - const parsed = Number(value) - if (Number.isFinite(parsed)) { - return parsed - } - } - return null -} - -/** Validates an untrusted response body as a ServiceDto before any mapper trusts its shape (docs/adr/011). */ -export function decodeServiceDto(payload: unknown): ServiceDto { - if (!isServiceDto(payload)) { - throw new Error('Malformed service payload received from the API') - } - return payload -} - -/** Validates the GET /api/v1/services paged envelope, including its pagination metadata (docs/adr/011). */ -export function decodePagedServiceDto(payload: unknown): PagedServiceDto { - if (typeof payload !== 'object' || payload === null) { - throw new Error('Malformed paged service list payload received from the API') - } - const record = payload as Record - const totalCount = toFiniteNumber(record.totalCount) - const page = toFiniteNumber(record.page) - const pageSize = toFiniteNumber(record.pageSize) - - if ( - !Array.isArray(record.items) || - !record.items.every(isServiceDto) || - totalCount === null || - page === null || - pageSize === null - ) { - throw new Error('Malformed paged service list payload received from the API') - } - - return { items: record.items, totalCount, page, pageSize } -} - -export function mapServiceDtoToDomain(dto: ServiceDto): Service { - return Service.create({ - id: dto.id, - code: dto.code, - name: dto.name, - durationMinutes: dto.durationMinutes, - minDurationMinutes: dto.minDurationMinutes, - maxDurationMinutes: dto.maxDurationMinutes, - price: dto.price, - maxDiscountPercentage: dto.maxDiscountPercentage, - tags: dto.tags, - ...(dto.description !== null ? { description: dto.description } : {}), - ...(dto.categoryId !== null ? { categoryId: dto.categoryId } : {}), - ...(dto.categoryName !== null ? { categoryName: dto.categoryName } : {}), - }) -} diff --git a/apps/admin-frontend/src/features/catalog/infrastructure/mappers/tagMapper.test.ts b/apps/admin-frontend/src/features/catalog/infrastructure/mappers/tagMapper.test.ts index f6f946f..1cdbf3f 100644 --- a/apps/admin-frontend/src/features/catalog/infrastructure/mappers/tagMapper.test.ts +++ b/apps/admin-frontend/src/features/catalog/infrastructure/mappers/tagMapper.test.ts @@ -4,33 +4,48 @@ import { decodeTagDto, decodeTagDtoArray, } from '@/features/catalog/infrastructure/mappers/tagMapper' -import { InvalidTagError } from '@/features/catalog/domain/errors/InvalidTagError' describe('mapTagDtoToDomain', () => { it('maps every field from the DTO', () => { - const tag = mapTagDtoToDomain({ + const result = mapTagDtoToDomain({ id: 'tag-1', name: 'VIP', color: '#0d9488', description: 'High-value client', }) - expect(tag.id).toBe('tag-1') - expect(tag.name).toBe('VIP') - expect(tag.color).toBe('#0d9488') - expect(tag.description).toBe('High-value client') + expect(result.success).toBe(true) + if (!result.success) return + expect(result.value.id).toBe('tag-1') + expect(result.value.name).toBe('VIP') + expect(result.value.color).toBe('#0d9488') + expect(result.value.description).toBe('High-value client') }) it('maps a null description to undefined', () => { - const tag = mapTagDtoToDomain({ id: 'tag-1', name: 'VIP', color: '#0d9488', description: null }) + const result = mapTagDtoToDomain({ + id: 'tag-1', + name: 'VIP', + color: '#0d9488', + description: null, + }) - expect(tag.description).toBeUndefined() + expect(result.success).toBe(true) + if (!result.success) return + expect(result.value.description).toBeUndefined() }) - it('propagates the domain validation failure for an invalid color', () => { - expect(() => - mapTagDtoToDomain({ id: 'tag-1', name: 'VIP', color: '#123456', description: null }), - ).toThrow(InvalidTagError) + it('maps the domain validation failure for an invalid color to a curated AppError', () => { + const result = mapTagDtoToDomain({ + id: 'tag-1', + name: 'VIP', + color: '#123456', + description: null, + }) + + expect(result.success).toBe(false) + if (result.success) return + expect(result.error.code).toBe('unexpected') }) }) diff --git a/apps/admin-frontend/src/features/catalog/infrastructure/mappers/tagMapper.ts b/apps/admin-frontend/src/features/catalog/infrastructure/mappers/tagMapper.ts index e9d1fbd..f03a7d4 100644 --- a/apps/admin-frontend/src/features/catalog/infrastructure/mappers/tagMapper.ts +++ b/apps/admin-frontend/src/features/catalog/infrastructure/mappers/tagMapper.ts @@ -1,5 +1,8 @@ import { Tag } from '@/features/catalog/domain/entities/Tag' import type { components } from '@/features/catalog/infrastructure/generated/services-api' +import type { AppError } from '@/shared/application/AppError' +import { failure, type Result } from '@/shared/application/Result' +import { malformedResponseError } from '@/shared/infrastructure/http/malformedResponseError' /** The TagDto shape - generated from the live OpenAPI contract, not * hand-maintained (see src/features/catalog/infrastructure/generated/services-api.d.ts). */ @@ -34,11 +37,12 @@ export function decodeTagDtoArray(payload: unknown): TagDto[] { return payload } -export function mapTagDtoToDomain(dto: TagDto): Tag { - return Tag.create({ +export function mapTagDtoToDomain(dto: TagDto): Result { + const result = Tag.create({ id: dto.id, name: dto.name, color: dto.color, ...(dto.description !== null ? { description: dto.description } : {}), }) + return result.success ? result : failure(malformedResponseError()) } diff --git a/apps/admin-frontend/src/features/catalog/infrastructure/repositories/ApiCategoryRepository.test.ts b/apps/admin-frontend/src/features/catalog/infrastructure/repositories/ApiCategoryRepository.test.ts index c4c29b1..53a090f 100644 --- a/apps/admin-frontend/src/features/catalog/infrastructure/repositories/ApiCategoryRepository.test.ts +++ b/apps/admin-frontend/src/features/catalog/infrastructure/repositories/ApiCategoryRepository.test.ts @@ -4,9 +4,6 @@ import { server } from '@/test/mocks/server' import { ApiCategoryRepository } from '@/features/catalog/infrastructure/repositories/ApiCategoryRepository' import { AuthenticatedHttpClient } from '@/shared/infrastructure/http/AuthenticatedHttpClient' import { categoryFixture } from '@/test/mocks/handlers/categoryHandlers' -import { Tenant } from '@/test/fixtures/authEntityFixtures' -import { User } from '@/test/fixtures/authEntityFixtures' -import type { TenantContext } from '@/features/auth' const baseUrl = 'https://api.test' @@ -17,20 +14,17 @@ function buildRepository(): ApiCategoryRepository { return new ApiCategoryRepository(httpClient) } -function buildTenantContext(): TenantContext { - const tenant = Tenant.create('tenant-123') - return { tenant, user: User.create({ id: 'user-1', tenant }) } -} - describe('ApiCategoryRepository', () => { it('lists categories mapped to domain entities', async () => { const repository = buildRepository() - const categories = await repository.listAll(buildTenantContext()) + const result = await repository.listAll() - expect(categories).toHaveLength(1) - expect(categories[0]?.id).toBe(categoryFixture.id) - expect(categories[0]?.name).toBe(categoryFixture.name) + expect(result.success).toBe(true) + if (!result.success) return + expect(result.value).toHaveLength(1) + expect(result.value[0]?.id).toBe(categoryFixture.id) + expect(result.value[0]?.name).toBe(categoryFixture.name) }) it('sends the search term as a query parameter', async () => { @@ -42,7 +36,7 @@ describe('ApiCategoryRepository', () => { ) const repository = buildRepository() - await repository.listAll(buildTenantContext(), { search: 'massa' }) + await repository.listAll({ search: 'massa' }) }) it('omits the search query parameter when the search term is blank', async () => { @@ -54,7 +48,7 @@ describe('ApiCategoryRepository', () => { ) const repository = buildRepository() - await repository.listAll(buildTenantContext(), { search: ' ' }) + await repository.listAll({ search: ' ' }) }) it('creates a category and returns the mapped result', async () => { @@ -66,9 +60,40 @@ describe('ApiCategoryRepository', () => { ) const repository = buildRepository() - const category = await repository.create(buildTenantContext(), { name: 'Massagens' }) + const result = await repository.create({ name: 'Massagens' }) + + expect(result.success).toBe(true) + if (!result.success) return + expect(result.value.id).toBe(categoryFixture.id) + }) + + it('gets a category by id', async () => { + server.use( + http.get(`${baseUrl}/api/v1/categories/category-1`, () => HttpResponse.json(categoryFixture)), + ) + const repository = buildRepository() + + const result = await repository.getById('category-1') - expect(category.id).toBe(categoryFixture.id) + expect(result.success).toBe(true) + if (!result.success) return + expect(result.value.id).toBe(categoryFixture.id) + expect(result.value.name).toBe(categoryFixture.name) + }) + + it('maps a missing category to a notFound AppError', async () => { + server.use( + http.get(`${baseUrl}/api/v1/categories/missing`, () => + HttpResponse.json({ title: "Categoria 'missing' não foi encontrada." }, { status: 404 }), + ), + ) + const repository = buildRepository() + + const result = await repository.getById('missing') + + expect(result.success).toBe(false) + if (result.success) return + expect(result.error.code).toBe('notFound') }) it('updates a category at the correct path', async () => { @@ -83,11 +108,11 @@ describe('ApiCategoryRepository', () => { ) const repository = buildRepository() - const category = await repository.update(buildTenantContext(), 'category-1', { - name: 'Renamed', - }) + const result = await repository.update('category-1', { name: 'Renamed' }) - expect(category.name).toBe('Renamed') + expect(result.success).toBe(true) + if (!result.success) return + expect(result.value.name).toBe('Renamed') }) it('deletes a category at the correct path', async () => { @@ -100,7 +125,7 @@ describe('ApiCategoryRepository', () => { ) const repository = buildRepository() - await repository.delete(buildTenantContext(), 'category-1') + await repository.delete('category-1') expect(deleteWasCalled).toBe(true) }) @@ -113,8 +138,10 @@ describe('ApiCategoryRepository', () => { ) const repository = buildRepository() - await expect(repository.listAll(buildTenantContext())).rejects.toThrow( - 'Não foi possível concluir a operação. Tente novamente.', - ) + const result = await repository.listAll() + + expect(result.success).toBe(false) + if (result.success) return + expect(result.error.message).toBe('Não foi possível concluir a operação. Tente novamente.') }) }) diff --git a/apps/admin-frontend/src/features/catalog/infrastructure/repositories/ApiCategoryRepository.ts b/apps/admin-frontend/src/features/catalog/infrastructure/repositories/ApiCategoryRepository.ts index eec2201..0e0891d 100644 --- a/apps/admin-frontend/src/features/catalog/infrastructure/repositories/ApiCategoryRepository.ts +++ b/apps/admin-frontend/src/features/catalog/infrastructure/repositories/ApiCategoryRepository.ts @@ -6,7 +6,8 @@ import type { UpdateCategoryInput, } from '@/features/catalog/application/repositories/CategoryRepository' import type { HttpClient } from '@/shared/application/HttpClient' -import type { TenantContext } from '@/features/auth' +import type { AppError } from '@/shared/application/AppError' +import { flatMapResult, combineResults, type Result } from '@/shared/application/Result' import { mapCategoryDtoToDomain, decodeCategoryDto, @@ -16,14 +17,9 @@ import type { components } from '@/features/catalog/infrastructure/generated/ser const CATEGORIES_URL = '/api/v1/categories' -// The route id is always keyed into the PUT body too (docs/adr/010) so the -// two are structurally incapable of diverging, even though the backend -// controller only ever trusts the route id. type CreateCategoryRequestBody = components['schemas']['CreateCategoryCommand'] type UpdateCategoryRequestBody = components['schemas']['UpdateCategoryCommand'] -// tenantContext is accepted for structural enforcement only - tenant scope -// travels in the X-Tenant-Id header the HttpClient attaches. export class ApiCategoryRepository implements CategoryRepository { private readonly httpClient: HttpClient @@ -31,36 +27,34 @@ export class ApiCategoryRepository implements CategoryRepository { this.httpClient = httpClient } - async listAll( - _tenantContext: TenantContext, - options: ListAllCategoriesOptions = {}, - ): Promise { + async listAll(options: ListAllCategoriesOptions = {}): Promise> { const query = new URLSearchParams() if (options.search !== undefined && options.search.trim() !== '') { query.set('search', options.search.trim()) } const suffix = query.toString() === '' ? '' : `?${query.toString()}` - const dtos = await this.httpClient.get(`${CATEGORIES_URL}${suffix}`, decodeCategoryDtoArray) - return dtos.map(mapCategoryDtoToDomain) + const result = await this.httpClient.get(`${CATEGORIES_URL}${suffix}`, decodeCategoryDtoArray) + return flatMapResult(result, dtos => combineResults(dtos.map(mapCategoryDtoToDomain))) } - async create(_tenantContext: TenantContext, input: CreateCategoryInput): Promise { + async getById(id: string): Promise> { + const result = await this.httpClient.get(`${CATEGORIES_URL}/${id}`, decodeCategoryDto) + return flatMapResult(result, mapCategoryDtoToDomain) + } + + async create(input: CreateCategoryInput): Promise> { const body = { name: input.name } satisfies CreateCategoryRequestBody - const dto = await this.httpClient.post(CATEGORIES_URL, body, decodeCategoryDto) - return mapCategoryDtoToDomain(dto) + const result = await this.httpClient.post(CATEGORIES_URL, body, decodeCategoryDto) + return flatMapResult(result, mapCategoryDtoToDomain) } - async update( - _tenantContext: TenantContext, - id: string, - input: UpdateCategoryInput, - ): Promise { + async update(id: string, input: UpdateCategoryInput): Promise> { const body: UpdateCategoryRequestBody = { categoryId: id, name: input.name } - const dto = await this.httpClient.put(`${CATEGORIES_URL}/${id}`, body, decodeCategoryDto) - return mapCategoryDtoToDomain(dto) + const result = await this.httpClient.put(`${CATEGORIES_URL}/${id}`, body, decodeCategoryDto) + return flatMapResult(result, mapCategoryDtoToDomain) } - async delete(_tenantContext: TenantContext, id: string): Promise { - await this.httpClient.delete(`${CATEGORIES_URL}/${id}`) + async delete(id: string): Promise> { + return this.httpClient.delete(`${CATEGORIES_URL}/${id}`) } } diff --git a/apps/admin-frontend/src/features/catalog/infrastructure/repositories/ApiServiceRepository.test.ts b/apps/admin-frontend/src/features/catalog/infrastructure/repositories/ApiServiceRepository.test.ts deleted file mode 100644 index cc90f86..0000000 --- a/apps/admin-frontend/src/features/catalog/infrastructure/repositories/ApiServiceRepository.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { http, HttpResponse } from 'msw' -import { server } from '@/test/mocks/server' -import { ApiServiceRepository } from '@/features/catalog/infrastructure/repositories/ApiServiceRepository' -import { AuthenticatedHttpClient } from '@/shared/infrastructure/http/AuthenticatedHttpClient' -import { serviceFixture } from '@/test/mocks/handlers/serviceHandlers' -import { Tenant } from '@/test/fixtures/authEntityFixtures' -import { User } from '@/test/fixtures/authEntityFixtures' -import type { TenantContext } from '@/features/auth' -import type { CreateServiceInput } from '@/features/catalog/application/repositories/ServiceRepository' - -const baseUrl = 'https://api.test' - -function buildRepository(): ApiServiceRepository { - const httpClient = new AuthenticatedHttpClient(baseUrl, () => - Promise.resolve({ accessToken: 'token-123', tenantId: 'tenant-123' }), - ) - return new ApiServiceRepository(httpClient) -} - -function buildTenantContext(): TenantContext { - const tenant = Tenant.create('tenant-123') - return { tenant, user: User.create({ id: 'user-1', tenant }) } -} - -const createInput: CreateServiceInput = { - name: 'Massagem relaxante', - durationMinutes: 60, - minDurationMinutes: 30, - maxDurationMinutes: 90, - price: 150, - maxDiscountPercentage: 10, -} - -describe('ApiServiceRepository', () => { - it('lists services mapped to domain entities', async () => { - const repository = buildRepository() - - const result = await repository.listAll(buildTenantContext()) - - expect(result.services).toHaveLength(1) - expect(result.services[0]?.id).toBe(serviceFixture.id) - expect(result.services[0]?.name).toBe(serviceFixture.name) - expect(result.totalCount).toBe(1) - expect(result.page).toBe(1) - expect(result.pageSize).toBe(20) - }) - - it('sends the requested page and pageSize as query params', async () => { - let capturedUrl: URL | undefined - server.use( - http.get(`${baseUrl}/api/v1/services`, ({ request }) => { - capturedUrl = new URL(request.url) - return HttpResponse.json({ items: [serviceFixture], totalCount: 1, page: 2, pageSize: 5 }) - }), - ) - const repository = buildRepository() - - const result = await repository.listAll(buildTenantContext(), { page: 2, pageSize: 5 }) - - expect(capturedUrl?.searchParams.get('page')).toBe('2') - expect(capturedUrl?.searchParams.get('pageSize')).toBe('5') - expect(result.page).toBe(2) - expect(result.pageSize).toBe(5) - }) - - it('sends search, categoryId, and tagId as query params when provided', async () => { - let capturedUrl: URL | undefined - server.use( - http.get(`${baseUrl}/api/v1/services`, ({ request }) => { - capturedUrl = new URL(request.url) - return HttpResponse.json({ items: [serviceFixture], totalCount: 1, page: 1, pageSize: 20 }) - }), - ) - const repository = buildRepository() - - await repository.listAll(buildTenantContext(), { - search: 'corte', - categoryId: 'category-1', - tagId: 'tag-1', - }) - - expect(capturedUrl?.searchParams.get('search')).toBe('corte') - expect(capturedUrl?.searchParams.get('categoryId')).toBe('category-1') - expect(capturedUrl?.searchParams.get('tagId')).toBe('tag-1') - }) - - it('omits search, categoryId, and tagId query params when not provided', async () => { - let capturedUrl: URL | undefined - server.use( - http.get(`${baseUrl}/api/v1/services`, ({ request }) => { - capturedUrl = new URL(request.url) - return HttpResponse.json({ items: [serviceFixture], totalCount: 1, page: 1, pageSize: 20 }) - }), - ) - const repository = buildRepository() - - await repository.listAll(buildTenantContext()) - - expect(capturedUrl?.searchParams.has('search')).toBe(false) - expect(capturedUrl?.searchParams.has('categoryId')).toBe(false) - expect(capturedUrl?.searchParams.has('tagId')).toBe(false) - }) - - it('creates a service, sending omitted description/categoryId/tagIds as explicit null', async () => { - server.use( - http.post(`${baseUrl}/api/v1/services`, async ({ request }) => { - // CreateServiceCommand marks these fields required-but-nullable in - // the OpenAPI schema, not optional - an absent app-side value must - // still be sent as an explicit `null` key, not omitted. - expect(await request.json()).toEqual({ - ...createInput, - description: null, - categoryId: null, - tagIds: null, - }) - return HttpResponse.json(serviceFixture, { status: 201 }) - }), - ) - const repository = buildRepository() - - const service = await repository.create(buildTenantContext(), createInput) - - expect(service.id).toBe(serviceFixture.id) - }) - - it('creates a service, sending provided description/categoryId/tagIds as-is', async () => { - server.use( - http.post(`${baseUrl}/api/v1/services`, async ({ request }) => { - expect(await request.json()).toEqual({ - ...createInput, - description: 'Uma massagem relaxante de corpo inteiro', - categoryId: 'category-1', - tagIds: ['tag-1'], - }) - return HttpResponse.json(serviceFixture, { status: 201 }) - }), - ) - const repository = buildRepository() - - await repository.create(buildTenantContext(), { - ...createInput, - description: 'Uma massagem relaxante de corpo inteiro', - categoryId: 'category-1', - tagIds: ['tag-1'], - }) - }) - - it('updates a service at the correct path', async () => { - server.use( - http.put(`${baseUrl}/api/v1/services/service-1`, async ({ request }) => { - // serviceId mirrors the route id explicitly (docs/adr/010) - the - // backend overwrites it regardless, but the two must never - // structurally be able to diverge. Optional fields the input - // omitted are sent as explicit null, matching the OpenAPI schema - // (`null | string`, not optional) rather than omitting the key. - expect(await request.json()).toEqual({ - ...createInput, - serviceId: 'service-1', - name: 'Renamed', - description: null, - categoryId: null, - tagIds: null, - }) - return HttpResponse.json({ ...serviceFixture, name: 'Renamed' }) - }), - ) - const repository = buildRepository() - - const service = await repository.update(buildTenantContext(), 'service-1', { - ...createInput, - name: 'Renamed', - }) - - expect(service.name).toBe('Renamed') - }) - - it('deletes a service at the correct path', async () => { - let deleteWasCalled = false - server.use( - http.delete(`${baseUrl}/api/v1/services/service-1`, () => { - deleteWasCalled = true - return new HttpResponse(null, { status: 204 }) - }), - ) - const repository = buildRepository() - - await repository.delete(buildTenantContext(), 'service-1') - - expect(deleteWasCalled).toBe(true) - }) - - it('propagates a curated AppError from the HttpClient on a non-2xx response, not the raw backend title', async () => { - server.use( - http.get(`${baseUrl}/api/v1/services`, () => - HttpResponse.json({ title: 'Something went wrong' }, { status: 500 }), - ), - ) - const repository = buildRepository() - - await expect(repository.listAll(buildTenantContext())).rejects.toThrow( - 'Não foi possível concluir a operação. Tente novamente.', - ) - }) -}) diff --git a/apps/admin-frontend/src/features/catalog/infrastructure/repositories/ApiServiceRepository.ts b/apps/admin-frontend/src/features/catalog/infrastructure/repositories/ApiServiceRepository.ts deleted file mode 100644 index ea1ebad..0000000 --- a/apps/admin-frontend/src/features/catalog/infrastructure/repositories/ApiServiceRepository.ts +++ /dev/null @@ -1,105 +0,0 @@ -import type { Service } from '@/features/catalog/domain/entities/Service' -import type { - CreateServiceInput, - ListAllServicesOptions, - PagedServices, - ServiceRepository, - UpdateServiceInput, -} from '@/features/catalog/application/repositories/ServiceRepository' -import type { HttpClient } from '@/shared/application/HttpClient' -import type { TenantContext } from '@/features/auth' -import { - mapServiceDtoToDomain, - decodeServiceDto, - decodePagedServiceDto, -} from '@/features/catalog/infrastructure/mappers/serviceMapper' -import type { components } from '@/features/catalog/infrastructure/generated/services-api' - -const DEFAULT_PAGE = 1 -const DEFAULT_PAGE_SIZE = 20 - -const SERVICES_URL = '/api/v1/services' - -// The route id is always keyed into the PUT body too (docs/adr/010) so the -// two are structurally incapable of diverging, even though the backend -// controller only ever trusts the route id. -type CreateServiceRequestBody = components['schemas']['CreateServiceCommand'] -type UpdateServiceRequestBody = components['schemas']['UpdateServiceCommand'] - -// tenantContext is accepted for structural enforcement only - tenant scope -// travels in the X-Tenant-Id header the HttpClient attaches. -export class ApiServiceRepository implements ServiceRepository { - private readonly httpClient: HttpClient - - constructor(httpClient: HttpClient) { - this.httpClient = httpClient - } - - async listAll( - _tenantContext: TenantContext, - options: ListAllServicesOptions = {}, - ): Promise { - const { page = DEFAULT_PAGE, pageSize = DEFAULT_PAGE_SIZE, search, categoryId, tagId } = options - const query = new URLSearchParams({ page: String(page), pageSize: String(pageSize) }) - if (search !== undefined && search.trim() !== '') { - query.set('search', search.trim()) - } - if (categoryId !== undefined) { - query.set('categoryId', categoryId) - } - if (tagId !== undefined) { - query.set('tagId', tagId) - } - const envelope = await this.httpClient.get( - `${SERVICES_URL}?${query.toString()}`, - decodePagedServiceDto, - ) - return { - services: envelope.items.map(mapServiceDtoToDomain), - totalCount: envelope.totalCount, - page: envelope.page, - pageSize: envelope.pageSize, - } - } - - async create(_tenantContext: TenantContext, input: CreateServiceInput): Promise { - const body = { - name: input.name, - description: input.description ?? null, - durationMinutes: input.durationMinutes, - minDurationMinutes: input.minDurationMinutes, - maxDurationMinutes: input.maxDurationMinutes, - price: input.price, - maxDiscountPercentage: input.maxDiscountPercentage, - categoryId: input.categoryId ?? null, - tagIds: input.tagIds !== undefined ? [...input.tagIds] : null, - } satisfies CreateServiceRequestBody - const dto = await this.httpClient.post(SERVICES_URL, body, decodeServiceDto) - return mapServiceDtoToDomain(dto) - } - - async update( - _tenantContext: TenantContext, - id: string, - input: UpdateServiceInput, - ): Promise { - const body: UpdateServiceRequestBody = { - serviceId: id, - name: input.name, - description: input.description ?? null, - durationMinutes: input.durationMinutes, - minDurationMinutes: input.minDurationMinutes, - maxDurationMinutes: input.maxDurationMinutes, - price: input.price, - maxDiscountPercentage: input.maxDiscountPercentage, - categoryId: input.categoryId ?? null, - tagIds: input.tagIds !== undefined ? [...input.tagIds] : null, - } - const dto = await this.httpClient.put(`${SERVICES_URL}/${id}`, body, decodeServiceDto) - return mapServiceDtoToDomain(dto) - } - - async delete(_tenantContext: TenantContext, id: string): Promise { - await this.httpClient.delete(`${SERVICES_URL}/${id}`) - } -} diff --git a/apps/admin-frontend/src/features/catalog/infrastructure/repositories/ApiTagRepository.test.ts b/apps/admin-frontend/src/features/catalog/infrastructure/repositories/ApiTagRepository.test.ts index 14a13f8..71a212b 100644 --- a/apps/admin-frontend/src/features/catalog/infrastructure/repositories/ApiTagRepository.test.ts +++ b/apps/admin-frontend/src/features/catalog/infrastructure/repositories/ApiTagRepository.test.ts @@ -4,9 +4,6 @@ import { server } from '@/test/mocks/server' import { ApiTagRepository } from '@/features/catalog/infrastructure/repositories/ApiTagRepository' import { AuthenticatedHttpClient } from '@/shared/infrastructure/http/AuthenticatedHttpClient' import { tagFixture } from '@/test/mocks/handlers/tagHandlers' -import { Tenant } from '@/test/fixtures/authEntityFixtures' -import { User } from '@/test/fixtures/authEntityFixtures' -import type { TenantContext } from '@/features/auth' const baseUrl = 'https://api.test' @@ -17,20 +14,17 @@ function buildRepository(): ApiTagRepository { return new ApiTagRepository(httpClient) } -function buildTenantContext(): TenantContext { - const tenant = Tenant.create('tenant-123') - return { tenant, user: User.create({ id: 'user-1', tenant }) } -} - describe('ApiTagRepository', () => { it('lists tags mapped to domain entities', async () => { const repository = buildRepository() - const tags = await repository.listAll(buildTenantContext()) + const result = await repository.listAll() - expect(tags).toHaveLength(1) - expect(tags[0]?.id).toBe(tagFixture.id) - expect(tags[0]?.name).toBe(tagFixture.name) + expect(result.success).toBe(true) + if (!result.success) return + expect(result.value).toHaveLength(1) + expect(result.value[0]?.id).toBe(tagFixture.id) + expect(result.value[0]?.name).toBe(tagFixture.name) }) it('sends the search term as a query parameter', async () => { @@ -42,7 +36,7 @@ describe('ApiTagRepository', () => { ) const repository = buildRepository() - await repository.listAll(buildTenantContext(), { search: 'vip' }) + await repository.listAll({ search: 'vip' }) }) it('creates a tag, sending an omitted description as explicit null', async () => { @@ -61,9 +55,11 @@ describe('ApiTagRepository', () => { ) const repository = buildRepository() - const tag = await repository.create(buildTenantContext(), { name: 'VIP', color: '#0d9488' }) + const result = await repository.create({ name: 'VIP', color: '#0d9488' }) - expect(tag.id).toBe(tagFixture.id) + expect(result.success).toBe(true) + if (!result.success) return + expect(result.value.id).toBe(tagFixture.id) }) it('creates a tag, sending a provided description as-is', async () => { @@ -79,7 +75,7 @@ describe('ApiTagRepository', () => { ) const repository = buildRepository() - await repository.create(buildTenantContext(), { + await repository.create({ name: 'VIP', color: '#0d9488', description: 'High-value returning client', @@ -103,12 +99,14 @@ describe('ApiTagRepository', () => { ) const repository = buildRepository() - const tag = await repository.update(buildTenantContext(), 'tag-1', { + const result = await repository.update('tag-1', { name: 'Renamed', color: '#ef4444', }) - expect(tag.name).toBe('Renamed') + expect(result.success).toBe(true) + if (!result.success) return + expect(result.value.name).toBe('Renamed') }) it('deletes a tag at the correct path', async () => { @@ -121,7 +119,7 @@ describe('ApiTagRepository', () => { ) const repository = buildRepository() - await repository.delete(buildTenantContext(), 'tag-1') + await repository.delete('tag-1') expect(deleteWasCalled).toBe(true) }) @@ -134,8 +132,10 @@ describe('ApiTagRepository', () => { ) const repository = buildRepository() - await expect(repository.listAll(buildTenantContext())).rejects.toThrow( - 'Não foi possível concluir a operação. Tente novamente.', - ) + const result = await repository.listAll() + + expect(result.success).toBe(false) + if (result.success) return + expect(result.error.message).toBe('Não foi possível concluir a operação. Tente novamente.') }) }) diff --git a/apps/admin-frontend/src/features/catalog/infrastructure/repositories/ApiTagRepository.ts b/apps/admin-frontend/src/features/catalog/infrastructure/repositories/ApiTagRepository.ts index e481b8b..a67538c 100644 --- a/apps/admin-frontend/src/features/catalog/infrastructure/repositories/ApiTagRepository.ts +++ b/apps/admin-frontend/src/features/catalog/infrastructure/repositories/ApiTagRepository.ts @@ -6,7 +6,8 @@ import type { UpdateTagInput, } from '@/features/catalog/application/repositories/TagRepository' import type { HttpClient } from '@/shared/application/HttpClient' -import type { TenantContext } from '@/features/auth' +import type { AppError } from '@/shared/application/AppError' +import { flatMapResult, combineResults, type Result } from '@/shared/application/Result' import { mapTagDtoToDomain, decodeTagDto, @@ -21,8 +22,8 @@ type UpdateTagRequestBody = components['schemas']['UpdateTagCommand'] const TAGS_URL = '/api/v1/tags' -// tenantContext is accepted for structural enforcement only - tenant scope -// travels in the X-Tenant-Id header the HttpClient attaches. +// Tenant scope travels in the X-Tenant-Id header the HttpClient attaches - +// no tenantContext parameter here, matching TagRepository's contract. export class ApiTagRepository implements TagRepository { private readonly httpClient: HttpClient @@ -30,38 +31,38 @@ export class ApiTagRepository implements TagRepository { this.httpClient = httpClient } - async listAll(_tenantContext: TenantContext, options: ListAllTagsOptions = {}): Promise { + async listAll(options: ListAllTagsOptions = {}): Promise> { const query = new URLSearchParams() if (options.search !== undefined && options.search.trim() !== '') { query.set('search', options.search.trim()) } const suffix = query.toString() === '' ? '' : `?${query.toString()}` - const dtos = await this.httpClient.get(`${TAGS_URL}${suffix}`, decodeTagDtoArray) - return dtos.map(mapTagDtoToDomain) + const result = await this.httpClient.get(`${TAGS_URL}${suffix}`, decodeTagDtoArray) + return flatMapResult(result, dtos => combineResults(dtos.map(mapTagDtoToDomain))) } - async create(_tenantContext: TenantContext, input: CreateTagInput): Promise { + async create(input: CreateTagInput): Promise> { const body = { name: input.name, color: input.color, description: input.description ?? null, } satisfies CreateTagRequestBody - const dto = await this.httpClient.post(TAGS_URL, body, decodeTagDto) - return mapTagDtoToDomain(dto) + const result = await this.httpClient.post(TAGS_URL, body, decodeTagDto) + return flatMapResult(result, mapTagDtoToDomain) } - async update(_tenantContext: TenantContext, id: string, input: UpdateTagInput): Promise { + async update(id: string, input: UpdateTagInput): Promise> { const body: UpdateTagRequestBody = { tagId: id, name: input.name, color: input.color, description: input.description ?? null, } - const dto = await this.httpClient.put(`${TAGS_URL}/${id}`, body, decodeTagDto) - return mapTagDtoToDomain(dto) + const result = await this.httpClient.put(`${TAGS_URL}/${id}`, body, decodeTagDto) + return flatMapResult(result, mapTagDtoToDomain) } - async delete(_tenantContext: TenantContext, id: string): Promise { - await this.httpClient.delete(`${TAGS_URL}/${id}`) + async delete(id: string): Promise> { + return this.httpClient.delete(`${TAGS_URL}/${id}`) } } diff --git a/apps/admin-frontend/src/features/catalog/presentation/categories/CategoriesPage.test.tsx b/apps/admin-frontend/src/features/catalog/presentation/categories/CategoriesPage.test.tsx deleted file mode 100644 index 4754b91..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/categories/CategoriesPage.test.tsx +++ /dev/null @@ -1,349 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { render, screen, within, fireEvent, act } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { CategoriesPage } from '@/features/catalog/presentation/categories/CategoriesPage' -import { AppContainerContext } from '@/app/providers/AppContainerContext' -import { AuthProvider } from '@/features/auth' -import type { AppContainer, CatalogFacade } from '@/app/composition/container' -import { Category } from '@/features/catalog/domain/entities/Category' -import { Tenant } from '@/test/fixtures/authEntityFixtures' -import { User } from '@/test/fixtures/authEntityFixtures' -import { MALICIOUS_PAYLOADS } from '@/test/fixtures/maliciousPayloads' -import { createFakeAppContainer } from '@/test/fixtures/createFakeAppContainer' -import { AppError } from '@/shared/application/AppError' - -const tenant = Tenant.create('tenant-123') -const tenantContext = { tenant, user: User.create({ id: 'user-1', tenant }) } -const massagensCategory = Category.create({ id: 'category-1', name: 'Massagens' }) - -function buildContainer(overrides: Partial = {}): AppContainer { - return createFakeAppContainer({ - auth: { getCurrentSession: { execute: vi.fn(() => Promise.resolve(tenantContext)) } }, - catalog: { - listCategories: { execute: vi.fn(() => Promise.resolve([massagensCategory])) }, - createCategory: { execute: vi.fn(() => Promise.resolve(massagensCategory)) }, - updateCategory: { execute: vi.fn(() => Promise.resolve(massagensCategory)) }, - deleteCategory: { execute: vi.fn(() => Promise.resolve()) }, - ...overrides, - }, - }) -} - -function renderCategoriesPage(container: AppContainer): void { - render( - - - - - , - ) -} - -describe('CategoriesPage', () => { - it('renders the category list once loaded', async () => { - renderCategoriesPage(buildContainer()) - - expect(await screen.findByText('Massagens')).toBeInTheDocument() - }) - - it('shows an empty state when there are no categories', async () => { - renderCategoriesPage( - buildContainer({ listCategories: { execute: vi.fn(() => Promise.resolve([])) } }), - ) - - expect(await screen.findByText(/nenhuma categoria ainda/i)).toBeInTheDocument() - }) - - it('shows an error state when loading categories fails', async () => { - renderCategoriesPage( - buildContainer({ - listCategories: { - execute: vi.fn(() => - Promise.reject( - new AppError({ code: 'network', message: 'network down', retryable: true }), - ), - ), - }, - }), - ) - - expect( - await screen.findByText(/não foi possível carregar as categorias: network down/i), - ).toBeInTheDocument() - }) - - it('creates a category through the form and refreshes the list', async () => { - const createCategorySpy = vi.fn(() => Promise.resolve(massagensCategory)) - const listCategoriesSpy = vi.fn(() => Promise.resolve([massagensCategory])) - renderCategoriesPage( - buildContainer({ - createCategory: { execute: createCategorySpy }, - listCategories: { execute: listCategoriesSpy }, - }), - ) - await screen.findByText('Massagens') - listCategoriesSpy.mockClear() - - await userEvent.click(screen.getByRole('button', { name: /nova categoria/i })) - await userEvent.type(screen.getByLabelText('Nome'), 'Estética') - await userEvent.click(screen.getByRole('button', { name: /criar categoria/i })) - - expect(createCategorySpy).toHaveBeenCalledExactlyOnceWith(tenantContext, { name: 'Estética' }) - await vi.waitFor(() => { - expect(listCategoriesSpy).toHaveBeenCalledTimes(1) - }) - expect(screen.queryByRole('button', { name: /criar categoria/i })).not.toBeInTheDocument() - }) - - it('closes the form normally when creation succeeds even if the follow-up refetch fails', async () => { - // The mutation itself succeeded - a failed background refresh afterward - // must not be reported to the user as "creation failed". - const esteticaCategory = Category.create({ id: 'category-2', name: 'Estética' }) - const createCategorySpy = vi.fn(() => Promise.resolve(esteticaCategory)) - const listCategoriesSpy = vi - .fn() - .mockResolvedValueOnce([massagensCategory]) - .mockRejectedValueOnce(new Error('network down')) - renderCategoriesPage( - buildContainer({ - createCategory: { execute: createCategorySpy }, - listCategories: { execute: listCategoriesSpy }, - }), - ) - await screen.findByText('Massagens') - - await userEvent.click(screen.getByRole('button', { name: /nova categoria/i })) - await userEvent.type(screen.getByLabelText('Nome'), 'Estética') - await userEvent.click(screen.getByRole('button', { name: /criar categoria/i })) - - // The dialog closes as normal - the create call itself succeeded. - await vi.waitFor(() => { - expect(screen.queryByRole('dialog')).not.toBeInTheDocument() - }) - // The newly created category is visible immediately (optimistic insert), - // alongside the last known-good list, instead of being lost when the - // background refresh below fails. - expect(screen.getByText('Massagens')).toBeInTheDocument() - expect(screen.getByText('Estética')).toBeInTheDocument() - expect( - await screen.findByText(/não foi possível atualizar a lista de categorias/i), - ).toBeInTheDocument() - }) - - it('shows a validation error and does not submit when the name is blank', async () => { - const createCategorySpy = vi.fn(() => Promise.resolve(massagensCategory)) - renderCategoriesPage(buildContainer({ createCategory: { execute: createCategorySpy } })) - await screen.findByText('Massagens') - - await userEvent.click(screen.getByRole('button', { name: /nova categoria/i })) - await userEvent.click(screen.getByRole('button', { name: /criar categoria/i })) - - expect( - await screen.findByText(/o nome da categoria deve ter entre 1 e 60 caracteres/i), - ).toBeInTheDocument() - expect(createCategorySpy).not.toHaveBeenCalled() - - await userEvent.type(screen.getByLabelText('Nome'), 'Estética') - expect( - screen.queryByText(/o nome da categoria deve ter entre 1 e 60 caracteres/i), - ).not.toBeInTheDocument() - }) - - it('does not carry a previously edited category into a freshly opened create dialog', async () => { - renderCategoriesPage(buildContainer()) - await screen.findByText('Massagens') - - await userEvent.click(screen.getByRole('button', { name: /editar/i })) - const editDialog = await screen.findByRole('dialog') - expect(within(editDialog).getByText('Editar categoria')).toBeInTheDocument() - expect(screen.getByLabelText('Nome')).toHaveValue('Massagens') - await userEvent.click(screen.getByRole('button', { name: /cancelar/i })) - - await userEvent.click(screen.getByRole('button', { name: /nova categoria/i })) - const createDialog = await screen.findByRole('dialog') - expect(within(createDialog).getByText('Nova categoria')).toBeInTheDocument() - expect(screen.getByLabelText('Nome')).toHaveValue('') - }) - - it('shows a form error when creation fails and keeps the form open', async () => { - renderCategoriesPage( - buildContainer({ - createCategory: { - execute: vi.fn(() => Promise.reject(new Error('Category name is already in use.'))), - }, - }), - ) - await screen.findByText('Massagens') - - await userEvent.click(screen.getByRole('button', { name: /nova categoria/i })) - await userEvent.type(screen.getByLabelText('Nome'), 'Massagens') - await userEvent.click(screen.getByRole('button', { name: /criar categoria/i })) - - expect(await screen.findByText('Category name is already in use.')).toBeInTheDocument() - expect(screen.getByRole('button', { name: /criar categoria/i })).toBeInTheDocument() - }) - - describe('structured server errors', () => { - it('maps a validation field error from the API onto the Nome field and focuses it', async () => { - const validationError = new AppError({ - code: 'validation', - message: 'Ocorreram erros de validação.', - retryable: false, - rawFieldErrors: { Name: 'O nome é obrigatório.' }, - }) - renderCategoriesPage( - buildContainer({ - createCategory: { execute: vi.fn(() => Promise.reject(validationError)) }, - }), - ) - await screen.findByText('Massagens') - - await userEvent.click(screen.getByRole('button', { name: /nova categoria/i })) - await userEvent.type(screen.getByLabelText('Nome'), 'Qualquer') - await userEvent.click(screen.getByRole('button', { name: /criar categoria/i })) - - const fieldError = await screen.findByText('O nome é obrigatório.') - expect(fieldError).toHaveAttribute('role', 'alert') - expect(screen.getByLabelText('Nome')).toHaveAttribute('aria-invalid', 'true') - expect(screen.getByLabelText('Nome')).toHaveFocus() - // A validation ProblemDetails fully mapped to a field has no unmapped - // remainder - it must not also duplicate as a global banner message. - expect(screen.queryByText('Ocorreram erros de validação.')).not.toBeInTheDocument() - }) - - it('maps a duplicate-name conflict code from the API onto the Nome field', async () => { - const conflictError = new AppError({ - code: 'conflict', - message: 'Já existe uma categoria com esse nome.', - retryable: false, - backendCode: 'Category.DuplicateName', - }) - renderCategoriesPage( - buildContainer({ createCategory: { execute: vi.fn(() => Promise.reject(conflictError)) } }), - ) - await screen.findByText('Massagens') - - await userEvent.click(screen.getByRole('button', { name: /nova categoria/i })) - await userEvent.type(screen.getByLabelText('Nome'), 'Massagens') - await userEvent.click(screen.getByRole('button', { name: /criar categoria/i })) - - const fieldError = await screen.findByText('Já existe uma categoria com esse nome.') - expect(fieldError).toHaveAttribute('role', 'alert') - expect(screen.getByLabelText('Nome')).toHaveFocus() - }) - }) - - it('edits a category through the inline form', async () => { - const updateCategorySpy = vi.fn(() => Promise.resolve(massagensCategory)) - renderCategoriesPage(buildContainer({ updateCategory: { execute: updateCategorySpy } })) - await screen.findByText('Massagens') - - await userEvent.click(screen.getByRole('button', { name: /editar/i })) - const nameInput = screen.getByLabelText('Nome') - await userEvent.clear(nameInput) - await userEvent.type(nameInput, 'Renamed') - await userEvent.click(screen.getByRole('button', { name: /salvar alterações/i })) - - expect(updateCategorySpy).toHaveBeenCalledExactlyOnceWith(tenantContext, 'category-1', { - name: 'Renamed', - }) - }) - - describe('delete', () => { - it('shows a confirmation dialog naming the category before deleting', async () => { - renderCategoriesPage(buildContainer()) - await screen.findByText('Massagens') - - await userEvent.click(screen.getByRole('button', { name: /excluir/i })) - - const alertDialog = await screen.findByRole('alertdialog') - expect(within(alertDialog).getByText(/excluir categoria/i)).toBeInTheDocument() - expect(within(alertDialog).getByText(/"Massagens"/)).toBeInTheDocument() - }) - - it('deletes the category when the confirmation is accepted', async () => { - const deleteCategorySpy = vi.fn(() => Promise.resolve()) - renderCategoriesPage(buildContainer({ deleteCategory: { execute: deleteCategorySpy } })) - await screen.findByText('Massagens') - - await userEvent.click(screen.getByRole('button', { name: /excluir/i })) - const alertDialog = await screen.findByRole('alertdialog') - await userEvent.click(within(alertDialog).getByRole('button', { name: 'Excluir' })) - - expect(deleteCategorySpy).toHaveBeenCalledExactlyOnceWith(tenantContext, 'category-1') - await vi.waitFor(() => { - expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() - }) - }) - - it('does not delete the category when the confirmation is cancelled', async () => { - const deleteCategorySpy = vi.fn(() => Promise.resolve()) - renderCategoriesPage(buildContainer({ deleteCategory: { execute: deleteCategorySpy } })) - await screen.findByText('Massagens') - - await userEvent.click(screen.getByRole('button', { name: /excluir/i })) - const alertDialog = await screen.findByRole('alertdialog') - await userEvent.click(within(alertDialog).getByRole('button', { name: /cancelar/i })) - - expect(deleteCategorySpy).not.toHaveBeenCalled() - await vi.waitFor(() => { - expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() - }) - }) - - it('shows an error and keeps the dialog open when deletion fails', async () => { - const deleteCategorySpy = vi.fn(() => Promise.reject(new Error('Category is in use.'))) - renderCategoriesPage(buildContainer({ deleteCategory: { execute: deleteCategorySpy } })) - await screen.findByText('Massagens') - - await userEvent.click(screen.getByRole('button', { name: /excluir/i })) - const alertDialog = await screen.findByRole('alertdialog') - await userEvent.click(within(alertDialog).getByRole('button', { name: 'Excluir' })) - - expect(await within(alertDialog).findByText('Category is in use.')).toBeInTheDocument() - expect(screen.getByRole('alertdialog')).toBeInTheDocument() - }) - }) - - describe('search', () => { - it('refetches with the debounced search term after the user stops typing', async () => { - const listCategoriesSpy = vi.fn(() => Promise.resolve([massagensCategory])) - renderCategoriesPage(buildContainer({ listCategories: { execute: listCategoriesSpy } })) - await screen.findByText('Massagens') - listCategoriesSpy.mockClear() - - vi.useFakeTimers() - try { - fireEvent.change(screen.getByLabelText('Buscar categoria por nome'), { - target: { value: 'massa' }, - }) - expect(listCategoriesSpy).not.toHaveBeenCalled() - - await act(async () => { - await vi.advanceTimersByTimeAsync(300) - }) - - expect(listCategoriesSpy).toHaveBeenCalledExactlyOnceWith(tenantContext, { - search: 'massa', - }) - } finally { - vi.useRealTimers() - } - }) - }) - - describe('security', () => { - it.each(MALICIOUS_PAYLOADS)('renders "%s" as inert text, not markup', async payload => { - const maliciousCategory = Category.create({ id: 'malicious-1', name: payload }) - renderCategoriesPage( - buildContainer({ - listCategories: { execute: vi.fn(() => Promise.resolve([maliciousCategory])) }, - }), - ) - - expect(await screen.findByText(payload)).toBeInTheDocument() - expect(document.querySelector('script')).not.toBeInTheDocument() - expect(document.querySelector('img[onerror]')).not.toBeInTheDocument() - }) - }) -}) diff --git a/apps/admin-frontend/src/features/catalog/presentation/categories/CategoriesPage.tsx b/apps/admin-frontend/src/features/catalog/presentation/categories/CategoriesPage.tsx deleted file mode 100644 index 8cf79e8..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/categories/CategoriesPage.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import type { JSX } from 'react' -import { PageHeader } from '@/shared/presentation/components/PageHeader' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { useCategoriesPage } from '@/features/catalog/presentation/categories/hooks/useCategoriesPage' -import { CategoriesTable } from '@/features/catalog/presentation/categories/components/CategoriesTable' -import { CategoryEditorDialog } from '@/features/catalog/presentation/categories/components/CategoryEditorDialog' -import { CategoryDeleteDialog } from '@/features/catalog/presentation/categories/components/CategoryDeleteDialog' - -export function CategoriesPage(): JSX.Element { - const { - searchInput, - onSearchInputChange, - categories, - listState, - hasActiveSearch, - onRetry, - onOpenCreate, - onEdit, - onDelete, - dialog, - deleteDialog, - } = useCategoriesPage() - - return ( -
- Nova categoria} - /> - -
- { - onSearchInputChange(event.target.value) - }} - /> -
- - - - - - -
- ) -} diff --git a/apps/admin-frontend/src/features/catalog/presentation/categories/components/CategoryDeleteDialog.tsx b/apps/admin-frontend/src/features/catalog/presentation/categories/components/CategoryDeleteDialog.tsx deleted file mode 100644 index 973ca45..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/categories/components/CategoryDeleteDialog.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import type { JSX } from 'react' -import type { Category } from '@/features/catalog/domain/entities/Category' -import { DeleteConfirmationDialog } from '@/shared/presentation/components/DeleteConfirmationDialog' - -export interface CategoryDeleteDialogProps { - target: Category | null - error: string | null - isDeleting: boolean - onCancel: () => void - onConfirm: () => void -} - -export function CategoryDeleteDialog({ - target, - error, - isDeleting, - onCancel, - onConfirm, -}: CategoryDeleteDialogProps): JSX.Element { - return ( - - Tem certeza que deseja excluir a categoria "{target?.name}"? Essa ação não pode ser - desfeita. - - } - error={error} - isDeleting={isDeleting} - onCancel={onCancel} - onConfirm={onConfirm} - /> - ) -} diff --git a/apps/admin-frontend/src/features/catalog/presentation/categories/components/CategoryEditorDialog.tsx b/apps/admin-frontend/src/features/catalog/presentation/categories/components/CategoryEditorDialog.tsx deleted file mode 100644 index 6870811..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/categories/components/CategoryEditorDialog.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import type { JSX } from 'react' -import type { Category } from '@/features/catalog/domain/entities/Category' -import { - CategoryForm, - type CategoryFormValues, - type CategoryFormField, -} from '@/features/catalog/presentation/categories/forms/CategoryForm' -import type { ServerFormError } from '@/shared/presentation/forms/serverFormError' -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' - -type CategoryEditorContent = - | { kind: 'create'; title: string; submitLabel: string; initialValues: CategoryFormValues } - | { - kind: 'edit' - item: Category - title: string - submitLabel: string - initialValues: CategoryFormValues - } - -export interface CategoryEditorDialogProps { - isOpen: boolean - content: CategoryEditorContent | null - isSubmitting: boolean - serverError: ServerFormError | null - onCancel: () => void - onSubmit: (values: CategoryFormValues) => Promise -} - -export function CategoryEditorDialog({ - isOpen, - content, - isSubmitting, - serverError, - onCancel, - onSubmit, -}: CategoryEditorDialogProps): JSX.Element { - return ( - { - if (!open) onCancel() - }} - > - - - {content?.title ?? ''} - - {content !== null && ( - - )} - - - ) -} diff --git a/apps/admin-frontend/src/features/catalog/presentation/categories/hooks/useCategories.test.tsx b/apps/admin-frontend/src/features/catalog/presentation/categories/hooks/useCategories.test.tsx deleted file mode 100644 index 8f86ad8..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/categories/hooks/useCategories.test.tsx +++ /dev/null @@ -1,206 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { renderHook, waitFor, act, type RenderHookResult } from '@testing-library/react' -import { - useCategories, - type UseCategoriesResult, -} from '@/features/catalog/presentation/categories/hooks/useCategories' -import { AppContainerContext } from '@/app/providers/AppContainerContext' -import { createFakeAppContainer } from '@/test/fixtures/createFakeAppContainer' -import type { AppContainer, CatalogFacade } from '@/app/composition/container' -import { Category } from '@/features/catalog/domain/entities/Category' -import { Tenant } from '@/test/fixtures/authEntityFixtures' -import { User } from '@/test/fixtures/authEntityFixtures' -import type { TenantContext } from '@/features/auth' - -const categoryFixture = Category.create({ id: 'category-1', name: 'Massagens' }) - -function createFakeContainer(overrides: Partial = {}): AppContainer { - return createFakeAppContainer({ - catalog: { - listCategories: { execute: vi.fn(() => Promise.resolve([categoryFixture])) }, - createCategory: { execute: vi.fn(() => Promise.resolve(categoryFixture)) }, - updateCategory: { execute: vi.fn(() => Promise.resolve(categoryFixture)) }, - deleteCategory: { execute: vi.fn(() => Promise.resolve()) }, - ...overrides, - }, - }) -} - -function buildTenantContext(): TenantContext { - const tenant = Tenant.create('tenant-123') - return { tenant, user: User.create({ id: 'user-1', tenant }) } -} - -function renderUseCategories( - container: AppContainer, - tenantContext: TenantContext | null, -): RenderHookResult { - return renderHook(() => useCategories(tenantContext), { - wrapper: ({ children }) => ( - {children} - ), - }) -} - -describe('useCategories', () => { - it('loads categories for the given tenant context', async () => { - const { result } = renderUseCategories(createFakeContainer(), buildTenantContext()) - - await waitFor(() => { - expect(result.current.listState.status).toBe('success') - }) - - expect(result.current.categories).toEqual([categoryFixture]) - }) - - it('returns an empty list without calling the use case when tenantContext is null', async () => { - const listCategoriesSpy = vi.fn(() => Promise.resolve([categoryFixture])) - const { result } = renderUseCategories( - createFakeContainer({ listCategories: { execute: listCategoriesSpy } }), - null, - ) - - await waitFor(() => { - expect(result.current.listState.status).toBe('success') - }) - - expect(result.current.categories).toEqual([]) - expect(listCategoriesSpy).not.toHaveBeenCalled() - }) - - it('creates a category then refetches the list', async () => { - const listCategoriesSpy = vi.fn(() => Promise.resolve([categoryFixture])) - const createCategorySpy = vi.fn(() => Promise.resolve(categoryFixture)) - const tenantContext = buildTenantContext() - const { result } = renderUseCategories( - createFakeContainer({ - listCategories: { execute: listCategoriesSpy }, - createCategory: { execute: createCategorySpy }, - }), - tenantContext, - ) - await waitFor(() => { - expect(result.current.listState.status).toBe('success') - }) - listCategoriesSpy.mockClear() - - await act(async () => { - await result.current.createCategory({ name: 'Massagens' }) - }) - - expect(createCategorySpy).toHaveBeenCalledExactlyOnceWith(tenantContext, { - name: 'Massagens', - }) - // The refetch fires in the background (not awaited by createCategory - // itself) - wait for it rather than asserting immediately. - await waitFor(() => { - expect(listCategoriesSpy).toHaveBeenCalledTimes(1) - }) - }) - - it('resolves as soon as the POST succeeds, without waiting for the background refetch', async () => { - let resolveRefetch: (() => void) | undefined - const listCategoriesSpy = vi - .fn<() => Promise>() - .mockResolvedValueOnce([categoryFixture]) - .mockImplementationOnce( - () => - new Promise(resolve => { - resolveRefetch = () => { - resolve([categoryFixture]) - } - }), - ) - const tenantContext = buildTenantContext() - const { result } = renderUseCategories( - createFakeContainer({ listCategories: { execute: listCategoriesSpy } }), - tenantContext, - ) - await waitFor(() => { - expect(result.current.listState.status).toBe('success') - }) - - // createCategory resolves even though the refetch it triggers never - // settles during this act() block - proving success doesn't depend on - // the background refetch completing. - await act(async () => { - await result.current.createCategory({ name: 'Nova categoria' }) - }) - - expect(result.current.categories).toContainEqual(categoryFixture) - - await act(async () => { - resolveRefetch?.() - await Promise.resolve() - }) - }) - - it('keeps the created category visible even if the background refetch fails', async () => { - const newCategory = Category.create({ id: 'category-2', name: 'Nova categoria' }) - const listCategoriesSpy = vi - .fn<() => Promise>() - .mockResolvedValueOnce([categoryFixture]) - .mockRejectedValueOnce(new Error('network down')) - const createCategorySpy = vi.fn(() => Promise.resolve(newCategory)) - const tenantContext = buildTenantContext() - const { result } = renderUseCategories( - createFakeContainer({ - listCategories: { execute: listCategoriesSpy }, - createCategory: { execute: createCategorySpy }, - }), - tenantContext, - ) - await waitFor(() => { - expect(result.current.listState.status).toBe('success') - }) - - await act(async () => { - await expect(result.current.createCategory({ name: 'Nova categoria' })).resolves.toEqual( - newCategory, - ) - }) - - // The optimistic insert survives the refetch failure below. - expect(result.current.categories).toEqual([categoryFixture, newCategory]) - - await waitFor(() => { - expect(result.current.listState.status).toBe('refreshError') - }) - // Still there after the failed refetch settles - not cleared, not - // reported as a failed creation. - expect(result.current.categories).toEqual([categoryFixture, newCategory]) - }) - - it('deletes a category then refetches the list', async () => { - const listCategoriesSpy = vi.fn(() => Promise.resolve([categoryFixture])) - const deleteCategorySpy = vi.fn(() => Promise.resolve()) - const tenantContext = buildTenantContext() - const { result } = renderUseCategories( - createFakeContainer({ - listCategories: { execute: listCategoriesSpy }, - deleteCategory: { execute: deleteCategorySpy }, - }), - tenantContext, - ) - await waitFor(() => { - expect(result.current.listState.status).toBe('success') - }) - listCategoriesSpy.mockClear() - - await act(async () => { - await result.current.deleteCategory('category-1') - }) - - expect(deleteCategorySpy).toHaveBeenCalledExactlyOnceWith(tenantContext, 'category-1') - expect(listCategoriesSpy).toHaveBeenCalledTimes(1) - }) - - it('rejects mutations when tenantContext is null', async () => { - const { result } = renderUseCategories(createFakeContainer(), null) - await waitFor(() => { - expect(result.current.listState.status).toBe('success') - }) - - await expect(result.current.createCategory({ name: 'Massagens' })).rejects.toThrow() - }) -}) diff --git a/apps/admin-frontend/src/features/catalog/presentation/categories/hooks/useCategories.ts b/apps/admin-frontend/src/features/catalog/presentation/categories/hooks/useCategories.ts deleted file mode 100644 index 577921a..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/categories/hooks/useCategories.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { useCallback } from 'react' -import { useAppContainer } from '@/app/providers/useAppContainer' -import { useAsync, toUiAsyncState, type AsyncState } from '@/shared/presentation/hooks/useAsync' -import { success, failure, type Result } from '@/shared/application/Result' -import type { UiError } from '@/shared/application/UiError' -import type { Category } from '@/features/catalog/domain/entities/Category' -import type { TenantContext } from '@/features/auth' -import type { - CreateCategoryInput, - UpdateCategoryInput, -} from '@/features/catalog/application/repositories/CategoryRepository' - -export interface UseCategoriesResult { - categories: readonly Category[] - listState: AsyncState - refetch: () => Promise - createCategory: (input: CreateCategoryInput) => Promise - updateCategory: (id: string, input: UpdateCategoryInput) => Promise - deleteCategory: (id: string) => Promise -} - -// tenantContext is nullable: the page can mount before useAuth() resolves. -// Guards below no-op until it does, then the changed identity re-triggers the fetch. -export function useCategories( - tenantContext: TenantContext | null, - search = '', -): UseCategoriesResult { - const { catalog } = useAppContainer() - - const listCategories = useCallback(async (): Promise> => { - if (tenantContext === null) { - return success([]) - } - try { - return success(await catalog.listCategories.execute(tenantContext, { search })) - } catch (error) { - return failure(error) - } - }, [tenantContext, catalog, search]) - - const asyncState = useAsync(listCategories, { resetKey: tenantContext?.tenant.id }) - const { data, execute, mutate, captureGeneration } = asyncState - - const createCategory = useCallback( - async (input: CreateCategoryInput): Promise => { - if (tenantContext === null) { - throw new Error('Não é possível criar uma categoria sem um contexto de tenant autenticado') - } - // Captured before the POST starts: if the tenant switches while this - // request is in flight, the mutate below must not insert tenant A's - // newly created category into what is now tenant B's list. - const generation = captureGeneration() - const category = await catalog.createCategory.execute(tenantContext, input) - // Insert immediately so the new category is selectable and shows up - // as soon as the POST succeeds - the mutation's success never - // depends on the background refetch below. If that refetch fails, - // this optimistic entry is what keeps the category visible (see - // useAsync's own status/error, surfaced separately by the page). - mutate(current => [...(current ?? []), category], generation) - void execute() - return category - }, - [tenantContext, catalog, execute, mutate, captureGeneration], - ) - - const updateCategory = useCallback( - async (id: string, input: UpdateCategoryInput): Promise => { - if (tenantContext === null) { - throw new Error( - 'Não é possível atualizar uma categoria sem um contexto de tenant autenticado', - ) - } - const category = await catalog.updateCategory.execute(tenantContext, id, input) - await execute() - return category - }, - [tenantContext, catalog, execute], - ) - - const deleteCategory = useCallback( - async (id: string): Promise => { - if (tenantContext === null) { - throw new Error( - 'Não é possível excluir uma categoria sem um contexto de tenant autenticado', - ) - } - await catalog.deleteCategory.execute(tenantContext, id) - await execute() - }, - [tenantContext, catalog, execute], - ) - - return { - categories: data ?? [], - listState: toUiAsyncState(asyncState), - refetch: async () => { - await execute() - }, - createCategory, - updateCategory, - deleteCategory, - } -} diff --git a/apps/admin-frontend/src/features/catalog/presentation/categories/hooks/useCategoriesPage.ts b/apps/admin-frontend/src/features/catalog/presentation/categories/hooks/useCategoriesPage.ts deleted file mode 100644 index d4062e6..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/categories/hooks/useCategoriesPage.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { useState } from 'react' -import { useAuth } from '@/features/auth' -import { useCategories } from '@/features/catalog/presentation/categories/hooks/useCategories' -import type { AsyncState } from '@/shared/presentation/hooks/useAsync' -import type { UiError } from '@/shared/application/UiError' -import { useDebouncedValue } from '@/shared/presentation/hooks/useDebouncedValue' -import { useDialogTarget, type DialogTarget } from '@/shared/presentation/hooks/useDialogTarget' -import { useDeleteConfirmation } from '@/shared/presentation/hooks/useDeleteConfirmation' -import type { Category } from '@/features/catalog/domain/entities/Category' -import type { - CategoryFormValues, - CategoryFormField, -} from '@/features/catalog/presentation/categories/forms/CategoryForm' -import { - mapApiErrorToForm, - type ServerFormError, -} from '@/shared/presentation/forms/serverFormError' -import { - categoryFieldMap, - categoryCodeFieldMap, -} from '@/features/catalog/presentation/categories/forms/categoryFieldMaps' - -const EMPTY_FORM_VALUES: CategoryFormValues = { name: '' } - -function toCategoryInput(values: CategoryFormValues): { name: string } { - return { name: values.name } -} - -function toFormValues(category: Category): CategoryFormValues { - return { name: category.name } -} - -export type CategoryEditorContent = - | { kind: 'create'; title: string; submitLabel: string; initialValues: CategoryFormValues } - | { - kind: 'edit' - item: Category - title: string - submitLabel: string - initialValues: CategoryFormValues - } - -function toEditorContent(target: DialogTarget): CategoryEditorContent { - if (target.kind === 'edit') { - return { - kind: 'edit', - item: target.item, - title: 'Editar categoria', - submitLabel: 'Salvar alterações', - initialValues: toFormValues(target.item), - } - } - return { - kind: 'create', - title: 'Nova categoria', - submitLabel: 'Criar categoria', - initialValues: EMPTY_FORM_VALUES, - } -} - -export interface UseCategoriesPageResult { - searchInput: string - onSearchInputChange: (value: string) => void - categories: readonly Category[] - listState: AsyncState - hasActiveSearch: boolean - onRetry: () => void - onOpenCreate: () => void - onEdit: (category: Category) => void - onDelete: (category: Category) => void - dialog: { - isOpen: boolean - content: CategoryEditorContent | null - isSubmitting: boolean - serverError: ServerFormError | null - onCancel: () => void - onSubmit: (values: CategoryFormValues) => Promise - } - deleteDialog: { - target: Category | null - error: string | null - isDeleting: boolean - onCancel: () => void - onConfirm: () => void - } -} - -/** Composes search, useCategories, dialog target, and delete confirmation into CategoriesPage's view models. */ -export function useCategoriesPage(): UseCategoriesPageResult { - const { tenantContext } = useAuth() - const [searchInput, setSearchInput] = useState('') - const debouncedSearch = useDebouncedValue(searchInput, 300) - const { categories, listState, refetch, createCategory, updateCategory, deleteCategory } = - useCategories(tenantContext, debouncedSearch) - - const dialogTarget = useDialogTarget() - const [serverError, setServerError] = useState | null>(null) - const [isSubmitting, setIsSubmitting] = useState(false) - const deletion = useDeleteConfirmation({ - onDelete: category => deleteCategory(category.id), - fallbackMessage: 'Não foi possível excluir a categoria.', - }) - - function openCreateForm(): void { - dialogTarget.openCreate() - setServerError(null) - } - - function openEditForm(category: Category): void { - dialogTarget.openEdit(category) - setServerError(null) - } - - function closeForm(): void { - dialogTarget.close() - setServerError(null) - } - - async function handleSubmit(values: CategoryFormValues): Promise { - setIsSubmitting(true) - setServerError(null) - try { - if (dialogTarget.formTarget?.kind === 'create') { - await createCategory(toCategoryInput(values)) - } else if (dialogTarget.formTarget?.kind === 'edit') { - await updateCategory(dialogTarget.formTarget.item.id, toCategoryInput(values)) - } - closeForm() - } catch (caughtError) { - setServerError( - mapApiErrorToForm( - caughtError, - categoryFieldMap, - categoryCodeFieldMap, - 'Não foi possível salvar a categoria.', - ), - ) - } finally { - setIsSubmitting(false) - } - } - - return { - searchInput, - onSearchInputChange: setSearchInput, - categories, - listState, - hasActiveSearch: debouncedSearch.trim() !== '', - onRetry: () => void refetch(), - onOpenCreate: openCreateForm, - onEdit: openEditForm, - onDelete: deletion.onRequestDelete, - dialog: { - isOpen: dialogTarget.isOpen, - content: - dialogTarget.displayTarget !== null ? toEditorContent(dialogTarget.displayTarget) : null, - isSubmitting, - serverError, - onCancel: closeForm, - onSubmit: handleSubmit, - }, - deleteDialog: { - target: deletion.target, - error: deletion.error, - isDeleting: deletion.isDeleting, - onCancel: deletion.onCancel, - onConfirm: () => void deletion.onConfirm(), - }, - } -} diff --git a/apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoriesListPage/CategoriesListPage.tsx b/apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoriesListPage/CategoriesListPage.tsx new file mode 100644 index 0000000..3a9278f --- /dev/null +++ b/apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoriesListPage/CategoriesListPage.tsx @@ -0,0 +1,75 @@ +import { useState, type JSX } from 'react' +import { Outlet, useNavigate } from 'react-router' +import { PageHeader } from '@/shared/presentation/components/PageHeader' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { DeleteConfirmationDialog } from '@/shared/presentation/components/DeleteConfirmationDialog' +import { useDebouncedValue } from '@/shared/presentation/hooks/useDebouncedValue' +import { useCategoriesListPage } from '@/features/catalog/presentation/categories/pages/CategoriesListPage/hooks/useCategoriesListPage' +import { CategoriesTable } from '@/features/catalog/presentation/categories/pages/CategoriesListPage/components/CategoriesTable' +import type { Category } from '@/features/catalog/domain/entities/Category' + +export function CategoriesListPage(): JSX.Element { + const navigate = useNavigate() + const [searchInput, setSearchInput] = useState('') + const debouncedSearch = useDebouncedValue(searchInput, 300) + const hasActiveSearch = debouncedSearch.trim() !== '' + const { categories, listState, onRetry, onDelete, deleteDialog } = + useCategoriesListPage(debouncedSearch) + + function handleEdit(category: Category): void { + void navigate(`/categories/${category.id}/edit`) + } + + return ( + <> +
+ { + void navigate('/categories/new') + }} + > + Nova categoria + + } + /> + +
+ { + setSearchInput(event.target.value) + }} + /> +
+ + +
+ + + + + + ) +} diff --git a/apps/admin-frontend/src/features/catalog/presentation/categories/components/CategoriesTable.tsx b/apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoriesListPage/components/CategoriesTable.tsx similarity index 60% rename from apps/admin-frontend/src/features/catalog/presentation/categories/components/CategoriesTable.tsx rename to apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoriesListPage/components/CategoriesTable.tsx index 6dffe2e..822f175 100644 --- a/apps/admin-frontend/src/features/catalog/presentation/categories/components/CategoriesTable.tsx +++ b/apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoriesListPage/components/CategoriesTable.tsx @@ -1,5 +1,5 @@ import type { JSX } from 'react' -import type { Category } from '@/features/catalog/domain/entities/Category' +import { Pencil, Trash2 } from 'lucide-react' import { Button } from '@/components/ui/button' import { Table, @@ -10,17 +10,7 @@ import { TableRow, } from '@/components/ui/table' import { CollectionFeedback } from '@/shared/presentation/components/CollectionFeedback' -import type { AsyncState } from '@/shared/presentation/hooks/useAsync' -import type { UiError } from '@/shared/application/UiError' - -export interface CategoriesTableProps { - categories: readonly Category[] - listState: AsyncState - hasActiveSearch: boolean - onRetry: () => void - onEdit: (category: Category) => void - onDelete: (category: Category) => void -} +import type { CategoriesTableProps } from '@/features/catalog/presentation/categories/pages/CategoriesListPage/components/CategoriesTable.types' export function CategoriesTable({ categories, @@ -46,39 +36,45 @@ export function CategoriesTable({ /> {categories.length > 0 && ( -
- +
+
Nome - Ações + Ações {categories.map(category => ( - - {category.name} + + {category.name} - +
diff --git a/apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoriesListPage/components/CategoriesTable.types.ts b/apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoriesListPage/components/CategoriesTable.types.ts new file mode 100644 index 0000000..41444b5 --- /dev/null +++ b/apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoriesListPage/components/CategoriesTable.types.ts @@ -0,0 +1,12 @@ +import type { Category } from '@/features/catalog/domain/entities/Category' +import type { AsyncState } from '@/shared/presentation/hooks/useAsync' +import type { UiError } from '@/shared/application/UiError' + +export interface CategoriesTableProps { + categories: readonly Category[] + listState: AsyncState + hasActiveSearch: boolean + onRetry: () => void + onEdit: (category: Category) => void + onDelete: (category: Category) => void +} diff --git a/apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoriesListPage/hooks/useCategoriesListPage.ts b/apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoriesListPage/hooks/useCategoriesListPage.ts new file mode 100644 index 0000000..3fe4a5d --- /dev/null +++ b/apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoriesListPage/hooks/useCategoriesListPage.ts @@ -0,0 +1,65 @@ +import { useCallback, useEffect, useRef } from 'react' +import { useLocation, useResolvedPath } from 'react-router' +import { useAppContainer } from '@/app/providers/useAppContainer' +import { useAsync, toUiAsyncState } from '@/shared/presentation/hooks/useAsync' +import { useCategoryDeletion } from '@/features/catalog/presentation/categories/pages/CategoriesListPage/hooks/useCategoryDeletion' +import type { Category } from '@/features/catalog/domain/entities/Category' +import type { AppError } from '@/shared/application/AppError' +import type { Result } from '@/shared/application/Result' +import type { UseCategoriesListPageResult } from '@/features/catalog/presentation/categories/pages/CategoriesListPage/hooks/useCategoriesListPage.types' + +export function useCategoriesListPage(search: string): UseCategoriesListPageResult { + const { catalog } = useAppContainer() + + const listCategories = useCallback( + (): Promise> => catalog.listCategories.execute({ search }), + [catalog, search], + ) + + const asyncState = useAsync(listCategories) + const { data, execute } = asyncState + const categories = data ?? [] + const listState = toUiAsyncState(asyncState) + + const deleteCategory = useCallback( + async (id: string): Promise> => { + const deleteResult = await catalog.deleteCategory.execute(id) + if (deleteResult.success) { + await execute() + } + return deleteResult + }, + [catalog, execute], + ) + + const deletion = useCategoryDeletion({ onDelete: deleteCategory }) + + const basePath = useResolvedPath('.').pathname + const location = useLocation() + const wasOnChildRoute = useRef(false) + useEffect(() => { + const onChildRoute = location.pathname !== basePath + if (wasOnChildRoute.current && !onChildRoute) { + void execute() + } + wasOnChildRoute.current = onChildRoute + // Only the route transition matters here - execute()'s identity also + // changes on every `search` update, which would refire this effect on + // each keystroke if listed as a dependency. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [location.pathname, basePath]) + + return { + categories, + listState, + onRetry: () => void execute(), + onDelete: deletion.onRequestDelete, + deleteDialog: { + target: deletion.target, + error: deletion.error, + isDeleting: deletion.isDeleting, + onCancel: deletion.onCancel, + onConfirm: () => void deletion.onConfirm(), + }, + } +} diff --git a/apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoriesListPage/hooks/useCategoriesListPage.types.ts b/apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoriesListPage/hooks/useCategoriesListPage.types.ts new file mode 100644 index 0000000..a045bfa --- /dev/null +++ b/apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoriesListPage/hooks/useCategoriesListPage.types.ts @@ -0,0 +1,17 @@ +import type { Category } from '@/features/catalog/domain/entities/Category' +import type { AsyncState } from '@/shared/presentation/hooks/useAsync' +import type { UiError } from '@/shared/application/UiError' + +export interface UseCategoriesListPageResult { + categories: readonly Category[] + listState: AsyncState + onRetry: () => void + onDelete: (category: Category) => void + deleteDialog: { + target: Category | null + error: string | null + isDeleting: boolean + onCancel: () => void + onConfirm: () => void + } +} diff --git a/apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoriesListPage/hooks/useCategoryDeletion.ts b/apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoriesListPage/hooks/useCategoryDeletion.ts new file mode 100644 index 0000000..74336a6 --- /dev/null +++ b/apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoriesListPage/hooks/useCategoryDeletion.ts @@ -0,0 +1,14 @@ +import type { Category } from '@/features/catalog/domain/entities/Category' +import { useDeleteConfirmation } from '@/shared/presentation/hooks/useDeleteConfirmation' +import type { + UseCategoryDeletionParams, + UseCategoryDeletionResult, +} from '@/features/catalog/presentation/categories/pages/CategoriesListPage/hooks/useCategoryDeletion.types' + +export function useCategoryDeletion({ + onDelete, +}: UseCategoryDeletionParams): UseCategoryDeletionResult { + return useDeleteConfirmation({ + onDelete: category => onDelete(category.id), + }) +} diff --git a/apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoriesListPage/hooks/useCategoryDeletion.types.ts b/apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoriesListPage/hooks/useCategoryDeletion.types.ts new file mode 100644 index 0000000..c8d1690 --- /dev/null +++ b/apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoriesListPage/hooks/useCategoryDeletion.types.ts @@ -0,0 +1,16 @@ +import type { Category } from '@/features/catalog/domain/entities/Category' +import type { AppError } from '@/shared/application/AppError' +import type { Result } from '@/shared/application/Result' + +export interface UseCategoryDeletionParams { + onDelete: (id: string) => Promise> +} + +export interface UseCategoryDeletionResult { + target: Category | null + error: string | null + isDeleting: boolean + onRequestDelete: (category: Category) => void + onCancel: () => void + onConfirm: () => Promise +} diff --git a/apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoriesRoutes.test.tsx b/apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoriesRoutes.test.tsx new file mode 100644 index 0000000..9aa74a4 --- /dev/null +++ b/apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoriesRoutes.test.tsx @@ -0,0 +1,318 @@ +import { act, fireEvent, render, screen, within, type RenderResult } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { axe } from 'jest-axe' +import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router' +import { describe, expect, it, vi } from 'vitest' +import { AppContainerContext } from '@/app/providers/AppContainerContext' +import type { AppContainer, CatalogFacade } from '@/app/composition/container' +import { AuthProvider } from '@/features/auth' +import { Tenant, User } from '@/test/fixtures/authEntityFixtures' +import { Category } from '@/features/catalog/domain/entities/Category' +import { CategoryEditorDialog } from '@/features/catalog/presentation/categories/pages/CategoryEditorDialog/CategoryEditorDialog' +import { CategoriesListPage } from '@/features/catalog/presentation/categories/pages/CategoriesListPage/CategoriesListPage' +import { AppError } from '@/shared/application/AppError' +import { success, failure } from '@/shared/application/Result' +import { createFakeAppContainer } from '@/test/fixtures/createFakeAppContainer' +import { MALICIOUS_PAYLOADS } from '@/test/fixtures/maliciousPayloads' +import { unwrapResult } from '@/test/fixtures/unwrapResult' + +const tenant = Tenant.create('tenant-123') +const tenantContext = { tenant, user: User.create({ id: 'user-1', tenant }) } +const categoryFixture = unwrapResult(Category.create({ id: 'category-1', name: 'Massagens' })) + +function buildContainer(overrides: Partial = {}): AppContainer { + return createFakeAppContainer({ + // AuthProvider hydrates its own session/tenant state on mount regardless + // of whether the catalog facade below needs tenantContext. + auth: { getCurrentSession: { execute: vi.fn(() => Promise.resolve(tenantContext)) } }, + catalog: { + listCategories: { execute: vi.fn(() => Promise.resolve(success([categoryFixture]))) }, + getCategory: { execute: vi.fn(() => Promise.resolve(success(categoryFixture))) }, + createCategory: { execute: vi.fn(() => Promise.resolve(success(categoryFixture))) }, + updateCategory: { execute: vi.fn(() => Promise.resolve(success(categoryFixture))) }, + deleteCategory: { execute: vi.fn(() => Promise.resolve(success(undefined))) }, + ...overrides, + }, + }) +} + +function renderCategoryRoute(path: string, container: AppContainer): RenderResult { + const routes: RouteObject[] = [ + { + path: '/categories', + element: , + children: [ + { path: 'new', element: }, + { path: ':id/edit', element: }, + ], + }, + ] + const router = createMemoryRouter(routes, { initialEntries: [path] }) + + return render( + + + + + , + ) +} + +describe('Categories routes', () => { + it('opens creation in a dialog without leaving the list', async () => { + renderCategoryRoute('/categories', buildContainer()) + + expect(await screen.findByText('Massagens')).toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: /nova categoria/i })) + + const dialog = await screen.findByRole('dialog') + expect(within(dialog).getByRole('heading', { name: 'Nova categoria' })).toBeInTheDocument() + expect(within(dialog).getByLabelText('Nome')).toHaveValue('') + expect(screen.getByText('Massagens')).toBeInTheDocument() + }) + + it('opens creation directly from its URL over the list', async () => { + renderCategoryRoute('/categories/new', buildContainer()) + + expect(await screen.findByRole('dialog')).toBeInTheDocument() + expect(await screen.findByText('Massagens')).toBeInTheDocument() + }) + + it('preserves list state when the creation route closes', async () => { + renderCategoryRoute('/categories', buildContainer()) + + await userEvent.type(screen.getByLabelText('Buscar categoria por nome'), 'massa') + await userEvent.click(screen.getByRole('button', { name: /nova categoria/i })) + expect(await screen.findByRole('dialog')).toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: 'Cancelar' })) + + await vi.waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + expect(screen.getByLabelText('Buscar categoria por nome')).toHaveValue('massa') + expect(screen.getByText('Massagens')).toBeInTheDocument() + }) + + it('creates a category through the dialog and keeps the listing route mounted', async () => { + const listCategoriesSpy = vi.fn(() => Promise.resolve(success([categoryFixture]))) + const createCategorySpy = vi.fn(() => Promise.resolve(success(categoryFixture))) + renderCategoryRoute( + '/categories', + buildContainer({ + listCategories: { execute: listCategoriesSpy }, + createCategory: { execute: createCategorySpy }, + }), + ) + + await screen.findByText('Massagens') + listCategoriesSpy.mockClear() + await userEvent.click(screen.getByRole('button', { name: /nova categoria/i })) + + await userEvent.type(screen.getByLabelText('Nome'), 'Estética') + await userEvent.click(screen.getByRole('button', { name: /criar categoria/i })) + + expect(createCategorySpy).toHaveBeenCalledExactlyOnceWith({ + name: 'Estética', + }) + await vi.waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + expect(screen.getByRole('heading', { name: 'Categorias' })).toBeInTheDocument() + // The editor writes directly to the backend and navigates back to the + // base /categories route; the list refetches on that route transition + // (docs/adr/013) instead of sharing mutation state with the editor. + await vi.waitFor(() => { + expect(listCategoriesSpy).toHaveBeenCalledTimes(1) + }) + }) + + it('uses compact record-specific actions and navigates editing to its route', async () => { + renderCategoryRoute('/categories', buildContainer()) + + await screen.findByText('Massagens') + await userEvent.click(screen.getByRole('button', { name: 'Editar categoria Massagens' })) + + const dialog = await screen.findByRole('dialog') + expect(within(dialog).getByRole('heading', { name: 'Editar categoria' })).toBeInTheDocument() + expect(within(dialog).getByLabelText('Nome')).toHaveValue('Massagens') + expect(screen.getByText('Massagens')).toBeInTheDocument() + }) + + it('loads the category identified by the edit route and updates it', async () => { + const updateCategorySpy = vi.fn(() => Promise.resolve(success(categoryFixture))) + renderCategoryRoute( + '/categories/category-1/edit', + buildContainer({ updateCategory: { execute: updateCategorySpy } }), + ) + + const nameInput = await screen.findByLabelText('Nome') + expect(nameInput).toHaveValue('Massagens') + await userEvent.clear(nameInput) + await userEvent.type(nameInput, 'Terapias') + await userEvent.click(screen.getByRole('button', { name: /salvar alterações/i })) + + expect(updateCategorySpy).toHaveBeenCalledExactlyOnceWith('category-1', { + name: 'Terapias', + }) + expect(await screen.findByRole('heading', { name: 'Categorias' })).toBeInTheDocument() + }) + + it('shows a not-found state when the edit route id is not in the tenant list', async () => { + renderCategoryRoute( + '/categories/missing/edit', + buildContainer({ + getCategory: { + execute: vi.fn(() => + Promise.resolve( + failure( + new AppError({ + code: 'notFound', + message: "Categoria 'missing' não foi encontrada.", + retryable: false, + backendCode: 'Category.NotFound', + }), + ), + ), + ), + }, + }), + ) + + expect(await screen.findByText('Categoria não encontrada.')).toBeInTheDocument() + expect(screen.queryByLabelText('Nome')).not.toBeInTheDocument() + }) + + it('retries when loading the category for editing fails', async () => { + const getCategorySpy = vi + .fn() + .mockResolvedValueOnce( + failure( + new AppError({ + code: 'network', + message: 'Não foi possível acessar o serviço.', + retryable: true, + }), + ), + ) + .mockResolvedValueOnce(success(categoryFixture)) + renderCategoryRoute( + '/categories/category-1/edit', + buildContainer({ getCategory: { execute: getCategorySpy } }), + ) + + expect( + await screen.findByText( + /não foi possível carregar a categoria: não foi possível acessar o serviço/i, + ), + ).toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: /tentar novamente/i })) + + expect(await screen.findByLabelText('Nome')).toHaveValue('Massagens') + expect(getCategorySpy).toHaveBeenCalledTimes(2) + }) + + it('maps a structured creation error to the name field and keeps the route open', async () => { + const conflictError = new AppError({ + code: 'conflict', + message: 'Já existe uma categoria com esse nome.', + retryable: false, + backendCode: 'Category.DuplicateName', + }) + renderCategoryRoute( + '/categories', + buildContainer({ + createCategory: { execute: vi.fn(() => Promise.resolve(failure(conflictError))) }, + }), + ) + + await screen.findByText('Massagens') + await userEvent.click(screen.getByRole('button', { name: /nova categoria/i })) + await userEvent.type(screen.getByLabelText('Nome'), 'Massagens') + await userEvent.click(screen.getByRole('button', { name: /criar categoria/i })) + + expect(await screen.findByText('Já existe uma categoria com esse nome.')).toHaveAttribute( + 'role', + 'alert', + ) + expect(screen.getByLabelText('Nome')).toHaveFocus() + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + it('confirms deletion from the list and refreshes the visible data source', async () => { + const listCategoriesSpy = vi + .fn() + .mockResolvedValueOnce(success([categoryFixture])) + .mockResolvedValueOnce(success([])) + const deleteCategorySpy = vi.fn(() => Promise.resolve(success(undefined))) + renderCategoryRoute( + '/categories', + buildContainer({ + listCategories: { execute: listCategoriesSpy }, + deleteCategory: { execute: deleteCategorySpy }, + }), + ) + await screen.findByText('Massagens') + + await userEvent.click(screen.getByRole('button', { name: /excluir/i })) + const alertDialog = await screen.findByRole('alertdialog') + expect(within(alertDialog).getByText(/"Massagens"/)).toBeInTheDocument() + await userEvent.click(within(alertDialog).getByRole('button', { name: 'Excluir' })) + + expect(deleteCategorySpy).toHaveBeenCalledExactlyOnceWith('category-1') + expect(await screen.findByText(/nenhuma categoria ainda/i)).toBeInTheDocument() + expect(screen.queryByText('Massagens')).not.toBeInTheDocument() + }) + + it('debounces searches on the list route', async () => { + const listCategoriesSpy = vi.fn(() => Promise.resolve(success([categoryFixture]))) + renderCategoryRoute( + '/categories', + buildContainer({ listCategories: { execute: listCategoriesSpy } }), + ) + await screen.findByText('Massagens') + listCategoriesSpy.mockClear() + + vi.useFakeTimers() + try { + fireEvent.change(screen.getByLabelText('Buscar categoria por nome'), { + target: { value: 'massa' }, + }) + expect(listCategoriesSpy).not.toHaveBeenCalled() + + await act(async () => { + await vi.advanceTimersByTimeAsync(300) + }) + + expect(listCategoriesSpy).toHaveBeenCalledExactlyOnceWith({ + search: 'massa', + }) + } finally { + vi.useRealTimers() + } + }) + + it.each(MALICIOUS_PAYLOADS)('renders the category name "%s" as inert text', async payload => { + const category = unwrapResult(Category.create({ id: 'malicious-1', name: payload })) + renderCategoryRoute( + '/categories', + buildContainer({ + listCategories: { execute: vi.fn(() => Promise.resolve(success([category]))) }, + }), + ) + + expect(await screen.findByText(payload)).toBeInTheDocument() + expect(document.querySelector('script')).not.toBeInTheDocument() + expect(document.querySelector('img[onerror]')).not.toBeInTheDocument() + }) + + it.each([ + ['/categories/new', 'Nova categoria'], + ['/categories/category-1/edit', 'Editar categoria'], + ])('has no detectable accessibility violations at %s', async (path, title) => { + const { container } = renderCategoryRoute(path, buildContainer()) + const dialog = await screen.findByRole('dialog') + expect(await within(dialog).findByRole('heading', { name: title })).toBeInTheDocument() + + expect(await axe(container)).toHaveNoViolations() + }) +}) diff --git a/apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoryEditorDialog/CategoryEditorDialog.tsx b/apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoryEditorDialog/CategoryEditorDialog.tsx new file mode 100644 index 0000000..cc88c58 --- /dev/null +++ b/apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoryEditorDialog/CategoryEditorDialog.tsx @@ -0,0 +1,63 @@ +import type { JSX } from 'react' +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { CategoryForm } from '@/features/catalog/presentation/categories/pages/CategoryEditorDialog/forms/CategoryForm' +import { useCategoryEditor } from '@/features/catalog/presentation/categories/pages/CategoryEditorDialog/hooks/useCategoryEditor' +import { StatusMessage } from '@/shared/presentation/components/StatusMessage' + +export function CategoryEditorDialog(): JSX.Element { + const editor = useCategoryEditor() + + return ( + { + if (!open) { + editor.onCancel() + } + }} + > + + + {editor.title} + + + {editor.content.status === 'loading' && ( + Carregando categoria… + )} + + {editor.content.status === 'loadError' && ( +
+ + Não foi possível carregar a categoria: {editor.content.message} + + +
+ )} + + {editor.content.status === 'notFound' && ( +
+ Categoria não encontrada. + +
+ )} + + {editor.content.status === 'ready' && ( + + )} +
+
+ ) +} diff --git a/apps/admin-frontend/src/features/catalog/presentation/categories/forms/CategoryForm.tsx b/apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoryEditorDialog/forms/CategoryForm.tsx similarity index 77% rename from apps/admin-frontend/src/features/catalog/presentation/categories/forms/CategoryForm.tsx rename to apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoryEditorDialog/forms/CategoryForm.tsx index 4e378bd..c9f0bee 100644 --- a/apps/admin-frontend/src/features/catalog/presentation/categories/forms/CategoryForm.tsx +++ b/apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoryEditorDialog/forms/CategoryForm.tsx @@ -1,30 +1,15 @@ import { useEffect, type JSX } from 'react' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' -import { z } from 'zod' import { TextField } from '@/shared/presentation/components/TextField' import { Button } from '@/components/ui/button' import { Spinner } from '@/components/ui/spinner' import { StatusMessage } from '@/shared/presentation/components/StatusMessage' -import type { ServerFormError } from '@/shared/presentation/forms/serverFormError' - -const NAME_MESSAGE = 'O nome da categoria deve ter entre 1 e 60 caracteres' - -const categoryFormSchema = z.object({ - name: z.string().trim().min(1, NAME_MESSAGE).max(60, NAME_MESSAGE), -}) - -export type CategoryFormValues = z.infer -export type CategoryFormField = keyof CategoryFormValues - -interface CategoryFormProps { - initialValues: CategoryFormValues - submitLabel: string - isSubmitting: boolean - serverError: ServerFormError | null - onCancel: () => void - onSubmit: (values: CategoryFormValues) => Promise -} +import { + categoryFormSchema, + type CategoryFormValues, + type CategoryFormProps, +} from '@/features/catalog/presentation/categories/pages/CategoryEditorDialog/forms/CategoryForm.types' export function CategoryForm({ initialValues, diff --git a/apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoryEditorDialog/forms/CategoryForm.types.ts b/apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoryEditorDialog/forms/CategoryForm.types.ts new file mode 100644 index 0000000..2dade84 --- /dev/null +++ b/apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoryEditorDialog/forms/CategoryForm.types.ts @@ -0,0 +1,20 @@ +import { z } from 'zod' +import type { ServerFormError } from '@/shared/presentation/forms/serverFormError' + +const NAME_MESSAGE = 'O nome da categoria deve ter entre 1 e 60 caracteres' + +export const categoryFormSchema = z.object({ + name: z.string().trim().min(1, NAME_MESSAGE).max(60, NAME_MESSAGE), +}) + +export type CategoryFormValues = z.infer +export type CategoryFormField = keyof CategoryFormValues + +export interface CategoryFormProps { + initialValues: CategoryFormValues + submitLabel: string + isSubmitting: boolean + serverError: ServerFormError | null + onCancel: () => void + onSubmit: (values: CategoryFormValues) => Promise +} diff --git a/apps/admin-frontend/src/features/catalog/presentation/categories/forms/categoryFieldMaps.ts b/apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoryEditorDialog/forms/categoryFieldMaps.ts similarity index 89% rename from apps/admin-frontend/src/features/catalog/presentation/categories/forms/categoryFieldMaps.ts rename to apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoryEditorDialog/forms/categoryFieldMaps.ts index e376a81..b3ea3e1 100644 --- a/apps/admin-frontend/src/features/catalog/presentation/categories/forms/categoryFieldMaps.ts +++ b/apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoryEditorDialog/forms/categoryFieldMaps.ts @@ -1,4 +1,4 @@ -import type { CategoryFormField } from '@/features/catalog/presentation/categories/forms/CategoryForm' +import type { CategoryFormField } from '@/features/catalog/presentation/categories/pages/CategoryEditorDialog/forms/CategoryForm.types' // Kept out of CategoryForm.tsx itself: a component file exporting a plain // runtime constant alongside its component breaks Vite Fast Refresh for diff --git a/apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoryEditorDialog/hooks/useCategoryEditor.ts b/apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoryEditorDialog/hooks/useCategoryEditor.ts new file mode 100644 index 0000000..3885ba7 --- /dev/null +++ b/apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoryEditorDialog/hooks/useCategoryEditor.ts @@ -0,0 +1,110 @@ +import { useCallback, useState } from 'react' +import { useNavigate, useParams } from 'react-router' +import { useAppContainer } from '@/app/providers/useAppContainer' +import { useAsync } from '@/shared/presentation/hooks/useAsync' +import { AppError } from '@/shared/application/AppError' +import { toUiError } from '@/shared/application/UiError' +import { failure, type Result } from '@/shared/application/Result' +import type { Category } from '@/features/catalog/domain/entities/Category' +import type { + CategoryFormField, + CategoryFormValues, +} from '@/features/catalog/presentation/categories/pages/CategoryEditorDialog/forms/CategoryForm.types' +import { + categoryCodeFieldMap, + categoryFieldMap, +} from '@/features/catalog/presentation/categories/pages/CategoryEditorDialog/forms/categoryFieldMaps' +import { + mapApiErrorToForm, + type ServerFormError, +} from '@/shared/presentation/forms/serverFormError' +import type { + CategoryEditorContent, + UseCategoryEditorResult, +} from '@/features/catalog/presentation/categories/pages/CategoryEditorDialog/hooks/useCategoryEditor.types' + +const EMPTY_FORM_VALUES: CategoryFormValues = { name: '' } + +// immediate: isEditing keeps this unreachable in practice - it only exists +// to satisfy useAsync's Result-returning contract without a throw. +const MISSING_CATEGORY_ID_ERROR = new AppError({ + code: 'unexpected', + message: 'Categoria não informada.', + retryable: false, +}) + +export function useCategoryEditor(): UseCategoryEditorResult { + const { id: categoryId } = useParams<{ id: string }>() + const navigate = useNavigate() + const { catalog } = useAppContainer() + const isEditing = categoryId !== undefined + + const fetchCategory = useCallback((): Promise> => { + if (categoryId === undefined) { + return Promise.resolve(failure(MISSING_CATEGORY_ID_ERROR)) + } + return catalog.getCategory.execute(categoryId) + }, [catalog, categoryId]) + + const categoryState = useAsync(fetchCategory, { immediate: isEditing }) + const [isSubmitting, setIsSubmitting] = useState(false) + const [serverError, setServerError] = useState | null>(null) + + function closeEditor(): void { + void navigate('..', { replace: true }) + } + + async function onSubmit(values: CategoryFormValues): Promise { + setIsSubmitting(true) + setServerError(null) + const result = + categoryId === undefined + ? await catalog.createCategory.execute({ name: values.name }) + : await catalog.updateCategory.execute(categoryId, { name: values.name }) + if (result.success) { + closeEditor() + } else { + setServerError( + mapApiErrorToForm( + result.error, + categoryFieldMap, + categoryCodeFieldMap, + isEditing + ? 'Não foi possível salvar a categoria.' + : 'Não foi possível criar a categoria.', + ), + ) + } + setIsSubmitting(false) + } + + let content: CategoryEditorContent + if (!isEditing) { + content = { status: 'ready', initialValues: EMPTY_FORM_VALUES } + } else if (categoryState.status === 'idle' || categoryState.status === 'loading') { + content = { status: 'loading' } + } else if (categoryState.status === 'initialError') { + const error = categoryState.error + content = + error.code === 'notFound' + ? { status: 'notFound' } + : { + status: 'loadError', + message: toUiError(error).message, + onRetry: () => void categoryState.execute(), + } + } else { + content = { status: 'ready', initialValues: { name: categoryState.data.name } } + } + + return { + title: isEditing ? 'Editar categoria' : 'Nova categoria', + submitLabel: isEditing ? 'Salvar alterações' : 'Criar categoria', + formKey: categoryId ?? 'new', + content, + isSubmitting, + serverError, + onCancel: closeEditor, + onSubmit, + } +} diff --git a/apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoryEditorDialog/hooks/useCategoryEditor.types.ts b/apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoryEditorDialog/hooks/useCategoryEditor.types.ts new file mode 100644 index 0000000..40e2dfc --- /dev/null +++ b/apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoryEditorDialog/hooks/useCategoryEditor.types.ts @@ -0,0 +1,22 @@ +import type { + CategoryFormField, + CategoryFormValues, +} from '@/features/catalog/presentation/categories/pages/CategoryEditorDialog/forms/CategoryForm.types' +import type { ServerFormError } from '@/shared/presentation/forms/serverFormError' + +export type CategoryEditorContent = + | { status: 'loading' } + | { status: 'loadError'; message: string; onRetry: () => void } + | { status: 'notFound' } + | { status: 'ready'; initialValues: CategoryFormValues } + +export interface UseCategoryEditorResult { + title: string + submitLabel: string + formKey: string + content: CategoryEditorContent + isSubmitting: boolean + serverError: ServerFormError | null + onCancel: () => void + onSubmit: (values: CategoryFormValues) => Promise +} diff --git a/apps/admin-frontend/src/features/catalog/presentation/services/ServicesPage.accessibilityAndSecurity.test.tsx b/apps/admin-frontend/src/features/catalog/presentation/services/ServicesPage.accessibilityAndSecurity.test.tsx deleted file mode 100644 index 0ed1234..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/services/ServicesPage.accessibilityAndSecurity.test.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { screen, within } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { axe } from 'jest-axe' -import { Service } from '@/features/catalog/domain/entities/Service' -import { MALICIOUS_PAYLOADS } from '@/test/fixtures/maliciousPayloads' -import { - tenantContext, - buildContainer, - renderServicesPage, -} from '@/features/catalog/presentation/services/ServicesPage.testSupport' - -describe('ServicesPage', () => { - describe('accessibility', () => { - it('has no axe violations with the create-service dialog open', async () => { - const container = renderServicesPage(buildContainer()) - await screen.findByText('Massagem relaxante') - - await userEvent.click(screen.getByRole('button', { name: /novo serviço/i })) - await screen.findByRole('dialog') - - expect(await axe(container)).toHaveNoViolations() - }) - }) - - describe('delete', () => { - it('shows a confirmation dialog naming the service before deleting', async () => { - renderServicesPage(buildContainer()) - await screen.findByText('Massagem relaxante') - - await userEvent.click(screen.getByRole('button', { name: /excluir/i })) - - const alertDialog = await screen.findByRole('alertdialog') - expect(within(alertDialog).getByText(/excluir serviço/i)).toBeInTheDocument() - expect(within(alertDialog).getByText(/"Massagem relaxante"/)).toBeInTheDocument() - }) - - it('deletes the service when the confirmation is accepted', async () => { - const deleteServiceSpy = vi.fn(() => Promise.resolve()) - renderServicesPage(buildContainer({ deleteService: { execute: deleteServiceSpy } })) - await screen.findByText('Massagem relaxante') - - await userEvent.click(screen.getByRole('button', { name: /excluir/i })) - const alertDialog = await screen.findByRole('alertdialog') - await userEvent.click(within(alertDialog).getByRole('button', { name: 'Excluir' })) - - expect(deleteServiceSpy).toHaveBeenCalledExactlyOnceWith(tenantContext, 'service-1') - await vi.waitFor(() => { - expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() - }) - }) - }) - - describe('security', () => { - it.each(MALICIOUS_PAYLOADS)('renders "%s" as inert text, not markup', async payload => { - const maliciousService = Service.create({ - id: 'malicious-1', - code: 1002, - name: payload, - durationMinutes: 30, - minDurationMinutes: 15, - maxDurationMinutes: 60, - price: 10, - maxDiscountPercentage: 0, - tags: [], - }) - renderServicesPage( - buildContainer({ - listServices: { - execute: vi.fn(() => - Promise.resolve({ - services: [maliciousService], - totalCount: 1, - page: 1, - pageSize: 20, - }), - ), - }, - }), - ) - - expect(await screen.findByText(payload)).toBeInTheDocument() - expect(document.querySelector('script')).not.toBeInTheDocument() - expect(document.querySelector('img[onerror]')).not.toBeInTheDocument() - }) - }) -}) diff --git a/apps/admin-frontend/src/features/catalog/presentation/services/ServicesPage.crud.test.tsx b/apps/admin-frontend/src/features/catalog/presentation/services/ServicesPage.crud.test.tsx deleted file mode 100644 index aa0f589..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/services/ServicesPage.crud.test.tsx +++ /dev/null @@ -1,303 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { screen, within } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { Category } from '@/features/catalog/domain/entities/Category' -import { Tag, TAG_COLOR_PALETTE } from '@/features/catalog/domain/entities/Tag' -import { AppError } from '@/shared/application/AppError' -import { - tenantContext, - massagemService, - massagensCategory, - vipTag, - buildContainer, - renderServicesPage, - getPopoverContent, -} from '@/features/catalog/presentation/services/ServicesPage.testSupport' - -describe('ServicesPage', () => { - it('renders the service list once loaded', async () => { - renderServicesPage(buildContainer()) - - expect(await screen.findByText('Massagem relaxante')).toBeInTheDocument() - expect(screen.getByText('1001')).toBeInTheDocument() - expect(screen.getByText('Massagens')).toBeInTheDocument() - expect(screen.getByText('60 min (30–90)')).toBeInTheDocument() - expect(screen.getByText('VIP')).toBeInTheDocument() - }) - - it('pins the Ações column to the right edge so it stays reachable on narrow viewports', async () => { - renderServicesPage(buildContainer()) - await screen.findByText('Massagem relaxante') - - const actionsHeader = screen.getByRole('columnheader', { name: 'Ações' }) - expect(actionsHeader.className).toMatch(/\bsticky\b/) - expect(actionsHeader.className).toMatch(/\bright-0\b/) - const editButton = screen.getByRole('button', { name: /editar/i }) - const actionsCell = editButton.closest('td') - expect(actionsCell?.className).toMatch(/\bsticky\b/) - expect(actionsCell?.className).toMatch(/\bright-0\b/) - }) - - it('shows an empty state when there are no services', async () => { - renderServicesPage( - buildContainer({ - listServices: { - execute: vi.fn(() => - Promise.resolve({ services: [], totalCount: 0, page: 1, pageSize: 20 }), - ), - }, - }), - ) - - expect(await screen.findByText(/nenhum serviço ainda/i)).toBeInTheDocument() - }) - - it('shows an error state when loading services fails', async () => { - renderServicesPage( - buildContainer({ - listServices: { - execute: vi.fn(() => - Promise.reject( - new AppError({ code: 'network', message: 'network down', retryable: true }), - ), - ), - }, - }), - ) - - expect( - await screen.findByText(/não foi possível carregar os serviços: network down/i), - ).toBeInTheDocument() - }) - - it('creates a service through the form and refreshes the list', async () => { - const createServiceSpy = vi.fn(() => Promise.resolve(massagemService)) - const listServicesSpy = vi.fn(() => - Promise.resolve({ services: [massagemService], totalCount: 1, page: 1, pageSize: 20 }), - ) - renderServicesPage( - buildContainer({ - createService: { execute: createServiceSpy }, - listServices: { execute: listServicesSpy }, - }), - ) - await screen.findByText('Massagem relaxante') - listServicesSpy.mockClear() - - await userEvent.click(screen.getByRole('button', { name: /novo serviço/i })) - await userEvent.type(screen.getByLabelText(/^nome$/i), 'Corte de cabelo') - await userEvent.type(screen.getByLabelText(/duração mínima/i), '15') - await userEvent.type(screen.getByLabelText(/^duração \(min\)$/i), '30') - await userEvent.type(screen.getByLabelText(/duração máxima/i), '45') - await userEvent.type(screen.getByLabelText(/preço/i), '80') - await userEvent.type(screen.getByLabelText(/desconto máximo/i), '5') - const submitButton = screen.getByRole('button', { name: /criar serviço/i }) - // The last field's blur kicks off one more async validation pass - // (mode: 'onTouched') - wait for it to resolve and re-enable the button - // before clicking, instead of racing it. - await vi.waitFor(() => { - expect(submitButton).toBeEnabled() - }) - await userEvent.click(submitButton) - - expect(createServiceSpy).toHaveBeenCalledExactlyOnceWith(tenantContext, { - name: 'Corte de cabelo', - description: null, - durationMinutes: 30, - minDurationMinutes: 15, - maxDurationMinutes: 45, - price: 80, - maxDiscountPercentage: 5, - categoryId: null, - tagIds: [], - }) - await vi.waitFor(() => { - expect(listServicesSpy).toHaveBeenCalledTimes(1) - }) - expect(screen.queryByRole('button', { name: /criar serviço/i })).not.toBeInTheDocument() - }) - - it('shows a validation error and does not submit when the duration range is invalid', async () => { - const createServiceSpy = vi.fn(() => Promise.resolve(massagemService)) - renderServicesPage(buildContainer({ createService: { execute: createServiceSpy } })) - await screen.findByText('Massagem relaxante') - - await userEvent.click(screen.getByRole('button', { name: /novo serviço/i })) - await userEvent.type(screen.getByLabelText(/^nome$/i), 'Corte de cabelo') - await userEvent.type(screen.getByLabelText(/duração mínima/i), '60') - await userEvent.type(screen.getByLabelText(/^duração \(min\)$/i), '30') - await userEvent.type(screen.getByLabelText(/duração máxima/i), '90') - await userEvent.type(screen.getByLabelText(/preço/i), '80') - await userEvent.type(screen.getByLabelText(/desconto máximo/i), '5') - await userEvent.click(screen.getByRole('button', { name: /criar serviço/i })) - - expect( - await screen.findByText( - /a duração mínima não pode ser maior que a duração padrão/i, - {}, - { timeout: 3000 }, - ), - ).toBeInTheDocument() - expect(createServiceSpy).not.toHaveBeenCalled() - }) - - it('clears the duration validation error as soon as the values become valid again', async () => { - const createServiceSpy = vi.fn(() => Promise.resolve(massagemService)) - renderServicesPage(buildContainer({ createService: { execute: createServiceSpy } })) - await screen.findByText('Massagem relaxante') - - await userEvent.click(screen.getByRole('button', { name: /novo serviço/i })) - const minField = screen.getByLabelText(/duração mínima/i) - const durationField = screen.getByLabelText(/^duração \(min\)$/i) - const maxField = screen.getByLabelText(/duração máxima/i) - await userEvent.type(screen.getByLabelText(/^nome$/i), 'Corte de cabelo') - await userEvent.type(minField, '60') - await userEvent.type(durationField, '30') - await userEvent.type(maxField, '15') - await userEvent.type(screen.getByLabelText(/preço/i), '80') - await userEvent.type(screen.getByLabelText(/desconto máximo/i), '5') - const submitButton = screen.getByRole('button', { name: /criar serviço/i }) - // A submit attempt always runs full validation regardless of mode, and - // (once attempted) marks every field for onChange revalidation from then - // on - the reliable way to first surface the cross-field error here. - await userEvent.click(submitButton) - - expect( - await screen.findByText( - /a duração mínima não pode ser maior que a duração padrão/i, - {}, - { timeout: 3000 }, - ), - ).toBeInTheDocument() - expect(createServiceSpy).not.toHaveBeenCalled() - - await userEvent.clear(minField) - await userEvent.type(minField, '15') - await userEvent.clear(durationField) - await userEvent.type(durationField, '30') - await userEvent.clear(maxField) - await userEvent.type(maxField, '60') - - await vi.waitFor( - () => { - expect( - screen.queryByText(/a duração mínima não pode ser maior que a duração padrão/i), - ).not.toBeInTheDocument() - }, - { timeout: 3000 }, - ) - await vi.waitFor(() => { - expect(submitButton).toBeEnabled() - }) - - await userEvent.click(submitButton) - await vi.waitFor(() => { - expect(createServiceSpy).toHaveBeenCalledTimes(1) - }) - }) - - it('toggles a tag on and includes it when creating a service', async () => { - const createServiceSpy = vi.fn(() => Promise.resolve(massagemService)) - renderServicesPage(buildContainer({ createService: { execute: createServiceSpy } })) - await screen.findByText('Massagem relaxante') - - await userEvent.click(screen.getByRole('button', { name: /novo serviço/i })) - const dialog = screen.getByRole('dialog') - await userEvent.type(screen.getByLabelText(/^nome$/i), 'Corte de cabelo') - await userEvent.type(screen.getByLabelText(/duração mínima/i), '15') - await userEvent.type(screen.getByLabelText(/^duração \(min\)$/i), '30') - await userEvent.type(screen.getByLabelText(/duração máxima/i), '45') - await userEvent.type(screen.getByLabelText(/preço/i), '80') - await userEvent.type(screen.getByLabelText(/desconto máximo/i), '5') - - await userEvent.click(within(dialog).getByRole('combobox', { name: 'Etiquetas' })) - await userEvent.click(screen.getByRole('option', { name: /vip/i })) - - const submitButton = screen.getByRole('button', { name: /criar serviço/i }) - await vi.waitFor(() => { - expect(submitButton).toBeEnabled() - }) - await userEvent.click(submitButton) - - expect(createServiceSpy).toHaveBeenCalledExactlyOnceWith( - tenantContext, - expect.objectContaining({ tagIds: ['tag-1'] }), - ) - }) - - describe('inline category and tag creation', () => { - it('creates a category from the service dialog, selects it, and keeps the dialog open', async () => { - const newCategory = Category.create({ id: 'category-2', name: 'Cabelo' }) - // Mirrors a real backend: once created, the next list call includes it. - const knownCategories = [massagensCategory] - const listCategoriesSpy = vi.fn(() => Promise.resolve([...knownCategories])) - const createCategorySpy = vi.fn(() => { - knownCategories.push(newCategory) - return Promise.resolve(newCategory) - }) - renderServicesPage( - buildContainer({ - createCategory: { execute: createCategorySpy }, - listCategories: { execute: listCategoriesSpy }, - }), - ) - await screen.findByText('Massagem relaxante') - - await userEvent.click(screen.getByRole('button', { name: /novo serviço/i })) - const dialog = screen.getByRole('dialog') - await userEvent.click(within(dialog).getByRole('combobox', { name: 'Categoria' })) - await userEvent.click(screen.getByRole('button', { name: /nova categoria/i })) - const categoryPopover = getPopoverContent('Nova categoria') - await userEvent.type( - within(categoryPopover).getByRole('textbox', { name: /^nome$/i }), - 'Cabelo', - ) - await userEvent.click( - within(categoryPopover).getByRole('button', { name: /criar categoria/i }), - ) - - expect(createCategorySpy).toHaveBeenCalledExactlyOnceWith(tenantContext, { name: 'Cabelo' }) - await vi.waitFor(() => { - expect(within(dialog).getByRole('combobox', { name: 'Categoria' })).toHaveTextContent( - 'Cabelo', - ) - }) - expect(screen.getByRole('dialog')).toBeInTheDocument() - }) - - it('creates a tag from the service dialog, toggles it on, and keeps the dialog open', async () => { - const newTag = Tag.create({ id: 'tag-2', name: 'Promoção', color: '#0ea5e9' }) - // Mirrors a real backend: once created, the next list call includes it. - const knownTags = [vipTag] - const listTagsSpy = vi.fn(() => Promise.resolve([...knownTags])) - const createTagSpy = vi.fn(() => { - knownTags.push(newTag) - return Promise.resolve(newTag) - }) - renderServicesPage( - buildContainer({ - createTag: { execute: createTagSpy }, - listTags: { execute: listTagsSpy }, - }), - ) - await screen.findByText('Massagem relaxante') - - await userEvent.click(screen.getByRole('button', { name: /novo serviço/i })) - const dialog = screen.getByRole('dialog') - await userEvent.click(within(dialog).getByRole('combobox', { name: 'Etiquetas' })) - await userEvent.click(screen.getByRole('button', { name: /nova etiqueta/i })) - const tagPopover = getPopoverContent('Nova etiqueta') - await userEvent.type(within(tagPopover).getByRole('textbox', { name: /^nome$/i }), 'Promoção') - await userEvent.click(within(tagPopover).getByRole('button', { name: /criar etiqueta/i })) - - expect(createTagSpy).toHaveBeenCalledExactlyOnceWith(tenantContext, { - name: 'Promoção', - color: TAG_COLOR_PALETTE[0], - }) - await vi.waitFor(() => { - expect(within(dialog).getByText('Promoção')).toBeInTheDocument() - }) - expect(screen.getByRole('dialog')).toBeInTheDocument() - }) - }) -}) diff --git a/apps/admin-frontend/src/features/catalog/presentation/services/ServicesPage.dialogLifecycle.test.tsx b/apps/admin-frontend/src/features/catalog/presentation/services/ServicesPage.dialogLifecycle.test.tsx deleted file mode 100644 index 17697ee..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/services/ServicesPage.dialogLifecycle.test.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { screen, within } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { - buildContainer, - renderServicesPage, -} from '@/features/catalog/presentation/services/ServicesPage.testSupport' - -describe('ServicesPage', () => { - describe('dialog close protection (unsaved changes)', () => { - it('closes immediately on Escape when the form was never touched', async () => { - renderServicesPage(buildContainer()) - await screen.findByText('Massagem relaxante') - - await userEvent.click(screen.getByRole('button', { name: /novo serviço/i })) - await screen.findByRole('dialog') - await userEvent.keyboard('{Escape}') - - await vi.waitFor(() => { - expect(screen.queryByRole('dialog')).not.toBeInTheDocument() - }) - expect(screen.queryByText(/descartar alterações/i)).not.toBeInTheDocument() - }) - - it('asks for confirmation instead of closing when Escape is pressed with unsaved changes', async () => { - renderServicesPage(buildContainer()) - await screen.findByText('Massagem relaxante') - - await userEvent.click(screen.getByRole('button', { name: /novo serviço/i })) - await userEvent.type(screen.getByLabelText(/^nome$/i), 'Rascunho não salvo') - await userEvent.keyboard('{Escape}') - - const confirm = await screen.findByRole('alertdialog') - expect(within(confirm).getByText(/descartar alterações/i)).toBeInTheDocument() - // The underlying form dialog stays mounted (values intact) even - // though Radix marks it aria-hidden while the confirmation is the - // topmost layer - queried with {hidden: true} for that reason. - expect(screen.getByRole('dialog', { hidden: true })).toBeInTheDocument() - }) - - it('preserves the typed values when the discard confirmation is cancelled', async () => { - renderServicesPage(buildContainer()) - await screen.findByText('Massagem relaxante') - - await userEvent.click(screen.getByRole('button', { name: /novo serviço/i })) - await userEvent.type(screen.getByLabelText(/^nome$/i), 'Rascunho não salvo') - await userEvent.keyboard('{Escape}') - const confirm = await screen.findByRole('alertdialog') - await userEvent.click(within(confirm).getByRole('button', { name: /continuar editando/i })) - - await vi.waitFor(() => { - expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() - }) - expect(screen.getByLabelText(/^nome$/i)).toHaveValue('Rascunho não salvo') - }) - - it('discards the draft and closes the dialog when confirmed', async () => { - renderServicesPage(buildContainer()) - await screen.findByText('Massagem relaxante') - - await userEvent.click(screen.getByRole('button', { name: /novo serviço/i })) - await userEvent.type(screen.getByLabelText(/^nome$/i), 'Rascunho não salvo') - await userEvent.keyboard('{Escape}') - const confirm = await screen.findByRole('alertdialog') - await userEvent.click(within(confirm).getByRole('button', { name: /^descartar$/i })) - - await vi.waitFor(() => { - expect(screen.queryByRole('dialog')).not.toBeInTheDocument() - expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() - }) - - await userEvent.click(screen.getByRole('button', { name: /novo serviço/i })) - expect(screen.getByLabelText(/^nome$/i)).toHaveValue('') - }) - - it('also intercepts Cancel when the form has unsaved changes', async () => { - renderServicesPage(buildContainer()) - await screen.findByText('Massagem relaxante') - - await userEvent.click(screen.getByRole('button', { name: /novo serviço/i })) - await userEvent.type(screen.getByLabelText(/^nome$/i), 'Rascunho não salvo') - await userEvent.click(screen.getByRole('button', { name: /^cancelar$/i })) - - const confirm = await screen.findByRole('alertdialog') - expect(within(confirm).getByText(/descartar alterações/i)).toBeInTheDocument() - }) - }) - - describe('focus restoration on dialog close', () => { - it('returns focus to "Novo serviço" after closing the create dialog with Escape', async () => { - renderServicesPage(buildContainer()) - await screen.findByText('Massagem relaxante') - - const triggerButton = screen.getByRole('button', { name: /novo serviço/i }) - await userEvent.click(triggerButton) - await screen.findByRole('dialog') - await userEvent.keyboard('{Escape}') - - await vi.waitFor(() => { - expect(screen.queryByRole('dialog')).not.toBeInTheDocument() - }) - expect(triggerButton).toHaveFocus() - }) - - it('returns focus to the row\'s "Editar" button after closing the edit dialog with Escape', async () => { - renderServicesPage(buildContainer()) - await screen.findByText('Massagem relaxante') - - const editButton = screen.getByRole('button', { name: /editar/i }) - await userEvent.click(editButton) - await screen.findByRole('dialog') - await userEvent.keyboard('{Escape}') - - await vi.waitFor(() => { - expect(screen.queryByRole('dialog')).not.toBeInTheDocument() - }) - expect(editButton).toHaveFocus() - }) - }) -}) diff --git a/apps/admin-frontend/src/features/catalog/presentation/services/ServicesPage.formValidation.test.tsx b/apps/admin-frontend/src/features/catalog/presentation/services/ServicesPage.formValidation.test.tsx deleted file mode 100644 index 7ec5fc8..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/services/ServicesPage.formValidation.test.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { AppError } from '@/shared/application/AppError' -import { - buildContainer, - renderServicesPage, -} from '@/features/catalog/presentation/services/ServicesPage.testSupport' - -describe('ServicesPage', () => { - describe('structured server errors', () => { - async function fillValidServiceForm(): Promise { - await userEvent.click(screen.getByRole('button', { name: /novo serviço/i })) - await userEvent.type(screen.getByLabelText(/^nome$/i), 'Corte de cabelo') - await userEvent.type(screen.getByLabelText(/duração mínima/i), '15') - await userEvent.type(screen.getByLabelText(/^duração \(min\)$/i), '30') - await userEvent.type(screen.getByLabelText(/duração máxima/i), '45') - await userEvent.type(screen.getByLabelText(/preço/i), '80') - await userEvent.type(screen.getByLabelText(/desconto máximo/i), '5') - const submitButton = screen.getByRole('button', { name: /criar serviço/i }) - await vi.waitFor(() => { - expect(submitButton).toBeEnabled() - }) - await userEvent.click(submitButton) - } - - it('maps a validation field error from the API onto the Nome field and focuses it', async () => { - const validationError = new AppError({ - code: 'validation', - message: 'Ocorreram erros de validação.', - retryable: false, - rawFieldErrors: { Name: 'O nome é obrigatório.' }, - }) - renderServicesPage( - buildContainer({ - createService: { execute: vi.fn(() => Promise.reject(validationError)) }, - }), - ) - await screen.findByText('Massagem relaxante') - - await fillValidServiceForm() - - const fieldError = await screen.findByText('O nome é obrigatório.') - expect(fieldError).toHaveAttribute('role', 'alert') - expect(screen.getByLabelText(/^nome$/i)).toHaveFocus() - }) - - it('maps a duplicate-name conflict code from the API onto the Nome field', async () => { - const conflictError = new AppError({ - code: 'conflict', - message: 'Já existe um serviço com esse nome.', - retryable: false, - backendCode: 'Service.DuplicateName', - }) - renderServicesPage( - buildContainer({ createService: { execute: vi.fn(() => Promise.reject(conflictError)) } }), - ) - await screen.findByText('Massagem relaxante') - - await fillValidServiceForm() - - const fieldError = await screen.findByText('Já existe um serviço com esse nome.') - expect(fieldError).toHaveAttribute('role', 'alert') - expect(screen.getByLabelText(/^nome$/i)).toHaveFocus() - }) - - it('maps a validation field error from the API onto the Categoria field and focuses its trigger', async () => { - const validationError = new AppError({ - code: 'validation', - message: 'Ocorreram erros de validação.', - retryable: false, - rawFieldErrors: { CategoryId: 'Categoria inválida.' }, - }) - renderServicesPage( - buildContainer({ - createService: { execute: vi.fn(() => Promise.reject(validationError)) }, - }), - ) - await screen.findByText('Massagem relaxante') - - await fillValidServiceForm() - - const fieldError = await screen.findByText('Categoria inválida.') - expect(fieldError).toHaveAttribute('role', 'alert') - // The categoryId field is wired through Controller/CreatableSingleSelect, - // which has no DOM node of its own unless the trigger forwards a ref - - // this proves setFocus('categoryId') actually lands somewhere focusable. - expect(screen.getByRole('combobox', { name: 'Categoria' })).toHaveFocus() - }) - }) - - describe('client-side validation focus', () => { - it('focuses Nome and flags every empty required field after submitting an empty form', async () => { - renderServicesPage(buildContainer()) - await screen.findByText('Massagem relaxante') - - await userEvent.click(screen.getByRole('button', { name: /novo serviço/i })) - await userEvent.click(screen.getByRole('button', { name: /criar serviço/i })) - - const nameField = screen.getByLabelText(/^nome$/i) - await screen.findByText(/informe o nome do serviço/i) - expect(nameField).toHaveFocus() - expect(nameField).toHaveAttribute('aria-invalid', 'true') - expect(screen.getByLabelText(/duração mínima/i)).toHaveAttribute('aria-invalid', 'true') - expect(screen.getByLabelText(/^duração \(min\)$/i)).toHaveAttribute('aria-invalid', 'true') - expect(screen.getByLabelText(/duração máxima/i)).toHaveAttribute('aria-invalid', 'true') - expect(screen.getByLabelText(/preço/i)).toHaveAttribute('aria-invalid', 'true') - expect(screen.getByLabelText(/desconto máximo/i)).toHaveAttribute('aria-invalid', 'true') - }) - }) -}) diff --git a/apps/admin-frontend/src/features/catalog/presentation/services/ServicesPage.listBehavior.test.tsx b/apps/admin-frontend/src/features/catalog/presentation/services/ServicesPage.listBehavior.test.tsx deleted file mode 100644 index f0ab485..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/services/ServicesPage.listBehavior.test.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { screen, fireEvent, act } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { - tenantContext, - massagemService, - buildContainer, - renderServicesPage, -} from '@/features/catalog/presentation/services/ServicesPage.testSupport' - -describe('ServicesPage', () => { - describe('pagination', () => { - it('shows the current page and total pages, disabling "Anterior" on the first page', async () => { - renderServicesPage( - buildContainer({ - listServices: { - execute: vi.fn(() => - Promise.resolve({ - services: [massagemService], - totalCount: 45, - page: 1, - pageSize: 20, - }), - ), - }, - }), - ) - await screen.findByText('Massagem relaxante') - - expect(screen.getByText('Página 1 de 3')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Anterior' })).toBeDisabled() - expect(screen.getByRole('button', { name: 'Próxima' })).toBeEnabled() - }) - - it('requests the next page when "Próxima" is clicked', async () => { - const listServicesSpy = vi.fn(() => - Promise.resolve({ services: [massagemService], totalCount: 45, page: 1, pageSize: 20 }), - ) - renderServicesPage(buildContainer({ listServices: { execute: listServicesSpy } })) - await screen.findByText('Massagem relaxante') - - await userEvent.click(screen.getByRole('button', { name: 'Próxima' })) - - await vi.waitFor(() => { - expect(listServicesSpy).toHaveBeenCalledWith(tenantContext, { - page: 2, - pageSize: 20, - search: '', - categoryId: undefined, - tagId: undefined, - }) - }) - }) - - it('disables "Próxima" on the last page', async () => { - renderServicesPage( - buildContainer({ - listServices: { - execute: vi.fn(() => - Promise.resolve({ - services: [massagemService], - totalCount: 20, - page: 1, - pageSize: 20, - }), - ), - }, - }), - ) - await screen.findByText('Massagem relaxante') - - expect(screen.getByText('Página 1 de 1')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Próxima' })).toBeDisabled() - }) - }) - - describe('search and filters', () => { - it('refetches with the debounced search term after the user stops typing', async () => { - const listServicesSpy = vi.fn(() => - Promise.resolve({ services: [massagemService], totalCount: 1, page: 1, pageSize: 20 }), - ) - renderServicesPage(buildContainer({ listServices: { execute: listServicesSpy } })) - await screen.findByText('Massagem relaxante') - listServicesSpy.mockClear() - - vi.useFakeTimers() - try { - fireEvent.change(screen.getByLabelText('Buscar serviço por nome'), { - target: { value: 'massa' }, - }) - expect(listServicesSpy).not.toHaveBeenCalled() - - await act(async () => { - await vi.advanceTimersByTimeAsync(300) - }) - - expect(listServicesSpy).toHaveBeenCalledWith(tenantContext, { - page: 1, - pageSize: 20, - search: 'massa', - categoryId: undefined, - tagId: undefined, - }) - } finally { - vi.useRealTimers() - } - }) - - it('refetches filtered by categoryId when a category is selected', async () => { - const listServicesSpy = vi.fn(() => - Promise.resolve({ services: [massagemService], totalCount: 1, page: 1, pageSize: 20 }), - ) - renderServicesPage(buildContainer({ listServices: { execute: listServicesSpy } })) - await screen.findByText('Massagem relaxante') - listServicesSpy.mockClear() - - await userEvent.click(screen.getByRole('combobox', { name: 'Filtrar por categoria' })) - await userEvent.click(screen.getByRole('option', { name: 'Massagens' })) - - await vi.waitFor(() => { - expect(listServicesSpy).toHaveBeenCalledWith(tenantContext, { - page: 1, - pageSize: 20, - search: '', - categoryId: 'category-1', - tagId: undefined, - }) - }) - }) - - it('refetches filtered by tagId when a tag is selected', async () => { - const listServicesSpy = vi.fn(() => - Promise.resolve({ services: [massagemService], totalCount: 1, page: 1, pageSize: 20 }), - ) - renderServicesPage(buildContainer({ listServices: { execute: listServicesSpy } })) - await screen.findByText('Massagem relaxante') - listServicesSpy.mockClear() - - await userEvent.click(screen.getByRole('combobox', { name: 'Filtrar por etiqueta' })) - await userEvent.click(screen.getByRole('option', { name: 'VIP' })) - - await vi.waitFor(() => { - expect(listServicesSpy).toHaveBeenCalledWith(tenantContext, { - page: 1, - pageSize: 20, - search: '', - categoryId: undefined, - tagId: 'tag-1', - }) - }) - }) - }) -}) diff --git a/apps/admin-frontend/src/features/catalog/presentation/services/ServicesPage.testSupport.tsx b/apps/admin-frontend/src/features/catalog/presentation/services/ServicesPage.testSupport.tsx deleted file mode 100644 index 2e17a77..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/services/ServicesPage.testSupport.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { vi } from 'vitest' -import { render, screen } from '@testing-library/react' -import { ServicesPage } from '@/features/catalog/presentation/services/ServicesPage' -import { AppContainerContext } from '@/app/providers/AppContainerContext' -import { AuthProvider } from '@/features/auth' -import type { AppContainer, CatalogFacade } from '@/app/composition/container' -import { Service } from '@/features/catalog/domain/entities/Service' -import { Category } from '@/features/catalog/domain/entities/Category' -import { Tag } from '@/features/catalog/domain/entities/Tag' -import { Tenant } from '@/test/fixtures/authEntityFixtures' -import { User } from '@/test/fixtures/authEntityFixtures' -import { createFakeAppContainer } from '@/test/fixtures/createFakeAppContainer' - -export const tenant = Tenant.create('tenant-123') -export const tenantContext = { tenant, user: User.create({ id: 'user-1', tenant }) } -export const massagemService = Service.create({ - id: 'service-1', - code: 1001, - name: 'Massagem relaxante', - description: 'Uma massagem relaxante de corpo inteiro', - durationMinutes: 60, - minDurationMinutes: 30, - maxDurationMinutes: 90, - price: 150, - maxDiscountPercentage: 10, - categoryId: 'category-1', - categoryName: 'Massagens', - tags: [{ id: 'tag-1', name: 'VIP', color: '#0d9488' }], -}) -export const massagensCategory = Category.create({ id: 'category-1', name: 'Massagens' }) -export const vipTag = Tag.create({ id: 'tag-1', name: 'VIP', color: '#0d9488' }) - -export function buildContainer(overrides: Partial = {}): AppContainer { - return createFakeAppContainer({ - auth: { getCurrentSession: { execute: vi.fn(() => Promise.resolve(tenantContext)) } }, - catalog: { - listServices: { - execute: vi.fn(() => - Promise.resolve({ services: [massagemService], totalCount: 1, page: 1, pageSize: 20 }), - ), - }, - createService: { execute: vi.fn(() => Promise.resolve(massagemService)) }, - updateService: { execute: vi.fn(() => Promise.resolve(massagemService)) }, - deleteService: { execute: vi.fn(() => Promise.resolve()) }, - listCategories: { execute: vi.fn(() => Promise.resolve([massagensCategory])) }, - createCategory: { execute: vi.fn(() => Promise.resolve(massagensCategory)) }, - updateCategory: { execute: vi.fn(() => Promise.resolve(massagensCategory)) }, - deleteCategory: { execute: vi.fn(() => Promise.resolve()) }, - listTags: { execute: vi.fn(() => Promise.resolve([vipTag])) }, - createTag: { execute: vi.fn(() => Promise.resolve(vipTag)) }, - updateTag: { execute: vi.fn(() => Promise.resolve(vipTag)) }, - deleteTag: { execute: vi.fn(() => Promise.resolve()) }, - ...overrides, - }, - }) -} - -export function renderServicesPage(container: AppContainer): HTMLElement { - return render( - - - - - , - ).container -} - -// The InlineCreatePopover's content is portaled to document.body as a -// sibling of the dialog, not a DOM descendant of it, so its fields must be -// queried through this scoped container rather than `within(dialog)`. -export function getPopoverContent(title: string): HTMLElement { - const heading = screen.getByText(title, { selector: 'p' }) - const content = heading.closest('[data-slot="popover-content"]') - if (content === null) { - throw new Error(`Expected the "${title}" popover to be open`) - } - return content as HTMLElement -} diff --git a/apps/admin-frontend/src/features/catalog/presentation/services/ServicesPage.tsx b/apps/admin-frontend/src/features/catalog/presentation/services/ServicesPage.tsx deleted file mode 100644 index 07bf237..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/services/ServicesPage.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import type { JSX } from 'react' -import { PageHeader } from '@/shared/presentation/components/PageHeader' -import { Button } from '@/components/ui/button' -import { useServicesPage } from '@/features/catalog/presentation/services/hooks/useServicesPage' -import { ServicesFilters } from '@/features/catalog/presentation/services/components/ServicesFilters' -import { ServicesList } from '@/features/catalog/presentation/services/components/ServicesList' -import { ServiceDialog } from '@/features/catalog/presentation/services/components/ServiceDialog' -import { ServiceDeleteDialog } from '@/features/catalog/presentation/services/components/ServiceDeleteDialog' - -export function ServicesPage(): JSX.Element { - const { onOpenCreate, filters, list, dialog, deleteDialog } = useServicesPage() - - return ( -
- Novo serviço} /> - - - - - - - - -
- ) -} diff --git a/apps/admin-frontend/src/features/catalog/presentation/services/components/ServiceBasicFields.tsx b/apps/admin-frontend/src/features/catalog/presentation/services/components/ServiceBasicFields.tsx deleted file mode 100644 index b50eba0..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/services/components/ServiceBasicFields.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import type { JSX } from 'react' -import { useFormContext, useWatch } from 'react-hook-form' -import { TextField } from '@/shared/presentation/components/TextField' -import { TextAreaField } from '@/shared/presentation/components/TextAreaField' -import { - SERVICE_NAME_MAX_LENGTH, - SERVICE_DESCRIPTION_MAX_LENGTH, - type ServiceFormInput, - type ServiceFormValues, -} from '@/features/catalog/presentation/services/forms/ServiceForm.schema' - -export function ServiceBasicFields(): JSX.Element { - const { - register, - control, - formState: { errors }, - } = useFormContext() - const descriptionValue = useWatch({ control, name: 'description' }) - - return ( - <> - - - - - ) -} diff --git a/apps/admin-frontend/src/features/catalog/presentation/services/components/ServiceCategoryField.tsx b/apps/admin-frontend/src/features/catalog/presentation/services/components/ServiceCategoryField.tsx deleted file mode 100644 index 3f14339..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/services/components/ServiceCategoryField.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import type { JSX } from 'react' -import { Controller, useFormContext } from 'react-hook-form' -import type { Category } from '@/features/catalog/domain/entities/Category' -import type { CreateCategoryInput } from '@/features/catalog/application/repositories/CategoryRepository' -import { CreatableSingleSelect } from '@/shared/presentation/components/CreatableSingleSelect' -import { - CategoryForm, - type CategoryFormValues, - type CategoryFormField, -} from '@/features/catalog/presentation/categories/forms/CategoryForm' -import { - categoryFieldMap, - categoryCodeFieldMap, -} from '@/features/catalog/presentation/categories/forms/categoryFieldMaps' -import { useCreateInline } from '@/shared/presentation/hooks/useCreateInline' -import type { ServiceCategoryOptions } from '@/features/catalog/presentation/services/models/servicePresentationModels' -import type { - ServiceFormInput, - ServiceFormValues, -} from '@/features/catalog/presentation/services/forms/ServiceForm.schema' - -const EMPTY_CATEGORY_FORM_VALUES: CategoryFormValues = { name: '' } - -function toCategoryInput(values: CategoryFormValues): CreateCategoryInput { - return { name: values.name } -} - -export interface ServiceCategoryFieldProps { - options: ServiceCategoryOptions -} - -export function ServiceCategoryField({ options }: ServiceCategoryFieldProps): JSX.Element { - const { - control, - formState: { errors }, - } = useFormContext() - const createCategory = useCreateInline( - options.onCreate, - categoryFieldMap, - categoryCodeFieldMap, - 'Não foi possível criar a categoria.', - ) - - return ( -
- ( - <> - - category.id} - getLabel={category => category.name} - onChange={field.onChange} - nullLabel="Sem categoria" - searchPlaceholder="Buscar categoria…" - emptyText="Nenhuma categoria encontrada." - createActionLabel="Nova categoria" - loadState={options.loadState} - onCreatePopoverClose={createCategory.reset} - isCreating={createCategory.isCreatingNow} - renderCreateForm={({ close, onCreated }) => ( - { - createCategory.reset() - close() - }} - onSubmit={values => createCategory.create(toCategoryInput(values), onCreated)} - /> - )} - /> - - )} - /> - {errors.categoryId?.message !== undefined && ( -

- {errors.categoryId.message} -

- )} -
- ) -} diff --git a/apps/admin-frontend/src/features/catalog/presentation/services/components/ServiceCommercialFields.tsx b/apps/admin-frontend/src/features/catalog/presentation/services/components/ServiceCommercialFields.tsx deleted file mode 100644 index cfde4b5..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/services/components/ServiceCommercialFields.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import type { JSX } from 'react' -import { useFormContext } from 'react-hook-form' -import { TextField } from '@/shared/presentation/components/TextField' -import type { - ServiceFormInput, - ServiceFormValues, -} from '@/features/catalog/presentation/services/forms/ServiceForm.schema' - -export function ServiceCommercialFields(): JSX.Element { - const { - register, - formState: { errors }, - } = useFormContext() - - return ( -
- - -
- ) -} diff --git a/apps/admin-frontend/src/features/catalog/presentation/services/components/ServiceDeleteDialog.tsx b/apps/admin-frontend/src/features/catalog/presentation/services/components/ServiceDeleteDialog.tsx deleted file mode 100644 index c6bbcd7..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/services/components/ServiceDeleteDialog.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import type { JSX } from 'react' -import type { Service } from '@/features/catalog/domain/entities/Service' -import { DeleteConfirmationDialog } from '@/shared/presentation/components/DeleteConfirmationDialog' - -export interface ServiceDeleteDialogProps { - target: Service | null - error: string | null - isDeleting: boolean - onCancel: () => void - onConfirm: () => void -} - -export function ServiceDeleteDialog({ - target, - error, - isDeleting, - onCancel, - onConfirm, -}: ServiceDeleteDialogProps): JSX.Element { - return ( - - Tem certeza que deseja excluir o serviço "{target?.name}"? Essa ação não pode ser - desfeita. - - } - error={error} - isDeleting={isDeleting} - onCancel={onCancel} - onConfirm={onConfirm} - /> - ) -} diff --git a/apps/admin-frontend/src/features/catalog/presentation/services/components/ServiceDialog.tsx b/apps/admin-frontend/src/features/catalog/presentation/services/components/ServiceDialog.tsx deleted file mode 100644 index 3696dca..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/services/components/ServiceDialog.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import type { JSX } from 'react' -import { ServiceForm } from '@/features/catalog/presentation/services/forms/ServiceForm' -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog' -import type { - DiscardConfirmationViewModel, - ServiceCategoryOptions, - ServiceEditorViewModel, - ServiceTagOptions, -} from '@/features/catalog/presentation/services/models/servicePresentationModels' - -export interface ServiceDialogProps { - editor: ServiceEditorViewModel - categoryOptions: ServiceCategoryOptions - tagOptions: ServiceTagOptions - discardConfirmation: DiscardConfirmationViewModel -} - -// One component, not two: closing a dirty form asks before discarding, so -// the dialog and its discard-confirmation are a single interaction. -export function ServiceDialog({ - editor, - categoryOptions, - tagOptions, - discardConfirmation, -}: ServiceDialogProps): JSX.Element { - return ( - <> - { - if (!open) editor.onRequestClose() - }} - > - { - event.preventDefault() - editor.formTriggerRef.current?.focus() - }} - > - - {editor.content?.title ?? ''} - - {editor.content !== null && ( - - )} - - - - { - if (!open) discardConfirmation.onCancel() - }} - > - - - Descartar alterações? - - Suas alterações não foram salvas. Deseja descartá-las? - - - - Continuar editando - - Descartar - - - - - - ) -} diff --git a/apps/admin-frontend/src/features/catalog/presentation/services/components/ServiceDurationFields.tsx b/apps/admin-frontend/src/features/catalog/presentation/services/components/ServiceDurationFields.tsx deleted file mode 100644 index eff3dc9..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/services/components/ServiceDurationFields.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import type { JSX } from 'react' -import { useFormContext } from 'react-hook-form' -import { TextField } from '@/shared/presentation/components/TextField' -import type { - ServiceFormInput, - ServiceFormValues, -} from '@/features/catalog/presentation/services/forms/ServiceForm.schema' - -export function ServiceDurationFields(): JSX.Element { - const { - register, - formState: { errors }, - } = useFormContext() - - return ( -
- - - -
- ) -} diff --git a/apps/admin-frontend/src/features/catalog/presentation/services/components/ServiceTableRow.tsx b/apps/admin-frontend/src/features/catalog/presentation/services/components/ServiceTableRow.tsx deleted file mode 100644 index 2c45a98..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/services/components/ServiceTableRow.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import type { JSX, MouseEvent } from 'react' -import type { Service } from '@/features/catalog/domain/entities/Service' -import { Button } from '@/components/ui/button' -import { TableCell, TableRow } from '@/components/ui/table' -import { - formatDuration, - formatPrice, -} from '@/features/catalog/presentation/services/models/serviceFormatters' - -export interface ServiceTableRowProps { - service: Service - onEdit: (service: Service, event: MouseEvent) => void - onDelete: (service: Service) => void -} - -export function ServiceTableRow({ service, onEdit, onDelete }: ServiceTableRowProps): JSX.Element { - return ( - - {service.code} - - {service.name} - - {service.categoryName ?? '—'} - {formatDuration(service)} - {formatPrice(service.price)} - {service.maxDiscountPercentage}% - -
- {service.tags.length === 0 && } - {service.tags.map(tag => ( - - - ))} -
-
- -
- - -
-
-
- ) -} diff --git a/apps/admin-frontend/src/features/catalog/presentation/services/components/ServiceTagsField.tsx b/apps/admin-frontend/src/features/catalog/presentation/services/components/ServiceTagsField.tsx deleted file mode 100644 index 3b202a8..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/services/components/ServiceTagsField.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import type { JSX } from 'react' -import { Controller, useFormContext } from 'react-hook-form' -import { TAG_COLOR_PALETTE, type Tag } from '@/features/catalog/domain/entities/Tag' -import type { CreateTagInput } from '@/features/catalog/application/repositories/TagRepository' -import { CreatableMultiSelect } from '@/shared/presentation/components/CreatableMultiSelect' -import { - TagForm, - type TagFormValues, - type TagFormField, -} from '@/features/catalog/presentation/tags/forms/TagForm' -import { - tagFieldMap, - tagCodeFieldMap, -} from '@/features/catalog/presentation/tags/forms/tagFieldMaps' -import { useCreateInline } from '@/shared/presentation/hooks/useCreateInline' -import type { ServiceTagOptions } from '@/features/catalog/presentation/services/models/servicePresentationModels' -import type { - ServiceFormInput, - ServiceFormValues, -} from '@/features/catalog/presentation/services/forms/ServiceForm.schema' - -const EMPTY_TAG_FORM_VALUES: TagFormValues = { - name: '', - color: TAG_COLOR_PALETTE[0], - description: '', -} - -function toTagInput(values: TagFormValues): CreateTagInput { - const description = values.description.trim() - return { - name: values.name, - color: values.color, - ...(description !== '' ? { description } : {}), - } -} - -export interface ServiceTagsFieldProps { - options: ServiceTagOptions -} - -export function ServiceTagsField({ options }: ServiceTagsFieldProps): JSX.Element { - const { - control, - formState: { errors }, - } = useFormContext() - const createTag = useCreateInline( - options.onCreate, - tagFieldMap, - tagCodeFieldMap, - 'Não foi possível criar a etiqueta.', - ) - - return ( -
- Etiquetas - ( - tag.id} - getLabel={tag => tag.name} - getColor={tag => tag.color} - onChange={field.onChange} - placeholder="Selecionar etiquetas" - searchPlaceholder="Buscar etiqueta…" - emptyText="Nenhuma etiqueta encontrada." - createActionLabel="Nova etiqueta" - loadState={options.loadState} - onCreatePopoverClose={createTag.reset} - isCreating={createTag.isCreatingNow} - renderCreateForm={({ close, onCreated }) => ( - { - createTag.reset() - close() - }} - onSubmit={values => createTag.create(toTagInput(values), onCreated)} - /> - )} - /> - )} - /> - {errors.tagIds?.message !== undefined && ( -

- {errors.tagIds.message} -

- )} -
- ) -} diff --git a/apps/admin-frontend/src/features/catalog/presentation/services/components/ServicesFilters.tsx b/apps/admin-frontend/src/features/catalog/presentation/services/components/ServicesFilters.tsx deleted file mode 100644 index 436ccf4..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/services/components/ServicesFilters.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import type { JSX } from 'react' -import type { Category } from '@/features/catalog/domain/entities/Category' -import type { Tag } from '@/features/catalog/domain/entities/Tag' -import { Input } from '@/components/ui/input' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' - -// Radix Select can't take an empty-string value, so each filter maps "no -// filter" ("") to a local sentinel and back - an implementation detail of -// this component, not something callers need to know about. -const ALL_CATEGORIES_VALUE = '__all_categories__' -const ALL_TAGS_VALUE = '__all_tags__' - -export interface ServiceFilterField { - value: string - onChange: (value: string) => void -} - -export interface ServicesFiltersProps { - search: ServiceFilterField - category: ServiceFilterField & { options: readonly Category[] } - tag: ServiceFilterField & { options: readonly Tag[] } -} - -export function ServicesFilters({ search, category, tag }: ServicesFiltersProps): JSX.Element { - return ( -
- { - search.onChange(event.target.value) - }} - /> - - -
- ) -} diff --git a/apps/admin-frontend/src/features/catalog/presentation/services/components/ServicesList.tsx b/apps/admin-frontend/src/features/catalog/presentation/services/components/ServicesList.tsx deleted file mode 100644 index 3d032f4..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/services/components/ServicesList.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import type { JSX, MouseEvent } from 'react' -import type { Service } from '@/features/catalog/domain/entities/Service' -import { CollectionFeedback } from '@/shared/presentation/components/CollectionFeedback' -import { ServicesTable } from '@/features/catalog/presentation/services/components/ServicesTable' -import { ServicesPagination } from '@/features/catalog/presentation/services/components/ServicesPagination' -import type { AsyncState } from '@/shared/presentation/hooks/useAsync' -import type { UiError } from '@/shared/application/UiError' - -export interface ServicesListProps { - services: readonly Service[] - listState: AsyncState - hasActiveFilters: boolean - page: number - totalPages: number - onPageChange: (page: number) => void - onRetry: () => void - onEdit: (service: Service, event: MouseEvent) => void - onDelete: (service: Service) => void -} - -/** Decides loading/error/empty/last-known-good; delegates rendering to ServicesTable/ServicesPagination. */ -export function ServicesList({ - services, - listState, - hasActiveFilters, - page, - totalPages, - onPageChange, - onRetry, - onEdit, - onDelete, -}: ServicesListProps): JSX.Element { - return ( -
- - - {services.length > 0 && ( - - )} - - {services.length > 0 && ( - - )} -
- ) -} diff --git a/apps/admin-frontend/src/features/catalog/presentation/services/components/ServicesPagination.tsx b/apps/admin-frontend/src/features/catalog/presentation/services/components/ServicesPagination.tsx deleted file mode 100644 index b096df5..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/services/components/ServicesPagination.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import type { JSX } from 'react' -import { Button } from '@/components/ui/button' - -export interface ServicesPaginationProps { - page: number - totalPages: number - onPageChange: (page: number) => void -} - -export function ServicesPagination({ - page, - totalPages, - onPageChange, -}: ServicesPaginationProps): JSX.Element { - return ( -
- - Página {page} de {totalPages} - -
- - -
-
- ) -} diff --git a/apps/admin-frontend/src/features/catalog/presentation/services/components/ServicesTable.tsx b/apps/admin-frontend/src/features/catalog/presentation/services/components/ServicesTable.tsx deleted file mode 100644 index 92c1a73..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/services/components/ServicesTable.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import type { JSX, MouseEvent } from 'react' -import type { Service } from '@/features/catalog/domain/entities/Service' -import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/components/ui/table' -import { ServiceTableRow } from '@/features/catalog/presentation/services/components/ServiceTableRow' - -export interface ServicesTableProps { - services: readonly Service[] - onEdit: (service: Service, event: MouseEvent) => void - onDelete: (service: Service) => void -} - -export function ServicesTable({ services, onEdit, onDelete }: ServicesTableProps): JSX.Element { - return ( -
-
- - - Código - Nome - Categoria - Duração - Preço - Desconto máx. - Etiquetas - - Ações - - - - - {services.map(service => ( - - ))} - -
-
- ) -} diff --git a/apps/admin-frontend/src/features/catalog/presentation/services/forms/ServiceForm.schema.test.ts b/apps/admin-frontend/src/features/catalog/presentation/services/forms/ServiceForm.schema.test.ts deleted file mode 100644 index 0ff7ee5..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/services/forms/ServiceForm.schema.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { - serviceFormSchema, - type ServiceFormInput, -} from '@/features/catalog/presentation/services/forms/ServiceForm.schema' - -function validInput(tagIds: string[]): ServiceFormInput { - return { - name: 'Corte de cabelo', - description: '', - durationMinutes: '30', - minDurationMinutes: '15', - maxDurationMinutes: '60', - price: '50', - maxDiscountPercentage: '10', - categoryId: null, - tagIds, - } -} - -describe('serviceFormSchema tagIds validation', () => { - it('accepts an empty tagIds list', () => { - expect(serviceFormSchema.safeParse(validInput([])).success).toBe(true) - }) - - it('accepts a single tag id', () => { - expect(serviceFormSchema.safeParse(validInput(['tag-1'])).success).toBe(true) - }) - - it('accepts multiple distinct tag ids', () => { - expect(serviceFormSchema.safeParse(validInput(['tag-1', 'tag-2', 'tag-3'])).success).toBe(true) - }) - - it('rejects a duplicated tag id', () => { - const result = serviceFormSchema.safeParse(validInput(['tag-1', 'tag-1'])) - - expect(result.success).toBe(false) - if (!result.success) { - const tagIdsIssue = result.error.issues.find(issue => issue.path.join('.') === 'tagIds') - expect(tagIdsIssue?.message).toMatch(/duplicad/) - } - }) - - it('rejects when only some tag ids are duplicated among otherwise-distinct ids', () => { - const result = serviceFormSchema.safeParse(validInput(['tag-1', 'tag-2', 'tag-1'])) - - expect(result.success).toBe(false) - if (!result.success) { - const tagIdsIssue = result.error.issues.find(issue => issue.path.join('.') === 'tagIds') - expect(tagIdsIssue).toBeDefined() - } - }) -}) - -function withDuration(durationMinutes: string): ServiceFormInput { - return { - ...validInput([]), - durationMinutes, - minDurationMinutes: durationMinutes, - maxDurationMinutes: durationMinutes, - } -} - -function withPrice(price: string): ServiceFormInput { - return { ...validInput([]), price } -} - -function withDiscount(maxDiscountPercentage: string): ServiceFormInput { - return { ...validInput([]), maxDiscountPercentage } -} - -describe('serviceFormSchema numeric precision', () => { - describe('durations', () => { - it.each(['30', '1', '1440'])('accepts the whole-minute duration "%s"', duration => { - expect(serviceFormSchema.safeParse(withDuration(duration)).success).toBe(true) - }) - - it.each(['30.5', '30.0', 'NaN', 'Infinity', '1e309', '', ' '])( - 'rejects the fractional/malformed duration "%s"', - duration => { - const result = serviceFormSchema.safeParse(withDuration(duration)) - expect(result.success).toBe(false) - }, - ) - }) - - describe('price', () => { - it.each(['10', '10.0', '10.00', '0'])('accepts the price "%s"', price => { - expect(serviceFormSchema.safeParse(withPrice(price)).success).toBe(true) - }) - - it.each(['10.123', 'NaN', 'Infinity', '1e309', '', ' '])( - 'rejects the malformed price "%s"', - price => { - expect(serviceFormSchema.safeParse(withPrice(price)).success).toBe(false) - }, - ) - - it('rejects a negative price with the domain-specific message', () => { - const result = serviceFormSchema.safeParse(withPrice('-5')) - - expect(result.success).toBe(false) - if (!result.success) { - const priceIssue = result.error.issues.find(issue => issue.path.join('.') === 'price') - expect(priceIssue?.message).toMatch(/não pode ser negativo/) - } - }) - }) - - describe('discount', () => { - it.each(['0', '10.5', '100', '99.99'])('accepts the discount "%s"', discount => { - expect(serviceFormSchema.safeParse(withDiscount(discount)).success).toBe(true) - }) - - it.each(['10.123', 'NaN', 'Infinity', '1e309', '', ' '])( - 'rejects the malformed discount "%s"', - discount => { - expect(serviceFormSchema.safeParse(withDiscount(discount)).success).toBe(false) - }, - ) - - it('rejects an out-of-range discount with the domain-specific message', () => { - const result = serviceFormSchema.safeParse(withDiscount('150')) - - expect(result.success).toBe(false) - if (!result.success) { - const discountIssue = result.error.issues.find( - issue => issue.path.join('.') === 'maxDiscountPercentage', - ) - expect(discountIssue?.message).toMatch(/entre 0 e 100/) - } - }) - }) -}) diff --git a/apps/admin-frontend/src/features/catalog/presentation/services/forms/ServiceForm.schema.ts b/apps/admin-frontend/src/features/catalog/presentation/services/forms/ServiceForm.schema.ts deleted file mode 100644 index d387926..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/services/forms/ServiceForm.schema.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { z } from 'zod' - -// Kept out of ServiceForm.tsx itself: a component file exporting plain -// runtime constants (the schema, the field/code maps) alongside its -// component breaks Vite Fast Refresh for that file -// (react-refresh/only-export-components). - -const NAME_MAX_LENGTH = 80 -const DESCRIPTION_MAX_LENGTH = 500 -const MAX_ALLOWED_DURATION_MINUTES = 1440 -const PRICE_MAX_DECIMALS = 2 -const DISCOUNT_MAX_DECIMALS = 2 - -// The inferred Zod chain type (ZodPipe> -// in v4) is impractical to spell out by hand and would need updating on every -// zod upgrade - inference is more robust here than a hand-written annotation. -// -// Duration fields are `int` in the backend DTO (CreateServiceCommand) - a -// fractional value like "30.5" would fail there, so it's rejected here too -// instead of being silently truncated or bounced back as a server error. -// A plain digit regex rejects scientific notation ("3e1") and Infinity/NaN -// up front too. -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -function integerField(message: string) { - return z - .string() - .refine(value => /^-?\d+$/.test(value.trim()), message) - .transform(value => Number(value.trim())) -} - -// Price/discount are `decimal` with PrecisionScale(_, 2) in the backend -// validator - mirrored here so a value the UI would round-trip incorrectly -// (e.g. "10.123") never reaches the server. The sign is allowed through so -// a negative amount still fails via the superRefine below with its own -// domain-specific message, instead of this generic format message. -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -function decimalField(maxDecimals: number, message: string) { - const pattern = new RegExp(`^-?\\d+(\\.\\d{1,${String(maxDecimals)}})?$`) - return z - .string() - .refine(value => pattern.test(value.trim()), message) - .transform(value => Number(value.trim())) -} - -export const serviceFormSchema = z - .object({ - name: z - .string() - .trim() - .min(1, 'Informe o nome do serviço.') - .max( - NAME_MAX_LENGTH, - `O nome do serviço deve ter no máximo ${String(NAME_MAX_LENGTH)} caracteres.`, - ), - description: z - .string() - .trim() - .max( - DESCRIPTION_MAX_LENGTH, - `A descrição não pode exceder ${String(DESCRIPTION_MAX_LENGTH)} caracteres.`, - ), - durationMinutes: integerField('Informe uma duração válida em minutos inteiros.'), - minDurationMinutes: integerField('Informe uma duração válida em minutos inteiros.'), - maxDurationMinutes: integerField('Informe uma duração válida em minutos inteiros.'), - price: decimalField( - PRICE_MAX_DECIMALS, - 'Informe um preço válido, com no máximo duas casas decimais.', - ), - maxDiscountPercentage: decimalField( - DISCOUNT_MAX_DECIMALS, - 'Informe um desconto válido, com no máximo duas casas decimais.', - ), - categoryId: z.string().nullable(), - tagIds: z.array(z.string()), - }) - .superRefine((values, ctx) => { - // A sibling field that fails its own integerField/decimalField refine is - // passed through here as its original raw string (zod still runs - // superRefine even when another field in the same object failed) - - // comparing against it with - // `<`/`>` would coerce it (e.g. '' becomes 0) and produce a spurious - // cross-field error. Only compare fields that actually parsed as numbers. - const isNumber = (value: unknown): value is number => - typeof value === 'number' && Number.isFinite(value) - const min = values.minDurationMinutes - const duration = values.durationMinutes - const max = values.maxDurationMinutes - const price = values.price - const discount = values.maxDiscountPercentage - - if (isNumber(min) && min < 1) { - ctx.addIssue({ - code: 'custom', - path: ['minDurationMinutes'], - message: 'A duração mínima deve ser de pelo menos 1 minuto.', - }) - } - - if (isNumber(min) && isNumber(duration) && min > duration) { - ctx.addIssue({ - code: 'custom', - path: ['minDurationMinutes'], - message: 'A duração mínima não pode ser maior que a duração padrão.', - }) - } else if (isNumber(duration) && isNumber(max) && duration > max) { - ctx.addIssue({ - code: 'custom', - path: ['maxDurationMinutes'], - message: 'A duração padrão não pode ser maior que a duração máxima.', - }) - } - - if (isNumber(max) && max > MAX_ALLOWED_DURATION_MINUTES) { - ctx.addIssue({ - code: 'custom', - path: ['maxDurationMinutes'], - message: `A duração máxima não pode exceder ${String(MAX_ALLOWED_DURATION_MINUTES)} minutos (24 horas).`, - }) - } - - if (isNumber(price) && price < 0) { - ctx.addIssue({ code: 'custom', path: ['price'], message: 'O preço não pode ser negativo.' }) - } - - if (isNumber(discount) && (discount < 0 || discount > 100)) { - ctx.addIssue({ - code: 'custom', - path: ['maxDiscountPercentage'], - message: 'O desconto máximo deve estar entre 0 e 100%.', - }) - } - - if (new Set(values.tagIds).size !== values.tagIds.length) { - ctx.addIssue({ - code: 'custom', - path: ['tagIds'], - message: 'A lista de etiquetas não pode conter itens duplicados.', - }) - } - }) - -export type ServiceFormInput = z.input -export type ServiceFormValues = z.output -export type ServiceFormField = keyof ServiceFormInput - -export const SERVICE_NAME_MAX_LENGTH = NAME_MAX_LENGTH -export const SERVICE_DESCRIPTION_MAX_LENGTH = DESCRIPTION_MAX_LENGTH diff --git a/apps/admin-frontend/src/features/catalog/presentation/services/forms/ServiceForm.tsx b/apps/admin-frontend/src/features/catalog/presentation/services/forms/ServiceForm.tsx deleted file mode 100644 index 2f53734..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/services/forms/ServiceForm.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { useEffect, type JSX } from 'react' -import { FormProvider, useForm } from 'react-hook-form' -import { zodResolver } from '@hookform/resolvers/zod' -import type { ServerFormError } from '@/shared/presentation/forms/serverFormError' -import { Button } from '@/components/ui/button' -import { Spinner } from '@/components/ui/spinner' -import { StatusMessage } from '@/shared/presentation/components/StatusMessage' -import { ServiceBasicFields } from '@/features/catalog/presentation/services/components/ServiceBasicFields' -import { ServiceDurationFields } from '@/features/catalog/presentation/services/components/ServiceDurationFields' -import { ServiceCommercialFields } from '@/features/catalog/presentation/services/components/ServiceCommercialFields' -import { ServiceCategoryField } from '@/features/catalog/presentation/services/components/ServiceCategoryField' -import { ServiceTagsField } from '@/features/catalog/presentation/services/components/ServiceTagsField' -import type { - ServiceCategoryOptions, - ServiceTagOptions, -} from '@/features/catalog/presentation/services/models/servicePresentationModels' -import { - serviceFormSchema, - type ServiceFormInput, - type ServiceFormValues, - type ServiceFormField, -} from '@/features/catalog/presentation/services/forms/ServiceForm.schema' - -export interface ServiceFormContent { - code: number | null - initialValues: ServiceFormInput - submitLabel: string -} - -interface ServiceFormProps { - content: ServiceFormContent - categoryOptions: ServiceCategoryOptions - tagOptions: ServiceTagOptions - isSubmitting: boolean - serverError: ServerFormError | null - onCancel: () => void - onSubmit: (values: ServiceFormValues) => Promise - onDirtyChange: (isDirty: boolean) => void -} - -export function ServiceForm({ - content, - categoryOptions, - tagOptions, - isSubmitting, - serverError, - onCancel, - onSubmit, - onDirtyChange, -}: ServiceFormProps): JSX.Element { - const { code, initialValues, submitLabel } = content - const methods = useForm({ - resolver: zodResolver(serviceFormSchema), - defaultValues: initialValues, - mode: 'onTouched', - reValidateMode: 'onChange', - }) - const { - handleSubmit, - setError, - setFocus, - formState: { errors, isDirty }, - } = methods - const hasErrors = Object.keys(errors).length > 0 - - useEffect(() => { - onDirtyChange(isDirty) - }, [isDirty, onDirtyChange]) - - useEffect(() => { - if (serverError === null) { - return - } - for (const { field, message } of serverError.fieldErrors) { - setError(field, { type: 'server', message }) - } - if (serverError.firstField !== null) { - setFocus(serverError.firstField) - } - }, [serverError, setError, setFocus]) - - return ( - -
void handleSubmit(onSubmit)(e)} noValidate className="space-y-4"> - {code !== null && ( -
- Código -

{code}

-
- )} - - - - - - - - {serverError?.globalMessage != null && ( - {serverError.globalMessage} - )} - -
- - -
- -
- ) -} diff --git a/apps/admin-frontend/src/features/catalog/presentation/services/forms/serviceFieldMaps.ts b/apps/admin-frontend/src/features/catalog/presentation/services/forms/serviceFieldMaps.ts deleted file mode 100644 index 0067e07..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/services/forms/serviceFieldMaps.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { ServiceFormField } from '@/features/catalog/presentation/services/forms/ServiceForm.schema' - -// Kept out of ServiceForm.tsx itself: a component file exporting a plain -// runtime constant alongside its component breaks Vite Fast Refresh for -// that file (react-refresh/only-export-components). - -/** Backend PascalCase property name -> ServiceForm's field name. */ -export const serviceFieldMap: Record = { - Name: 'name', - Description: 'description', - DurationMinutes: 'durationMinutes', - MinDurationMinutes: 'minDurationMinutes', - MaxDurationMinutes: 'maxDurationMinutes', - Price: 'price', - MaxDiscountPercentage: 'maxDiscountPercentage', - CategoryId: 'categoryId', - TagIds: 'tagIds', -} - -/** Conflict/NotFound/Forbidden `code` -> the ServiceForm field it should highlight. */ -export const serviceCodeFieldMap: Record = { - 'Service.DuplicateName': 'name', - 'Category.NotFound': 'categoryId', - 'Tag.NotFound': 'tagIds', -} diff --git a/apps/admin-frontend/src/features/catalog/presentation/services/hooks/useServiceDeletion.test.tsx b/apps/admin-frontend/src/features/catalog/presentation/services/hooks/useServiceDeletion.test.tsx deleted file mode 100644 index d79c60a..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/services/hooks/useServiceDeletion.test.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { renderHook, act } from '@testing-library/react' -import { Service } from '@/features/catalog/domain/entities/Service' -import { useServiceDeletion } from '@/features/catalog/presentation/services/hooks/useServiceDeletion' - -const service = Service.create({ - id: 'service-1', - code: 1001, - name: 'Massagem relaxante', - durationMinutes: 60, - minDurationMinutes: 30, - maxDurationMinutes: 90, - price: 150, - maxDiscountPercentage: 10, - tags: [], -}) - -describe('useServiceDeletion', () => { - it('starts with no target', () => { - const { result } = renderHook(() => useServiceDeletion({ onDelete: vi.fn() })) - - expect(result.current.target).toBeNull() - expect(result.current.error).toBeNull() - expect(result.current.isDeleting).toBe(false) - }) - - it('onRequestDelete sets the target and clears any previous error', () => { - const { result } = renderHook(() => useServiceDeletion({ onDelete: vi.fn() })) - - act(() => { - result.current.onRequestDelete(service) - }) - - expect(result.current.target).toBe(service) - expect(result.current.error).toBeNull() - }) - - it('onCancel clears the target', () => { - const { result } = renderHook(() => useServiceDeletion({ onDelete: vi.fn() })) - act(() => { - result.current.onRequestDelete(service) - }) - - act(() => { - result.current.onCancel() - }) - - expect(result.current.target).toBeNull() - }) - - it('onConfirm calls onDelete with the target id and clears the target on success', async () => { - const onDelete = vi.fn(() => Promise.resolve()) - const { result } = renderHook(() => useServiceDeletion({ onDelete })) - act(() => { - result.current.onRequestDelete(service) - }) - - await act(async () => { - await result.current.onConfirm() - }) - - expect(onDelete).toHaveBeenCalledExactlyOnceWith('service-1') - expect(result.current.target).toBeNull() - }) - - it('keeps the target and surfaces an error message when onDelete fails', async () => { - const onDelete = vi.fn(() => Promise.reject(new Error('falhou'))) - const { result } = renderHook(() => useServiceDeletion({ onDelete })) - act(() => { - result.current.onRequestDelete(service) - }) - - await act(async () => { - await result.current.onConfirm() - }) - - expect(result.current.target).toBe(service) - expect(result.current.error).toBe('falhou') - expect(result.current.isDeleting).toBe(false) - }) - - it('onConfirm without a target is a no-op', async () => { - const onDelete = vi.fn(() => Promise.resolve()) - const { result } = renderHook(() => useServiceDeletion({ onDelete })) - - await act(async () => { - await result.current.onConfirm() - }) - - expect(onDelete).not.toHaveBeenCalled() - }) -}) diff --git a/apps/admin-frontend/src/features/catalog/presentation/services/hooks/useServiceDeletion.ts b/apps/admin-frontend/src/features/catalog/presentation/services/hooks/useServiceDeletion.ts deleted file mode 100644 index fd613be..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/services/hooks/useServiceDeletion.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { useState } from 'react' -import type { Service } from '@/features/catalog/domain/entities/Service' -import { messageFrom } from '@/features/catalog/presentation/services/models/serviceFormatters' - -interface UseServiceDeletionParams { - onDelete: (id: string) => Promise -} - -export interface UseServiceDeletionResult { - target: Service | null - error: string | null - isDeleting: boolean - onRequestDelete: (service: Service) => void - onCancel: () => void - onConfirm: () => Promise -} - -export function useServiceDeletion({ - onDelete, -}: UseServiceDeletionParams): UseServiceDeletionResult { - const [target, setTarget] = useState(null) - const [error, setError] = useState(null) - const [isDeleting, setIsDeleting] = useState(false) - - function onRequestDelete(service: Service): void { - setTarget(service) - setError(null) - } - - function onCancel(): void { - setTarget(null) - setError(null) - } - - async function onConfirm(): Promise { - if (target === null) { - return - } - setIsDeleting(true) - setError(null) - try { - await onDelete(target.id) - setTarget(null) - } catch (caughtError) { - setError(messageFrom(caughtError, 'Não foi possível excluir o serviço.')) - } finally { - setIsDeleting(false) - } - } - - return { target, error, isDeleting, onRequestDelete, onCancel, onConfirm } -} diff --git a/apps/admin-frontend/src/features/catalog/presentation/services/hooks/useServiceEditor.test.tsx b/apps/admin-frontend/src/features/catalog/presentation/services/hooks/useServiceEditor.test.tsx deleted file mode 100644 index 0e6b6b0..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/services/hooks/useServiceEditor.test.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { renderHook, act } from '@testing-library/react' -import type { MouseEvent } from 'react' -import { Service } from '@/features/catalog/domain/entities/Service' -import { AppError } from '@/shared/application/AppError' -import { useServiceEditor } from '@/features/catalog/presentation/services/hooks/useServiceEditor' -import type { ServiceFormValues } from '@/features/catalog/presentation/services/forms/ServiceForm.schema' - -const service = Service.create({ - id: 'service-1', - code: 1001, - name: 'Massagem relaxante', - durationMinutes: 60, - minDurationMinutes: 30, - maxDurationMinutes: 90, - price: 150, - maxDiscountPercentage: 10, - tags: [], -}) - -const formValues: ServiceFormValues = { - name: 'Corte', - description: '', - durationMinutes: 30, - minDurationMinutes: 15, - maxDurationMinutes: 45, - price: 80, - maxDiscountPercentage: 5, - categoryId: null, - tagIds: [], -} - -function fakeClickEvent(): MouseEvent { - return { - currentTarget: document.createElement('button'), - } as unknown as MouseEvent -} - -describe('useServiceEditor', () => { - it('starts closed', () => { - const { result } = renderHook(() => useServiceEditor({ onCreate: vi.fn(), onUpdate: vi.fn() })) - - expect(result.current.editor.isOpen).toBe(false) - expect(result.current.editor.content).toBeNull() - }) - - it('onOpenCreate opens a create target with the create title/label and no code', () => { - const { result } = renderHook(() => useServiceEditor({ onCreate: vi.fn(), onUpdate: vi.fn() })) - - act(() => { - result.current.onOpenCreate(fakeClickEvent()) - }) - - expect(result.current.editor.isOpen).toBe(true) - expect(result.current.editor.content?.kind).toBe('create') - expect(result.current.editor.content?.code).toBeNull() - expect(result.current.editor.content?.title).toBe('Novo serviço') - expect(result.current.editor.content?.submitLabel).toBe('Criar serviço') - }) - - it('onOpenEdit opens with the service as target, its code, and the edit title/label', () => { - const { result } = renderHook(() => useServiceEditor({ onCreate: vi.fn(), onUpdate: vi.fn() })) - - act(() => { - result.current.onOpenEdit(service, fakeClickEvent()) - }) - - expect(result.current.editor.content?.kind).toBe('edit') - expect( - result.current.editor.content?.kind === 'edit' ? result.current.editor.content.item : null, - ).toBe(service) - expect(result.current.editor.content?.code).toBe(1001) - expect(result.current.editor.content?.title).toBe('Editar serviço') - expect(result.current.editor.content?.submitLabel).toBe('Salvar alterações') - }) - - it('onRequestClose closes immediately when the form is not dirty', () => { - const { result } = renderHook(() => useServiceEditor({ onCreate: vi.fn(), onUpdate: vi.fn() })) - act(() => { - result.current.onOpenCreate(fakeClickEvent()) - }) - - act(() => { - result.current.editor.onRequestClose() - }) - - expect(result.current.editor.isOpen).toBe(false) - expect(result.current.discardConfirmation.isOpen).toBe(false) - }) - - it('onRequestClose asks for discard confirmation instead of closing when the form is dirty', () => { - const { result } = renderHook(() => useServiceEditor({ onCreate: vi.fn(), onUpdate: vi.fn() })) - act(() => { - result.current.onOpenCreate(fakeClickEvent()) - }) - act(() => { - result.current.editor.onDirtyChange(true) - }) - - act(() => { - result.current.editor.onRequestClose() - }) - - expect(result.current.editor.isOpen).toBe(true) - expect(result.current.discardConfirmation.isOpen).toBe(true) - }) - - it('discardConfirmation.onConfirm closes the form', () => { - const { result } = renderHook(() => useServiceEditor({ onCreate: vi.fn(), onUpdate: vi.fn() })) - act(() => { - result.current.onOpenCreate(fakeClickEvent()) - }) - act(() => { - result.current.editor.onDirtyChange(true) - }) - act(() => { - result.current.editor.onRequestClose() - }) - - act(() => { - result.current.discardConfirmation.onConfirm() - }) - - expect(result.current.editor.isOpen).toBe(false) - expect(result.current.discardConfirmation.isOpen).toBe(false) - }) - - it('discardConfirmation.onCancel keeps the form open and dismisses only the confirmation', () => { - const { result } = renderHook(() => useServiceEditor({ onCreate: vi.fn(), onUpdate: vi.fn() })) - act(() => { - result.current.onOpenCreate(fakeClickEvent()) - }) - act(() => { - result.current.editor.onDirtyChange(true) - }) - act(() => { - result.current.editor.onRequestClose() - }) - - act(() => { - result.current.discardConfirmation.onCancel() - }) - - expect(result.current.editor.isOpen).toBe(true) - expect(result.current.discardConfirmation.isOpen).toBe(false) - }) - - it('submitting a new service calls onCreate and closes the dialog on success', async () => { - const onCreate = vi.fn(() => Promise.resolve(service)) - const { result } = renderHook(() => useServiceEditor({ onCreate, onUpdate: vi.fn() })) - act(() => { - result.current.onOpenCreate(fakeClickEvent()) - }) - - await act(async () => { - await result.current.editor.onSubmit(formValues) - }) - - expect(onCreate).toHaveBeenCalledExactlyOnceWith({ - name: 'Corte', - description: null, - durationMinutes: 30, - minDurationMinutes: 15, - maxDurationMinutes: 45, - price: 80, - maxDiscountPercentage: 5, - categoryId: null, - tagIds: [], - }) - expect(result.current.editor.isOpen).toBe(false) - }) - - it('submitting an edit calls onUpdate with the target id', async () => { - const onUpdate = vi.fn(() => Promise.resolve(service)) - const { result } = renderHook(() => useServiceEditor({ onCreate: vi.fn(), onUpdate })) - act(() => { - result.current.onOpenEdit(service, fakeClickEvent()) - }) - - await act(async () => { - await result.current.editor.onSubmit(formValues) - }) - - expect(onUpdate).toHaveBeenCalledExactlyOnceWith( - 'service-1', - expect.objectContaining({ name: 'Corte' }), - ) - }) - - it('keeps the dialog open and surfaces a mapped server error when submit fails', async () => { - const onCreate = vi.fn(() => - Promise.reject( - new AppError({ - code: 'validation', - message: 'Ocorreram erros de validação.', - retryable: false, - rawFieldErrors: { Name: 'O nome é obrigatório.' }, - }), - ), - ) - const { result } = renderHook(() => useServiceEditor({ onCreate, onUpdate: vi.fn() })) - act(() => { - result.current.onOpenCreate(fakeClickEvent()) - }) - - await act(async () => { - await result.current.editor.onSubmit(formValues) - }) - - expect(result.current.editor.isOpen).toBe(true) - expect( - result.current.editor.serverError?.fieldErrors.find(({ field }) => field === 'name')?.message, - ).toBe('O nome é obrigatório.') - expect(result.current.editor.isSubmitting).toBe(false) - }) -}) diff --git a/apps/admin-frontend/src/features/catalog/presentation/services/hooks/useServiceEditor.ts b/apps/admin-frontend/src/features/catalog/presentation/services/hooks/useServiceEditor.ts deleted file mode 100644 index f57d570..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/services/hooks/useServiceEditor.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { useRef, useState, type MouseEvent, type RefObject } from 'react' -import type { Service } from '@/features/catalog/domain/entities/Service' -import type { - CreateServiceInput, - UpdateServiceInput, -} from '@/features/catalog/application/repositories/ServiceRepository' -import { - mapApiErrorToForm, - type ServerFormError, -} from '@/shared/presentation/forms/serverFormError' -import { - serviceFieldMap, - serviceCodeFieldMap, -} from '@/features/catalog/presentation/services/forms/serviceFieldMaps' -import type { - ServiceFormField, - ServiceFormValues, -} from '@/features/catalog/presentation/services/forms/ServiceForm.schema' -import { - toServiceInput, - toServiceFormValues, - EMPTY_SERVICE_FORM_VALUES, -} from '@/features/catalog/presentation/services/models/serviceFormatters' -import type { - DiscardConfirmationViewModel, - ServiceEditorContent, - ServiceEditorViewModel, - ServiceFormTarget, -} from '@/features/catalog/presentation/services/models/servicePresentationModels' - -function toEditorContent(target: ServiceFormTarget): ServiceEditorContent { - if (target.kind === 'edit') { - return { - kind: 'edit', - item: target.item, - title: 'Editar serviço', - submitLabel: 'Salvar alterações', - code: target.item.code, - initialValues: toServiceFormValues(target.item), - } - } - return { - kind: 'create', - title: 'Novo serviço', - submitLabel: 'Criar serviço', - code: null, - initialValues: EMPTY_SERVICE_FORM_VALUES, - } -} - -interface UseServiceEditorParams { - onCreate: (input: CreateServiceInput) => Promise - onUpdate: (id: string, input: UpdateServiceInput) => Promise -} - -export interface UseServiceEditorResult { - onOpenCreate: (event: MouseEvent) => void - onOpenEdit: (service: Service, event: MouseEvent) => void - editor: ServiceEditorViewModel - discardConfirmation: DiscardConfirmationViewModel -} - -/** Target, dirty/submit state, and discard-confirmation for the create/edit dialog. */ -export function useServiceEditor({ - onCreate, - onUpdate, -}: UseServiceEditorParams): UseServiceEditorResult { - const [formTarget, setFormTarget] = useState(null) - const [displayTarget, setDisplayTarget] = useState(null) - const [serverError, setServerError] = useState | null>(null) - const [isSubmitting, setIsSubmitting] = useState(false) - const [isFormDirty, setIsFormDirty] = useState(false) - const [showDiscardConfirm, setShowDiscardConfirm] = useState(false) - // Restores focus to whichever button opened the dialog once it closes, - // instead of relying on Radix's previously-focused-element fallback - // (unreliable when open is driven externally, not via DialogTrigger). - const formTriggerRef: RefObject = useRef(null) - - function onOpenCreate(event: MouseEvent): void { - formTriggerRef.current = event.currentTarget - setFormTarget({ kind: 'create' }) - setDisplayTarget({ kind: 'create' }) - setServerError(null) - setIsFormDirty(false) - } - - function onOpenEdit(service: Service, event: MouseEvent): void { - formTriggerRef.current = event.currentTarget - setFormTarget({ kind: 'edit', item: service }) - setDisplayTarget({ kind: 'edit', item: service }) - setServerError(null) - setIsFormDirty(false) - } - - function closeForm(): void { - // displayTarget stays as-is: Dialog fades out over ~100ms after `open` - // flips to false, and clearing it here would blank the form mid-animation. - setFormTarget(null) - setServerError(null) - setIsFormDirty(false) - setShowDiscardConfirm(false) - } - - function requestCloseForm(): void { - if (isFormDirty) { - setShowDiscardConfirm(true) - return - } - closeForm() - } - - async function submit(values: ServiceFormValues): Promise { - setIsSubmitting(true) - setServerError(null) - try { - if (formTarget?.kind === 'create') { - await onCreate(toServiceInput(values)) - } else if (formTarget?.kind === 'edit') { - await onUpdate(formTarget.item.id, toServiceInput(values)) - } - closeForm() - } catch (caughtError) { - setServerError( - mapApiErrorToForm( - caughtError, - serviceFieldMap, - serviceCodeFieldMap, - 'Não foi possível salvar o serviço.', - ), - ) - } finally { - setIsSubmitting(false) - } - } - - return { - onOpenCreate, - onOpenEdit, - editor: { - isOpen: formTarget !== null, - content: displayTarget !== null ? toEditorContent(displayTarget) : null, - isSubmitting, - serverError, - formTriggerRef, - onRequestClose: requestCloseForm, - onSubmit: submit, - onDirtyChange: setIsFormDirty, - }, - discardConfirmation: { - isOpen: showDiscardConfirm, - onCancel: () => { - setShowDiscardConfirm(false) - }, - onConfirm: closeForm, - }, - } -} diff --git a/apps/admin-frontend/src/features/catalog/presentation/services/hooks/useServiceFilters.test.tsx b/apps/admin-frontend/src/features/catalog/presentation/services/hooks/useServiceFilters.test.tsx deleted file mode 100644 index 7ad8c12..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/services/hooks/useServiceFilters.test.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { renderHook, act } from '@testing-library/react' -import { useServiceFilters } from '@/features/catalog/presentation/services/hooks/useServiceFilters' - -describe('useServiceFilters', () => { - beforeEach(() => { - vi.useFakeTimers() - }) - - afterEach(() => { - vi.useRealTimers() - }) - - it('starts with every filter empty and no active filters', () => { - const { result } = renderHook(() => useServiceFilters()) - - expect(result.current.searchInput).toBe('') - expect(result.current.debouncedSearch).toBe('') - expect(result.current.categoryFilter).toBe('') - expect(result.current.tagFilter).toBe('') - expect(result.current.hasActiveFilters).toBe(false) - }) - - it('only reflects the debounced search once the delay elapses', () => { - const { result } = renderHook(() => useServiceFilters()) - - act(() => { - result.current.onSearchInputChange('massa') - }) - expect(result.current.searchInput).toBe('massa') - expect(result.current.debouncedSearch).toBe('') - - act(() => { - vi.advanceTimersByTime(300) - }) - expect(result.current.debouncedSearch).toBe('massa') - }) - - it('does not count a blank debounced search as an active filter', () => { - const { result } = renderHook(() => useServiceFilters()) - - act(() => { - result.current.onSearchInputChange(' ') - }) - act(() => { - vi.advanceTimersByTime(300) - }) - - expect(result.current.hasActiveFilters).toBe(false) - }) - - it('marks hasActiveFilters once the debounced search is non-blank', () => { - const { result } = renderHook(() => useServiceFilters()) - - act(() => { - result.current.onSearchInputChange('corte') - }) - act(() => { - vi.advanceTimersByTime(300) - }) - - expect(result.current.hasActiveFilters).toBe(true) - }) - - it('marks hasActiveFilters when a category filter is set, independent of search', () => { - const { result } = renderHook(() => useServiceFilters()) - - act(() => { - result.current.onCategoryFilterChange('category-1') - }) - - expect(result.current.hasActiveFilters).toBe(true) - }) - - it('marks hasActiveFilters when a tag filter is set, independent of search', () => { - const { result } = renderHook(() => useServiceFilters()) - - act(() => { - result.current.onTagFilterChange('tag-1') - }) - - expect(result.current.hasActiveFilters).toBe(true) - }) -}) diff --git a/apps/admin-frontend/src/features/catalog/presentation/services/hooks/useServiceFilters.ts b/apps/admin-frontend/src/features/catalog/presentation/services/hooks/useServiceFilters.ts deleted file mode 100644 index 4772baf..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/services/hooks/useServiceFilters.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useState } from 'react' -import { useDebouncedValue } from '@/shared/presentation/hooks/useDebouncedValue' - -export interface UseServiceFiltersResult { - searchInput: string - onSearchInputChange: (value: string) => void - categoryFilter: string - onCategoryFilterChange: (value: string) => void - tagFilter: string - onTagFilterChange: (value: string) => void - debouncedSearch: string - hasActiveFilters: boolean -} - -export function useServiceFilters(): UseServiceFiltersResult { - const [searchInput, setSearchInput] = useState('') - const debouncedSearch = useDebouncedValue(searchInput, 300) - const [categoryFilter, setCategoryFilter] = useState('') - const [tagFilter, setTagFilter] = useState('') - - return { - searchInput, - onSearchInputChange: setSearchInput, - categoryFilter, - onCategoryFilterChange: setCategoryFilter, - tagFilter, - onTagFilterChange: setTagFilter, - debouncedSearch, - hasActiveFilters: debouncedSearch.trim() !== '' || categoryFilter !== '' || tagFilter !== '', - } -} diff --git a/apps/admin-frontend/src/features/catalog/presentation/services/hooks/useServices.test.tsx b/apps/admin-frontend/src/features/catalog/presentation/services/hooks/useServices.test.tsx deleted file mode 100644 index aca7d75..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/services/hooks/useServices.test.tsx +++ /dev/null @@ -1,281 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { renderHook, waitFor, act, type RenderHookResult } from '@testing-library/react' -import { - useServices, - type UseServicesResult, -} from '@/features/catalog/presentation/services/hooks/useServices' -import { AppContainerContext } from '@/app/providers/AppContainerContext' -import { createFakeAppContainer } from '@/test/fixtures/createFakeAppContainer' -import type { AppContainer, CatalogFacade } from '@/app/composition/container' -import { Service } from '@/features/catalog/domain/entities/Service' -import { Tenant } from '@/test/fixtures/authEntityFixtures' -import { User } from '@/test/fixtures/authEntityFixtures' -import type { TenantContext } from '@/features/auth' -import type { CreateServiceInput } from '@/features/catalog/application/repositories/ServiceRepository' - -const serviceFixture = Service.create({ - id: 'service-1', - code: 1001, - name: 'Massagem relaxante', - durationMinutes: 60, - minDurationMinutes: 30, - maxDurationMinutes: 90, - price: 150, - maxDiscountPercentage: 10, - tags: [], -}) - -const createInput: CreateServiceInput = { - name: 'Massagem relaxante', - durationMinutes: 60, - minDurationMinutes: 30, - maxDurationMinutes: 90, - price: 150, - maxDiscountPercentage: 10, -} - -const pagedFixture = { services: [serviceFixture], totalCount: 1, page: 1, pageSize: 20 } - -function createFakeContainer(overrides: Partial = {}): AppContainer { - return createFakeAppContainer({ - catalog: { - listServices: { execute: vi.fn(() => Promise.resolve(pagedFixture)) }, - createService: { execute: vi.fn(() => Promise.resolve(serviceFixture)) }, - updateService: { execute: vi.fn(() => Promise.resolve(serviceFixture)) }, - deleteService: { execute: vi.fn(() => Promise.resolve()) }, - ...overrides, - }, - }) -} - -function buildTenantContext(): TenantContext { - const tenant = Tenant.create('tenant-123') - return { tenant, user: User.create({ id: 'user-1', tenant }) } -} - -function renderUseServices( - container: AppContainer, - tenantContext: TenantContext | null, -): RenderHookResult { - return renderHook(() => useServices(tenantContext), { - wrapper: ({ children }) => ( - {children} - ), - }) -} - -describe('useServices', () => { - it('loads services for the given tenant context', async () => { - const { result } = renderUseServices(createFakeContainer(), buildTenantContext()) - - await waitFor(() => { - expect(result.current.listState.status).toBe('success') - }) - - expect(result.current.services).toEqual([serviceFixture]) - }) - - it('returns an empty list without calling the use case when tenantContext is null', async () => { - const listServicesSpy = vi.fn(() => Promise.resolve(pagedFixture)) - const { result } = renderUseServices( - createFakeContainer({ listServices: { execute: listServicesSpy } }), - null, - ) - - await waitFor(() => { - expect(result.current.listState.status).toBe('success') - }) - - expect(result.current.services).toEqual([]) - expect(listServicesSpy).not.toHaveBeenCalled() - }) - - it('creates a service then refetches the list', async () => { - const listServicesSpy = vi.fn(() => Promise.resolve(pagedFixture)) - const createServiceSpy = vi.fn(() => Promise.resolve(serviceFixture)) - const tenantContext = buildTenantContext() - const { result } = renderUseServices( - createFakeContainer({ - listServices: { execute: listServicesSpy }, - createService: { execute: createServiceSpy }, - }), - tenantContext, - ) - await waitFor(() => { - expect(result.current.listState.status).toBe('success') - }) - listServicesSpy.mockClear() - - await act(async () => { - await result.current.createService(createInput) - }) - - expect(createServiceSpy).toHaveBeenCalledExactlyOnceWith(tenantContext, createInput) - // The refetch fires in the background (not awaited by createService - // itself) - wait for it rather than asserting immediately. - await waitFor(() => { - expect(listServicesSpy).toHaveBeenCalledTimes(1) - }) - }) - - it('keeps the created service visible even if the background refetch fails', async () => { - const newService = Service.create({ - id: 'service-2', - code: 1002, - name: 'Novo serviço', - durationMinutes: 45, - minDurationMinutes: 30, - maxDurationMinutes: 60, - price: 90, - maxDiscountPercentage: 5, - tags: [], - }) - const listServicesSpy = vi - .fn<() => Promise>() - .mockResolvedValueOnce(pagedFixture) - .mockRejectedValueOnce(new Error('network down')) - const createServiceSpy = vi.fn(() => Promise.resolve(newService)) - const tenantContext = buildTenantContext() - const { result } = renderUseServices( - createFakeContainer({ - listServices: { execute: listServicesSpy }, - createService: { execute: createServiceSpy }, - }), - tenantContext, - ) - await waitFor(() => { - expect(result.current.listState.status).toBe('success') - }) - - await act(async () => { - await expect(result.current.createService(createInput)).resolves.toEqual(newService) - }) - - // The optimistic insert survives the refetch failure below. - expect(result.current.services).toEqual([serviceFixture, newService]) - expect(result.current.totalCount).toBe(2) - - await waitFor(() => { - expect(result.current.listState.status).toBe('refreshError') - }) - // Still there after the failed refetch settles - not cleared, not - // reported as a failed creation. - expect(result.current.services).toEqual([serviceFixture, newService]) - }) - - it('deletes a service then refetches the list', async () => { - const listServicesSpy = vi.fn(() => Promise.resolve(pagedFixture)) - const deleteServiceSpy = vi.fn(() => Promise.resolve()) - const tenantContext = buildTenantContext() - const { result } = renderUseServices( - createFakeContainer({ - listServices: { execute: listServicesSpy }, - deleteService: { execute: deleteServiceSpy }, - }), - tenantContext, - ) - await waitFor(() => { - expect(result.current.listState.status).toBe('success') - }) - listServicesSpy.mockClear() - - await act(async () => { - await result.current.deleteService('service-1') - }) - - expect(deleteServiceSpy).toHaveBeenCalledExactlyOnceWith(tenantContext, 'service-1') - expect(listServicesSpy).toHaveBeenCalledTimes(1) - }) - - it('rejects mutations when tenantContext is null', async () => { - const { result } = renderUseServices(createFakeContainer(), null) - await waitFor(() => { - expect(result.current.listState.status).toBe('success') - }) - - await expect(result.current.createService(createInput)).rejects.toThrow() - }) - - it('exposes the paged metadata from the use case result', async () => { - const { result } = renderUseServices( - createFakeContainer({ - listServices: { - execute: vi.fn(() => - Promise.resolve({ services: [serviceFixture], totalCount: 42, page: 1, pageSize: 20 }), - ), - }, - }), - buildTenantContext(), - ) - - await waitFor(() => { - expect(result.current.listState.status).toBe('success') - }) - - expect(result.current.totalCount).toBe(42) - expect(result.current.page).toBe(1) - expect(result.current.pageSize).toBe(20) - }) - - it('steps back a page after deleting the last item on a page past the first', async () => { - const tenantContext = buildTenantContext() - const listServicesSpy = vi - .fn() - // Initial load of page 1 (called by setPage(2) below via a fresh fetch) - .mockResolvedValueOnce({ services: [serviceFixture], totalCount: 21, page: 1, pageSize: 20 }) - // After setPage(2): one item on page 2 - .mockResolvedValueOnce({ services: [serviceFixture], totalCount: 21, page: 2, pageSize: 20 }) - // After deleting it: page 2 is now empty - .mockResolvedValueOnce({ services: [], totalCount: 20, page: 2, pageSize: 20 }) - // After stepping back to page 1: has data again - .mockResolvedValueOnce({ services: [serviceFixture], totalCount: 20, page: 1, pageSize: 20 }) - const deleteServiceSpy = vi.fn(() => Promise.resolve()) - const { result } = renderUseServices( - createFakeContainer({ - listServices: { execute: listServicesSpy }, - deleteService: { execute: deleteServiceSpy }, - }), - tenantContext, - ) - await waitFor(() => { - expect(result.current.listState.status).toBe('success') - }) - - act(() => { - result.current.setPage(2) - }) - await waitFor(() => { - expect(result.current.page).toBe(2) - }) - - await act(async () => { - await result.current.deleteService('service-1') - }) - - await waitFor(() => { - expect(result.current.page).toBe(1) - }) - }) - - it('refetches with the new page when setPage is called', async () => { - const listServicesSpy = vi.fn(() => Promise.resolve(pagedFixture)) - const tenantContext = buildTenantContext() - const { result } = renderUseServices( - createFakeContainer({ listServices: { execute: listServicesSpy } }), - tenantContext, - ) - await waitFor(() => { - expect(result.current.listState.status).toBe('success') - }) - listServicesSpy.mockClear() - - act(() => { - result.current.setPage(2) - }) - - await waitFor(() => { - expect(result.current.page).toBe(2) - }) - expect(listServicesSpy).toHaveBeenCalledWith(tenantContext, { page: 2, pageSize: 20 }) - }) -}) diff --git a/apps/admin-frontend/src/features/catalog/presentation/services/hooks/useServices.ts b/apps/admin-frontend/src/features/catalog/presentation/services/hooks/useServices.ts deleted file mode 100644 index 10ec506..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/services/hooks/useServices.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { useCallback, useState } from 'react' -import { useAppContainer } from '@/app/providers/useAppContainer' -import { useAsync, type AsyncState } from '@/shared/presentation/hooks/useAsync' -import { success, failure, type Result } from '@/shared/application/Result' -import { toUiError, type UiError } from '@/shared/application/UiError' -import type { Service } from '@/features/catalog/domain/entities/Service' -import type { TenantContext } from '@/features/auth' -import type { - CreateServiceInput, - PagedServices, - UpdateServiceInput, -} from '@/features/catalog/application/repositories/ServiceRepository' - -const DEFAULT_PAGE_SIZE = 20 -const EMPTY_PAGE: PagedServices = { - services: [], - totalCount: 0, - page: 1, - pageSize: DEFAULT_PAGE_SIZE, -} - -export interface UseServicesResult { - services: readonly Service[] - listState: AsyncState - page: number - pageSize: number - totalCount: number - setPage: (page: number) => void - refetch: () => Promise - createService: (input: CreateServiceInput) => Promise - updateService: (id: string, input: UpdateServiceInput) => Promise - deleteService: (id: string) => Promise -} - -/** Unwraps the paged envelope's `services` array into its own AsyncState, curating the error the same way every other list does. */ -function toServicesListState( - state: AsyncState, -): AsyncState { - switch (state.status) { - case 'refreshing': - return { status: 'refreshing', data: state.data.services, error: null } - case 'success': - return { status: 'success', data: state.data.services, error: null } - case 'initialError': - return { status: 'initialError', data: null, error: toUiError(state.error) } - case 'refreshError': - return { status: 'refreshError', data: state.data.services, error: toUiError(state.error) } - default: - return state - } -} - -export interface UseServicesFilters { - search?: string - categoryId?: string - tagId?: string -} - -// tenantContext is nullable: the page can mount before useAuth() resolves. -// Guards below no-op until it does, then the changed identity re-triggers the fetch. -export function useServices( - tenantContext: TenantContext | null, - filters: UseServicesFilters = {}, -): UseServicesResult { - const { catalog } = useAppContainer() - const [page, setPage] = useState(1) - const { search, categoryId, tagId } = filters - - const listServices = useCallback(async (): Promise> => { - if (tenantContext === null) { - return success(EMPTY_PAGE) - } - try { - return success( - await catalog.listServices.execute(tenantContext, { - page, - pageSize: DEFAULT_PAGE_SIZE, - ...(search !== undefined ? { search } : {}), - ...(categoryId !== undefined ? { categoryId } : {}), - ...(tagId !== undefined ? { tagId } : {}), - }), - ) - } catch (error) { - return failure(error) - } - }, [tenantContext, catalog, page, search, categoryId, tagId]) - - const asyncState = useAsync(listServices, { resetKey: tenantContext?.tenant.id }) - const { data, execute, mutate, captureGeneration } = asyncState - - const createService = useCallback( - async (input: CreateServiceInput): Promise => { - if (tenantContext === null) { - throw new Error('Não é possível criar um serviço sem um contexto de tenant autenticado') - } - // Captured before the POST starts: if the tenant switches while this - // request is in flight, the mutate below must not insert tenant A's - // newly created service into what is now tenant B's list. - const generation = captureGeneration() - const service = await catalog.createService.execute(tenantContext, input) - // Insert immediately so the new service shows up as soon as the POST - // succeeds - the mutation's success never depends on the background - // refetch below. If that refetch fails, this optimistic entry is - // what keeps the service visible (see useAsync's own status/error, - // surfaced separately by the page). - mutate( - current => - current === null - ? current - : { - ...current, - services: [...current.services, service], - totalCount: current.totalCount + 1, - }, - generation, - ) - void execute() - return service - }, - [tenantContext, catalog, execute, mutate, captureGeneration], - ) - - const updateService = useCallback( - async (id: string, input: UpdateServiceInput): Promise => { - if (tenantContext === null) { - throw new Error('Não é possível atualizar um serviço sem um contexto de tenant autenticado') - } - const service = await catalog.updateService.execute(tenantContext, id, input) - await execute() - return service - }, - [tenantContext, catalog, execute], - ) - - const deleteService = useCallback( - async (id: string): Promise => { - if (tenantContext === null) { - throw new Error('Não é possível excluir um serviço sem um contexto de tenant autenticado') - } - await catalog.deleteService.execute(tenantContext, id) - const refreshed = await execute() - // Deleting the last item on a page past the first leaves the user - // stranded on a now-empty page - step back to the last page that - // still has data instead (docs/adr/0012's frontend counterpart). - if (refreshed?.services.length === 0 && page > 1) { - setPage(page - 1) - } - }, - [tenantContext, catalog, execute, page], - ) - - return { - services: data?.services ?? [], - listState: toServicesListState(asyncState), - page, - pageSize: data?.pageSize ?? DEFAULT_PAGE_SIZE, - totalCount: data?.totalCount ?? 0, - setPage, - refetch: async () => { - await execute() - }, - createService, - updateService, - deleteService, - } -} diff --git a/apps/admin-frontend/src/features/catalog/presentation/services/hooks/useServicesPage.ts b/apps/admin-frontend/src/features/catalog/presentation/services/hooks/useServicesPage.ts deleted file mode 100644 index 06904fd..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/services/hooks/useServicesPage.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { useEffect, type MouseEvent } from 'react' -import { useAuth } from '@/features/auth' -import { useServices } from '@/features/catalog/presentation/services/hooks/useServices' -import { useCategories } from '@/features/catalog/presentation/categories/hooks/useCategories' -import { useTags } from '@/features/catalog/presentation/tags/hooks/useTags' -import type { AsyncState } from '@/shared/presentation/hooks/useAsync' -import type { UiError } from '@/shared/application/UiError' -import type { Category } from '@/features/catalog/domain/entities/Category' -import type { Tag } from '@/features/catalog/domain/entities/Tag' -import type { Service } from '@/features/catalog/domain/entities/Service' -import { useServiceFilters } from '@/features/catalog/presentation/services/hooks/useServiceFilters' -import { useServiceEditor } from '@/features/catalog/presentation/services/hooks/useServiceEditor' -import { useServiceDeletion } from '@/features/catalog/presentation/services/hooks/useServiceDeletion' -import { toSelectLoadState } from '@/features/catalog/presentation/services/models/serviceFormatters' -import type { - DiscardConfirmationViewModel, - ServiceCategoryOptions, - ServiceEditorViewModel, - ServiceTagOptions, -} from '@/features/catalog/presentation/services/models/servicePresentationModels' - -interface ServicesFiltersViewModel { - search: { value: string; onChange: (value: string) => void } - category: { value: string; onChange: (value: string) => void; options: readonly Category[] } - tag: { value: string; onChange: (value: string) => void; options: readonly Tag[] } -} - -interface ServicesListViewModel { - services: readonly Service[] - listState: AsyncState - hasActiveFilters: boolean - page: number - totalPages: number - onPageChange: (page: number) => void - onRetry: () => void - onEdit: (service: Service, event: MouseEvent) => void - onDelete: (service: Service) => void -} - -interface ServiceDeleteDialogViewModel { - target: Service | null - error: string | null - isDeleting: boolean - onCancel: () => void - onConfirm: () => void -} - -export interface UseServicesPageResult { - onOpenCreate: (event: MouseEvent) => void - filters: ServicesFiltersViewModel - list: ServicesListViewModel - dialog: { - editor: ServiceEditorViewModel - categoryOptions: ServiceCategoryOptions - tagOptions: ServiceTagOptions - discardConfirmation: DiscardConfirmationViewModel - } - deleteDialog: ServiceDeleteDialogViewModel -} - -/** Composes the filters/list/editor/deletion hooks into ServicesPage's view models - owns no machine of its own. */ -export function useServicesPage(): UseServicesPageResult { - const { tenantContext } = useAuth() - const filters = useServiceFilters() - - const { - services, - listState, - page, - pageSize, - totalCount, - setPage, - refetch, - createService, - updateService, - deleteService, - } = useServices(tenantContext, { - search: filters.debouncedSearch, - ...(filters.categoryFilter !== '' ? { categoryId: filters.categoryFilter } : {}), - ...(filters.tagFilter !== '' ? { tagId: filters.tagFilter } : {}), - }) - const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)) - - const { - categories, - listState: categoriesListState, - createCategory, - refetch: refetchCategories, - } = useCategories(tenantContext) - const { tags, listState: tagsListState, createTag, refetch: refetchTags } = useTags(tenantContext) - - useEffect(() => { - setPage(1) - // Only re-run when a filter narrows the result set - setPage/page - // themselves aren't inputs to this reset, they're what it resets. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filters.debouncedSearch, filters.categoryFilter, filters.tagFilter]) - - const editor = useServiceEditor({ onCreate: createService, onUpdate: updateService }) - const deletion = useServiceDeletion({ onDelete: deleteService }) - - return { - onOpenCreate: editor.onOpenCreate, - filters: { - search: { value: filters.searchInput, onChange: filters.onSearchInputChange }, - category: { - value: filters.categoryFilter, - onChange: filters.onCategoryFilterChange, - options: categories, - }, - tag: { value: filters.tagFilter, onChange: filters.onTagFilterChange, options: tags }, - }, - list: { - services, - listState, - hasActiveFilters: filters.hasActiveFilters, - page, - totalPages, - onPageChange: setPage, - onRetry: () => void refetch(), - onEdit: editor.onOpenEdit, - onDelete: deletion.onRequestDelete, - }, - dialog: { - editor: editor.editor, - categoryOptions: { - items: categories, - loadState: toSelectLoadState(categoriesListState, () => void refetchCategories()), - onCreate: createCategory, - }, - tagOptions: { - items: tags, - loadState: toSelectLoadState(tagsListState, () => void refetchTags()), - onCreate: createTag, - }, - discardConfirmation: editor.discardConfirmation, - }, - deleteDialog: { - target: deletion.target, - error: deletion.error, - isDeleting: deletion.isDeleting, - onCancel: deletion.onCancel, - onConfirm: () => void deletion.onConfirm(), - }, - } -} diff --git a/apps/admin-frontend/src/features/catalog/presentation/services/models/serviceFormatters.ts b/apps/admin-frontend/src/features/catalog/presentation/services/models/serviceFormatters.ts deleted file mode 100644 index 2ad5b3c..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/services/models/serviceFormatters.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { Service } from '@/features/catalog/domain/entities/Service' -import type { - CreateServiceInput, - UpdateServiceInput, -} from '@/features/catalog/application/repositories/ServiceRepository' -import type { - ServiceFormInput, - ServiceFormValues, -} from '@/features/catalog/presentation/services/forms/ServiceForm.schema' -import type { SelectLoadState } from '@/shared/presentation/components/SelectLoadState' -import type { AsyncState } from '@/shared/presentation/hooks/useAsync' -import type { UiError } from '@/shared/application/UiError' - -// Kept out of the page/components themselves: plain runtime helpers -// exported alongside a component would break Vite Fast Refresh for that -// file (react-refresh/only-export-components). - -export const EMPTY_SERVICE_FORM_VALUES: ServiceFormInput = { - name: '', - description: '', - durationMinutes: '', - minDurationMinutes: '', - maxDurationMinutes: '', - price: '', - maxDiscountPercentage: '', - categoryId: null, - tagIds: [], -} - -const currencyFormatter = new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }) - -export function formatPrice(price: number): string { - return currencyFormatter.format(price) -} - -export function formatDuration(service: Service): string { - const duration = String(service.durationMinutes) - const min = String(service.minDurationMinutes) - const max = String(service.maxDurationMinutes) - return `${duration} min (${min}–${max})` -} - -export function toServiceInput(values: ServiceFormValues): CreateServiceInput | UpdateServiceInput { - const description = values.description.trim() - return { - name: values.name.trim(), - description: description !== '' ? description : null, - durationMinutes: values.durationMinutes, - minDurationMinutes: values.minDurationMinutes, - maxDurationMinutes: values.maxDurationMinutes, - price: values.price, - maxDiscountPercentage: values.maxDiscountPercentage, - categoryId: values.categoryId, - tagIds: values.tagIds, - } -} - -export function toServiceFormValues(service: Service): ServiceFormInput { - return { - name: service.name, - description: service.description ?? '', - durationMinutes: String(service.durationMinutes), - minDurationMinutes: String(service.minDurationMinutes), - maxDurationMinutes: String(service.maxDurationMinutes), - price: String(service.price), - maxDiscountPercentage: String(service.maxDiscountPercentage), - categoryId: service.categoryId ?? null, - tagIds: service.tags.map(tag => tag.id), - } -} - -export function toSelectLoadState( - state: AsyncState, - onRetry: () => void, -): SelectLoadState { - switch (state.status) { - case 'success': - return { status: 'success' } - case 'initialError': - case 'refreshError': - return { - status: 'error', - message: state.error.message, - ...(state.error.retryable ? { onRetry } : {}), - } - default: - return { status: 'loading' } - } -} - -export function messageFrom(error: unknown, fallback: string): string { - return error instanceof Error ? error.message : fallback -} diff --git a/apps/admin-frontend/src/features/catalog/presentation/services/models/servicePresentationModels.ts b/apps/admin-frontend/src/features/catalog/presentation/services/models/servicePresentationModels.ts deleted file mode 100644 index 1c59429..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/services/models/servicePresentationModels.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { RefObject } from 'react' -import type { Category } from '@/features/catalog/domain/entities/Category' -import type { Tag } from '@/features/catalog/domain/entities/Tag' -import type { Service } from '@/features/catalog/domain/entities/Service' -import type { CreateCategoryInput } from '@/features/catalog/application/repositories/CategoryRepository' -import type { CreateTagInput } from '@/features/catalog/application/repositories/TagRepository' -import type { SelectLoadState } from '@/shared/presentation/components/SelectLoadState' -import type { ServerFormError } from '@/shared/presentation/forms/serverFormError' -import type { - ServiceFormField, - ServiceFormInput, - ServiceFormValues, -} from '@/features/catalog/presentation/services/forms/ServiceForm.schema' - -// Neutral module: useServiceEditor and ServiceDialog/ServiceForm import from -// here, never from each other. - -/** A discriminant, not a sentinel value - unlike `'new' | Service`, this can never collide with a legitimate Service. */ -export type ServiceFormTarget = { kind: 'create' } | { kind: 'edit'; item: Service } - -export type ServiceEditorContent = - | { - kind: 'create' - title: string - submitLabel: string - code: null - initialValues: ServiceFormInput - } - | { - kind: 'edit' - item: Service - title: string - submitLabel: string - code: number - initialValues: ServiceFormInput - } - -export interface ServiceCategoryOptions { - items: readonly Category[] - loadState: SelectLoadState - onCreate: (input: CreateCategoryInput) => Promise -} - -export interface ServiceTagOptions { - items: readonly Tag[] - loadState: SelectLoadState - onCreate: (input: CreateTagInput) => Promise -} - -export interface ServiceEditorViewModel { - isOpen: boolean - content: ServiceEditorContent | null - isSubmitting: boolean - serverError: ServerFormError | null - formTriggerRef: RefObject - onRequestClose: () => void - onSubmit: (values: ServiceFormValues) => Promise - onDirtyChange: (isDirty: boolean) => void -} - -export interface DiscardConfirmationViewModel { - isOpen: boolean - onCancel: () => void - onConfirm: () => void -} diff --git a/apps/admin-frontend/src/features/catalog/presentation/tags/TagsPage.test.tsx b/apps/admin-frontend/src/features/catalog/presentation/tags/TagsPage.test.tsx index f560392..0cf5819 100644 --- a/apps/admin-frontend/src/features/catalog/presentation/tags/TagsPage.test.tsx +++ b/apps/admin-frontend/src/features/catalog/presentation/tags/TagsPage.test.tsx @@ -1,44 +1,61 @@ import { describe, it, expect, vi } from 'vitest' import { render, screen, within, fireEvent, act } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router' import { TagsPage } from '@/features/catalog/presentation/tags/TagsPage' +import { TagEditorDialog } from '@/features/catalog/presentation/tags/pages/TagEditorDialog' import { AppContainerContext } from '@/app/providers/AppContainerContext' import { AuthProvider } from '@/features/auth' import type { AppContainer, CatalogFacade } from '@/app/composition/container' import { Tag } from '@/features/catalog/domain/entities/Tag' -import { Tenant } from '@/test/fixtures/authEntityFixtures' -import { User } from '@/test/fixtures/authEntityFixtures' +import { Tenant, User } from '@/test/fixtures/authEntityFixtures' import { MALICIOUS_PAYLOADS } from '@/test/fixtures/maliciousPayloads' import { createFakeAppContainer } from '@/test/fixtures/createFakeAppContainer' import { AppError } from '@/shared/application/AppError' +import { success, failure } from '@/shared/application/Result' +import { unwrapResult } from '@/test/fixtures/unwrapResult' const tenant = Tenant.create('tenant-123') const tenantContext = { tenant, user: User.create({ id: 'user-1', tenant }) } -const vipTag = Tag.create({ - id: 'tag-1', - name: 'VIP', - color: '#0d9488', - description: 'High-value client', -}) +const vipTag = unwrapResult( + Tag.create({ + id: 'tag-1', + name: 'VIP', + color: '#0d9488', + description: 'High-value client', + }), +) function buildContainer(overrides: Partial = {}): AppContainer { return createFakeAppContainer({ auth: { getCurrentSession: { execute: vi.fn(() => Promise.resolve(tenantContext)) } }, catalog: { - listTags: { execute: vi.fn(() => Promise.resolve([vipTag])) }, - createTag: { execute: vi.fn(() => Promise.resolve(vipTag)) }, - updateTag: { execute: vi.fn(() => Promise.resolve(vipTag)) }, - deleteTag: { execute: vi.fn(() => Promise.resolve()) }, + listTags: { execute: vi.fn(() => Promise.resolve(success([vipTag]))) }, + createTag: { execute: vi.fn(() => Promise.resolve(success(vipTag))) }, + updateTag: { execute: vi.fn(() => Promise.resolve(success(vipTag))) }, + deleteTag: { execute: vi.fn(() => Promise.resolve(success(undefined))) }, ...overrides, }, }) } function renderTagsPage(container: AppContainer): void { + const routes: RouteObject[] = [ + { + path: '/tags', + element: , + children: [ + { path: 'new', element: }, + { path: ':id/edit', element: }, + ], + }, + ] + const router = createMemoryRouter(routes, { initialEntries: ['/tags'] }) + render( - + , ) @@ -53,7 +70,9 @@ describe('TagsPage', () => { }) it('shows an empty state when there are no tags', async () => { - renderTagsPage(buildContainer({ listTags: { execute: vi.fn(() => Promise.resolve([])) } })) + renderTagsPage( + buildContainer({ listTags: { execute: vi.fn(() => Promise.resolve(success([]))) } }), + ) expect(await screen.findByText(/nenhuma etiqueta ainda/i)).toBeInTheDocument() }) @@ -63,8 +82,8 @@ describe('TagsPage', () => { buildContainer({ listTags: { execute: vi.fn(() => - Promise.reject( - new AppError({ code: 'network', message: 'network down', retryable: true }), + Promise.resolve( + failure(new AppError({ code: 'network', message: 'network down', retryable: true })), ), ), }, @@ -80,7 +99,15 @@ describe('TagsPage', () => { renderTagsPage( buildContainer({ listTags: { - execute: vi.fn(() => Promise.reject(new Error('undefined.trim is not a function'))), + // A repository always resolves Result, but toUiError's + // fallback branch (for anything that isn't an AppError instance) is + // still defense-in-depth worth covering here - the cast simulates + // that contract being violated internally. + execute: vi.fn(() => + Promise.resolve( + failure(new Error('undefined.trim is not a function') as unknown as AppError), + ), + ), }, }), ) @@ -94,8 +121,8 @@ describe('TagsPage', () => { }) it('creates a tag through the form and refreshes the list', async () => { - const createTagSpy = vi.fn(() => Promise.resolve(vipTag)) - const listTagsSpy = vi.fn(() => Promise.resolve([vipTag])) + const createTagSpy = vi.fn(() => Promise.resolve(success(vipTag))) + const listTagsSpy = vi.fn(() => Promise.resolve(success([vipTag]))) renderTagsPage( buildContainer({ createTag: { execute: createTagSpy }, listTags: { execute: listTagsSpy } }), ) @@ -107,7 +134,7 @@ describe('TagsPage', () => { await userEvent.click(screen.getByRole('radio', { name: 'Cor #ef4444' })) await userEvent.click(screen.getByRole('button', { name: /criar etiqueta/i })) - expect(createTagSpy).toHaveBeenCalledExactlyOnceWith(tenantContext, { + expect(createTagSpy).toHaveBeenCalledExactlyOnceWith({ name: 'Returning', color: '#ef4444', }) @@ -118,7 +145,7 @@ describe('TagsPage', () => { }) it('shows a validation error and does not submit when the name is blank', async () => { - const createTagSpy = vi.fn(() => Promise.resolve(vipTag)) + const createTagSpy = vi.fn(() => Promise.resolve(success(vipTag))) renderTagsPage(buildContainer({ createTag: { execute: createTagSpy } })) await screen.findByText('VIP') @@ -156,7 +183,11 @@ describe('TagsPage', () => { renderTagsPage( buildContainer({ createTag: { - execute: vi.fn(() => Promise.reject(new Error('Tag name is already in use.'))), + execute: vi.fn(() => + Promise.resolve( + failure(new Error('Tag name is already in use.') as unknown as AppError), + ), + ), }, }), ) @@ -182,7 +213,9 @@ describe('TagsPage', () => { }, }) renderTagsPage( - buildContainer({ createTag: { execute: vi.fn(() => Promise.reject(validationError)) } }), + buildContainer({ + createTag: { execute: vi.fn(() => Promise.resolve(failure(validationError))) }, + }), ) await screen.findByText('VIP') @@ -207,7 +240,9 @@ describe('TagsPage', () => { backendCode: 'Tag.DuplicateName', }) renderTagsPage( - buildContainer({ createTag: { execute: vi.fn(() => Promise.reject(conflictError)) } }), + buildContainer({ + createTag: { execute: vi.fn(() => Promise.resolve(failure(conflictError))) }, + }), ) await screen.findByText('VIP') @@ -222,7 +257,7 @@ describe('TagsPage', () => { }) it('edits a tag through the inline form', async () => { - const updateTagSpy = vi.fn(() => Promise.resolve(vipTag)) + const updateTagSpy = vi.fn(() => Promise.resolve(success(vipTag))) renderTagsPage(buildContainer({ updateTag: { execute: updateTagSpy } })) await screen.findByText('VIP') @@ -232,7 +267,7 @@ describe('TagsPage', () => { await userEvent.type(nameInput, 'Renamed') await userEvent.click(screen.getByRole('button', { name: /salvar alterações/i })) - expect(updateTagSpy).toHaveBeenCalledExactlyOnceWith(tenantContext, 'tag-1', { + expect(updateTagSpy).toHaveBeenCalledExactlyOnceWith('tag-1', { name: 'Renamed', color: '#0d9488', description: 'High-value client', @@ -252,7 +287,7 @@ describe('TagsPage', () => { }) it('deletes the tag when the confirmation is accepted', async () => { - const deleteTagSpy = vi.fn(() => Promise.resolve()) + const deleteTagSpy = vi.fn(() => Promise.resolve(success(undefined))) renderTagsPage(buildContainer({ deleteTag: { execute: deleteTagSpy } })) await screen.findByText('VIP') @@ -260,14 +295,14 @@ describe('TagsPage', () => { const alertDialog = await screen.findByRole('alertdialog') await userEvent.click(within(alertDialog).getByRole('button', { name: 'Excluir' })) - expect(deleteTagSpy).toHaveBeenCalledExactlyOnceWith(tenantContext, 'tag-1') + expect(deleteTagSpy).toHaveBeenCalledExactlyOnceWith('tag-1') await vi.waitFor(() => { expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() }) }) it('does not delete the tag when the confirmation is cancelled', async () => { - const deleteTagSpy = vi.fn(() => Promise.resolve()) + const deleteTagSpy = vi.fn(() => Promise.resolve(success(undefined))) renderTagsPage(buildContainer({ deleteTag: { execute: deleteTagSpy } })) await screen.findByText('VIP') @@ -282,7 +317,11 @@ describe('TagsPage', () => { }) it('shows an error and keeps the dialog open when deletion fails', async () => { - const deleteTagSpy = vi.fn(() => Promise.reject(new Error('Tag is in use.'))) + const deleteTagSpy = vi.fn(() => + Promise.resolve( + failure(new AppError({ code: 'conflict', message: 'Tag is in use.', retryable: false })), + ), + ) renderTagsPage(buildContainer({ deleteTag: { execute: deleteTagSpy } })) await screen.findByText('VIP') @@ -297,7 +336,7 @@ describe('TagsPage', () => { describe('search', () => { it('refetches with the debounced search term after the user stops typing', async () => { - const listTagsSpy = vi.fn(() => Promise.resolve([vipTag])) + const listTagsSpy = vi.fn(() => Promise.resolve(success([vipTag]))) renderTagsPage(buildContainer({ listTags: { execute: listTagsSpy } })) await screen.findByText('VIP') listTagsSpy.mockClear() @@ -313,7 +352,7 @@ describe('TagsPage', () => { await vi.advanceTimersByTimeAsync(300) }) - expect(listTagsSpy).toHaveBeenCalledExactlyOnceWith(tenantContext, { search: 'vip' }) + expect(listTagsSpy).toHaveBeenCalledExactlyOnceWith({ search: 'vip' }) } finally { vi.useRealTimers() } @@ -322,9 +361,13 @@ describe('TagsPage', () => { describe('security', () => { it.each(MALICIOUS_PAYLOADS)('renders "%s" as inert text, not markup', async payload => { - const maliciousTag = Tag.create({ id: 'malicious-1', name: payload, color: '#0d9488' }) + const maliciousTag = unwrapResult( + Tag.create({ id: 'malicious-1', name: payload, color: '#0d9488' }), + ) renderTagsPage( - buildContainer({ listTags: { execute: vi.fn(() => Promise.resolve([maliciousTag])) } }), + buildContainer({ + listTags: { execute: vi.fn(() => Promise.resolve(success([maliciousTag]))) }, + }), ) expect(await screen.findByText(payload)).toBeInTheDocument() diff --git a/apps/admin-frontend/src/features/catalog/presentation/tags/TagsPage.tsx b/apps/admin-frontend/src/features/catalog/presentation/tags/TagsPage.tsx index 0f44b77..3690281 100644 --- a/apps/admin-frontend/src/features/catalog/presentation/tags/TagsPage.tsx +++ b/apps/admin-frontend/src/features/catalog/presentation/tags/TagsPage.tsx @@ -1,13 +1,14 @@ import type { JSX } from 'react' +import { Outlet, useNavigate } from 'react-router' import { PageHeader } from '@/shared/presentation/components/PageHeader' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' +import { DeleteConfirmationDialog } from '@/shared/presentation/components/DeleteConfirmationDialog' import { useTagsPage } from '@/features/catalog/presentation/tags/hooks/useTagsPage' import { TagsTable } from '@/features/catalog/presentation/tags/components/TagsTable' -import { TagEditorDialog } from '@/features/catalog/presentation/tags/components/TagEditorDialog' -import { TagDeleteDialog } from '@/features/catalog/presentation/tags/components/TagDeleteDialog' export function TagsPage(): JSX.Element { + const navigate = useNavigate() const { searchInput, onSearchInputChange, @@ -15,44 +16,64 @@ export function TagsPage(): JSX.Element { listState, hasActiveSearch, onRetry, - onOpenCreate, - onEdit, + editorContext, onDelete, - dialog, deleteDialog, } = useTagsPage() + function handleEdit(tag: (typeof tags)[number]): void { + void navigate(`/tags/${tag.id}/edit`) + } + return ( -
- Nova etiqueta} - /> + <> +
+ { + void navigate('/tags/new') + }} + > + Nova etiqueta + + } + /> + +
+ { + onSearchInputChange(event.target.value) + }} + /> +
-
- { - onSearchInputChange(event.target.value) - }} +
- - - + - -
+ + ) } diff --git a/apps/admin-frontend/src/features/catalog/presentation/tags/components/TagDeleteDialog.tsx b/apps/admin-frontend/src/features/catalog/presentation/tags/components/TagDeleteDialog.tsx deleted file mode 100644 index 2be1f43..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/tags/components/TagDeleteDialog.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import type { JSX } from 'react' -import type { Tag } from '@/features/catalog/domain/entities/Tag' -import { DeleteConfirmationDialog } from '@/shared/presentation/components/DeleteConfirmationDialog' - -export interface TagDeleteDialogProps { - target: Tag | null - error: string | null - isDeleting: boolean - onCancel: () => void - onConfirm: () => void -} - -export function TagDeleteDialog({ - target, - error, - isDeleting, - onCancel, - onConfirm, -}: TagDeleteDialogProps): JSX.Element { - return ( - - Tem certeza de que deseja excluir a etiqueta "{target?.name}"? Essa ação não pode ser - desfeita. - - } - error={error} - isDeleting={isDeleting} - onCancel={onCancel} - onConfirm={onConfirm} - /> - ) -} diff --git a/apps/admin-frontend/src/features/catalog/presentation/tags/components/TagEditorDialog.tsx b/apps/admin-frontend/src/features/catalog/presentation/tags/components/TagEditorDialog.tsx deleted file mode 100644 index a4131a8..0000000 --- a/apps/admin-frontend/src/features/catalog/presentation/tags/components/TagEditorDialog.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import type { JSX } from 'react' -import type { Tag } from '@/features/catalog/domain/entities/Tag' -import { - TagForm, - type TagFormValues, - type TagFormField, -} from '@/features/catalog/presentation/tags/forms/TagForm' -import type { ServerFormError } from '@/shared/presentation/forms/serverFormError' -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' - -type TagEditorContent = - | { kind: 'create'; title: string; submitLabel: string; initialValues: TagFormValues } - | { kind: 'edit'; item: Tag; title: string; submitLabel: string; initialValues: TagFormValues } - -export interface TagEditorDialogProps { - isOpen: boolean - content: TagEditorContent | null - isSubmitting: boolean - serverError: ServerFormError | null - onCancel: () => void - onSubmit: (values: TagFormValues) => Promise -} - -export function TagEditorDialog({ - isOpen, - content, - isSubmitting, - serverError, - onCancel, - onSubmit, -}: TagEditorDialogProps): JSX.Element { - return ( - { - if (!open) onCancel() - }} - > - - - {content?.title ?? ''} - - {content !== null && ( - - )} - - - ) -} diff --git a/apps/admin-frontend/src/features/catalog/presentation/tags/hooks/useTagEditor.ts b/apps/admin-frontend/src/features/catalog/presentation/tags/hooks/useTagEditor.ts new file mode 100644 index 0000000..34a5ec4 --- /dev/null +++ b/apps/admin-frontend/src/features/catalog/presentation/tags/hooks/useTagEditor.ts @@ -0,0 +1,124 @@ +import { useState } from 'react' +import { useNavigate, useOutletContext, useParams } from 'react-router' +import type { + TagFormField, + TagFormValues, +} from '@/features/catalog/presentation/tags/forms/TagForm' +import { + tagCodeFieldMap, + tagFieldMap, +} from '@/features/catalog/presentation/tags/forms/tagFieldMaps' +import type { UseTagsResult } from '@/features/catalog/presentation/tags/hooks/useTags' +import { + mapApiErrorToForm, + type ServerFormError, +} from '@/shared/presentation/forms/serverFormError' +import { TAG_COLOR_PALETTE } from '@/features/catalog/domain/entities/Tag' + +const EMPTY_FORM_VALUES: TagFormValues = { + name: '', + color: TAG_COLOR_PALETTE[0], + description: '', +} + +export type TagEditorContent = + | { status: 'loading' } + | { status: 'loadError'; message: string; onRetry: () => void } + | { status: 'notFound' } + | { status: 'ready'; initialValues: TagFormValues } + +export interface UseTagEditorResult { + title: string + submitLabel: string + formKey: string + content: TagEditorContent + isSubmitting: boolean + serverError: ServerFormError | null + onCancel: () => void + onSubmit: (values: TagFormValues) => Promise +} + +function toTagInput(values: TagFormValues): { + name: string + color: string + description?: string +} { + const description = values.description.trim() + return { + name: values.name, + color: values.color, + ...(description !== '' ? { description } : {}), + } +} + +export function useTagEditor(): UseTagEditorResult { + const { id: tagId } = useParams<{ id: string }>() + const navigate = useNavigate() + const { tags, listState, refetch, createTag, updateTag } = useOutletContext() + const [isSubmitting, setIsSubmitting] = useState(false) + const [serverError, setServerError] = useState | null>(null) + const isEditing = tagId !== undefined + + function closeEditor(): void { + void navigate('..', { replace: true }) + } + + async function onSubmit(values: TagFormValues): Promise { + setIsSubmitting(true) + setServerError(null) + const result = + tagId === undefined + ? await createTag(toTagInput(values)) + : await updateTag(tagId, toTagInput(values)) + if (result.success) { + closeEditor() + } else { + setServerError( + mapApiErrorToForm( + result.error, + tagFieldMap, + tagCodeFieldMap, + isEditing ? 'Não foi possível salvar a etiqueta.' : 'Não foi possível criar a etiqueta.', + ), + ) + } + setIsSubmitting(false) + } + + let content: TagEditorContent + if (!isEditing) { + content = { status: 'ready', initialValues: EMPTY_FORM_VALUES } + } else if (listState.status === 'idle' || listState.status === 'loading') { + content = { status: 'loading' } + } else if (listState.status === 'initialError') { + content = { + status: 'loadError', + message: listState.error.message, + onRetry: () => void refetch(), + } + } else { + const tag = tags.find(item => item.id === tagId) + content = + tag === undefined + ? { status: 'notFound' } + : { + status: 'ready', + initialValues: { + name: tag.name, + color: tag.color, + description: tag.description ?? '', + }, + } + } + + return { + title: isEditing ? 'Editar etiqueta' : 'Nova etiqueta', + submitLabel: isEditing ? 'Salvar alterações' : 'Criar etiqueta', + formKey: tagId ?? 'new', + content, + isSubmitting, + serverError, + onCancel: closeEditor, + onSubmit, + } +} diff --git a/apps/admin-frontend/src/features/catalog/presentation/tags/hooks/useTags.test.tsx b/apps/admin-frontend/src/features/catalog/presentation/tags/hooks/useTags.test.tsx index 6703e37..8b33d57 100644 --- a/apps/admin-frontend/src/features/catalog/presentation/tags/hooks/useTags.test.tsx +++ b/apps/admin-frontend/src/features/catalog/presentation/tags/hooks/useTags.test.tsx @@ -5,19 +5,21 @@ import { AppContainerContext } from '@/app/providers/AppContainerContext' import { createFakeAppContainer } from '@/test/fixtures/createFakeAppContainer' import type { AppContainer, CatalogFacade } from '@/app/composition/container' import { Tag } from '@/features/catalog/domain/entities/Tag' -import { Tenant } from '@/test/fixtures/authEntityFixtures' -import { User } from '@/test/fixtures/authEntityFixtures' import type { TenantContext } from '@/features/auth' +import { Tenant, User } from '@/test/fixtures/authEntityFixtures' +import { success, failure, type Result } from '@/shared/application/Result' +import { AppError } from '@/shared/application/AppError' +import { unwrapResult } from '@/test/fixtures/unwrapResult' -const tagFixture = Tag.create({ id: 'tag-1', name: 'VIP', color: '#0d9488' }) +const tagFixture = unwrapResult(Tag.create({ id: 'tag-1', name: 'VIP', color: '#0d9488' })) function createFakeContainer(overrides: Partial = {}): AppContainer { return createFakeAppContainer({ catalog: { - listTags: { execute: vi.fn(() => Promise.resolve([tagFixture])) }, - createTag: { execute: vi.fn(() => Promise.resolve(tagFixture)) }, - updateTag: { execute: vi.fn(() => Promise.resolve(tagFixture)) }, - deleteTag: { execute: vi.fn(() => Promise.resolve()) }, + listTags: { execute: vi.fn(() => Promise.resolve(success([tagFixture]))) }, + createTag: { execute: vi.fn(() => Promise.resolve(success(tagFixture))) }, + updateTag: { execute: vi.fn(() => Promise.resolve(success(tagFixture))) }, + deleteTag: { execute: vi.fn(() => Promise.resolve(success(undefined))) }, ...overrides, }, }) @@ -51,7 +53,7 @@ describe('useTags', () => { }) it('returns an empty list without calling the use case when tenantContext is null', async () => { - const listTagsSpy = vi.fn(() => Promise.resolve([tagFixture])) + const listTagsSpy = vi.fn(() => Promise.resolve(success([tagFixture]))) const { result } = renderUseTags( createFakeContainer({ listTags: { execute: listTagsSpy } }), null, @@ -66,8 +68,8 @@ describe('useTags', () => { }) it('creates a tag then refetches the list', async () => { - const listTagsSpy = vi.fn(() => Promise.resolve([tagFixture])) - const createTagSpy = vi.fn(() => Promise.resolve(tagFixture)) + const listTagsSpy = vi.fn(() => Promise.resolve(success([tagFixture]))) + const createTagSpy = vi.fn(() => Promise.resolve(success(tagFixture))) const tenantContext = buildTenantContext() const { result } = renderUseTags( createFakeContainer({ @@ -85,7 +87,7 @@ describe('useTags', () => { await result.current.createTag({ name: 'VIP', color: '#0d9488' }) }) - expect(createTagSpy).toHaveBeenCalledExactlyOnceWith(tenantContext, { + expect(createTagSpy).toHaveBeenCalledExactlyOnceWith({ name: 'VIP', color: '#0d9488', }) @@ -97,12 +99,14 @@ describe('useTags', () => { }) it('keeps the created tag visible even if the background refetch fails', async () => { - const newTag = Tag.create({ id: 'tag-2', name: 'Returning', color: '#ef4444' }) + const newTag = unwrapResult(Tag.create({ id: 'tag-2', name: 'Returning', color: '#ef4444' })) const listTagsSpy = vi - .fn<() => Promise>() - .mockResolvedValueOnce([tagFixture]) - .mockRejectedValueOnce(new Error('network down')) - const createTagSpy = vi.fn(() => Promise.resolve(newTag)) + .fn<() => Promise>>() + .mockResolvedValueOnce(success([tagFixture])) + .mockResolvedValueOnce( + failure(new AppError({ code: 'network', message: 'network down', retryable: true })), + ) + const createTagSpy = vi.fn(() => Promise.resolve(success(newTag))) const tenantContext = buildTenantContext() const { result } = renderUseTags( createFakeContainer({ @@ -118,7 +122,7 @@ describe('useTags', () => { await act(async () => { await expect( result.current.createTag({ name: 'Returning', color: '#ef4444' }), - ).resolves.toEqual(newTag) + ).resolves.toEqual(success(newTag)) }) // The optimistic insert survives the refetch failure below. @@ -133,8 +137,8 @@ describe('useTags', () => { }) it('deletes a tag then refetches the list', async () => { - const listTagsSpy = vi.fn(() => Promise.resolve([tagFixture])) - const deleteTagSpy = vi.fn(() => Promise.resolve()) + const listTagsSpy = vi.fn(() => Promise.resolve(success([tagFixture]))) + const deleteTagSpy = vi.fn(() => Promise.resolve(success(undefined))) const tenantContext = buildTenantContext() const { result } = renderUseTags( createFakeContainer({ @@ -152,17 +156,19 @@ describe('useTags', () => { await result.current.deleteTag('tag-1') }) - expect(deleteTagSpy).toHaveBeenCalledExactlyOnceWith(tenantContext, 'tag-1') + expect(deleteTagSpy).toHaveBeenCalledExactlyOnceWith('tag-1') expect(listTagsSpy).toHaveBeenCalledTimes(1) }) - it('rejects mutations when tenantContext is null', async () => { + it('resolves to a Failure when tenantContext is null', async () => { const { result } = renderUseTags(createFakeContainer(), null) await waitFor(() => { expect(result.current.listState.status).toBe('success') }) - await expect(result.current.createTag({ name: 'VIP', color: '#0d9488' })).rejects.toThrow() + const outcome = await result.current.createTag({ name: 'VIP', color: '#0d9488' }) + + expect(outcome.success).toBe(false) }) it('does not let a create started against the previous tenant leak into the new tenant after a switch', async () => { @@ -176,15 +182,17 @@ describe('useTags', () => { let resolveCreate: ((tag: Tag) => void) | undefined const createTagSpy = vi.fn( () => - new Promise(resolve => { - resolveCreate = resolve + new Promise>(resolve => { + resolveCreate = tag => { + resolve(success(tag)) + } }), ) const listTagsSpy = vi - .fn<() => Promise>() - .mockResolvedValueOnce([tagFixture]) // tenant A's initial load - .mockResolvedValueOnce([]) // tenant B's auto-fetch right after the switch - .mockResolvedValue([tagFixture]) // any further stale tenant-A refetch + .fn<() => Promise>>() + .mockResolvedValueOnce(success([tagFixture])) // tenant A's initial load + .mockResolvedValueOnce(success([])) // tenant B's auto-fetch right after the switch + .mockResolvedValue(success([tagFixture])) // any further stale tenant-A refetch const container = createFakeContainer({ listTags: { execute: listTagsSpy }, @@ -207,7 +215,7 @@ describe('useTags', () => { expect(result.current.tags).toEqual([tagFixture]) // Start a create against tenant A - deliberately left pending. - let createPromise: Promise | undefined + let createPromise: Promise> | undefined act(() => { createPromise = result.current.createTag({ name: 'VIP', color: '#0d9488' }) }) diff --git a/apps/admin-frontend/src/features/catalog/presentation/tags/hooks/useTags.ts b/apps/admin-frontend/src/features/catalog/presentation/tags/hooks/useTags.ts index b133e43..85324f8 100644 --- a/apps/admin-frontend/src/features/catalog/presentation/tags/hooks/useTags.ts +++ b/apps/admin-frontend/src/features/catalog/presentation/tags/hooks/useTags.ts @@ -1,8 +1,9 @@ import { useCallback } from 'react' import { useAppContainer } from '@/app/providers/useAppContainer' import { useAsync, toUiAsyncState, type AsyncState } from '@/shared/presentation/hooks/useAsync' -import { success, failure, type Result } from '@/shared/application/Result' import type { UiError } from '@/shared/application/UiError' +import { AppError } from '@/shared/application/AppError' +import { failure, success, type Result } from '@/shared/application/Result' import type { Tag } from '@/features/catalog/domain/entities/Tag' import type { TenantContext } from '@/features/auth' import type { @@ -14,73 +15,84 @@ export interface UseTagsResult { tags: readonly Tag[] listState: AsyncState refetch: () => Promise - createTag: (input: CreateTagInput) => Promise - updateTag: (id: string, input: UpdateTagInput) => Promise - deleteTag: (id: string) => Promise + createTag: (input: CreateTagInput) => Promise> + updateTag: (id: string, input: UpdateTagInput) => Promise> + deleteTag: (id: string) => Promise> } +// tenantContext is nullable: the page can mount before useAuth() resolves - +// create/update/delete are never reachable from the UI in that window, but +// this keeps the guard a Result instead of a throw for that same reason +// useCategoryEditor's own unreachable guard is. +const NO_TENANT_CONTEXT_ERROR = new AppError({ + code: 'unexpected', + message: 'Não é possível concluir esta ação sem um contexto de tenant autenticado.', + retryable: false, +}) + // tenantContext is nullable: the page can mount before useAuth() resolves. // Guards below no-op until it does, then the changed identity re-triggers the fetch. export function useTags(tenantContext: TenantContext | null, search = ''): UseTagsResult { const { catalog } = useAppContainer() - const listTags = useCallback(async (): Promise> => { + const listTags = useCallback((): Promise> => { if (tenantContext === null) { - return success([]) - } - try { - return success(await catalog.listTags.execute(tenantContext, { search })) - } catch (error) { - return failure(error) + return Promise.resolve(success([])) } + return catalog.listTags.execute({ search }) }, [tenantContext, catalog, search]) const asyncState = useAsync(listTags, { resetKey: tenantContext?.tenant.id }) const { data, execute, mutate, captureGeneration } = asyncState const createTag = useCallback( - async (input: CreateTagInput): Promise => { + async (input: CreateTagInput): Promise> => { if (tenantContext === null) { - throw new Error('Não é possível criar uma etiqueta sem um contexto de tenant autenticado') + return failure(NO_TENANT_CONTEXT_ERROR) } // Captured before the POST starts: if the tenant switches while this // request is in flight, the mutate below must not insert tenant A's // newly created tag into what is now tenant B's list. const generation = captureGeneration() - const tag = await catalog.createTag.execute(tenantContext, input) - // Insert immediately so the new tag is selectable and shows up as - // soon as the POST succeeds - the mutation's success never depends - // on the background refetch below. If that refetch fails, this - // optimistic entry is what keeps the tag visible as a chip (see - // useAsync's own status/error, surfaced separately by the page). - mutate(current => [...(current ?? []), tag], generation) - void execute() - return tag + const createResult = await catalog.createTag.execute(input) + if (createResult.success) { + // Insert immediately so the new tag is selectable and shows up as + // soon as the POST succeeds - the mutation's success never depends + // on the background refetch below. If that refetch fails, this + // optimistic entry is what keeps the tag visible as a chip (see + // useAsync's own status/error, surfaced separately by the page). + mutate(current => [...(current ?? []), createResult.value], generation) + void execute() + } + return createResult }, [tenantContext, catalog, execute, mutate, captureGeneration], ) const updateTag = useCallback( - async (id: string, input: UpdateTagInput): Promise => { + async (id: string, input: UpdateTagInput): Promise> => { if (tenantContext === null) { - throw new Error( - 'Não é possível atualizar uma etiqueta sem um contexto de tenant autenticado', - ) + return failure(NO_TENANT_CONTEXT_ERROR) } - const tag = await catalog.updateTag.execute(tenantContext, id, input) - await execute() - return tag + const updateResult = await catalog.updateTag.execute(id, input) + if (updateResult.success) { + await execute() + } + return updateResult }, [tenantContext, catalog, execute], ) const deleteTag = useCallback( - async (id: string): Promise => { + async (id: string): Promise> => { if (tenantContext === null) { - throw new Error('Não é possível excluir uma etiqueta sem um contexto de tenant autenticado') + return failure(NO_TENANT_CONTEXT_ERROR) } - await catalog.deleteTag.execute(tenantContext, id) - await execute() + const deleteResult = await catalog.deleteTag.execute(id) + if (deleteResult.success) { + await execute() + } + return deleteResult }, [tenantContext, catalog, execute], ) diff --git a/apps/admin-frontend/src/features/catalog/presentation/tags/hooks/useTagsPage.ts b/apps/admin-frontend/src/features/catalog/presentation/tags/hooks/useTagsPage.ts index bf851af..7b7a326 100644 --- a/apps/admin-frontend/src/features/catalog/presentation/tags/hooks/useTagsPage.ts +++ b/apps/admin-frontend/src/features/catalog/presentation/tags/hooks/useTagsPage.ts @@ -4,62 +4,9 @@ import { useTags } from '@/features/catalog/presentation/tags/hooks/useTags' import type { AsyncState } from '@/shared/presentation/hooks/useAsync' import type { UiError } from '@/shared/application/UiError' import { useDebouncedValue } from '@/shared/presentation/hooks/useDebouncedValue' -import { useDialogTarget, type DialogTarget } from '@/shared/presentation/hooks/useDialogTarget' import { useDeleteConfirmation } from '@/shared/presentation/hooks/useDeleteConfirmation' -import { TAG_COLOR_PALETTE, type Tag, type TagColor } from '@/features/catalog/domain/entities/Tag' -import type { - TagFormValues, - TagFormField, -} from '@/features/catalog/presentation/tags/forms/TagForm' -import { - mapApiErrorToForm, - type ServerFormError, -} from '@/shared/presentation/forms/serverFormError' -import { - tagFieldMap, - tagCodeFieldMap, -} from '@/features/catalog/presentation/tags/forms/tagFieldMaps' - -const EMPTY_FORM_VALUES: TagFormValues = { name: '', color: TAG_COLOR_PALETTE[0], description: '' } - -function toTagInput(values: TagFormValues): { - name: string - color: TagColor - description?: string -} { - const description = values.description.trim() - return { - name: values.name, - color: values.color, - ...(description !== '' ? { description } : {}), - } -} - -function toFormValues(tag: Tag): TagFormValues { - return { name: tag.name, color: tag.color, description: tag.description ?? '' } -} - -export type TagEditorContent = - | { kind: 'create'; title: string; submitLabel: string; initialValues: TagFormValues } - | { kind: 'edit'; item: Tag; title: string; submitLabel: string; initialValues: TagFormValues } - -function toEditorContent(target: DialogTarget): TagEditorContent { - if (target.kind === 'edit') { - return { - kind: 'edit', - item: target.item, - title: 'Editar etiqueta', - submitLabel: 'Salvar alterações', - initialValues: toFormValues(target.item), - } - } - return { - kind: 'create', - title: 'Nova etiqueta', - submitLabel: 'Criar etiqueta', - initialValues: EMPTY_FORM_VALUES, - } -} +import type { Tag } from '@/features/catalog/domain/entities/Tag' +import type { UseTagsResult } from '@/features/catalog/presentation/tags/hooks/useTags' export interface UseTagsPageResult { searchInput: string @@ -68,17 +15,8 @@ export interface UseTagsPageResult { listState: AsyncState hasActiveSearch: boolean onRetry: () => void - onOpenCreate: () => void - onEdit: (tag: Tag) => void + editorContext: UseTagsResult onDelete: (tag: Tag) => void - dialog: { - isOpen: boolean - content: TagEditorContent | null - isSubmitting: boolean - serverError: ServerFormError | null - onCancel: () => void - onSubmit: (values: TagFormValues) => Promise - } deleteDialog: { target: Tag | null error: string | null @@ -88,63 +26,16 @@ export interface UseTagsPageResult { } } -/** Composes search, useTags, dialog target, and delete confirmation into TagsPage's view models. */ export function useTagsPage(): UseTagsPageResult { const { tenantContext } = useAuth() const [searchInput, setSearchInput] = useState('') const debouncedSearch = useDebouncedValue(searchInput, 300) - const { tags, listState, refetch, createTag, updateTag, deleteTag } = useTags( - tenantContext, - debouncedSearch, - ) - - const dialogTarget = useDialogTarget() - const [serverError, setServerError] = useState | null>(null) - const [isSubmitting, setIsSubmitting] = useState(false) + const tagsSource = useTags(tenantContext, debouncedSearch) + const { tags, listState, refetch, deleteTag } = tagsSource const deletion = useDeleteConfirmation({ onDelete: tag => deleteTag(tag.id), - fallbackMessage: 'Não foi possível excluir a etiqueta.', }) - function openCreateForm(): void { - dialogTarget.openCreate() - setServerError(null) - } - - function openEditForm(tag: Tag): void { - dialogTarget.openEdit(tag) - setServerError(null) - } - - function closeForm(): void { - dialogTarget.close() - setServerError(null) - } - - async function handleSubmit(values: TagFormValues): Promise { - setIsSubmitting(true) - setServerError(null) - try { - if (dialogTarget.formTarget?.kind === 'create') { - await createTag(toTagInput(values)) - } else if (dialogTarget.formTarget?.kind === 'edit') { - await updateTag(dialogTarget.formTarget.item.id, toTagInput(values)) - } - closeForm() - } catch (caughtError) { - setServerError( - mapApiErrorToForm( - caughtError, - tagFieldMap, - tagCodeFieldMap, - 'Não foi possível salvar a etiqueta.', - ), - ) - } finally { - setIsSubmitting(false) - } - } - return { searchInput, onSearchInputChange: setSearchInput, @@ -152,18 +43,8 @@ export function useTagsPage(): UseTagsPageResult { listState, hasActiveSearch: debouncedSearch.trim() !== '', onRetry: () => void refetch(), - onOpenCreate: openCreateForm, - onEdit: openEditForm, + editorContext: tagsSource, onDelete: deletion.onRequestDelete, - dialog: { - isOpen: dialogTarget.isOpen, - content: - dialogTarget.displayTarget !== null ? toEditorContent(dialogTarget.displayTarget) : null, - isSubmitting, - serverError, - onCancel: closeForm, - onSubmit: handleSubmit, - }, deleteDialog: { target: deletion.target, error: deletion.error, diff --git a/apps/admin-frontend/src/features/catalog/presentation/tags/pages/TagEditorDialog.tsx b/apps/admin-frontend/src/features/catalog/presentation/tags/pages/TagEditorDialog.tsx new file mode 100644 index 0000000..e484027 --- /dev/null +++ b/apps/admin-frontend/src/features/catalog/presentation/tags/pages/TagEditorDialog.tsx @@ -0,0 +1,63 @@ +import type { JSX } from 'react' +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { TagForm } from '@/features/catalog/presentation/tags/forms/TagForm' +import { useTagEditor } from '@/features/catalog/presentation/tags/hooks/useTagEditor' +import { StatusMessage } from '@/shared/presentation/components/StatusMessage' + +export function TagEditorDialog(): JSX.Element { + const editor = useTagEditor() + + return ( + { + if (!open) { + editor.onCancel() + } + }} + > + + + {editor.title} + + + {editor.content.status === 'loading' && ( + Carregando etiqueta… + )} + + {editor.content.status === 'loadError' && ( +
+ + Não foi possível carregar a etiqueta: {editor.content.message} + + +
+ )} + + {editor.content.status === 'notFound' && ( +
+ Etiqueta não encontrada. + +
+ )} + + {editor.content.status === 'ready' && ( + + )} +
+
+ ) +} diff --git a/apps/admin-frontend/src/shared/application/HttpClient.ts b/apps/admin-frontend/src/shared/application/HttpClient.ts index 59e461c..ae24b96 100644 --- a/apps/admin-frontend/src/shared/application/HttpClient.ts +++ b/apps/admin-frontend/src/shared/application/HttpClient.ts @@ -1,12 +1,18 @@ +import type { AppError } from '@/shared/application/AppError' +import type { Result } from '@/shared/application/Result' + // Validates/narrows an unknown JSON payload into T, throwing if it doesn't // match - a repository's own mapper owns this, never a bare `as T` (docs/adr/011). export type Decoder = (payload: unknown) => T // A generic type parameter alone validates nothing at runtime - the Decoder -// is what actually stands between an untrusted response body and a T. +// is what actually stands between an untrusted response body and a T. Never +// rejects - every failure (network, auth, malformed payload, backend error) +// comes back as Result.failure(AppError) so a caller's type signature can't +// forget that a request can fail. export interface HttpClient { - get(path: string, decode: Decoder): Promise - post(path: string, body: unknown, decode: Decoder): Promise - put(path: string, body: unknown, decode: Decoder): Promise - delete(path: string): Promise + get(path: string, decode: Decoder): Promise> + post(path: string, body: unknown, decode: Decoder): Promise> + put(path: string, body: unknown, decode: Decoder): Promise> + delete(path: string): Promise> } diff --git a/apps/admin-frontend/src/shared/infrastructure/http/AuthenticatedHttpClient.test.ts b/apps/admin-frontend/src/shared/infrastructure/http/AuthenticatedHttpClient.test.ts index 83c7f73..275bf91 100644 --- a/apps/admin-frontend/src/shared/infrastructure/http/AuthenticatedHttpClient.test.ts +++ b/apps/admin-frontend/src/shared/infrastructure/http/AuthenticatedHttpClient.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest' import { server } from '@/test/mocks/server' import { AuthenticatedHttpClient } from '@/shared/infrastructure/http/AuthenticatedHttpClient' import { AppError } from '@/shared/application/AppError' +import { success, type Result } from '@/shared/application/Result' import type { RequestSession } from '@/shared/application/RequestSession' import type { Decoder } from '@/shared/application/HttpClient' @@ -35,6 +36,16 @@ function decodeWidget(payload: unknown): Widget { // decoded value - a real decoder would be pure overhead there. const ignoreBody: Decoder = payload => payload +// The client never rejects - every failure is a Result.failure(AppError). +// Narrows that for the many tests that only care about the error branch. +async function expectFailure(promise: Promise>): Promise { + const result = await promise + if (result.success) { + throw new Error('expected a failure Result, got success') + } + return result.error +} + describe('AuthenticatedHttpClient', () => { it('attaches the bearer token and returns the parsed JSON body', async () => { server.use( @@ -48,7 +59,7 @@ describe('AuthenticatedHttpClient', () => { const result = await client.get('/widgets/1', decodeWidget) - expect(result).toEqual({ id: '1', name: 'Widget' }) + expect(result).toEqual(success({ id: '1', name: 'Widget' })) }) it('sends a JSON body and Content-Type on post', async () => { @@ -64,23 +75,23 @@ describe('AuthenticatedHttpClient', () => { const result = await client.post('/widgets', { name: 'Widget' }, decodeWidget) - expect(result).toEqual({ id: '1', name: 'Widget' }) + expect(result).toEqual(success({ id: '1', name: 'Widget' })) }) - it('throws an unauthenticated AppError instead of making a request when there is no session', async () => { + it('resolves an unauthenticated AppError instead of making a request when there is no session', async () => { const client = new AuthenticatedHttpClient(baseUrl, noSession) - const error = await client.get('/widgets/1', ignoreBody).catch((thrown: unknown) => thrown) + const error = await expectFailure(client.get('/widgets/1', ignoreBody)) expect(error).toBeInstanceOf(AppError) - expect((error as AppError).code).toBe('unauthenticated') + expect(error.code).toBe('unauthenticated') }) it('notifies the session invalidation notifier when there is no session', async () => { const notifyUnauthenticated = vi.fn() const client = new AuthenticatedHttpClient(baseUrl, noSession, { notifyUnauthenticated }) - await client.get('/widgets/1', ignoreBody).catch(() => undefined) + await client.get('/widgets/1', ignoreBody) expect(notifyUnauthenticated).toHaveBeenCalledTimes(1) }) @@ -142,13 +153,11 @@ describe('AuthenticatedHttpClient', () => { const client = new AuthenticatedHttpClient(baseUrl, withSession('token-123')) - const error = await client - .get('/widgets/missing', ignoreBody) - .catch((thrown: unknown) => thrown) + const error = await expectFailure(client.get('/widgets/missing', ignoreBody)) expect(error).toBeInstanceOf(AppError) - expect((error as AppError).code).toBe('notFound') - expect((error as AppError).message).toBe('Widget not found') + expect(error.code).toBe('notFound') + expect(error.message).toBe('Widget not found') }) it('maps a 400 without a structured errors map to validation, preserving the detail as the message', async () => { @@ -160,13 +169,11 @@ describe('AuthenticatedHttpClient', () => { const client = new AuthenticatedHttpClient(baseUrl, withSession('token-123')) - const error = await client - .get('/widgets/detail-only', ignoreBody) - .catch((thrown: unknown) => thrown) + const error = await expectFailure(client.get('/widgets/detail-only', ignoreBody)) expect(error).toBeInstanceOf(AppError) - expect((error as AppError).code).toBe('validation') - expect((error as AppError).message).toBe('Only a detail here.') + expect(error.code).toBe('validation') + expect(error.message).toBe('Only a detail here.') }) it('maps an unrecognized 5xx status to a curated unexpected AppError, not the raw statusText', async () => { @@ -176,18 +183,14 @@ describe('AuthenticatedHttpClient', () => { const client = new AuthenticatedHttpClient(baseUrl, withSession('token-123')) - const error = await client - .get('/widgets/empty-body', ignoreBody) - .catch((thrown: unknown) => thrown) + const error = await expectFailure(client.get('/widgets/empty-body', ignoreBody)) expect(error).toBeInstanceOf(AppError) - expect((error as AppError).code).toBe('unexpected') + expect(error.code).toBe('unexpected') // The raw "Internal Server Error" statusText must never reach the user // directly - only the curated pt-BR message does. - expect((error as AppError).message).not.toBe('Internal Server Error') - expect((error as AppError).message).toBe( - 'Não foi possível concluir a operação. Tente novamente.', - ) + expect(error.message).not.toBe('Internal Server Error') + expect(error.message).toBe('Não foi possível concluir a operação. Tente novamente.') }) it('maps a 401 response to an unauthenticated AppError', async () => { @@ -195,10 +198,10 @@ describe('AuthenticatedHttpClient', () => { const client = new AuthenticatedHttpClient(baseUrl, withSession('token-123')) - const error = await client.get('/widgets/1', ignoreBody).catch((thrown: unknown) => thrown) + const error = await expectFailure(client.get('/widgets/1', ignoreBody)) expect(error).toBeInstanceOf(AppError) - expect((error as AppError).code).toBe('unauthenticated') + expect(error.code).toBe('unauthenticated') }) it('maps a fetch-level network failure to a network AppError', async () => { @@ -206,11 +209,11 @@ describe('AuthenticatedHttpClient', () => { const client = new AuthenticatedHttpClient(baseUrl, withSession('token-123')) - const error = await client.get('/widgets/1', ignoreBody).catch((thrown: unknown) => thrown) + const error = await expectFailure(client.get('/widgets/1', ignoreBody)) expect(error).toBeInstanceOf(AppError) - expect((error as AppError).code).toBe('network') - expect((error as AppError).retryable).toBe(true) + expect(error.code).toBe('network') + expect(error.retryable).toBe(true) }) it('maps a request-timeout abort to a timeout AppError', async () => { @@ -222,11 +225,11 @@ describe('AuthenticatedHttpClient', () => { const client = new AuthenticatedHttpClient(baseUrl, withSession('token-123')) - const error = await client.get('/widgets/1', ignoreBody).catch((thrown: unknown) => thrown) + const error = await expectFailure(client.get('/widgets/1', ignoreBody)) expect(error).toBeInstanceOf(AppError) - expect((error as AppError).code).toBe('timeout') - expect((error as AppError).retryable).toBe(true) + expect(error.code).toBe('timeout') + expect(error.retryable).toBe(true) fetchSpy.mockRestore() }) @@ -239,7 +242,7 @@ describe('AuthenticatedHttpClient', () => { notifyUnauthenticated, }) - await client.get('/widgets/1', ignoreBody).catch(() => undefined) + await client.get('/widgets/1', ignoreBody) expect(notifyUnauthenticated).toHaveBeenCalledTimes(1) }) @@ -252,7 +255,7 @@ describe('AuthenticatedHttpClient', () => { notifyUnauthenticated, }) - await client.get('/widgets/missing', ignoreBody).catch(() => undefined) + await client.get('/widgets/missing', ignoreBody) expect(notifyUnauthenticated).not.toHaveBeenCalled() }) @@ -262,7 +265,9 @@ describe('AuthenticatedHttpClient', () => { const client = new AuthenticatedHttpClient(baseUrl, withSession('token-123')) - await expect(client.delete('/widgets/1')).resolves.toBeUndefined() + const result = await client.delete('/widgets/1') + + expect(result).toEqual(success(undefined)) }) it('produces a curated AppError, not the raw decode failure, when the decoder rejects the payload', async () => { @@ -274,13 +279,11 @@ describe('AuthenticatedHttpClient', () => { const client = new AuthenticatedHttpClient(baseUrl, withSession('token-123')) - const error = await client - .get('/widgets/malformed', decodeWidget) - .catch((thrown: unknown) => thrown) + const error = await expectFailure(client.get('/widgets/malformed', decodeWidget)) expect(error).toBeInstanceOf(AppError) - expect((error as AppError).code).toBe('unexpected') - expect((error as AppError).message).toBe('Ocorreu um erro inesperado. Tente novamente.') + expect(error.code).toBe('unexpected') + expect(error.message).toBe('Ocorreu um erro inesperado. Tente novamente.') }) it('produces a curated AppError, not a raw SyntaxError, for a 2xx response with an unparsable body', async () => { @@ -290,12 +293,10 @@ describe('AuthenticatedHttpClient', () => { const client = new AuthenticatedHttpClient(baseUrl, withSession('token-123')) - const error = await client - .get('/widgets/not-json', decodeWidget) - .catch((thrown: unknown) => thrown) + const error = await expectFailure(client.get('/widgets/not-json', decodeWidget)) expect(error).toBeInstanceOf(AppError) - expect((error as AppError).code).toBe('unexpected') + expect(error.code).toBe('unexpected') }) it('passes undefined to the decoder for a 204 response on get/post/put, never a bare `undefined as T`', async () => { diff --git a/apps/admin-frontend/src/shared/infrastructure/http/AuthenticatedHttpClient.ts b/apps/admin-frontend/src/shared/infrastructure/http/AuthenticatedHttpClient.ts index 3470196..f459ede 100644 --- a/apps/admin-frontend/src/shared/infrastructure/http/AuthenticatedHttpClient.ts +++ b/apps/admin-frontend/src/shared/infrastructure/http/AuthenticatedHttpClient.ts @@ -1,17 +1,16 @@ import type { Decoder, HttpClient } from '@/shared/application/HttpClient' import type { SessionInvalidationNotifier } from '@/shared/application/SessionEventBus' import type { GetRequestSession } from '@/shared/application/RequestSession' -import { ApiError } from '@/shared/infrastructure/http/ApiError' -import { parseProblemDetails } from '@/shared/infrastructure/http/ProblemDetails' +import type { AppError } from '@/shared/application/AppError' +import { type Result, success, failure } from '@/shared/application/Result' import { UnauthenticatedError } from '@/shared/infrastructure/http/UnauthenticatedError' import { NetworkError } from '@/shared/infrastructure/http/NetworkError' import { TimeoutError } from '@/shared/infrastructure/http/TimeoutError' import { mapErrorToAppError } from '@/shared/infrastructure/http/mapErrorToAppError' +import { parseApiResponse } from '@/shared/infrastructure/http/parseApiResponse' const NOOP_SESSION_NOTIFIER: SessionInvalidationNotifier = { - notifyUnauthenticated: () => { - /* no-op default so tests that don't care about session invalidation don't need to pass one */ - }, + notifyUnauthenticated: () => void 0, } /** The only HttpClient implementation (docs/API.md) - every REST repository depends on the port, not this class. */ @@ -30,31 +29,33 @@ export class AuthenticatedHttpClient implements HttpClient { this.sessionInvalidationNotifier = sessionInvalidationNotifier } - async get(path: string, decode: Decoder): Promise { + async get(path: string, decode: Decoder): Promise> { return this.request(decode, 'GET', path) } - async post(path: string, body: unknown, decode: Decoder): Promise { + async post(path: string, body: unknown, decode: Decoder): Promise> { return this.request(decode, 'POST', path, body) } - async put(path: string, body: unknown, decode: Decoder): Promise { + async put(path: string, body: unknown, decode: Decoder): Promise> { return this.request(decode, 'PUT', path, body) } - async delete(path: string): Promise { - await this.request(() => undefined, 'DELETE', path) + async delete(path: string): Promise> { + return this.request(() => undefined, 'DELETE', path) } - // Every failure path below is converted to AppError by the catch at the - // bottom - callers never see ApiError/UnauthenticatedError/NetworkError/ - // TimeoutError directly (docs/adr/007, docs/adr/011). + // Attaches the bearer token and tenant header, makes the request, and + // reacts to session-level failures (missing session, 401, timeout, + // network). Response-body parsing is delegated to parseApiResponse - + // this class only knows the JWT/session side. Never rejects - every + // failure comes back as Result.failure(AppError) instead. private async request( decode: Decoder, method: string, path: string, body?: unknown, - ): Promise { + ): Promise> { try { const requestSession = await this.getRequestSession() if (requestSession === null) { @@ -94,25 +95,15 @@ export class AuthenticatedHttpClient implements HttpClient { : new NetworkError() } - if (!response.ok) { - if (response.status === 401) { - this.sessionInvalidationNotifier.notifyUnauthenticated() - throw new UnauthenticatedError() - } - const rawPayload: unknown = await response.json().catch(() => null) - const payload = parseProblemDetails(rawPayload) - const message = payload?.title ?? payload?.detail ?? response.statusText - throw new ApiError(response.status, message, payload ?? undefined) - } - - if (response.status === 204) { - return decode(undefined) + if (response.status === 401) { + this.sessionInvalidationNotifier.notifyUnauthenticated() + throw new UnauthenticatedError() } - const rawBody: unknown = await response.json() - return decode(rawBody) + const value = await parseApiResponse(response, decode) + return success(value) } catch (error) { - throw mapErrorToAppError(error) + return failure(mapErrorToAppError(error)) } } } diff --git a/apps/admin-frontend/src/shared/infrastructure/http/malformedResponseError.ts b/apps/admin-frontend/src/shared/infrastructure/http/malformedResponseError.ts new file mode 100644 index 0000000..a2c4387 --- /dev/null +++ b/apps/admin-frontend/src/shared/infrastructure/http/malformedResponseError.ts @@ -0,0 +1,12 @@ +import { AppError } from '@/shared/application/AppError' + +// A domain entity rejecting data that already passed shape validation +// means the backend sent something a client can't reasonably act on - +// not a validation/conflict/notFound outcome the user caused. +export function malformedResponseError(): AppError { + return new AppError({ + code: 'unexpected', + message: 'Não foi possível processar os dados recebidos do servidor.', + retryable: true, + }) +} diff --git a/apps/admin-frontend/src/shared/infrastructure/http/mapErrorToAppError.ts b/apps/admin-frontend/src/shared/infrastructure/http/mapErrorToAppError.ts index a10fe04..a123ffd 100644 --- a/apps/admin-frontend/src/shared/infrastructure/http/mapErrorToAppError.ts +++ b/apps/admin-frontend/src/shared/infrastructure/http/mapErrorToAppError.ts @@ -14,9 +14,9 @@ function flattenFieldErrors(errors: ProblemDetails['errors']): Record = {} for (const [field, fieldErrors] of Object.entries(errors)) { - const message = fieldErrors[0]?.message - if (message !== undefined) { - flattened[field] = message + const messages = fieldErrors.map(fieldError => fieldError.message) + if (messages.length > 0) { + flattened[field] = messages.join(' ') } } diff --git a/apps/admin-frontend/src/shared/infrastructure/http/parseApiResponse.ts b/apps/admin-frontend/src/shared/infrastructure/http/parseApiResponse.ts new file mode 100644 index 0000000..967772c --- /dev/null +++ b/apps/admin-frontend/src/shared/infrastructure/http/parseApiResponse.ts @@ -0,0 +1,33 @@ +import type { Decoder } from '@/shared/application/HttpClient' +import { ApiError } from '@/shared/infrastructure/http/ApiError' +import { parseProblemDetails } from '@/shared/infrastructure/http/ProblemDetails' + +// Every non-204 success response is wrapped in { data, success, timestamp } +// by the backend's shared ResultExtensions.ToActionResult (docs/API.md) - +// unwrap once here so every entity's decoder validates the resource shape +// directly, instead of the envelope around it. +function unwrapData(rawBody: unknown): unknown { + if (typeof rawBody === 'object' && rawBody !== null && 'data' in rawBody) { + return rawBody.data + } + return rawBody +} + +// Everything about interpreting a response body - success envelope, +// ProblemDetails, decode - lives here so AuthenticatedHttpClient only +// has to know how to attach a JWT and make the request. +export async function parseApiResponse(response: Response, decode: Decoder): Promise { + if (!response.ok) { + const rawPayload: unknown = await response.json().catch(() => null) + const payload = parseProblemDetails(rawPayload) + const message = payload?.title ?? payload?.detail ?? response.statusText + throw new ApiError(response.status, message, payload ?? undefined) + } + + if (response.status === 204) { + return decode(undefined) + } + + const rawBody: unknown = await response.json() + return decode(unwrapData(rawBody)) +} diff --git a/apps/admin-frontend/src/shared/presentation/components/CollectionFeedback.tsx b/apps/admin-frontend/src/shared/presentation/components/CollectionFeedback.tsx index bc3dfdd..cb629f9 100644 --- a/apps/admin-frontend/src/shared/presentation/components/CollectionFeedback.tsx +++ b/apps/admin-frontend/src/shared/presentation/components/CollectionFeedback.tsx @@ -6,15 +6,12 @@ import type { UiError } from '@/shared/application/UiError' export interface CollectionFeedbackProps { state: AsyncState loadingMessage: string - /** Shown when the initial load fails and there's nothing to display yet. */ loadErrorMessage: string - /** Shown when a refresh fails but the last known-good list is still visible. */ refreshErrorMessage: string emptyMessage: string onRetry: () => void } -/** Shared loading/error/empty/last-known-good states for a tenant-scoped list - Tags/Categories' reference pattern. */ export function CollectionFeedback({ state, loadingMessage, @@ -34,8 +31,6 @@ export function CollectionFeedback({ ) - // A refresh failing after items were already loaded keeps showing the - // last known-good list instead of a blocking error. case 'refreshError': return ( diff --git a/apps/admin-frontend/src/shared/presentation/components/DeleteConfirmationDialog.tsx b/apps/admin-frontend/src/shared/presentation/components/DeleteConfirmationDialog.tsx index 7e8fd3a..962b947 100644 --- a/apps/admin-frontend/src/shared/presentation/components/DeleteConfirmationDialog.tsx +++ b/apps/admin-frontend/src/shared/presentation/components/DeleteConfirmationDialog.tsx @@ -13,17 +13,21 @@ import { StatusMessage } from '@/shared/presentation/components/StatusMessage' export interface DeleteConfirmationDialogProps { isOpen: boolean - title: string - description: ReactNode + entityName: string + entityType: string + title?: string + description?: ReactNode error: string | null isDeleting: boolean onCancel: () => void onConfirm: () => void } -/** Shared delete AlertDialog behind Tags/Categories/Services - callers own the entity-specific title/description. */ +/** Shared delete AlertDialog behind Tags/Categories/Services - generates default title/description from entity info. */ export function DeleteConfirmationDialog({ isOpen, + entityName, + entityType, title, description, error, @@ -31,6 +35,14 @@ export function DeleteConfirmationDialog({ onCancel, onConfirm, }: DeleteConfirmationDialogProps): JSX.Element { + const entityLabel = entityType.split(' ').pop() ?? entityType + const defaultTitle = `Excluir ${entityLabel}` + const defaultDescription = ( + <> + Tem certeza que deseja excluir {entityType} "{entityName}"? Essa ação não pode ser desfeita. + + ) + return ( - {title} - {description} + {title ?? defaultTitle} + {description ?? defaultDescription} {error !== null && {error}} diff --git a/apps/admin-frontend/src/shared/presentation/components/ErrorBoundary.tsx b/apps/admin-frontend/src/shared/presentation/components/ErrorBoundary.tsx index 772de39..7fc033d 100644 --- a/apps/admin-frontend/src/shared/presentation/components/ErrorBoundary.tsx +++ b/apps/admin-frontend/src/shared/presentation/components/ErrorBoundary.tsx @@ -1,4 +1,4 @@ -import { Component, type ErrorInfo, type ReactNode } from 'react' +import { Component, type ReactNode } from 'react' import { ErrorScreen } from '@/shared/presentation/components/ErrorScreen' import { isChunkLoadError } from '@/shared/presentation/components/isChunkLoadError' @@ -12,7 +12,10 @@ interface ErrorBoundaryState { } // Catches render/lifecycle errors only - not a pre-render crash or errors -// from event handlers/async code. RouteErrorElement covers router-level errors. +// from event handlers/async code (main.tsx's window listeners cover those). +// RouteErrorElement covers router-level errors. Reporting for everything +// this boundary catches happens once, centrally, via createRoot's +// onCaughtError in main.tsx - not duplicated here in componentDidCatch. export class ErrorBoundary extends Component { override state: ErrorBoundaryState = { hasError: false, isChunkLoadError: false } @@ -20,10 +23,6 @@ export class ErrorBoundary extends Component { if (this.state.isChunkLoadError) { // A stale chunk reference can't recover by re-rendering the same diff --git a/apps/admin-frontend/src/shared/presentation/hooks/useCreateInline.test.ts b/apps/admin-frontend/src/shared/presentation/hooks/useCreateInline.test.ts deleted file mode 100644 index 2a24c81..0000000 --- a/apps/admin-frontend/src/shared/presentation/hooks/useCreateInline.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { renderHook, waitFor, act } from '@testing-library/react' -import { useCreateInline } from '@/shared/presentation/hooks/useCreateInline' - -type Field = 'name' -const FIELD_MAP: Record = { Name: 'name' } -const CODE_FIELD_MAP: Record = {} -const FALLBACK_MESSAGE = 'Não foi possível criar o registro.' - -describe('useCreateInline', () => { - it('calls onCreated with the created item on success', async () => { - const createdItem = { id: '1', name: 'VIP' } - const createFn = vi.fn(() => Promise.resolve(createdItem)) - const onCreated = vi.fn() - const { result } = renderHook(() => - useCreateInline(createFn, FIELD_MAP, CODE_FIELD_MAP, FALLBACK_MESSAGE), - ) - - await act(async () => { - await result.current.create({ name: 'VIP' }, onCreated) - }) - - expect(onCreated).toHaveBeenCalledExactlyOnceWith(createdItem) - expect(result.current.isCreating).toBe(false) - expect(result.current.serverError).toBeNull() - }) - - it('tracks isCreating true while the request is pending', async () => { - let resolveCreate: ((item: { id: string; name: string }) => void) | undefined - const createFn = vi.fn( - () => - new Promise<{ id: string; name: string }>(resolve => { - resolveCreate = resolve - }), - ) - const { result } = renderHook(() => - useCreateInline(createFn, FIELD_MAP, CODE_FIELD_MAP, FALLBACK_MESSAGE), - ) - - let createPromise: Promise - act(() => { - createPromise = result.current.create({ name: 'VIP' }, vi.fn()) - }) - - await waitFor(() => { - expect(result.current.isCreating).toBe(true) - }) - - await act(async () => { - resolveCreate?.({ id: '1', name: 'VIP' }) - await createPromise - }) - - expect(result.current.isCreating).toBe(false) - }) - - it('sets a server error on failure without calling onCreated', async () => { - const createFn = vi.fn(() => Promise.reject(new Error('network down'))) - const onCreated = vi.fn() - const { result } = renderHook(() => - useCreateInline(createFn, FIELD_MAP, CODE_FIELD_MAP, FALLBACK_MESSAGE), - ) - - await act(async () => { - await result.current.create({ name: 'VIP' }, onCreated) - }) - - expect(onCreated).not.toHaveBeenCalled() - expect(result.current.isCreating).toBe(false) - expect(result.current.serverError?.globalMessage).toBe('network down') - }) - - it('reset() clears a server error and isCreating immediately', async () => { - const createFn = vi.fn(() => Promise.reject(new Error('boom'))) - const { result } = renderHook(() => - useCreateInline(createFn, FIELD_MAP, CODE_FIELD_MAP, FALLBACK_MESSAGE), - ) - - await act(async () => { - await result.current.create({ name: 'VIP' }, vi.fn()) - }) - expect(result.current.serverError).not.toBeNull() - - act(() => { - result.current.reset() - }) - - expect(result.current.serverError).toBeNull() - expect(result.current.isCreating).toBe(false) - }) - - it('does not call onCreated when the user cancels (reset) while the create is still pending', async () => { - let resolveCreate: ((item: { id: string; name: string }) => void) | undefined - const createFn = vi.fn( - () => - new Promise<{ id: string; name: string }>(resolve => { - resolveCreate = resolve - }), - ) - const onCreated = vi.fn() - const { result } = renderHook(() => - useCreateInline(createFn, FIELD_MAP, CODE_FIELD_MAP, FALLBACK_MESSAGE), - ) - - let createPromise: Promise - act(() => { - // Mirrors ServiceForm: the inline form's own "Cancelar" button is not - // disabled while isCreating is true, so a user can click it mid-request. - createPromise = result.current.create({ name: 'VIP' }, onCreated) - }) - await waitFor(() => { - expect(result.current.isCreating).toBe(true) - }) - - act(() => { - result.current.reset() - }) - expect(result.current.isCreating).toBe(false) - - await act(async () => { - resolveCreate?.({ id: '1', name: 'VIP' }) - await createPromise - }) - - expect(onCreated).not.toHaveBeenCalled() - // The stale resolution must not resurrect the "creating" state the user - // already canceled out of, nor silently apply a delayed success. - expect(result.current.isCreating).toBe(false) - }) - - it('does not set a server error from a request that failed after the user already canceled it', async () => { - let rejectCreate: ((error: Error) => void) | undefined - const createFn = vi.fn( - () => - new Promise<{ id: string; name: string }>((_resolve, reject) => { - rejectCreate = reject - }), - ) - const { result } = renderHook(() => - useCreateInline(createFn, FIELD_MAP, CODE_FIELD_MAP, FALLBACK_MESSAGE), - ) - - let createPromise: Promise - act(() => { - createPromise = result.current.create({ name: 'VIP' }, vi.fn()) - }) - await waitFor(() => { - expect(result.current.isCreating).toBe(true) - }) - - act(() => { - result.current.reset() - }) - - await act(async () => { - rejectCreate?.(new Error('too late')) - await createPromise - }) - - expect(result.current.serverError).toBeNull() - }) -}) diff --git a/apps/admin-frontend/src/shared/presentation/hooks/useCreateInline.ts b/apps/admin-frontend/src/shared/presentation/hooks/useCreateInline.ts deleted file mode 100644 index 4ae0020..0000000 --- a/apps/admin-frontend/src/shared/presentation/hooks/useCreateInline.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react' -import { - mapApiErrorToForm, - type ServerFormError, -} from '@/shared/presentation/forms/serverFormError' - -interface UseCreateInlineResult { - isCreating: boolean - serverError: ServerFormError | null - create: (input: TInput, onCreated: (item: TItem) => void) => Promise - reset: () => void - /** Ref-backed, synchronous read of isCreating - unlike the isCreating boolean above, has zero lag against a same-tick DOM event (e.g. a disabled-button blur) fired before React re-renders. */ - isCreatingNow: () => boolean -} - -// Shared state machine behind every inline "create without leaving this -// form" flow - keeps the outer form untouched and the popover open on error, -// instead of duplicating this per entity. -export function useCreateInline( - createFn: (input: TInput) => Promise, - fieldMap: Record, - codeFieldMap: Record, - fallbackMessage: string, -): UseCreateInlineResult { - const [isCreating, setIsCreatingState] = useState(false) - const [serverError, setServerError] = useState | null>(null) - // Guards unmount AND a "Cancelar" click while createFn is still in flight - - // reset() bumps the generation so a stale create() skips onCreated/serverError. - const isMountedRef = useRef(true) - const generationRef = useRef(0) - const isCreatingRef = useRef(false) - - const setIsCreating = useCallback((value: boolean): void => { - isCreatingRef.current = value - setIsCreatingState(value) - }, []) - - useEffect(() => { - isMountedRef.current = true - return () => { - isMountedRef.current = false - } - }, []) - - const create = useCallback( - async (input: TInput, onCreated: (item: TItem) => void): Promise => { - const generation = generationRef.current - const isStillWanted = (): boolean => - isMountedRef.current && generation === generationRef.current - - setIsCreating(true) - setServerError(null) - try { - const item = await createFn(input) - if (isStillWanted()) { - onCreated(item) - } - } catch (caughtError) { - if (isStillWanted()) { - setServerError(mapApiErrorToForm(caughtError, fieldMap, codeFieldMap, fallbackMessage)) - } - } finally { - if (isStillWanted()) { - setIsCreating(false) - } - } - }, - [createFn, fieldMap, codeFieldMap, fallbackMessage, setIsCreating], - ) - - const reset = useCallback((): void => { - generationRef.current += 1 - setServerError(null) - setIsCreating(false) - }, [setIsCreating]) - - const isCreatingNow = useCallback((): boolean => isCreatingRef.current, []) - - return { isCreating, serverError, create, reset, isCreatingNow } -} diff --git a/apps/admin-frontend/src/shared/presentation/hooks/useDeleteConfirmation.ts b/apps/admin-frontend/src/shared/presentation/hooks/useDeleteConfirmation.ts index e8655d1..3f21d9c 100644 --- a/apps/admin-frontend/src/shared/presentation/hooks/useDeleteConfirmation.ts +++ b/apps/admin-frontend/src/shared/presentation/hooks/useDeleteConfirmation.ts @@ -1,8 +1,10 @@ import { useCallback, useEffect, useRef, useState } from 'react' +import type { AppError } from '@/shared/application/AppError' +import type { Result } from '@/shared/application/Result' +import { toUiError } from '@/shared/application/UiError' interface UseDeleteConfirmationParams { - onDelete: (item: T) => Promise - fallbackMessage: string + onDelete: (item: T) => Promise> } export interface UseDeleteConfirmationResult { @@ -17,7 +19,6 @@ export interface UseDeleteConfirmationResult { /** Shared target/progress/error state behind every delete-with-confirm flow (Tags/Categories/Services). */ export function useDeleteConfirmation({ onDelete, - fallbackMessage, }: UseDeleteConfirmationParams): UseDeleteConfirmationResult { const [target, setTarget] = useState(null) const [error, setError] = useState(null) @@ -55,21 +56,18 @@ export function useDeleteConfirmation({ setIsDeleting(true) setError(null) - try { - await onDelete(target) + const result = await onDelete(target) + if (result.success) { if (isStillWanted()) { setTarget(null) } - } catch (caughtError) { - if (isStillWanted()) { - setError(caughtError instanceof Error ? caughtError.message : fallbackMessage) - } - } finally { - if (isStillWanted()) { - setIsDeleting(false) - } + } else if (isStillWanted()) { + setError(toUiError(result.error).message) + } + if (isStillWanted()) { + setIsDeleting(false) } - }, [target, isDeleting, onDelete, fallbackMessage]) + }, [target, isDeleting, onDelete]) return { target, error, isDeleting, onRequestDelete, onCancel, onConfirm } } diff --git a/apps/admin-frontend/src/shared/presentation/hooks/useDialogTarget.ts b/apps/admin-frontend/src/shared/presentation/hooks/useDialogTarget.ts index 6113776..5d2306b 100644 --- a/apps/admin-frontend/src/shared/presentation/hooks/useDialogTarget.ts +++ b/apps/admin-frontend/src/shared/presentation/hooks/useDialogTarget.ts @@ -12,7 +12,7 @@ export interface UseDialogTargetResult { close: () => void } -/** Shared "which record (if any) opened the create/edit dialog" state - TagsPage/CategoriesPage's reference pattern. */ +/** Shared "which record (if any) opened the create/edit dialog" state. */ export function useDialogTarget(): UseDialogTargetResult { const [formTarget, setFormTarget] = useState | null>(null) const [displayTarget, setDisplayTarget] = useState | null>(null) diff --git a/apps/admin-frontend/src/test/fixtures/createFakeAppContainer.ts b/apps/admin-frontend/src/test/fixtures/createFakeAppContainer.ts index 2b1284e..775094e 100644 --- a/apps/admin-frontend/src/test/fixtures/createFakeAppContainer.ts +++ b/apps/admin-frontend/src/test/fixtures/createFakeAppContainer.ts @@ -1,15 +1,35 @@ import { vi } from 'vitest' import type { AppContainer, AuthFacade, CatalogFacade } from '@/app/composition/container' import { createFakeSessionEventBus } from '@/test/fixtures/fakeSessionEventBus' -import { success } from '@/shared/application/Result' +import { AppError } from '@/shared/application/AppError' +import { AuthFlowError } from '@/features/auth/application/errors/AuthFlowError' +import { failure, success, type Result } from '@/shared/application/Result' -const NOT_USED_IN_THIS_FAKE = (): Promise => - Promise.reject(new Error('not used in this fake')) +// auth/catalog execute() never rejects (it returns Result), so each "should +// not be called" default must resolve to a failure - a raw rejection here +// would surface as an unhandled promise rejection instead of a normal +// error state. +const AUTH_NOT_USED_ERROR = new AuthFlowError({ + code: 'unexpected', + flowCode: 'AUTH_LOGIN_FAILED', + message: 'not used in this fake', + retryable: false, +}) +const AUTH_NOT_USED_IN_THIS_FAKE = (): Promise> => + Promise.resolve(failure(AUTH_NOT_USED_ERROR)) + +const CATALOG_NOT_USED_ERROR = new AppError({ + code: 'unexpected', + message: 'not used in this fake', + retryable: false, +}) +const CATALOG_NOT_USED_IN_THIS_FAKE = (): Promise> => + Promise.resolve(failure(CATALOG_NOT_USED_ERROR)) function defaultAuthFacade(): AuthFacade { return { initiateLogin: { execute: vi.fn(() => Promise.resolve(success(undefined))) }, - handleAuthCallback: { execute: vi.fn(NOT_USED_IN_THIS_FAKE) }, + handleAuthCallback: { execute: vi.fn(AUTH_NOT_USED_IN_THIS_FAKE) }, getCurrentSession: { execute: vi.fn(() => Promise.resolve(null)) }, logout: { execute: vi.fn(() => Promise.resolve(success(undefined))) }, sessionEvents: createFakeSessionEventBus(), @@ -18,20 +38,15 @@ function defaultAuthFacade(): AuthFacade { function defaultCatalogFacade(): CatalogFacade { return { - listTags: { execute: vi.fn(() => Promise.resolve([])) }, - createTag: { execute: vi.fn(NOT_USED_IN_THIS_FAKE) }, - updateTag: { execute: vi.fn(NOT_USED_IN_THIS_FAKE) }, - deleteTag: { execute: vi.fn(NOT_USED_IN_THIS_FAKE) }, - listCategories: { execute: vi.fn(() => Promise.resolve([])) }, - createCategory: { execute: vi.fn(NOT_USED_IN_THIS_FAKE) }, - updateCategory: { execute: vi.fn(NOT_USED_IN_THIS_FAKE) }, - deleteCategory: { execute: vi.fn(NOT_USED_IN_THIS_FAKE) }, - listServices: { - execute: vi.fn(() => Promise.resolve({ services: [], totalCount: 0, page: 1, pageSize: 20 })), - }, - createService: { execute: vi.fn(NOT_USED_IN_THIS_FAKE) }, - updateService: { execute: vi.fn(NOT_USED_IN_THIS_FAKE) }, - deleteService: { execute: vi.fn(NOT_USED_IN_THIS_FAKE) }, + listTags: { execute: vi.fn(() => Promise.resolve(success([]))) }, + createTag: { execute: vi.fn(CATALOG_NOT_USED_IN_THIS_FAKE) }, + updateTag: { execute: vi.fn(CATALOG_NOT_USED_IN_THIS_FAKE) }, + deleteTag: { execute: vi.fn(CATALOG_NOT_USED_IN_THIS_FAKE) }, + listCategories: { execute: vi.fn(() => Promise.resolve(success([]))) }, + getCategory: { execute: vi.fn(CATALOG_NOT_USED_IN_THIS_FAKE) }, + createCategory: { execute: vi.fn(CATALOG_NOT_USED_IN_THIS_FAKE) }, + updateCategory: { execute: vi.fn(CATALOG_NOT_USED_IN_THIS_FAKE) }, + deleteCategory: { execute: vi.fn(CATALOG_NOT_USED_IN_THIS_FAKE) }, } } diff --git a/apps/admin-frontend/src/test/mocks/handlers/categoryHandlers.ts b/apps/admin-frontend/src/test/mocks/handlers/categoryHandlers.ts index 2702edd..b81e73e 100644 --- a/apps/admin-frontend/src/test/mocks/handlers/categoryHandlers.ts +++ b/apps/admin-frontend/src/test/mocks/handlers/categoryHandlers.ts @@ -19,6 +19,8 @@ export const categoryHandlers = [ }), ), + http.get(`${API_BASE_URL}/api/v1/categories/:id`, () => HttpResponse.json(categoryFixture)), + http.put(`${API_BASE_URL}/api/v1/categories/:id`, () => HttpResponse.json(categoryFixture)), http.delete( diff --git a/apps/admin-frontend/src/test/mocks/handlers/index.ts b/apps/admin-frontend/src/test/mocks/handlers/index.ts index 10f82e0..47cd29d 100644 --- a/apps/admin-frontend/src/test/mocks/handlers/index.ts +++ b/apps/admin-frontend/src/test/mocks/handlers/index.ts @@ -1,8 +1,7 @@ import type { RequestHandler } from 'msw' import { tagHandlers } from '@/test/mocks/handlers/tagHandlers' import { categoryHandlers } from '@/test/mocks/handlers/categoryHandlers' -import { serviceHandlers } from '@/test/mocks/handlers/serviceHandlers' // Handlers are added incrementally, one resource at a time, as each // infrastructure-layer repository is built. -export const handlers: RequestHandler[] = [...tagHandlers, ...categoryHandlers, ...serviceHandlers] +export const handlers: RequestHandler[] = [...tagHandlers, ...categoryHandlers] diff --git a/apps/admin-frontend/src/test/mocks/handlers/serviceHandlers.ts b/apps/admin-frontend/src/test/mocks/handlers/serviceHandlers.ts deleted file mode 100644 index bc7f030..0000000 --- a/apps/admin-frontend/src/test/mocks/handlers/serviceHandlers.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { http, HttpResponse } from 'msw' -import type { ServiceDto } from '@/features/catalog/infrastructure/mappers/serviceMapper' - -const API_BASE_URL = 'https://api.test' - -export const serviceFixture: ServiceDto = { - id: 'service-1', - code: 1001, - name: 'Massagem relaxante', - description: 'Uma massagem relaxante de corpo inteiro', - durationMinutes: 60, - minDurationMinutes: 30, - maxDurationMinutes: 90, - price: 150, - maxDiscountPercentage: 10, - categoryId: 'category-1', - categoryName: 'Massagens', - tags: [{ id: 'tag-1', name: 'VIP', color: '#0d9488' }], -} - -/** Backing dataset for the paginated GET handler below - a single entry by default. */ -export const serviceFixtures: ServiceDto[] = [serviceFixture] - -const DEFAULT_PAGE = 1 -const DEFAULT_PAGE_SIZE = 20 - -/** Default happy-path handlers for /api/v1/services - override per-test with server.use(). */ -export const serviceHandlers = [ - http.get(`${API_BASE_URL}/api/v1/services`, ({ request }) => { - const url = new URL(request.url) - const page = Number(url.searchParams.get('page') ?? DEFAULT_PAGE) - const pageSize = Number(url.searchParams.get('pageSize') ?? DEFAULT_PAGE_SIZE) - const start = (page - 1) * pageSize - const items = serviceFixtures.slice(start, start + pageSize) - return HttpResponse.json({ - items, - totalCount: serviceFixtures.length, - page, - pageSize, - }) - }), - - http.post(`${API_BASE_URL}/api/v1/services`, () => - HttpResponse.json(serviceFixture, { - status: 201, - headers: { Location: `/api/v1/services/${serviceFixture.id}` }, - }), - ), - - http.put(`${API_BASE_URL}/api/v1/services/:id`, () => HttpResponse.json(serviceFixture)), - - http.delete(`${API_BASE_URL}/api/v1/services/:id`, () => new HttpResponse(null, { status: 204 })), -] diff --git a/scripts/tests/test_architecture_guard.py b/scripts/tests/test_architecture_guard.py index b911d10..a6faeb8 100644 --- a/scripts/tests/test_architecture_guard.py +++ b/scripts/tests/test_architecture_guard.py @@ -997,6 +997,7 @@ def test_run_all_on_clean_repo_has_no_blocking_findings(self) -> None: "infra/postgres/init/001-service-roles.sh", ': "${APP_DB_PASSWORD:?APP_DB_PASSWORD is required}"\n', ) + findings = ag.run_all() blocking = [f for f in findings if f.severity == "blocking"]