From ae0c8b71f16aafbaaea2bb9cd0bf1038ed12f5f7 Mon Sep 17 00:00:00 2001 From: Everton William Thoele Schuster Date: Sun, 2 Aug 2026 12:04:58 -0300 Subject: [PATCH 1/2] Migrate Catalog (Categories/Services/Tags) to Result errors; remove Services vertical; physical reorg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split out of #69 (part 3 of 4 — see that PR for the full picture). Recreated from origin/main after #70 and #71 merged, since this repo's convention (and the split-large-coderabbit-pr skill) is a sequential series, not stacking on an unmerged branch — CodeRabbit also doesn't review PRs whose base isn't the default branch, so stacking silently skipped review for this and the next PR in the series. Same content as the original #72, just re-based; no functional change. This PR is larger than the <100-file target used for the other PRs in this series, deliberately — see "Why this couldn't be split further" below. - Categories, Services, and Tags all move to the Result-based error convention docs/adr/014 establishes: domain entities' create() methods, mappers, and API repositories return Result instead of throwing; useAsync.ts (already Result-based, landed in #71) is the one hook every feature's data layer builds on now. - app/composition/container.ts's CatalogFacade drops the use-case-class indirection (ListCategories/CreateTag/etc. as separate classes) for direct repository delegation (`{ execute: repo.method }`) - there's no orchestration between the facade and the repository, so the extra class per operation wasn't earning its keep. The 24 now-orphaned use-case-class files (application/use-cases/{categories,services,tags}/) are deleted. - Services' frontend implementation (ServicesPage, ServiceForm, six ServicesPage.*.test.tsx files, all its components/hooks/models) is fully removed, reverting `/services` to a placeholder page (app/pages/ServicesPage/ServicesPage.tsx) - this vertical is going back to `stub` status, see docs/STATUS.md. - Categories moves to a routed create/edit dialog (features/catalog/presentation/categories/pages/CategoriesListPage/, .../CategoryEditorDialog/) per docs/adr/012, replacing the old flat CategoriesPage.tsx/useCategories.ts/CategoryEditorDialog.tsx shape. - Tags gets the equivalent internal move (hooks/useTagEditor.ts, pages/TagEditorDialog.tsx) and its own Result migration (Tag.ts/tagMapper.ts/ApiTagRepository.ts) - Tags itself is not being removed here, just migrated; its removal is a later PR in this stack (docs/adr/016). - shared/: AuthenticatedHttpClient's get/post/put/delete now return Result instead of throwing; DeleteConfirmationDialog takes entityName/entityType instead of a raw title/description pair; useCreateInline is removed (no longer used once Services - its only consumer - is gone). - Also removes .husky/pre-commit and fixes architecture_guard.py's precommit check accordingly (already merged independently via #70; included here too since this branch's own ancestry needed it before #70 existed). ## Why this couldn't be split further I initially tried a narrower "Category-only foundation" PR (~99 files) deferring Services/Tags. That failed a real build: app/composition/ container.ts wires TagRepository with its *new* method signature directly (tagRepository.listAll(options) instead of the old (tenantContext, options) two-arg form) - not just a return-type change useAsync-style, but the interface itself. Making that build without also migrating TagRepository/ApiTagRepository/tagMapper/Tag.ts for real isn't a smaller wrapper shim - it's the same size of work as just finishing the migration, since there's no reduced version of an interface signature. Categories, Services, and Tags share container.ts's catalog wiring, router.tsx, and AuthenticatedHttpClient tightly enough that they're one atomic, verified-buildable unit at this layer - mirroring why Auth couldn't be split from Catalog either, just one layer down. ## Test plan - [x] `npm install` + `npm run build --workspace=apps/admin-frontend` — green - [x] `npm run lint --workspace=apps/admin-frontend` — clean, 0 warnings - [x] `npm run format:check --workspace=apps/admin-frontend` — clean - [x] `npm run test --workspace=apps/admin-frontend` — 368/368 passing - [x] `scripts/sync_agent_skills.py --check`, `scripts/check_agent_governance.py`, `scripts/architecture_guard.py` — all pass Co-Authored-By: Claude Sonnet 5 --- apps/admin-frontend/.agent.md | 113 ++++++ apps/admin-frontend/AGENTS.md | 9 +- apps/admin-frontend/docs/API.md | 19 +- apps/admin-frontend/docs/DECISIONS.md | 31 +- apps/admin-frontend/docs/STATUS.md | 26 +- .../docs/adr/001-clean-architecture-layers.md | 2 +- .../docs/adr/012-routed-category-editor.md | 66 ++++ .../adr/013-category-editor-fetches-by-id.md | 64 ++++ ...14-catalog-result-errors-and-global-net.md | 116 ++++++ .../docs/adr/015-auth-result-errors.md | 86 +++++ .../e2e/categories-mobile.spec.ts | 95 +++++ .../src/app/composition/container.test.ts | 19 +- .../src/app/composition/container.ts | 62 ++-- .../src/app/layouts/AdminLayout.test.tsx | 2 +- apps/admin-frontend/src/app/main.tsx | 35 +- .../app/pages/ServicesPage/ServicesPage.tsx | 7 + apps/admin-frontend/src/app/routes/router.tsx | 40 +- .../repositories/CategoryRepository.ts | 12 +- .../repositories/ServiceRepository.ts | 49 --- .../application/repositories/TagRepository.ts | 11 +- .../createFakeCategoryRepository.ts | 17 +- .../createFakeServiceRepository.ts | 13 - .../test-helpers/createFakeTagRepository.ts | 16 +- .../categories/CreateCategory.test.ts | 27 -- .../use-cases/categories/CreateCategory.ts | 18 - .../categories/DeleteCategory.test.ts | 23 -- .../use-cases/categories/DeleteCategory.ts | 14 - .../categories/ListCategories.test.ts | 26 -- .../use-cases/categories/ListCategories.ts | 18 - .../categories/UpdateCategory.test.ts | 31 -- .../use-cases/categories/UpdateCategory.ts | 18 - .../use-cases/services/CreateService.test.ts | 45 --- .../use-cases/services/CreateService.ts | 18 - .../use-cases/services/DeleteService.test.ts | 23 -- .../use-cases/services/DeleteService.ts | 14 - .../use-cases/services/ListServices.test.ts | 44 --- .../use-cases/services/ListServices.ts | 18 - .../use-cases/services/UpdateService.test.ts | 49 --- .../use-cases/services/UpdateService.ts | 18 - .../use-cases/tags/CreateTag.test.ts | 27 -- .../application/use-cases/tags/CreateTag.ts | 18 - .../use-cases/tags/DeleteTag.test.ts | 23 -- .../application/use-cases/tags/DeleteTag.ts | 14 - .../use-cases/tags/ListTags.test.ts | 26 -- .../application/use-cases/tags/ListTags.ts | 18 - .../use-cases/tags/UpdateTag.test.ts | 27 -- .../application/use-cases/tags/UpdateTag.ts | 18 - .../catalog/domain/entities/Category.test.ts | 38 +- .../catalog/domain/entities/Category.ts | 11 +- .../catalog/domain/entities/Service.test.ts | 200 ---------- .../catalog/domain/entities/Service.ts | 156 -------- .../catalog/domain/entities/Tag.test.ts | 62 ++-- .../features/catalog/domain/entities/Tag.ts | 21 +- .../domain/errors/InvalidServiceError.ts | 3 - .../src/features/catalog/index.ts | 25 +- .../mappers/categoryMapper.test.ts | 19 +- .../infrastructure/mappers/categoryMapper.ts | 8 +- .../mappers/serviceMapper.test.ts | 149 -------- .../infrastructure/mappers/serviceMapper.ts | 138 ------- .../infrastructure/mappers/tagMapper.test.ts | 39 +- .../infrastructure/mappers/tagMapper.ts | 8 +- .../ApiCategoryRepository.test.ts | 75 ++-- .../repositories/ApiCategoryRepository.ts | 42 +-- .../repositories/ApiServiceRepository.test.ts | 205 ---------- .../repositories/ApiServiceRepository.ts | 105 ------ .../repositories/ApiTagRepository.test.ts | 44 +-- .../repositories/ApiTagRepository.ts | 29 +- .../categories/CategoriesPage.test.tsx | 349 ------------------ .../categories/CategoriesPage.tsx | 58 --- .../components/CategoryDeleteDialog.tsx | 36 -- .../components/CategoryEditorDialog.tsx | 63 ---- .../categories/hooks/useCategories.test.tsx | 206 ----------- .../categories/hooks/useCategories.ts | 103 ------ .../categories/hooks/useCategoriesPage.ts | 170 --------- .../CategoriesListPage/CategoriesListPage.tsx | 75 ++++ .../components/CategoriesTable.tsx | 42 +-- .../components/CategoriesTable.types.ts | 12 + .../hooks/useCategoriesListPage.ts | 65 ++++ .../hooks/useCategoriesListPage.types.ts | 17 + .../hooks/useCategoryDeletion.ts | 14 + .../hooks/useCategoryDeletion.types.ts | 16 + .../pages/CategoriesRoutes.test.tsx | 318 ++++++++++++++++ .../CategoryEditorDialog.tsx | 63 ++++ .../forms/CategoryForm.tsx | 25 +- .../forms/CategoryForm.types.ts | 20 + .../forms/categoryFieldMaps.ts | 2 +- .../hooks/useCategoryEditor.ts | 110 ++++++ .../hooks/useCategoryEditor.types.ts | 22 ++ ...icesPage.accessibilityAndSecurity.test.tsx | 87 ----- .../services/ServicesPage.crud.test.tsx | 303 --------------- .../ServicesPage.dialogLifecycle.test.tsx | 120 ------ .../ServicesPage.formValidation.test.tsx | 111 ------ .../ServicesPage.listBehavior.test.tsx | 153 -------- .../services/ServicesPage.testSupport.tsx | 78 ---- .../presentation/services/ServicesPage.tsx | 32 -- .../components/ServiceBasicFields.tsx | 44 --- .../components/ServiceCategoryField.tsx | 94 ----- .../components/ServiceCommercialFields.tsx | 34 -- .../components/ServiceDeleteDialog.tsx | 36 -- .../services/components/ServiceDialog.tsx | 97 ----- .../components/ServiceDurationFields.tsx | 40 -- .../services/components/ServiceTableRow.tsx | 69 ---- .../services/components/ServiceTagsField.tsx | 100 ----- .../services/components/ServicesFilters.tsx | 81 ---- .../services/components/ServicesList.tsx | 57 --- .../components/ServicesPagination.tsx | 44 --- .../services/components/ServicesTable.tsx | 43 --- .../services/forms/ServiceForm.schema.test.ts | 134 ------- .../services/forms/ServiceForm.schema.ts | 147 -------- .../services/forms/ServiceForm.tsx | 120 ------ .../services/forms/serviceFieldMaps.ts | 25 -- .../hooks/useServiceDeletion.test.tsx | 92 ----- .../services/hooks/useServiceDeletion.ts | 52 --- .../services/hooks/useServiceEditor.test.tsx | 216 ----------- .../services/hooks/useServiceEditor.ts | 157 -------- .../services/hooks/useServiceFilters.test.tsx | 84 ----- .../services/hooks/useServiceFilters.ts | 31 -- .../services/hooks/useServices.test.tsx | 281 -------------- .../services/hooks/useServices.ts | 166 --------- .../services/hooks/useServicesPage.ts | 146 -------- .../services/models/serviceFormatters.ts | 93 ----- .../models/servicePresentationModels.ts | 65 ---- .../presentation/tags/TagsPage.test.tsx | 111 ++++-- .../catalog/presentation/tags/TagsPage.tsx | 83 +++-- .../tags/components/TagDeleteDialog.tsx | 36 -- .../tags/components/TagEditorDialog.tsx | 57 --- .../presentation/tags/hooks/useTagEditor.ts | 124 +++++++ .../presentation/tags/hooks/useTags.test.tsx | 66 ++-- .../presentation/tags/hooks/useTags.ts | 78 ++-- .../presentation/tags/hooks/useTagsPage.ts | 131 +------ .../tags/pages/TagEditorDialog.tsx | 63 ++++ .../src/shared/application/HttpClient.ts | 16 +- .../http/AuthenticatedHttpClient.test.ts | 89 ++--- .../http/AuthenticatedHttpClient.ts | 51 ++- .../http/malformedResponseError.ts | 12 + .../infrastructure/http/mapErrorToAppError.ts | 6 +- .../infrastructure/http/parseApiResponse.ts | 33 ++ .../components/CollectionFeedback.tsx | 5 - .../components/DeleteConfirmationDialog.tsx | 22 +- .../presentation/components/ErrorBoundary.tsx | 11 +- .../hooks/useCreateInline.test.ts | 162 -------- .../presentation/hooks/useCreateInline.ts | 80 ---- .../hooks/useDeleteConfirmation.ts | 26 +- .../presentation/hooks/useDialogTarget.ts | 2 +- .../test/fixtures/createFakeAppContainer.ts | 51 ++- .../test/mocks/handlers/categoryHandlers.ts | 2 + .../src/test/mocks/handlers/index.ts | 3 +- .../test/mocks/handlers/serviceHandlers.ts | 53 --- scripts/tests/test_architecture_guard.py | 1 + 149 files changed, 2333 insertions(+), 7121 deletions(-) create mode 100644 apps/admin-frontend/.agent.md create mode 100644 apps/admin-frontend/docs/adr/012-routed-category-editor.md create mode 100644 apps/admin-frontend/docs/adr/013-category-editor-fetches-by-id.md create mode 100644 apps/admin-frontend/docs/adr/014-catalog-result-errors-and-global-net.md create mode 100644 apps/admin-frontend/docs/adr/015-auth-result-errors.md create mode 100644 apps/admin-frontend/e2e/categories-mobile.spec.ts create mode 100644 apps/admin-frontend/src/app/pages/ServicesPage/ServicesPage.tsx delete mode 100644 apps/admin-frontend/src/features/catalog/application/repositories/ServiceRepository.ts delete mode 100644 apps/admin-frontend/src/features/catalog/application/test-helpers/createFakeServiceRepository.ts delete mode 100644 apps/admin-frontend/src/features/catalog/application/use-cases/categories/CreateCategory.test.ts delete mode 100644 apps/admin-frontend/src/features/catalog/application/use-cases/categories/CreateCategory.ts delete mode 100644 apps/admin-frontend/src/features/catalog/application/use-cases/categories/DeleteCategory.test.ts delete mode 100644 apps/admin-frontend/src/features/catalog/application/use-cases/categories/DeleteCategory.ts delete mode 100644 apps/admin-frontend/src/features/catalog/application/use-cases/categories/ListCategories.test.ts delete mode 100644 apps/admin-frontend/src/features/catalog/application/use-cases/categories/ListCategories.ts delete mode 100644 apps/admin-frontend/src/features/catalog/application/use-cases/categories/UpdateCategory.test.ts delete mode 100644 apps/admin-frontend/src/features/catalog/application/use-cases/categories/UpdateCategory.ts delete mode 100644 apps/admin-frontend/src/features/catalog/application/use-cases/services/CreateService.test.ts delete mode 100644 apps/admin-frontend/src/features/catalog/application/use-cases/services/CreateService.ts delete mode 100644 apps/admin-frontend/src/features/catalog/application/use-cases/services/DeleteService.test.ts delete mode 100644 apps/admin-frontend/src/features/catalog/application/use-cases/services/DeleteService.ts delete mode 100644 apps/admin-frontend/src/features/catalog/application/use-cases/services/ListServices.test.ts delete mode 100644 apps/admin-frontend/src/features/catalog/application/use-cases/services/ListServices.ts delete mode 100644 apps/admin-frontend/src/features/catalog/application/use-cases/services/UpdateService.test.ts delete mode 100644 apps/admin-frontend/src/features/catalog/application/use-cases/services/UpdateService.ts delete mode 100644 apps/admin-frontend/src/features/catalog/application/use-cases/tags/CreateTag.test.ts delete mode 100644 apps/admin-frontend/src/features/catalog/application/use-cases/tags/CreateTag.ts delete mode 100644 apps/admin-frontend/src/features/catalog/application/use-cases/tags/DeleteTag.test.ts delete mode 100644 apps/admin-frontend/src/features/catalog/application/use-cases/tags/DeleteTag.ts delete mode 100644 apps/admin-frontend/src/features/catalog/application/use-cases/tags/ListTags.test.ts delete mode 100644 apps/admin-frontend/src/features/catalog/application/use-cases/tags/ListTags.ts delete mode 100644 apps/admin-frontend/src/features/catalog/application/use-cases/tags/UpdateTag.test.ts delete mode 100644 apps/admin-frontend/src/features/catalog/application/use-cases/tags/UpdateTag.ts delete mode 100644 apps/admin-frontend/src/features/catalog/domain/entities/Service.test.ts delete mode 100644 apps/admin-frontend/src/features/catalog/domain/entities/Service.ts delete mode 100644 apps/admin-frontend/src/features/catalog/domain/errors/InvalidServiceError.ts delete mode 100644 apps/admin-frontend/src/features/catalog/infrastructure/mappers/serviceMapper.test.ts delete mode 100644 apps/admin-frontend/src/features/catalog/infrastructure/mappers/serviceMapper.ts delete mode 100644 apps/admin-frontend/src/features/catalog/infrastructure/repositories/ApiServiceRepository.test.ts delete mode 100644 apps/admin-frontend/src/features/catalog/infrastructure/repositories/ApiServiceRepository.ts delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/categories/CategoriesPage.test.tsx delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/categories/CategoriesPage.tsx delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/categories/components/CategoryDeleteDialog.tsx delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/categories/components/CategoryEditorDialog.tsx delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/categories/hooks/useCategories.test.tsx delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/categories/hooks/useCategories.ts delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/categories/hooks/useCategoriesPage.ts create mode 100644 apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoriesListPage/CategoriesListPage.tsx rename apps/admin-frontend/src/features/catalog/presentation/categories/{ => pages/CategoriesListPage}/components/CategoriesTable.tsx (60%) create mode 100644 apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoriesListPage/components/CategoriesTable.types.ts create mode 100644 apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoriesListPage/hooks/useCategoriesListPage.ts create mode 100644 apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoriesListPage/hooks/useCategoriesListPage.types.ts create mode 100644 apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoriesListPage/hooks/useCategoryDeletion.ts create mode 100644 apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoriesListPage/hooks/useCategoryDeletion.types.ts create mode 100644 apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoriesRoutes.test.tsx create mode 100644 apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoryEditorDialog/CategoryEditorDialog.tsx rename apps/admin-frontend/src/features/catalog/presentation/categories/{ => pages/CategoryEditorDialog}/forms/CategoryForm.tsx (77%) create mode 100644 apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoryEditorDialog/forms/CategoryForm.types.ts rename apps/admin-frontend/src/features/catalog/presentation/categories/{ => pages/CategoryEditorDialog}/forms/categoryFieldMaps.ts (89%) create mode 100644 apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoryEditorDialog/hooks/useCategoryEditor.ts create mode 100644 apps/admin-frontend/src/features/catalog/presentation/categories/pages/CategoryEditorDialog/hooks/useCategoryEditor.types.ts delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/services/ServicesPage.accessibilityAndSecurity.test.tsx delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/services/ServicesPage.crud.test.tsx delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/services/ServicesPage.dialogLifecycle.test.tsx delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/services/ServicesPage.formValidation.test.tsx delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/services/ServicesPage.listBehavior.test.tsx delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/services/ServicesPage.testSupport.tsx delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/services/ServicesPage.tsx delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/services/components/ServiceBasicFields.tsx delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/services/components/ServiceCategoryField.tsx delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/services/components/ServiceCommercialFields.tsx delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/services/components/ServiceDeleteDialog.tsx delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/services/components/ServiceDialog.tsx delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/services/components/ServiceDurationFields.tsx delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/services/components/ServiceTableRow.tsx delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/services/components/ServiceTagsField.tsx delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/services/components/ServicesFilters.tsx delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/services/components/ServicesList.tsx delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/services/components/ServicesPagination.tsx delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/services/components/ServicesTable.tsx delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/services/forms/ServiceForm.schema.test.ts delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/services/forms/ServiceForm.schema.ts delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/services/forms/ServiceForm.tsx delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/services/forms/serviceFieldMaps.ts delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/services/hooks/useServiceDeletion.test.tsx delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/services/hooks/useServiceDeletion.ts delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/services/hooks/useServiceEditor.test.tsx delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/services/hooks/useServiceEditor.ts delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/services/hooks/useServiceFilters.test.tsx delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/services/hooks/useServiceFilters.ts delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/services/hooks/useServices.test.tsx delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/services/hooks/useServices.ts delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/services/hooks/useServicesPage.ts delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/services/models/serviceFormatters.ts delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/services/models/servicePresentationModels.ts delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/tags/components/TagDeleteDialog.tsx delete mode 100644 apps/admin-frontend/src/features/catalog/presentation/tags/components/TagEditorDialog.tsx create mode 100644 apps/admin-frontend/src/features/catalog/presentation/tags/hooks/useTagEditor.ts create mode 100644 apps/admin-frontend/src/features/catalog/presentation/tags/pages/TagEditorDialog.tsx create mode 100644 apps/admin-frontend/src/shared/infrastructure/http/malformedResponseError.ts create mode 100644 apps/admin-frontend/src/shared/infrastructure/http/parseApiResponse.ts delete mode 100644 apps/admin-frontend/src/shared/presentation/hooks/useCreateInline.test.ts delete mode 100644 apps/admin-frontend/src/shared/presentation/hooks/useCreateInline.ts delete mode 100644 apps/admin-frontend/src/test/mocks/handlers/serviceHandlers.ts 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..46a3253 --- /dev/null +++ b/apps/admin-frontend/e2e/categories-mobile.spec.ts @@ -0,0 +1,95 @@ +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') { + await route.fulfill({ json: categories }) + 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"] From 9fa9c0f06f04175d461561c4b4e12dcea235de92 Mon Sep 17 00:00:00 2001 From: Everton William Thoele Schuster Date: Sun, 2 Aug 2026 12:11:23 -0300 Subject: [PATCH 2/2] Fix categories-mobile.spec.ts's mock for the GET-by-id endpoint useCategoryEditor fetches its own category via GET /api/v1/categories/{id} (docs/adr/013), but this spec's route mock matched any /api/v1/categories* path and always returned the full list array regardless of whether the request was for the collection or a single id - so the by-id fetch received an array instead of a CategoryDto, and the edit dialog's Nome field never populated. Mock now inspects the last path segment and returns the matching single category (404 if not found) for a by-id GET, the full list otherwise. Verified against the real Playwright suite (production build + preview, matching CI): all 10 e2e specs pass, including this one. Co-Authored-By: Claude Sonnet 5 --- .../admin-frontend/e2e/categories-mobile.spec.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/admin-frontend/e2e/categories-mobile.spec.ts b/apps/admin-frontend/e2e/categories-mobile.spec.ts index 46a3253..5d1834f 100644 --- a/apps/admin-frontend/e2e/categories-mobile.spec.ts +++ b/apps/admin-frontend/e2e/categories-mobile.spec.ts @@ -22,7 +22,21 @@ test.describe('categories on smartphones', () => { const request = route.request() if (request.method() === 'GET') { - await route.fulfill({ json: categories }) + 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 }