diff --git a/.agents/COMPONENT-OWNERSHIP.md b/.agents/COMPONENT-OWNERSHIP.md index b625d9184..046de495c 100644 --- a/.agents/COMPONENT-OWNERSHIP.md +++ b/.agents/COMPONENT-OWNERSHIP.md @@ -21,3 +21,4 @@ because nobody owned the contract that a re-homing broke. | Component | Owner agent | Contract requirement | Registered | |---|---|---|---| | `apps/service-management` | `kimi-code-primary-001` | `126` | 2026-08-05 | +| `apps/service-management-api` | `kimi-code-primary-001` | `126` | 2026-09-07 | diff --git a/.agents/NFR-REGISTRY.md b/.agents/NFR-REGISTRY.md index 43b715a33..449eef75f 100644 --- a/.agents/NFR-REGISTRY.md +++ b/.agents/NFR-REGISTRY.md @@ -38,6 +38,8 @@ This file consolidates non-functional requirements already requested and stored so component drift and re-homing breaks fail checks instead of serving silent defaults. - `127` Mandatory `rtk` and Caveman usage in every agent session +- Soft-delete tombstones (`deletedAt`) are the default delete path for User/Organization; uniqueness is released on tombstone; ids stay reserved. Physical purge is opt-in (JUM-822): 90-day floor, dev PM2 dry-run by default, `--commit` required to drop PII, id ledger never reused. +- Entity metrics (`GET /{entities}/metrics`) are bounded by `x-metrics-capabilities` and exclude tombstones (JUM-793). - `128` Requirement changes take precedence in the release process (sequence only, no gate exemption) - `129` Mandatory Firebase RTDB agent progress bus (`agent-bus:publish|watch|status`) - `130` Measured claims and bounded work: no unmeasured numbers, no proxy stated as cause @@ -46,6 +48,7 @@ This file consolidates non-functional requirements already requested and stored - `133` Declared indexes for ordered queries: `.indexOn` in versioned rules, or order by key - `134` No flaky tests: a suite establishes what it depends on, never sleeps to synchronise - `135` No fake tests: assert the effect, declare the assertions, never target a percentage +- `136` Frontend knows the backend only through its OAS: spec document or generated SDKs, never backend source ## Documentation and Governance NFRs diff --git a/.agents/README.md b/.agents/README.md index ceb745577..1b4f4282a 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -102,6 +102,7 @@ See also: - [133-declared-indexes-for-ordered-queries](requirements/software/133-declared-indexes-for-ordered-queries.md) - [134-no-flaky-tests](requirements/software/134-no-flaky-tests.md) - [135-no-fake-tests](requirements/software/135-no-fake-tests.md) +- [136-frontend-knows-backend-only-through-oas](requirements/software/136-frontend-knows-backend-only-through-oas.md) ## Software and Product Requirements diff --git a/.agents/requirements/project/113-private-free-repository-owned-ci.md b/.agents/requirements/project/113-private-free-repository-owned-ci.md index 42eb37ddc..f256ca8af 100644 --- a/.agents/requirements/project/113-private-free-repository-owned-ci.md +++ b/.agents/requirements/project/113-private-free-repository-owned-ci.md @@ -1,25 +1,31 @@ -# 113 - Private Free Repository-Owned CI and Coverage Evidence +# 113 - Public Open Source CI and Coverage Evidence - Status: Active - Nature: NFR (CI/CD, security, coverage, governance) -- Source: Project owner decision, 2026-08-01; amended 2026-08-03 after GitHub Actions billing stopped hosted execution; amended 2026-08-09 to keep task delivery to `dev` under a cheap layer-aware gate; amended 2026-08-09 to restore GitHub Actions and disable CircleCI. +- Source: Project owner decision, 2026-08-01; amended 2026-08-03 after GitHub Actions billing stopped hosted execution; amended 2026-08-09 to keep task delivery to `dev` under a cheap layer-aware gate; amended 2026-08-09 to restore GitHub Actions and disable CircleCI; amended 2026-09-13 after transfer to the public `web2solutions/Jumentix` repository to re-enable free open-source GitHub Actions, CircleCI, Codecov and SonarQube Cloud integrations. - Amends: `107` CircleCI redundancy. -- Replaces: `014` external Codecov dependency with GitHub Actions-hosted Codecov publishing. +- Replaces: `014` external Codecov dependency with repository-owned coverage gates plus public Codecov publishing. ## Requirement -1. `XpertMinds/Jumentix` remains private and canonical. -2. Required delivery evidence must have a zero-cost, repository-owned path. Paid-only provider checks must not be required. -3. GitHub Actions is the canonical orchestrator. Hosted billing failures are handled by - repository-owned self-hosted runners; CircleCI disabled means `.circleci/config.yml` - must not be present or required. +1. `web2solutions/Jumentix` is public and canonical. +2. Required delivery evidence must have a free open-source path. Paid-only provider checks must not be required. +3. GitHub Actions is the canonical orchestrator on GitHub-hosted `ubuntu-latest` + runners. CircleCI is enabled as the secondary public CI mirror and must use + the same context classifier and destination-aware gate policy. 4. Coverage is produced by Jest, checked fail-closed by `ci-cd/check-coverage-thresholds.js`, and checked at patch level by `ci-cd/check-patch-coverage.js`. Missing reports fail. -5. Coverage evidence is uploaded as a GitHub Actions artifact and published to Codecov from GitHub Actions when `CODECOV_TOKEN` is configured. Codecov publishing is required for visibility, but Codecov is not the coverage authority and cannot weaken repository-owned thresholds. -6. CircleCI workflows are disabled in this repository. A configured duplicate pipeline is not redundancy and must not be represented as a passing provider. -7. GitHub Actions jobs must use the repository-owned self-hosted runner label `jumentix` - until hosted runner billing is explicitly restored through a governed requirement change. +5. Coverage evidence is retained as CI artifacts and published to Codecov when + `CODECOV_TOKEN` is configured. Codecov publishing is required for + visibility, but Codecov is not the coverage authority and cannot weaken + repository-owned thresholds. +6. CircleCI workflows are enabled for the public repository and must halt + non-selected heavy jobs before starting costly work. +7. GitHub Actions jobs must use GitHub-hosted `ubuntu-latest` runners and Node + 22 compatibility setup. 7. The full coverage gate runs in GitHub Actions for `dev -> main` release promotions, `main` pushes, and scheduled/manual full runs so task delivery to `dev` remains fast. Local commands may run coverage diagnostically, but local `ci:gate` and task PR gates must not be the production coverage authority. -8. SonarQube Cloud may remain as defense-in-depth while operational, but repository-owned coverage and security gates remain authoritative if it becomes unavailable. +8. SonarQube Cloud remains active on the public `web2solutions_Jumentix` + project while configured, but repository-owned coverage and security gates + remain authoritative if it becomes unavailable. 9. GitHub Actions jobs use frozen dependencies, deterministic pinned tools/actions where available, explicit failure on missing evidence, and retained evidence artifacts. 10. Pending, skipped, missing, timed-out, cancelled, quota-blocked, or provider-inaccessible checks are never passing. 11. No `--no-verify`, admin bypass, force merge, swallowed failure, or synthetic green is allowed. @@ -37,5 +43,5 @@ - `bun run ci:check-provider` - `bun run integrations:check` -- GitHub Actions `coverage` job during full-suite contexts: `bun run test:coverage && bun run coverage:check && bun run coverage:patch`, followed by Codecov upload. +- Full-suite `coverage` job during release/main contexts: `bun run test:coverage && bun run coverage:check && bun run coverage:patch`, followed by Codecov upload and SonarQube Cloud scan. - Required task PR checks use GitHub Actions job names `branch-gate` and `third-party-review`; required release/main checks use `branch-gate`, `workspace-builds`, `workspace-tests`, `integration`, `coverage`, `website`, `third-party-review`, and `database-matrix`. diff --git a/.agents/requirements/software/126-service-management-ownership-and-public-contracts.md b/.agents/requirements/software/126-service-management-ownership-and-public-contracts.md index 70f9dd0ff..9364914a8 100644 --- a/.agents/requirements/software/126-service-management-ownership-and-public-contracts.md +++ b/.agents/requirements/software/126-service-management-ownership-and-public-contracts.md @@ -6,7 +6,7 @@ adoption", milestone H1 (Correctness & runtime alignment), 2026-08-05. - Strengthens: `038`, `043`. Relates to: `044`, `052`, `123` and Linear `JUM-458`, `JUM-558`, `JUM-459`, `JUM-460`, `JUM-461`, `JUM-462`, `JUM-543`, `JUM-466`, - `JUM-468`, `JUM-475`, `JUM-484`, `JUM-547`, `JUM-492`. + `JUM-468`, `JUM-475`, `JUM-484`, `JUM-547`, `JUM-492`, `JUM-748`. ## Context @@ -26,6 +26,11 @@ contract they converge on, and the smoke expansion in `JUM-466` asserts it. - `apps/service-management` MUST have a registered owner in the component ownership registry `.agents/COMPONENT-OWNERSHIP.md` (established by this requirement). The registered owner is agent `kimi-code-primary-001`. + - `apps/service-management-api` owns platform Service Management APIs that support + the designer itself, including the shared `Catalogs` runtime. Generated-service + template code in `apps/backend-template` MUST NOT ship the Service Management + `Catalogs` module, `/catalogs` OAS paths, catalog stores, or catalog sync runtime + by default. - Any change to a public contract pinned here MUST update this requirement (and the registry sync set listed in Evidence) in the same PR. @@ -58,7 +63,7 @@ contract they converge on, and the smoke expansion in `JUM-466` asserts it. `JUMENTIX_RABBITMQ_URL`); MUST NOT appear in the GET response and MUST NOT be writable, since the response crosses the same boundary as the write. The tier is enforced by omission from both allowlists and proven by test. - The full 23-key classification of `.env.dev` (each addition to the editable set + The full 24-key classification of `.env.dev` (each addition to the editable set is a security decision with a written reason): - *Editable (write allowlist, 9 keys):* - `JUMENTIX_HTTP_FRAMEWORK` — REST framework selector; the designer's primary @@ -89,7 +94,7 @@ contract they converge on, and the smoke expansion in `JUM-466` asserts it. in the template env files, and the endpoint rejects values with embedded credentials (userinfo) or non-`redis://`/`rediss://` protocols, so the tool cannot be used to store secrets through this key. - - *Read-only (read allowlist only, 14 keys):* + - *Read-only (read allowlist only, 15 keys):* - `JUMENTIX_DATABASE_NAME` — logical database name; non-secret config, not a topology selector. - `JUMENTIX_ENABLE_BASIC_AUTH` — authentication posture toggle; @@ -105,6 +110,10 @@ contract they converge on, and the smoke expansion in `JUM-466` asserts it. - `JUMENTIX_AUTH_MAX_LOGIN_ATTEMPTS`, `JUMENTIX_AUTH_LOGIN_WINDOW_SECONDS`, `JUMENTIX_AUTH_LOCKOUT_SECONDS` — brute-force protection policy (Requirement `044`); security-relevant. + - `JUMENTIX_SERVICE_MANAGEMENT_CATALOG_API_URL` — explicit endpoint for the + platform-owned Service Management catalog API. The designer reads it to + avoid same-origin fallback to generated-service template routes; it is + non-secret and never writable through the browser. - *Never exposed (3 keys):* `JUMENTIX_JWT_TOKEN_SECRET_KEY` (signing key), `JUMENTIX_REDIS_PASSWORD` (credential), `JUMENTIX_RABBITMQ_URL` (credential-bearing URL embedding `user:password`). @@ -206,8 +215,8 @@ contract they converge on, and the smoke expansion in `JUM-466` asserts it. - **Contract 1b — `GET /api/runtime/pm2-ecosystem` (landed by `JUM-480`).** Read-only; the single source of the designer's PM2 runtime profile preview. - **Environments and file mapping.** Accepted `environment` values: - `dev`/`development` → `ecosystem.dev.cjs`, `staging` → - `ecosystem.staging.cjs`, `production`/`prod` → `ecosystem.production.cjs`, + `dev`/`development` → `ecosystem.dev.config.cjs`, `staging` → + `ecosystem.staging.config.cjs`, `production`/`prod` → `ecosystem.production.config.cjs`, `ci`/`test` → `ecosystem.ci.cjs`. Same resolution discipline as Contract 1: case-insensitive after trimming, unknown values explicitly rejected with `400` and the accepted list, default `NODE_ENV` or `dev` when omitted. @@ -228,19 +237,80 @@ contract they converge on, and the smoke expansion in `JUM-466` asserts it. empty preview. An unreadable or broken ecosystem file is the 500 class `{ "error": "PM2 ecosystem file operation failed.", "code", "path", "details" }`, parallel to the env-file filesystem class. + - **Contract 1c — `GET /api/runtime/pm2-metrics` (amended by `JUM-736`, host/async-context by Monitoring WebSocket delivery).** + Read-only one-shot snapshot; remains the HTTP Contract for tests and tools. + The Monitoring tab's **primary live UI path** is Contract 1e (WebSocket); + this GET MUST stay available and shape-compatible for Contract 1c consumers. + - **Metrics source.** The endpoint MUST collect live process data through + the PM2 Node API (`pm2.connect`, `pm2.list`, `pm2.disconnect`). It MUST NOT + infer process health from the ecosystem file, shell output or command + strings. `JUMENTIX_SERVICE_MANAGEMENT_PM2_MODULE` may replace the module + path only for tests. + - **Environment comparison.** Accepted `environment` values and ecosystem + file resolution match Contract 1b. The response compares the selected + ecosystem's expected app names with PM2's live process list so missing + expected processes are visible without reading a terminal. + - **Host metrics.** Success payloads MUST include `host` with CPU usage + (aggregate + per-core sample), memory (total/used/free/process RSS sum), + and disk volumes for the project root, temp dir, and optional + `JUMENTIX_SERVICE_MANAGEMENT_DISK_PATHS` entries (`fs.statfs`). + - **Async context scrape.** When a process exposes a local HTTP port via + ecosystem env (`JUMENTIX_HTTP_PORT` / `PORT`), the collector MAY scrape + `GET http://127.0.0.1:/async-context-metrics` with a short timeout + and attach `asyncContext` on that process; `summary.asyncContextActiveSum` + aggregates successful scrapes. The scrape payload includes counters, + `lastCorrelationIds`, and `recentStores` (redacted Map snapshots — + keys matching `/password|token|secret|authorization|cookie/i` become + `[REDACTED]`). Scrape failures MUST NOT fail the whole metrics response. + - **Per-process disk I/O.** Each process SHOULD carry `diskIo` collected from + the OS using the process `pid`: Linux `/proc//io`; Darwin + `proc_pid_rusage` via in-process Bun FFI (`darwinProcessDiskIo.js`); Windows PowerShell + `IOReadBytes`/`IOWriteBytes`. Failures and unsupported platforms MUST use + honest envelopes (`supported: false` or `error`/`code`) — never invent zeros. + - **Response shape.** Success is `{ source: "pm2", collectedAt, + environment, ecosystem, summary, host, processes }`. `ecosystem` carries + `{ fileName, path, exists, expectedProcessCount, missingExpected }`; + `summary` carries process counts, online/stopped/errored counts, total CPU, + total memory, status counts and `asyncContextActiveSum`; each process carries + `{ name, pmId, pid, namespace, status, cpuPercent, memoryBytes, restartCount, + unstableRestarts, uptimeMs, startedAt, script, interpreter, watching, + customMetrics, asyncContext?, diskIo? }`. + - **Honest failure state.** Unsupported environments reuse Contract 1's + `400` invalid-environment envelope. PM2 connection/list/module failures + are `500` with `{ "error": "PM2 metrics collection failed.", "code", + "details" }`. + + - **Contract 1e — `WS /api/runtime/pm2-ws` (Monitoring live stream + actions).** + Primary Monitoring-tab transport. Uses the `ws` package on the Service + Management HTTP server upgrade path; MUST NOT replace Contract 1c. + - **Subscribe.** Client sends `{ type: "subscribe", environment, intervalMs, + filters? }`. `intervalMs` is clamped to `[500, 2000]` (default `1000`). + Server pushes `{ type: "metrics", payload }` where `payload` matches + Contract 1c success shape. + - **Actions.** Client may send `{ type: "action", action, scope, name?, + pmId?, namespace? }` with `action` ∈ { `start`, `stop`, `restart` } and + `scope` ∈ { `process`, `namespace`, `ecosystem-missing` }. Server replies + `{ type: "action-result", ok, action, scope, name?, error? }` and MAY push + a fresh metrics frame after success. + - **Honesty.** Unauthorized/invalid payloads and PM2 failures return + explicit `error` / `action-result` frames; the stream MUST NOT invent + healthy process data when PM2 collection fails. 4. **Contract 2 — `service-management.v1` storage schema (historically the localStorage storage schema).** - - The entire suite state (all four tabs) persists as ONE JSON payload under the + - The entire suite state (all persisted authoring tabs) persists as ONE JSON payload under the single pinned key `service-management.v1` — historically a localStorage key; since `JUM-484`'s landed one-way migration, a key in Cana's `designerDocuments` IndexedDB object store. The migration copied the exact - documents across without changing the wire format — with exactly these top-level - sections: `domains`, `relationships`, `selectedDomainId`, `selectedEntityId`, - `selectedRelationshipId`, `idCounter`, `activeTab`, `interfaces`, - `serviceConfiguration`, `runtimeEnvironment`, `deployments`, `view`. + documents across without changing the persisted domain/interface/deploy wire + format. `JUM-736` adds the generated-code workspace as another additive + section. The current document has exactly these top-level + sections: `domains`, `relationships`, `selectedDomainId`, `selectedEntityId`, + `selectedRelationshipId`, `idCounter`, `activeTab`, `interfaces`, + `serviceConfiguration`, `runtimeEnvironment`, `codeWorkspace`, + `monitoringHistory`, `deployments`, `view`. - `activeTab` ∈ { `domain-designer`, `interface-designer`, `service-config`, - `deploy-management` } — one per tab. + `deploy-management`, `monitoring`, `code-workspace` } — one per visible tab. - `serviceConfiguration`: `{ serviceKind, runMode, cloudProvider, staticAssetsPath, ports: { rest, websocket, grpc } }` with `serviceKind` ∈ { `rest-api`, `websocket-rest-api`, `grpc-rest-api` }, @@ -250,6 +320,21 @@ contract they converge on, and the smoke expansion in `JUM-466` asserts it. - `runtimeEnvironment`: `{ environment, fileName, values }` mirroring Contract 1 (environment enum and the visible runtime keys — the editable and read-only tiers; never-exposed keys never enter this state). + - `codeWorkspace`: `{ activePath, files }`, where `files` is keyed by generated + path and each value is `{ path, state, baseContent, generatedContent, content, + updatedAt }`. `state` ∈ { `generated`, `edited`, `stale` }. Generated files + follow the current model automatically; user-edited files become `stale` when + the generator output changes underneath them until the user explicitly keeps + their edit or takes the regenerated version. This is a backward-compatible + additive section; older payloads normalize to `{ files: {}, activePath: "" }`. + - `monitoringHistory`: `{ version: 1, updatedAt, environment, samples, processes }` + — local Monitoring telemetry cache (not domain-package export). `samples` is a + ring (max 60) of aggregate ticks `{ t, hostCpu, hostMemUsedPercent, cpuTotal, + memTotal, onlineRatio, asyncActiveSum }`. `processes` maps + `${namespace}::${name}` to spark series `{ cpu, mem, restarts, asyncActive, + diskReadBytes, diskWriteBytes }` (each series max 60; max 40 process keys, + LRU). Older payloads normalize to an empty history. Charts use D3 vendored + under `vendor/d3` (no CDN). - `deployments`: array of deploy targets aligned to the Requirement 059 Service Management metadata contract (`JUM-481`), each `{ name, region, runtime, serviceType, deployTarget, runtimeProtocol, @@ -299,10 +384,11 @@ contract they converge on, and the smoke expansion in `JUM-466` asserts it. - **JSON** (`domain-designer.json`): the full-suite document (shape landed by `JUM-547`): `{ kind: "service-management-suite", version: "2.0.0", domains, relationships, interfaces, serviceConfiguration, - runtimeEnvironment, deployments, view }` — all four tabs, re-importable - shape. `interfaces` entries are `{ type, framework, entrypoint, - controller }`; `serviceConfiguration` and `deployments` carry the - Contract 2 shapes. The pre-`JUM-547` shape was `{ domains, relationships, + runtimeEnvironment, codeWorkspace, deployments, view }` — all persisted + authoring sections, re-importable shape. `interfaces` entries are + `{ type, framework, entrypoint, controller }`; `serviceConfiguration` and + `deployments` carry the Contract 2 shapes. The pre-`JUM-547` shape was + `{ domains, relationships, view }` with no `kind`/`version`; import MUST keep accepting it, defaulting the missing sections (backward compatibility). Import MUST refuse a document whose `version` major is newer than the importer's, a @@ -502,6 +588,8 @@ contract they converge on, and the smoke expansion in `JUM-466` asserts it. first entry names `kimi-code-primary-001` as owner of `apps/service-management`. - Contract sources of truth: `apps/service-management/server.js`, `apps/service-management/script.js`, `apps/service-management/index.html`, + `apps/service-management-api/src/ServiceManagementCatalogAPI.ts`, + `apps/service-management-api/spec/1.0.0.yml`, `apps/backend-template/src/interface/runtime/RuntimeEnvironment.ts`, `documentation/md/RUNTIME-ENVIRONMENT-CONTRACTS.md`, `documentation/md/SERVICE-MANAGEMENT-APPLICATION.md`. @@ -530,14 +618,40 @@ contract they converge on, and the smoke expansion in `JUM-466` asserts it. (designer-side no-hardcoded-command rule). - Contract 3 amended by `JUM-547` (branch `kimi/feature/JUM-547-full-suite-export-import`): the JSON export became the - versioned full-suite document carrying all four tabs, and the + versioned full-suite document carrying all persisted authoring sections, and the `runtimeEnvironment` decision (selection crosses, values never leave the machine) is recorded above, in the JSON export bullet. Pinned by `apps/backend-template/test/unit/service-management/designerRoundTrip.test.ts` (full-suite deep-equal, backward/forward compatibility) and `apps/backend-template/test/unit/service-management/designerExporters.test.ts` - (document shape). The Contract 2 storage schema is unchanged — no versioned - key bump. + (document shape). +- Contract 2 and 3 amended by `JUM-736`: the Code Workspace tab persists + generated-file overlays in `codeWorkspace`, suite JSON export/import carries that + section, and boilerplate bundle export applies edited/stale file content. Pinned + by `apps/backend-template/test/unit/service-management/designerState.test.ts`, + `apps/backend-template/test/unit/service-management/designerRoundTrip.test.ts` + and `apps/backend-template/test/unit/service-management/designerExporters.test.ts`. + This is additive and normalizes old payloads to an empty workspace, so there is + no versioned key bump. +- Contract 1c amended by `JUM-736` and extended for host + async-context fields: + the Monitoring HTTP one-shot `GET /api/runtime/pm2-metrics` still collects via + the PM2 Node API, compares live processes with the selected ecosystem file, and + now also returns `host` CPU/memory/disk plus optional per-process + `asyncContext` scrapes. Pinned by + `apps/backend-template/test/integration/ServiceManagement/pm2Ecosystem.integration.test.ts` + and `apps/service-management/test/unit/pm2EcosystemUi.contract.test.ts`. +- Contract 1e: Monitoring live UI uses `WS /api/runtime/pm2-ws` (500–2000 ms + interval, default 1000) with process/namespace/ecosystem-missing start/stop/ + restart actions. Contract 1c remains the HTTP one-shot. Pinned by the same + integration + UI contract suites. +- Contract 1d amended by `JUM-748`: the shared catalog moved out of + `apps/backend-template` into the Service Management platform API + `apps/service-management-api`. The designer's `catalogSyncClient` now fails closed + without an explicit `JUMENTIX_SERVICE_MANAGEMENT_CATALOG_API_URL`; PM2 starts a + dedicated catalog API process next to the designer; and + `apps/backend-template/test/unit/ownership/backendTemplateCatalogOwnership.test.ts` + prevents catalog runtime, `/catalogs` OAS paths, catalog stores, and catalog role + scopes from drifting back into the generated-service template. - Contract 3 amended by `JUM-492` (branch `kimi/feature/JUM-492-domain-package-versioning`): the domain package became a versioned document (`package` block with name/version/dependencies), with diff --git a/.agents/requirements/software/136-frontend-knows-backend-only-through-oas.md b/.agents/requirements/software/136-frontend-knows-backend-only-through-oas.md new file mode 100644 index 000000000..9bdeef402 --- /dev/null +++ b/.agents/requirements/software/136-frontend-knows-backend-only-through-oas.md @@ -0,0 +1,69 @@ +# Requirement 136 - Frontend Knows the Backend Only Through Its OAS + +## Context + +Jumentix generates applications in three shapes: 100% offline (frontend +consuming backend controllers in-process through contracts), hybrid +(frontend + backend), and backend-only. The frontend workspace +(`apps/frontend`) started as a copied SPA seed; nothing in a copied codebase +stops a future change from importing `apps/backend-template` source directly +— a shortcut that couples the two deployables and turns every backend +refactor into a frontend incident. + +The backend already publishes its contract surface without exposing code: +when it runs, it serves Swagger UI, and the OpenAPI specification behind it +is available as a JSON document (the runtime representation the UI renders) +or as the versioned YAML spec file. That document is the whole interface a +frontend may know. + +## Mandatory Rules + +1. **Frontend applications never read backend source code.** No import, no + type-only import, no path alias, no monorepo shortcut from + `apps/frontend/**` into `apps/backend-template/**` or any backend-only + implementation. If a type is needed on the frontend, it comes from a + published client package or is generated from the spec — never from + backend source. +2. **The OAS document is the only backend reference.** Frontends integrate + against the OpenAPI specification: the JSON representation served at + runtime by the running backend's Swagger UI, or the versioned YAML spec + file. Behaviour not described by the spec does not exist for the + frontend. +3. **Workspace client packages and generated SDKs are allowed.** Packages + published from `packages/**` (for example `@jumentix/cana`, + `@jumentix/sdk-rest-client`) and SDKs generated from the OAS spec are + clients and contracts, not backend code. Consuming them is the sanctioned + path, including for realtime transports whose contracts are published the + same way. +4. **Offline and hybrid apps follow the same boundary.** A 100% offline app + consumes the contracts as data — generated clients, adapters, and local + persistence wired to the same vocabulary. "Runs in the same process" is + never a license to import backend internals. + +## Acceptance Criteria + +1. No file under `apps/frontend/**` imports from `apps/backend-template/**`; + `bun run arch:check-workspace-boundaries` stays green with the frontend + workspace present. +2. Frontend integration code references the OAS document (runtime JSON or + versioned YAML) or an SDK generated from it; hand-written payload shapes + duplicating backend internals do not appear in `apps/frontend/src/**`. +3. The principle is stated where frontend work is guided: the requirement + index, `apps/frontend/README.md`, and the workspace agent instructions. + +## Evidence and Scope + +- Machine-verifiable boundary: `arch:check-workspace-boundaries` and the + workspace boundary checks in `ci:gate` run with `apps/frontend` present. +- Stated in `apps/frontend/README.md`, `apps/frontend/AGENTS.md` and + `apps/frontend/CLAUDE.md` (JUM-758). +- The contract surface the frontend consumes grew vendor extensions it renders + from (`x-label`, `x-list-capabilities`, `x-references` on arrays) and the + frontend proves it with unit, component (`@vue/test-utils` under bun:test) and + Docker-backed Cypress suites — `documentation/md/FRONTEND-SEED-AND-XCRUD.md`, + `documentation/md/PAGINATED-LIST-CONTRACT.md` (JUM-776, JUM-777, JUM-778, JUM-780). +- The backend side of the contract surface — Swagger UI and the route/spec + parity gate (`oas:check-routes`) — already exists; this requirement binds + the frontend to consume only that surface. +- Complements the contract-first requirements that govern the backend + template and the generated SDK packages. diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 000000000..366ffa505 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,425 @@ +version: 2.1 + +executors: + node_bun: + docker: + - image: cimg/node:22.21 + environment: + BUN_VERSION: 1.3.13 + JUMENTIX_CI_GATE_RESULT_FILE: artifacts/ci/branch-quality-gate.json + JUMENTIX_CI_MATRIX_RESULT_FILE: artifacts/ci/full-test-matrix.json + JUMENTIX_FULL_MATRIX_SKIP_CELLS: workspace-builds,workspace-tests,website-prepublish,integration + AAA_JWT_TOKEN_SECRET_KEY: ci_jwt_secret_key + +commands: + prepare_checkout: + steps: + - run: + name: Checkout repository over public HTTPS + command: | + set -euo pipefail + repository_url="https://github.com/${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}.git" + git clone --filter=blob:none "$repository_url" . + git checkout "${CIRCLE_SHA1}" + - run: + name: Fetch branch references + command: git fetch --prune origin "+refs/heads/*:refs/remotes/origin/*" + - run: + name: Install Bun + command: | + curl -fsSL https://bun.sh/install | bash -s "bun-v$BUN_VERSION" + echo 'export PATH="$HOME/.bun/bin:$PATH"' >> "$BASH_ENV" + - run: + name: Install frozen dependencies + command: | + source "$BASH_ENV" + bun ci-cd/check-bun-version.js + bun install --frozen-lockfile + bun run compat:check-node-version + resolve_pr_metadata: + steps: + - run: + name: Resolve pull request metadata + command: | + if [ -z "${CIRCLE_PULL_REQUEST:-}" ]; then + { + echo "export AAA_CI_IS_PULL_REQUEST=0" + echo "export JUMENTIX_CI_IS_PULL_REQUEST=0" + echo "export JUMENTIX_QUALITY_GATE_TARGET=${CIRCLE_BRANCH:-dev}" + echo "export JUMENTIX_PR_HEAD_REF=${CIRCLE_BRANCH:-}" + echo "export AAA_PR_HEAD_REF=${CIRCLE_BRANCH:-}" + } >> "$BASH_ENV" + exit 0 + fi + + PR_NUMBER="${CIRCLE_PULL_REQUEST##*/}" + REPO="${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}" + node - "$REPO" "$PR_NUMBER" >> "$BASH_ENV" \<<'NODE' + const [repo, number] = process.argv.slice(2); + const url = `https://api.github.com/repos/${repo}/pulls/${number}`; + const response = await fetch(url, { + headers: { accept: 'application/vnd.github+json' } + }); + if (!response.ok) { + throw new Error(`failed to resolve PR metadata: ${response.status} ${response.statusText}`); + } + const pr = await response.json(); + const quote = (value) => JSON.stringify(String(value || '')); + console.log('export AAA_CI_IS_PULL_REQUEST=1'); + console.log('export JUMENTIX_CI_IS_PULL_REQUEST=1'); + console.log(`export JUMENTIX_PR_BASE_REF=${quote(pr.base?.ref)}`); + console.log(`export AAA_PR_BASE_REF=${quote(pr.base?.ref)}`); + console.log(`export JUMENTIX_QUALITY_GATE_TARGET=${quote(pr.base?.ref)}`); + console.log(`export JUMENTIX_PR_HEAD_REF=${quote(pr.head?.ref)}`); + console.log(`export AAA_PR_HEAD_REF=${quote(pr.head?.ref)}`); + console.log(`export JUMENTIX_PR_TITLE=${quote(pr.title)}`); + console.log(`export AAA_PR_TITLE=${quote(pr.title)}`); + console.log(`export JUMENTIX_PR_BODY=${quote(pr.body)}`); + console.log(`export AAA_PR_BODY=${quote(pr.body)}`); + NODE + require_ci_job: + parameters: + job: + type: string + steps: + - run: + name: Classify CI context for << parameters.job >> + command: | + source "$BASH_ENV" + set +e + node ci-cd/classify-ci-context.js \ + --result-file artifacts/ci/ci-context.json \ + --require-job << parameters.job >> + status="$?" + set -e + if [ "$status" = "78" ]; then + circleci-agent step halt + exit 0 + fi + exit "$status" + +jobs: + branch-gate: + executor: node_bun + steps: + - prepare_checkout + - resolve_pr_metadata + - require_ci_job: + job: branch-gate + - run: + name: Install browser harness dependencies + command: | + source "$BASH_ENV" + bun x playwright install webkit + bun x playwright install-deps webkit + bun x cypress install + bun x cypress verify + - run: + name: Run canonical branch quality gate + no_output_timeout: 30m + command: | + source "$BASH_ENV" + bun run ci:gate:branch + - store_artifacts: + path: artifacts/ci + destination: branch-gate + + third-party-review: + executor: node_bun + steps: + - prepare_checkout + - resolve_pr_metadata + - require_ci_job: + job: third-party-review + - run: + name: Install pinned review tools + command: ci-cd/install-pinned-review-tools.sh "$HOME/review-tools" + - run: + name: Scan Git history for secrets + command: | + source "$BASH_ENV" + set +e + BASE_REF="${JUMENTIX_PR_BASE_REF:-${CIRCLE_BRANCH:-dev}}" + "$HOME/review-tools/gitleaks" git . \ + --log-opts="origin/${BASE_REF}..HEAD" \ + --redact --report-format sarif --report-path gitleaks.sarif + echo "$?" > gitleaks.exit + - run: + name: Run repository-owned Semgrep policy + command: | + source "$BASH_ENV" + set +e + BASE_REF="${JUMENTIX_PR_BASE_REF:-${CIRCLE_BRANCH:-dev}}" + "$HOME/review-tools/semgrep" scan --config .semgrep.yml --baseline-commit "origin/${BASE_REF}" \ + --sarif --output semgrep.sarif + echo "$?" > semgrep.exit + - run: + name: Enforce scanner outcomes + command: | + test "$(cat gitleaks.exit)" = "0" + test "$(cat semgrep.exit)" = "0" + - store_artifacts: + path: gitleaks.sarif + - store_artifacts: + path: semgrep.sarif + + workspace-builds: + executor: node_bun + steps: + - prepare_checkout + - resolve_pr_metadata + - require_ci_job: + job: workspace-builds + - run: + name: Run workspace package builds + no_output_timeout: 30m + command: | + source "$BASH_ENV" + bun run mono:build + + workspace-tests: + executor: node_bun + steps: + - prepare_checkout + - resolve_pr_metadata + - require_ci_job: + job: workspace-tests + - run: + name: Run workspace package tests + no_output_timeout: 30m + command: | + source "$BASH_ENV" + bun run mono:test + - store_artifacts: + path: artifacts/ci + destination: workspace-tests + + integration: + executor: node_bun + steps: + - prepare_checkout + - resolve_pr_metadata + - require_ci_job: + job: integration + - setup_remote_docker + - run: + name: Start Redis and RabbitMQ containers + command: ci-cd/ensure-local-ci-services.sh + - run: + name: Install WebKit for browser-based integration tests + command: | + source "$BASH_ENV" + bun x playwright install webkit + bun x playwright install-deps webkit + - run: + name: Run integration matrix + no_output_timeout: 30m + command: | + source "$BASH_ENV" + bun run ci:integration + - run: + name: Stop local integration services + when: always + command: ci-cd/cleanup-local-ci-services.sh + + coverage: + executor: node_bun + environment: + AAA_REDIS_HOST: 127.0.0.1 + AAA_REDIS_PORT: "6379" + AAA_BULLMQ_REDIS_HOST: 127.0.0.1 + AAA_BULLMQ_REDIS_PORT: "6379" + AAA_RABBITMQ_URL: amqp://127.0.0.1:5672 + steps: + - prepare_checkout + - resolve_pr_metadata + - require_ci_job: + job: coverage + - setup_remote_docker + - run: + name: Start Redis and RabbitMQ containers + command: ci-cd/ensure-local-ci-services.sh + - run: + name: Produce Jest coverage + command: | + source "$BASH_ENV" + bun run test:coverage + mkdir -p coverage/jest + cp coverage/coverage-final.json coverage/jest/coverage-final.json + JUMENTIX_COVERAGE_INCLUDE_BROWSER=0 JUMENTIX_COVERAGE_REQUIRE_BROWSER=0 bun run coverage:check + - run: + name: Produce browser coverage for every supported engine + no_output_timeout: 30m + command: | + source "$BASH_ENV" + bun x cypress install + bun x cypress verify + for engine in chrome firefox webkit; do + if [ "$engine" = webkit ]; then + bun x playwright install webkit + bun x playwright install-deps webkit + fi + bun ci-cd/run-browser-tests.js --browser "$engine" + bun ci-cd/write-browser-lcov.js + mkdir -p "coverage/browser-$engine" + cp coverage/browser/coverage-final.json "coverage/browser-$engine/coverage-final.json" + cp coverage/browser/lcov.info "coverage/browser-$engine/lcov.info" + done + - run: + name: Union browser coverage engines + command: | + source "$BASH_ENV" + bun ci-cd/merge-browser-coverage.js --from-dir coverage + - run: + name: Enforce patch coverage + command: | + source "$BASH_ENV" + if [ -n "${CIRCLE_PULL_REQUEST:-}" ]; then + bun run coverage:merge + bun run coverage:patch + else + echo "Patch coverage is PR-only; project coverage already passed." + fi + - run: + name: Install verified Codecov CLI + command: | + curl -Os https://cli.codecov.io/latest/linux/codecov + curl -Os https://cli.codecov.io/latest/linux/codecov.SHA256SUM + shasum -a 256 -c codecov.SHA256SUM + chmod +x codecov + - run: + name: Upload coverage to Codecov + command: | + test -n "${CODECOV_TOKEN:-}" + ./codecov --verbose upload-process --disable-search --fail-on-error \ + -t "$CODECOV_TOKEN" \ + -n "jumentix-circleci-${CIRCLE_WORKFLOW_ID}" \ + -F project \ + -f coverage/lcov.info \ + -f coverage/browser/lcov.info + - run: + name: Install Sonar scanner + command: | + sonar_version="7.1.0.4889" + sonar_dir="/tmp/sonar-scanner-${sonar_version}-linux-x64" + rm -rf "$sonar_dir" /tmp/sonar-scanner.zip + curl -fsSL -o /tmp/sonar-scanner.zip \ + "https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-${sonar_version}-linux-x64.zip" + unzip -qo /tmp/sonar-scanner.zip -d /tmp + echo "export PATH=${sonar_dir}/bin:\$PATH" >> "$BASH_ENV" + - run: + name: SonarQube Cloud Scan + command: | + source "$BASH_ENV" + test -n "${SONAR_TOKEN:-}" + sonar-scanner -Dsonar.scm.disabled=true + - run: + name: Report Sonar findings + command: | + source "$BASH_ENV" + bun ci-cd/report-sonar-findings.js + - run: + name: Stop local coverage services + when: always + command: ci-cd/cleanup-local-ci-services.sh + - store_artifacts: + path: coverage + destination: coverage + - store_artifacts: + path: artifacts/ci + destination: coverage-evidence + + website: + executor: node_bun + environment: + JUMENTIX_WEBSITE_CYPRESS_SKIP_BUILD: "1" + JUMENTIX_WEBSITE_CYPRESS_BROWSER: chrome + steps: + - prepare_checkout + - resolve_pr_metadata + - require_ci_job: + job: website + - run: + name: Build Storybook + command: | + source "$BASH_ENV" + bun run website:storybook:build + - run: + name: Validate Storybook inventory + command: | + source "$BASH_ENV" + bun run website:storybook:smoke + - run: + name: Validate publishable website content + command: | + source "$BASH_ENV" + bun run website:test:prepublish + - run: + name: Jest component, accessibility, and link quality gates + command: | + source "$BASH_ENV" + bun run website:test:unit + - run: + name: Install Cypress binary + command: | + source "$BASH_ENV" + bun x cypress install + - run: + name: Cypress website quality gates + command: | + source "$BASH_ENV" + bun run website:test:cypress + + database-matrix: + executor: node_bun + steps: + - prepare_checkout + - resolve_pr_metadata + - require_ci_job: + job: database-matrix + - setup_remote_docker + - run: + name: Ensure Docker runtime + command: ci-cd/ensure-docker-runtime.sh + - run: + name: Database driver smoke matrix against real containers + no_output_timeout: 45m + command: | + source "$BASH_ENV" + bun run test:smoke:db:all + - run: + name: Redis key-value integration against a real server + no_output_timeout: 15m + command: | + source "$BASH_ENV" + bun run smoke:key-value:redis + - run: + name: Dead-letter queue integration against a real Redis + no_output_timeout: 15m + command: | + source "$BASH_ENV" + bun run smoke:dead-letter:redis + - run: + name: Broker integration against real RabbitMQ and Redis + no_output_timeout: 15m + command: | + source "$BASH_ENV" + bun run smoke:message-mediator + - run: + name: External repository adapters against Cassandra and MongoDB + no_output_timeout: 20m + command: | + source "$BASH_ENV" + bun run smoke:db-repositories + +workflows: + ci: + jobs: + - branch-gate + - third-party-review + - workspace-builds + - workspace-tests + - integration + - coverage + - website + - database-matrix diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..3ac9820c3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules +**/node_modules +**/dist +**/.build +coverage +.git +apps/jumentix-website/* +!apps/jumentix-website/package.json +apps/frontend/template +**/cypress/videos +**/cypress/screenshots diff --git a/.eslintrc.js b/.eslintrc.js index f9f682b9d..5a28138cc 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -3,6 +3,11 @@ const path = require('path'); module.exports = { ignorePatterns: [ 'apps/jumentix-website/next-env.d.ts', + // apps/frontend/template is a vendored, frozen third-party catalog + // (CoreUI, MIT) kept as reference for generated frontends — like dist, + // vendored code is not held to this ruleset. apps/frontend/src follows + // the Jumentix standard (airbnb + semicolons) and IS linted here. + 'apps/frontend/template', '**/dist/**' ], parser: '@typescript-eslint/parser', @@ -73,6 +78,7 @@ module.exports = { packageDir: [ __dirname, path.join(__dirname, 'apps/backend-template'), + path.join(__dirname, 'apps/frontend'), path.join(__dirname, 'apps/jumentix-website'), path.join(__dirname, 'packages/sdk-rest-client'), path.join(__dirname, 'packages/sdk-websocket-client'), @@ -138,6 +144,18 @@ module.exports = { 'jest/require-top-level-describe': 'off' } }, + { + /* + * The frontend suites are bun:test, not Jest (JUM-760): fixture state + * lives in describe-scoped bindings shared between `beforeEach` and the + * tests, which is exactly what `jest/require-hook` forbids for Jest. + * Same exception shape as the package integration suites above. + */ + files: ['apps/frontend/test/**/*.ts'], + rules: { + 'jest/require-hook': 'off' + } + }, { /* * The browser suites are Mocha and Chai, not Jest (Requirement 112 §4). @@ -151,7 +169,9 @@ module.exports = { * these files run in a browser, which the Node parser configuration does * not assume. */ - files: ['cypress/**/*.js', 'packages/*/cypress/**/*.ts'], + // apps/frontend/cypress: the frontend e2e suite (JUM-776) runs in the same + // Mocha/Chai browser runtime as the package suites. + files: ['cypress/**/*.js', 'packages/*/cypress/**/*.ts', 'apps/frontend/cypress/**/*.ts'], env: { browser: true, mocha: true }, rules: { 'jest/expect-expect': 'off', diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11eadb75a..c63aeee50 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,11 +12,6 @@ on: schedule: - cron: '17 3 * * *' -permissions: - contents: read - pull-requests: read - security-events: write - env: BUN_VERSION: 1.3.13 JUMENTIX_CI_GATE_RESULT_FILE: artifacts/ci/branch-quality-gate.json @@ -26,7 +21,10 @@ env: jobs: branch-gate: name: ${{ github.event_name == 'push' && github.ref_name != 'dev' && github.ref_name != 'main' && 'task-branch-push' || 'branch-gate' }} - runs-on: [self-hosted, jumentix] + permissions: + contents: read + pull-requests: read + runs-on: ubuntu-latest env: AAA_JWT_TOKEN_SECRET_KEY: ci_jwt_secret_key JUMENTIX_TASK_TEST_MODE: range @@ -36,33 +34,15 @@ jobs: FIREBASE_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_KEY }} FIREBASE_DATABASE_URL: ${{ secrets.FIREBASE_DATABASE_URL }} steps: - - name: Checkout repository without JavaScript Actions - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - mkdir -p "$GITHUB_WORKSPACE" - cd "$GITHUB_WORKSPACE" - git init - git remote remove origin 2>/dev/null || true - git remote add origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" - if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then - git -c credential.helper= fetch --no-tags --prune origin "refs/pull/${{ github.event.pull_request.number }}/merge" - else - git -c credential.helper= fetch --no-tags --prune origin "${GITHUB_SHA}" - fi - git checkout --force FETCH_HEAD - git clean -fdx - - name: Use local Node.js 22 - run: | - set -euo pipefail - export NVM_DIR="$HOME/.nvm" - if [ -s "$NVM_DIR/nvm.sh" ]; then - . "$NVM_DIR/nvm.sh" - nvm use 22 - fi - node --version | grep -E '^v22\.' - echo "$(dirname "$(command -v node)")" >> "$GITHUB_PATH" + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: Use Node.js 22 + uses: actions/setup-node@v5 + with: + node-version: 22 + package-manager-cache: false - name: Fetch branch references run: git fetch --prune origin "+refs/heads/*:refs/remotes/origin/*" - name: Install Bun @@ -127,40 +107,24 @@ jobs: third-party-review: name: third-party-review + permissions: + contents: read if: >- github.event_name == 'pull_request' || github.ref_name == 'main' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' - runs-on: [self-hosted, jumentix] + runs-on: ubuntu-latest steps: - - name: Checkout repository without JavaScript Actions - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - mkdir -p "$GITHUB_WORKSPACE" - cd "$GITHUB_WORKSPACE" - git init - git remote remove origin 2>/dev/null || true - git remote add origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" - if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then - git -c credential.helper= fetch --no-tags --prune origin "refs/pull/${{ github.event.pull_request.number }}/merge" - else - git -c credential.helper= fetch --no-tags --prune origin "${GITHUB_SHA}" - fi - git checkout --force FETCH_HEAD - git clean -fdx - - name: Use local Node.js 22 - run: | - set -euo pipefail - export NVM_DIR="$HOME/.nvm" - if [ -s "$NVM_DIR/nvm.sh" ]; then - . "$NVM_DIR/nvm.sh" - nvm use 22 - fi - node --version | grep -E '^v22\.' - echo "$(dirname "$(command -v node)")" >> "$GITHUB_PATH" + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: Use Node.js 22 + uses: actions/setup-node@v5 + with: + node-version: 22 + package-manager-cache: false - name: Fetch branch references run: git fetch --prune origin "+refs/heads/*:refs/remotes/origin/*" - name: Install pinned review tools @@ -192,6 +156,11 @@ jobs: workspace-builds: name: workspace-builds + permissions: + contents: read + # Intentional: release/main/scheduled only (Req 087/113). Task PRs to `dev` + # and cheap `dev` pushes skip this job; `arch:check-workspace-boundaries` + # and `build:dev` still fail closed as branch-gate preflight (JUM-786). if: >- github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || @@ -200,35 +169,17 @@ jobs: github.head_ref == 'dev' || (startsWith(github.head_ref, 'codex/release/') && endsWith(github.head_ref, '-dev-main-signed-squash')) )) - runs-on: [self-hosted, jumentix] + runs-on: ubuntu-latest steps: - - name: Checkout repository without JavaScript Actions - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - mkdir -p "$GITHUB_WORKSPACE" - cd "$GITHUB_WORKSPACE" - git init - git remote remove origin 2>/dev/null || true - git remote add origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" - if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then - git -c credential.helper= fetch --no-tags --prune origin "refs/pull/${{ github.event.pull_request.number }}/merge" - else - git -c credential.helper= fetch --no-tags --prune origin "${GITHUB_SHA}" - fi - git checkout --force FETCH_HEAD - git clean -fdx - - name: Use local Node.js 22 - run: | - set -euo pipefail - export NVM_DIR="$HOME/.nvm" - if [ -s "$NVM_DIR/nvm.sh" ]; then - . "$NVM_DIR/nvm.sh" - nvm use 22 - fi - node --version | grep -E '^v22\.' - echo "$(dirname "$(command -v node)")" >> "$GITHUB_PATH" + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: Use Node.js 22 + uses: actions/setup-node@v5 + with: + node-version: 22 + package-manager-cache: false - name: Install Bun and dependencies run: | curl -fsSL https://bun.sh/install | bash -s "bun-v$BUN_VERSION" @@ -241,6 +192,8 @@ jobs: workspace-tests: name: workspace-tests + permissions: + contents: read if: >- github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || @@ -249,35 +202,17 @@ jobs: github.head_ref == 'dev' || (startsWith(github.head_ref, 'codex/release/') && endsWith(github.head_ref, '-dev-main-signed-squash')) )) - runs-on: [self-hosted, jumentix] + runs-on: ubuntu-latest steps: - - name: Checkout repository without JavaScript Actions - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - mkdir -p "$GITHUB_WORKSPACE" - cd "$GITHUB_WORKSPACE" - git init - git remote remove origin 2>/dev/null || true - git remote add origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" - if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then - git -c credential.helper= fetch --no-tags --prune origin "refs/pull/${{ github.event.pull_request.number }}/merge" - else - git -c credential.helper= fetch --no-tags --prune origin "${GITHUB_SHA}" - fi - git checkout --force FETCH_HEAD - git clean -fdx - - name: Use local Node.js 22 - run: | - set -euo pipefail - export NVM_DIR="$HOME/.nvm" - if [ -s "$NVM_DIR/nvm.sh" ]; then - . "$NVM_DIR/nvm.sh" - nvm use 22 - fi - node --version | grep -E '^v22\.' - echo "$(dirname "$(command -v node)")" >> "$GITHUB_PATH" + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: Use Node.js 22 + uses: actions/setup-node@v5 + with: + node-version: 22 + package-manager-cache: false - name: Install Bun and dependencies run: | curl -fsSL https://bun.sh/install | bash -s "bun-v$BUN_VERSION" @@ -293,6 +228,8 @@ jobs: integration: name: integration + permissions: + contents: read if: >- github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || @@ -301,37 +238,19 @@ jobs: github.head_ref == 'dev' || (startsWith(github.head_ref, 'codex/release/') && endsWith(github.head_ref, '-dev-main-signed-squash')) )) - runs-on: [self-hosted, jumentix] + runs-on: ubuntu-latest env: AAA_JWT_TOKEN_SECRET_KEY: ci_jwt_secret_key steps: - - name: Checkout repository without JavaScript Actions - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - mkdir -p "$GITHUB_WORKSPACE" - cd "$GITHUB_WORKSPACE" - git init - git remote remove origin 2>/dev/null || true - git remote add origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" - if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then - git -c credential.helper= fetch --no-tags --prune origin "refs/pull/${{ github.event.pull_request.number }}/merge" - else - git -c credential.helper= fetch --no-tags --prune origin "${GITHUB_SHA}" - fi - git checkout --force FETCH_HEAD - git clean -fdx - - name: Use local Node.js 22 - run: | - set -euo pipefail - export NVM_DIR="$HOME/.nvm" - if [ -s "$NVM_DIR/nvm.sh" ]; then - . "$NVM_DIR/nvm.sh" - nvm use 22 - fi - node --version | grep -E '^v22\.' - echo "$(dirname "$(command -v node)")" >> "$GITHUB_PATH" + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: Use Node.js 22 + uses: actions/setup-node@v5 + with: + node-version: 22 + package-manager-cache: false - name: Install Bun and dependencies run: | curl -fsSL https://bun.sh/install | bash -s "bun-v$BUN_VERSION" @@ -353,6 +272,8 @@ jobs: coverage: name: coverage + permissions: + contents: read if: >- github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || @@ -361,7 +282,7 @@ jobs: github.head_ref == 'dev' || (startsWith(github.head_ref, 'codex/release/') && endsWith(github.head_ref, '-dev-main-signed-squash')) )) - runs-on: [self-hosted, jumentix] + runs-on: ubuntu-latest env: AAA_JWT_TOKEN_SECRET_KEY: ci_jwt_secret_key AAA_REDIS_HOST: 127.0.0.1 @@ -372,35 +293,17 @@ jobs: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} steps: - - name: Checkout repository without JavaScript Actions - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - mkdir -p "$GITHUB_WORKSPACE" - cd "$GITHUB_WORKSPACE" - git init - git remote remove origin 2>/dev/null || true - git remote add origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" - if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then - git -c credential.helper= fetch --no-tags --prune origin "refs/pull/${{ github.event.pull_request.number }}/merge" - else - git -c credential.helper= fetch --no-tags --prune origin "${GITHUB_SHA}" - fi - git checkout --force FETCH_HEAD - git clean -fdx + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 - name: Fetch branch references for patch coverage run: git fetch --prune origin "+refs/heads/*:refs/remotes/origin/*" - - name: Use local Node.js 22 - run: | - set -euo pipefail - export NVM_DIR="$HOME/.nvm" - if [ -s "$NVM_DIR/nvm.sh" ]; then - . "$NVM_DIR/nvm.sh" - nvm use 22 - fi - node --version | grep -E '^v22\.' - echo "$(dirname "$(command -v node)")" >> "$GITHUB_PATH" + - name: Use Node.js 22 + uses: actions/setup-node@v5 + with: + node-version: 22 + package-manager-cache: false - name: Install Bun and dependencies run: | curl -fsSL https://bun.sh/install | bash -s "bun-v$BUN_VERSION" @@ -434,6 +337,8 @@ jobs: - name: Union browser coverage engines run: | bun ci-cd/merge-browser-coverage.js --from-dir coverage + - name: Produce frontend coverage for the patch report + run: bun run frontend:test:coverage - name: Enforce patch coverage run: | if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then @@ -442,29 +347,19 @@ jobs: else echo "Patch coverage is PR-only; project coverage already passed." fi - - name: Install verified Codecov CLI - run: | - set -euo pipefail - case "$(uname -s)" in - Darwin) codecov_os="macos" ;; - Linux) codecov_os="linux" ;; - *) echo "Unsupported Codecov CLI OS: $(uname -s)" >&2; exit 1 ;; - esac - curl -Os "https://cli.codecov.io/latest/${codecov_os}/codecov" - curl -Os "https://cli.codecov.io/latest/${codecov_os}/codecov.SHA256SUM" - shasum -a 256 -c codecov.SHA256SUM - chmod +x codecov - - name: Upload coverage to Codecov - run: | - test -n "${CODECOV_TOKEN:-}" - ./codecov --verbose upload-process --disable-search --fail-on-error \ - -t "$CODECOV_TOKEN" \ - -n "jumentix-${GITHUB_RUN_ID}" \ - -F project \ - -f coverage/lcov.info \ - -f coverage/browser/lcov.info + - name: Upload coverage reports to Codecov + # codecov/codecov-action@v5, pinned to the peeled tag commit for Sonar/GitHub Actions supply-chain policy. + uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage/lcov.info,coverage/browser/lcov.info + flags: project + name: jumentix-${{ github.run_id }} + fail_ci_if_error: true - name: Install Sonar scanner - if: vars.JUMENTIX_ENABLE_SONAR == 'true' + # SonarCloud Automatic Analysis owns the repository check by default. + # Enable this CI scanner only after disabling Automatic Analysis in SonarCloud. + if: vars.JUMENTIX_ENABLE_SONAR_CI == 'true' run: | set -euo pipefail case "$(uname -s)-$(uname -m)" in @@ -481,7 +376,7 @@ jobs: unzip -qo /tmp/sonar-scanner.zip -d /tmp echo "${sonar_dir}/bin" >> "$GITHUB_PATH" - name: SonarQube Cloud Scan - if: vars.JUMENTIX_ENABLE_SONAR == 'true' + if: vars.JUMENTIX_ENABLE_SONAR_CI == 'true' run: | SONAR_TARGET_BRANCH="${GITHUB_BASE_REF:-${GITHUB_REF_NAME:-}}" if [ "$SONAR_TARGET_BRANCH" != "main" ] && [ "$SONAR_TARGET_BRANCH" != "dev" ]; then @@ -491,7 +386,7 @@ jobs: test -n "${SONAR_TOKEN:-}" sonar-scanner -Dsonar.scm.disabled=true - name: Report Sonar findings - if: vars.JUMENTIX_ENABLE_SONAR == 'true' + if: vars.JUMENTIX_ENABLE_SONAR_CI == 'true' run: bun ci-cd/report-sonar-findings.js - name: Stop local coverage services if: always() @@ -502,6 +397,8 @@ jobs: website: name: website + permissions: + contents: read if: >- github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || @@ -510,38 +407,20 @@ jobs: github.head_ref == 'dev' || (startsWith(github.head_ref, 'codex/release/') && endsWith(github.head_ref, '-dev-main-signed-squash')) )) - runs-on: [self-hosted, jumentix] + runs-on: ubuntu-latest env: JUMENTIX_WEBSITE_CYPRESS_SKIP_BUILD: '1' JUMENTIX_WEBSITE_CYPRESS_BROWSER: chrome steps: - - name: Checkout repository without JavaScript Actions - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - mkdir -p "$GITHUB_WORKSPACE" - cd "$GITHUB_WORKSPACE" - git init - git remote remove origin 2>/dev/null || true - git remote add origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" - if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then - git -c credential.helper= fetch --no-tags --prune origin "refs/pull/${{ github.event.pull_request.number }}/merge" - else - git -c credential.helper= fetch --no-tags --prune origin "${GITHUB_SHA}" - fi - git checkout --force FETCH_HEAD - git clean -fdx - - name: Use local Node.js 22 - run: | - set -euo pipefail - export NVM_DIR="$HOME/.nvm" - if [ -s "$NVM_DIR/nvm.sh" ]; then - . "$NVM_DIR/nvm.sh" - nvm use 22 - fi - node --version | grep -E '^v22\.' - echo "$(dirname "$(command -v node)")" >> "$GITHUB_PATH" + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: Use Node.js 22 + uses: actions/setup-node@v5 + with: + node-version: 22 + package-manager-cache: false - name: Install Bun and dependencies run: | curl -fsSL https://bun.sh/install | bash -s "bun-v$BUN_VERSION" @@ -563,6 +442,8 @@ jobs: database-matrix: name: database-matrix + permissions: + contents: read if: >- github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || @@ -571,38 +452,20 @@ jobs: github.head_ref == 'dev' || (startsWith(github.head_ref, 'codex/release/') && endsWith(github.head_ref, '-dev-main-signed-squash')) )) - runs-on: [self-hosted, jumentix] + runs-on: ubuntu-latest env: FIREBASE_SERVICE_ACCOUNT_KEY: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_KEY }} FIREBASE_DATABASE_URL: ${{ secrets.FIREBASE_DATABASE_URL }} steps: - - name: Checkout repository without JavaScript Actions - env: - GITHUB_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - mkdir -p "$GITHUB_WORKSPACE" - cd "$GITHUB_WORKSPACE" - git init - git remote remove origin 2>/dev/null || true - git remote add origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" - if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then - git -c credential.helper= fetch --no-tags --prune origin "refs/pull/${{ github.event.pull_request.number }}/merge" - else - git -c credential.helper= fetch --no-tags --prune origin "${GITHUB_SHA}" - fi - git checkout --force FETCH_HEAD - git clean -fdx - - name: Use local Node.js 22 - run: | - set -euo pipefail - export NVM_DIR="$HOME/.nvm" - if [ -s "$NVM_DIR/nvm.sh" ]; then - . "$NVM_DIR/nvm.sh" - nvm use 22 - fi - node --version | grep -E '^v22\.' - echo "$(dirname "$(command -v node)")" >> "$GITHUB_PATH" + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: Use Node.js 22 + uses: actions/setup-node@v5 + with: + node-version: 22 + package-manager-cache: false - name: Install Bun and dependencies run: | curl -fsSL https://bun.sh/install | bash -s "bun-v$BUN_VERSION" diff --git a/.gitignore b/.gitignore index 48e2927d8..646c3e93e 100644 --- a/.gitignore +++ b/.gitignore @@ -99,6 +99,8 @@ out !src/modules/**/adapters/out/** !apps/backend-template/src/modules/**/adapters/out/ !apps/backend-template/src/modules/**/adapters/out/** +!apps/service-management-api/src/modules/**/adapters/out/ +!apps/service-management-api/src/modules/**/adapters/out/** !test/**/out/ !test/**/out/** @@ -165,3 +167,6 @@ apps/service-management/vendor/ # Vercel CLI local project link (machine-specific) .vercel/ + +# Backend dev SQLite databases (JUMENTIX_DATABASE_DRIVER=sqlite) +localhost/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 07e821988..dcc856bd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,32 +4,497 @@ ## All Changes +- 2026-09-15 test: close JUM-821 release patch coverage gap - Eduardo Almeida +- 2026-09-14 [JUM-821][Test] Cover remaining branch gap for the full-matrix floor (#336) - Eduardo A. +- 2026-09-14 [JUM-821][Fix] Break model import cycle, fix all CodeQL alerts, close branch coverage gap (#333) - Eduardo A. +- 2026-09-14 [JUM-804][Fix] Hydrate Cana user details for local profile (#335) - Eduardo A. +- 2026-09-14 fix(frontend): hydrate Cana users for profile PUT - Eduardo A. +- 2026-09-14 [JUM-802][Feature] Offline-first Cana seed and PWA shell (#334) - Eduardo A. +- 2026-09-14 refactor(frontend): split outbox drain for Sonar - Eduardo A. +- 2026-09-14 fix(ci): resolve yaml in isolated review tests - Eduardo A. +- 2026-09-14 feat(frontend): add offline Cana PWA seed - Eduardo A. +- 2026-09-14 [JUM-795][Feature] Multitask app shell with modules, taskbar and widgets (#332) - Eduardo A. +- 2026-09-14 fix(frontend): use real buttons on taskbar - Eduardo A. +- 2026-09-14 feat(frontend): add multitask module shell - Eduardo A. +- 2026-09-14 feat(persistence): add opt-in tombstone purge (#331) - Eduardo A. +- 2026-09-14 test(coverage): raise monorepo coverage above the full-matrix floor (JUM-821) (#330) - Eduardo A. +- 2026-09-14 [JUM-787][Feature] Contract vocabulary v2 — relations, keys, services, sync (#329) - Eduardo A. +- 2026-09-14 fix(api): ignore echoed deletedAt on write - Eduardo A. +- 2026-09-14 feat(contracts): add vocabulary v2 - Eduardo A. +- 2026-09-13 [JUM-821][Fix] Clear SonarQube security/reliability findings and reconcile main lineage (#328) - Eduardo A. +- 2026-09-13 fix(quality): clear SonarQube security/reliability findings blocking JUM-821 promotion - Eduardo Almeida +- 2026-09-13 chore(merge): reconcile origin/main into dev lineage for JUM-821 promotion - Eduardo Almeida +- 2026-09-13 ci: keep sonar scan encoding safe (JUM-784) (#326) - Eduardo A. +- 2026-09-13 [JUM-784][CI] Restore ci:gate health (boundaries, build:dev, branch-gate preflight) (#323) - Eduardo A. +- 2026-09-13 chore(ci): merge origin/dev into JUM-784 restore branch - Eduardo A. +- 2026-09-13 docs: avoid unavailable provider badges (JUM-568) (#324) - Eduardo A. +- 2026-09-13 fix(ci): restore ci:gate health on task and dev paths - Eduardo A. +- 2026-09-13 ci: use public https checkout in circleci (JUM-568) (#322) - Eduardo A. +- 2026-09-13 docs: fix ci coverage badges (JUM-568) (#321) - Eduardo A. +- 2026-09-13 test: isolate frontend auth guard storage (JUM-568) (#320) - Eduardo A. +- 2026-09-13 [JUM-568][CI] Rebind public CI providers (#319) - Eduardo A. +- 2026-09-13 [JUM-774][Feature] Frontend seed and X-CRUD kit hardening (JUM-774…782) (#318) - Eduardo A. +- 2026-09-13 feat(frontend): auto-redirect to login when the session expires (JUM-783) (#317) - Eduardo A. +- 2026-09-13 [JUM-733] Complete Service Management audit help epic (#316) - Eduardo A. +- 2026-09-13 fix(service-management): add accessible control help [JUM-733] - Eduardo Almeida +- 2026-09-12 [JUM-773][Feature] X-CRUD enterprise redesign: X-SYNTH toolbar/grid, array editors, FK labels (#315) - Eduardo A. +- 2026-09-11 feat(frontend): generic x-crud kit with users domain sub-apps and oas rbac (JUM-772) (#314) - Eduardo A. +- 2026-09-11 [JUM-757][Feature] GUI interface slot + interactive hexagonal architecture map (#298) - Eduardo A. +- 2026-09-11 feat: add GUI interface slot and interactive hexagonal architecture map [JUM-757] - Eduardo Almeida +- 2026-09-11 fix(service-management): repair pm2 lifecycle, self-guard, stable help, richer charts (JUM-770) (#313) - Eduardo A. +- 2026-09-10 [JUM-769][Fix] Enforce pattern-only OAS rules at input level, visible enum affordance, x-hide (#311) - Eduardo A. +- 2026-09-10 [JUM-767][Fix] Harden monitoring disk I/O, start-after-stop, async context, process help (#310) - Eduardo A. +- 2026-09-10 fix(service-management): harden monitoring disk I/O, start, async, help - Eduardo A. +- 2026-09-10 fix(contracts): complete oas-driven form validation (JUM-768) (#309) - Eduardo A. +- 2026-09-10 [JUM-767][Feature] Service Management monitoring WebSocket + D3/Cana/disk I/O/ALS (#308) - Eduardo A. +- 2026-09-10 feat(service-management): live PM2 monitoring over WebSocket - Eduardo A. +- 2026-09-10 feat(frontend): build forms from the OAS schema at runtime (JUM-766) (#307) - Eduardo A. +- 2026-09-10 feat(frontend): align OAS delete paths, section alerts, masks and network widget (JUM-765) (#306) - Eduardo A. +- 2026-09-09 chore(backend-template): seed admin eduardo with requested credentials (JUM-764) (#305) - Eduardo A. +- 2026-09-09 fix(backend-template): make seeding idempotent for persistent databases (JUM-763) (#304) - Eduardo A. +- 2026-09-09 fix(frontend): redirect invalid sessions to login automatically (JUM-762) (#303) - Eduardo A. +- 2026-09-09 feat(frontend): add editable profile page through the user menu (JUM-761) (#302) - Eduardo A. +- 2026-09-07 feat(frontend): add login and register over OAS via sdk-rest-client (JUM-760) (#301) - Eduardo A. +- 2026-09-07 fix(frontend): make frontend lint run on eslint 10 with scoped eslint-scope (JUM-759) (#300) - Eduardo A. +- 2026-09-07 feat(frontend): add frontend workspace seed and MVP shell with OAS boundary (JUM-758) (#299) - Eduardo A. +- 2026-09-07 [JUM-748] Extract Service Management catalog ownership (#297) - Eduardo A. +- 2026-09-07 fix(service-management): include ci catalog cors origins - Eduardo Almeida +- 2026-09-07 fix(service-management): scope catalog cors by environment - Eduardo Almeida +- 2026-09-07 fix(service-management): restore catalog api gate fixes - Eduardo Almeida +- 2026-09-07 chore(ci): update bun lock for service management api - Eduardo Almeida +- 2026-09-07 refactor(service-management): extract catalog ownership - Eduardo Almeida +- 2026-09-07 test(service-management): move designer suites to app ownership (#296) - Eduardo A. +- 2026-09-06 fix(service-management): flush Code Workspace edits before export - Eduardo A. +- 2026-09-06 test(ci): align readme badge contract with private dashboards (#292) - Eduardo A. +- 2026-09-06 docs(readme): replace broken coverage and sonar badges (#290) - Eduardo A. +- 2026-09-06 feat(service-management): polish designer workbench (#289) - Eduardo A. +- 2026-08-29 [JUM-732][Fix] Name every control in the generated field rows (#282) - Eduardo A. +- 2026-08-29 fix(service-management): name the runtime env fields explicitly [JUM-732] - Eduardo A. +- 2026-08-29 fix(service-management): name every control in the generated field rows [JUM-732] - Eduardo A. +- 2026-08-29 [JUM-730][Fix] Report the outcome of the action that just ran (#281) - Eduardo A. +- 2026-08-29 fix(service-management): report the outcome of the action that just ran [JUM-730] - Eduardo A. +- 2026-08-29 [JUM-734][Fix] Vendor the browser bundles before starting the designer (#280) - Eduardo A. +- 2026-08-29 fix(service-management): vendor the browser bundles before starting the designer [JUM-734] - Eduardo A. +- 2026-08-29 [JUM-728][Fix] Render shell documentation fences as copyable code blocks (#279) - Eduardo A. +- 2026-08-29 fix(website): render shell fences as copyable code blocks, not editors [JUM-728] - Eduardo A. +- 2026-08-29 [JUM-721][Release] Promote dev to main with signed squash (#278) - Eduardo A. +- 2026-08-29 [JUM-579][Bug] Ignore website metadata in patch coverage (#276) - Eduardo A. +- 2026-08-29 fix(coverage): ignore website navigation metadata in patch gate - Eduardo Almeida +- 2026-08-29 [JUM-579][Bug] Keep UUID generation covered centrally (#275) - Eduardo A. +- 2026-08-29 fix(ci): avoid duplicate required branch gate checks - Eduardo Almeida +- 2026-08-29 fix(coverage): keep uuid generation covered centrally - Eduardo Almeida +- 2026-08-29 [JUM-727][Docs] Publish the Service Manager and Domain Designer guide (#274) - Eduardo A. +- 2026-08-29 Merge remote-tracking branch 'origin/dev' into claude/docs/JUM-727-service-manager-guide - Eduardo A. +- 2026-08-29 docs(website): publish the Service Manager and Domain Designer guide [JUM-727] - Eduardo A. +- 2026-08-29 [JUM-579][Bug] Fetch main ref for patch coverage (#273) - Eduardo A. +- 2026-08-29 fix(ci): fetch main ref for patch coverage - Eduardo Almeida +- 2026-08-29 [JUM-579][Bug] Make review artifact uploads best-effort (#272) - Eduardo A. +- 2026-08-29 fix(ci): make review artifacts best effort - Eduardo Almeida +- 2026-08-29 [JUM-579][Bug] Gate project coverage before browser union (#271) - Eduardo A. +- 2026-08-29 fix(ci): gate coverage before browser union - Eduardo Almeida +- 2026-08-29 [JUM-579][Bug] Stabilize release website, harness, and coverage gates (#270) - Eduardo A. +- 2026-08-29 fix(ci): stabilize release website and harness gates - Eduardo Almeida +- 2026-08-29 [JUM-579][Bug] Prime Cana before monorepo builds (#269) - Eduardo A. +- 2026-08-29 fix(ci): prime Cana before monorepo builds - Eduardo Almeida +- 2026-08-29 [JUM-579][Bug] Restore release gate prerequisites (#268) - Eduardo A. +- 2026-08-29 chore(ci): refresh release gate checks - Eduardo Almeida +- 2026-08-29 fix(ci): bootstrap Docker and Storybook release gates - Eduardo Almeida +- 2026-08-29 [Release] Sync main ancestry into dev (#267) - Eduardo A. +- 2026-08-29 chore(release): sync main ancestry into dev - Eduardo Almeida +- 2026-08-29 test: retire branch coverage exception (JUM-721) (#265) - Eduardo A. +- 2026-08-29 [JUM-687][Fix] Reuse Supertest listeners across remaining HTTP adapter suites (#264) - Eduardo A. +- 2026-08-29 test(http): reuse listeners across adapter integration suites (JUM-687) - Eduardo Almeida +- 2026-08-29 [JUM-726][Fix] Use pinned Bun during GitHub Actions installs (#263) - Eduardo A. +- 2026-08-29 fix(ci): use pinned Bun during workflow installs (JUM-726) - Eduardo Almeida +- 2026-08-29 [JUM-687][Fix] Supertest listener churn behind the intermittent 404s, plus JUM-721 coverage (#256) - Eduardo A. +- 2026-08-29 Merge remote-tracking branch 'origin/dev' into claude/governance/JUM-721-branch-coverage - Eduardo Almeida +- 2026-08-29 [JUM-725][Chore] Consolidate August 2026 Dependabot updates (#262) - Eduardo A. +- 2026-08-29 chore(deps): consolidate dependabot updates (JUM-725) - Eduardo Almeida +- 2026-08-25 test(coverage): close 29 more branches and ratchet to 96.422% (JUM-721) - Eduardo Almeida +- 2026-08-25 test(express): listen once per suite instead of per request (JUM-687) - Eduardo Almeida +- 2026-08-25 fix(http): stop swallowing endpoint registration failures (JUM-687) - Eduardo Almeida - 2026-08-20 [JUM-724][Release] Promote dev to main with signed squash (#254) - Eduardo A. - 2026-08-20 chore(release): promote dev to main for JUM-724 - Eduardo A. +- 2026-08-20 [JUM-723][Chore] Consolidate Dependabot dependency updates (#253) - Eduardo A. +- 2026-08-20 chore(deps): consolidate dependabot updates - Eduardo A. +- 2026-08-20 [JUM-681][Quality] Coverage thresholds at 98%, integrity gate on the real CI paths (#245) - Eduardo A. +- 2026-08-20 Merge remote-tracking branch 'origin/dev' into claude/governance/JUM-681-threshold-98 - Eduardo A. +- 2026-08-20 test(service-management): use the shared server harness in both suites (JUM-722) - Eduardo Almeida +- 2026-08-19 chore(coverage): hand the branches exception to its successor issue (JUM-721) - Eduardo Almeida +- 2026-08-19 docs(testing): synchronise the coverage and integrity gates with what runs (JUM-720) - Eduardo Almeida +- 2026-08-19 chore(coverage): ratchet the branch floor to 95.902% (JUM-681) - Eduardo Almeida +- 2026-08-19 test(cana): wait for the late connection instead of sleeping past it (JUM-679) - Eduardo Almeida +- 2026-08-19 fix(ci): run the test-integrity guard on the gates CI actually selects (JUM-683) - Eduardo Almeida +- 2026-08-19 test(coverage): cover how a schema is resolved and how a failure is named (JUM-681) - Eduardo Almeida +- 2026-08-18 test(coverage): cover the entity manager's edits and its printers (JUM-681) - Eduardo Almeida +- 2026-08-18 test(coverage): cover the ambient defaults three callers rely on (JUM-681) - Eduardo Almeida +- 2026-08-18 test(coverage): pin the auth paths an environment decides (JUM-681) - Eduardo Almeida +- 2026-08-18 test(coverage): read the toolchain facts and refuse a stale compatibility table (JUM-681) - Eduardo Almeida +- 2026-08-18 test(coverage): map the realtime request into the domain event (JUM-681) - Eduardo Almeida +- 2026-08-15 fix(website): render complete Monaco code blocks (#247) - Eduardo A. +- 2026-08-15 chore(website): refresh code block checks - Eduardo A. +- 2026-08-15 test(coverage): cover the sync client's half-written marker and messageless failures (JUM-681) - Eduardo Almeida +- 2026-08-15 fix(website): render complete Monaco code blocks - Eduardo A. +- 2026-08-15 Merge remote-tracking branch 'origin/dev' into claude/governance/JUM-681-threshold-98 - Eduardo A. +- 2026-08-15 test(coverage): cover the built-in auth thresholds (JUM-681) - Eduardo Almeida +- 2026-08-15 test(coverage): drive the entity manager to the end of its flows (JUM-681) - Eduardo Almeida +- 2026-08-15 test(coverage): cover the validation messages, the BullMQ routing and the CLI defaults (JUM-681) - Eduardo Almeida +- 2026-08-15 test(coverage): cover the store indexes, the catalog aggregate and the empty sync (JUM-681) - Eduardo Almeida +- 2026-08-15 test(coverage): cover the domain manager refusals, repair the rename (JUM-681) - Eduardo Almeida +- 2026-08-15 fix(test): rename guard-defaults handles instead of exporting from a test file - Eduardo Almeida +- 2026-08-15 docs(website): expand adapter documentation - Eduardo A. +- 2026-08-15 fix(test): scope guard-defaults as a module so its consts stop colliding (JUM-681) - Eduardo Almeida +- 2026-08-15 test(coverage): cover the auth environment and the CLI refusals (JUM-681) - Eduardo Almeida +- 2026-08-14 fix(test): make the guard-defaults suite survive CI's own environment (JUM-681) - Eduardo Almeida +- 2026-08-14 test(coverage): cover the agent bus refusals and the catalog fallbacks (JUM-681) - Eduardo Almeida +- 2026-08-14 test(coverage): raise the thresholds to 98% and close 58 branches (JUM-681) - Eduardo Almeida +- 2026-08-14 fix(website): organize architecture playground ux - Eduardo A. +- 2026-08-14 fix(website): organize architecture playground ux - Eduardo A. +- 2026-08-13 fix(website): show live indexeddb flow in cana canvas - Eduardo A. +- 2026-08-13 fix(website): show live indexeddb flow in cana canvas - Eduardo A. +- 2026-08-13 fix(website): show live dlq flow in cana canvas - Eduardo A. +- 2026-08-13 Merge remote-tracking branch 'origin/dev' into fix/cana-dlq-live-canvas - Eduardo A. +- 2026-08-13 fix(website): show live dlq flow in cana canvas - Eduardo A. +- 2026-08-13 chore: untrack vercel cli local project link (JUM-719) (#241) - Eduardo A. +- 2026-08-13 fix(website): bundle release notes at build and add empty state (JUM-719) (#240) - Eduardo A. +- 2026-08-13 fix(website): show per-request cana canvas flow - Eduardo A. +- 2026-08-13 Merge remote-tracking branch 'origin/dev' into fix/cana-per-request-canvas-flow - Eduardo A. +- 2026-08-13 fix(website): bake changelog data at build instead of github api (JUM-718) (#238) - Eduardo A. +- 2026-08-13 fix(website): improve cana comparison charts - Eduardo A. +- 2026-08-13 Merge remote-tracking branch 'origin/dev' into fix/cana-per-request-canvas-flow - Eduardo A. +- 2026-08-13 test(ci): cover four guard behaviours, and state what the floor is really made of (JUM-681) (#237) - Eduardo A. +- 2026-08-13 Merge remote-tracking branch 'origin/dev' into fix/cana-per-request-canvas-flow - Eduardo A. +- 2026-08-13 fix(users): serialise organization membership writes, and seed sequentially (JUM-687) (#223) - Eduardo A. +- 2026-08-13 fix(website): show per-request cana canvas flow - Eduardo A. +- 2026-08-13 feat(website): graph cana 30s flow metrics - Eduardo A. +- 2026-08-13 Merge remote-tracking branch 'origin/dev' into fix/cana-infinite-30s-flow - Eduardo A. +- 2026-08-13 feat(website): graph cana 30s flow metrics - Eduardo A. +- 2026-08-13 [JUM-709][Feature] Day-by-day tasks and real code for zero-to-MVP paths (#235) - Eduardo A. +- 2026-08-13 feat(website): merge cana heavy data canvas - Eduardo A. +- 2026-08-13 Merge remote-tracking branch 'origin/dev' into fix/cana-multi-client-workers-canvas - Eduardo A. +- 2026-08-13 chore(deps): consolidate Dependabot updates (JUM-717) (#233) - Eduardo A. +- 2026-08-13 Merge remote-tracking branch 'origin/dev' into fix/cana-multi-client-workers-canvas - Eduardo A. +- 2026-08-13 feat(website): merge cana heavy data canvas - Eduardo A. +- 2026-08-13 [JUM-704][Bug] Install the four missing frameworks and make their adapters serve (#218) - Eduardo A. +- 2026-08-13 feat(website): show cana dlq client flow (#232) - Eduardo A. +- 2026-08-13 [JUM-708][Chore] Pin Bun to 1.3.13 until the frozen-lockfile regression is fixed (#227) - Eduardo A. +- 2026-08-13 fix(website): sync playground navigation and dlq canvas (#231) - Eduardo A. +- 2026-08-13 docs: expand use case code and dlq canvas (#230) - Eduardo A. +- 2026-08-13 docs: showcase mutex dead-letter replay (#229) - Eduardo A. +- 2026-08-13 docs: expand MVP use cases and mediator playgrounds (#228) - Eduardo A. +- 2026-08-13 docs: expand AI governance website showcase (#226) - Eduardo A. +- 2026-08-13 docs: expand Cana and Jumentix website showcases - Eduardo A. +- 2026-08-13 feat(cana): report what a count read (JUM-706) (#219) - Eduardo A. +- 2026-08-13 feat(website): expand commercial page content (#224) - Eduardo A. +- 2026-08-13 feat(website): expand commercial page content - Eduardo A. +- 2026-08-13 feat(website): add white Jumentix site icon (#222) - Eduardo A. +- 2026-08-13 feat(website): add white Jumentix site icon - Eduardo A. +- 2026-08-13 feat(website): show energized Jumentix eating sugarcane (#221) - Eduardo A. +- 2026-08-13 feat(website): energize Jumentix sugarcane art - Eduardo A. +- 2026-08-13 feat(website): show Jumentix eating sugarcane on Cana docs - Eduardo A. +- 2026-08-13 feat(website): promote Jumentix mascot brand identity (#220) - Eduardo A. +- 2026-08-13 feat(website): promote Jumentix mascot brand identity - Eduardo A. +- 2026-08-13 docs(JUM-707): split Cana usage guide and complete examples (#217) - Eduardo A. +- 2026-08-13 Merge remote-tracking branch 'origin/dev' into codex/docs/JUM-707-split-cana-usage-complete-examples - Eduardo A. +- 2026-08-13 docs(JUM-707): split Cana usage guide and complete examples - Eduardo A. +- 2026-08-13 fix(website): render mermaid diagrams, for the first time (JUM-664) (#216) - Eduardo A. +- 2026-08-12 [JUM-681][Test] Cover the paths the floor was blamed on, and correct the analysis (#215) - Eduardo A. +- 2026-08-12 feat(cana): report what a query examined, and drop the stopwatch (JUM-682) (#214) - Eduardo A. +- 2026-08-12 [JUM-705][Docs] Use English Cana example identifiers - Eduardo A. +- 2026-08-12 [JUM-701][Fix] Run the Monaco mount in tests, and stop the task-change gate reading the repository four times (#213) - Eduardo A. +- 2026-08-12 [JUM-698][Bug] Establish the request context in four adapters, and make their suites integrate (#211) - Eduardo A. +- 2026-08-12 [JUM-703][Docs] Expand Cana tutorials and integrations (#210) - Eduardo A. +- 2026-08-12 Merge remote-tracking branch 'origin/dev' into codex/docs/JUM-703-cana-performance-notes - Eduardo A. +- 2026-08-12 feat(cana): add React and Vue integration packages (JUM-703) - Eduardo A. +- 2026-08-12 fix(ci): count assertion declarations per test, not per file (JUM-702) (#209) - Eduardo A. +- 2026-08-12 feat(ci): make the test-integrity check mandatory (JUM-683) (#208) - Eduardo A. +- 2026-08-12 fix(ci): make the branch threshold a real one, with the gap as a dated floor (JUM-681) (#200) - Eduardo A. +- 2026-08-12 fix(ci): correct the mock-only rule, and make the Express suite integrate (JUM-678) (#199) - Eduardo A. +- 2026-08-12 test: declare assertions in the 31 suites that did not (JUM-677) (#197) - Eduardo A. +- 2026-08-12 test: wait on the event, not the clock (JUM-679) (#195) - Eduardo A. +- 2026-08-12 test(website): bring the website's suites under the test map (JUM-680) (#194) - Eduardo A. +- 2026-08-12 [JUM-700][Fix] Hide agent metadata and add Cana design notes - Eduardo A. +- 2026-08-12 test: bound the quarantine to suites that still exist (JUM-682) (#196) - Eduardo A. +- 2026-08-12 [JUM-699][Fix] Stabilize Cana playground theme and Monaco code widgets - Eduardo A. +- 2026-08-12 docs(JUM-690): add Cana framework tutorials (#198) - Eduardo A. +- 2026-08-12 test(website): add component, a11y, and link-quality jest gates for JUM-158 (#192) - Eduardo A. +- 2026-08-12 [JUM-688][Docs] Fix MDX build break from angle-bracket headings (#193) - Eduardo A. +- 2026-08-12 fix(website): escape MDX-breaking (<30 min) package headings (JUM-688) - Eduardo A. +- 2026-08-12 feat(governance): declare and gate test integrity — requirements 134 and 135 (#188) - Eduardo A. +- 2026-08-12 feat(dead-letter): run the replay worker for the life of the server (JUM-53) (#191) - Eduardo A. +- 2026-08-11 [JUM-658][Docs] Site-wide deep docs, playgrounds, SEO/AI (#184) - Eduardo A. +- 2026-08-11 fix(website): document always-on Vercel Analytics (JUM-686) - Eduardo A. +- 2026-08-11 docs(website): finish junior zero-to-pleno epic pass (JUM-671–676) - Eduardo A. +- 2026-08-11 docs(website): add public apps hubs and guide checklists (JUM-675, JUM-685) - Eduardo A. +- 2026-08-11 fix(website): exclude private packages; deepen public package docs (JUM-685) - Eduardo A. +- 2026-08-11 fix(website): dark-mode quote contrast and designer-core playground (JUM-665, JUM-666) - Eduardo A. +- 2026-08-11 docs(website): site-wide deep docs, playgrounds, and SEO/AI for JUM-658 - Eduardo A. +- 2026-08-11 docs(website): publish cana consumer docs and playgrounds - Eduardo A. +- 2026-08-11 fix(website): stop the nested navbar anchor that breaks hydration (JUM-664) (#190) - Eduardo A. +- 2026-08-11 [JUM-637][Governance] Reuse existing Firebase credentials for RTDB agent bus (#168) - Eduardo A. +- 2026-08-11 Merge remote-tracking branch 'origin/dev' into kimi/governance/JUM-637-reuse-firebase-rtdb-credentials - Eduardo A. +- 2026-08-11 [JUM-684][Chore] Consolidate Dependabot dependency updates (#189) - Eduardo A. +- 2026-08-11 chore(deps): consolidate Dependabot updates - Eduardo A. +- 2026-08-11 test(fastify): seed the user the localhost request authenticates as (JUM-663) (#187) - Eduardo A. +- 2026-08-11 test(restify): make the localhost suite establish what it depends on (JUM-663) (#186) - Eduardo A. +- 2026-08-11 fix(users): stop a refused caller from releasing another writer's lock (JUM-663) (#185) - Eduardo A. +- 2026-08-11 [JUM-53][Feature] Replay worker, real-Redis suite and full documentation (#183) - Eduardo A. +- 2026-08-11 [JUM-53][Feature] Dead-letter queue for mutex-refused transactions (#182) - Eduardo A. +- 2026-08-11 chore(lint): clear the 662 warnings so the next one is visible for JUM-657 (#181) - Eduardo A. +- 2026-08-11 fix(agent-bus): order recent events by key and gate RTDB indexes for JUM-656 (#180) - Eduardo A. +- 2026-08-11 chore(website): delete the orphaned content pages for JUM-640 (#179) - Eduardo A. +- 2026-08-10 [JUM-655][CI] Gate stale builds and orphaned content, declare requirements 130-132 (#177) - Eduardo A. +- 2026-08-10 chore(governance): gate the defect classes that recurred, and declare the rules - Eduardo Almeida +- 2026-08-10 [JUM-640][Docs] State the real cause of the orphaned docs content (#176) - Eduardo A. +- 2026-08-10 docs(website): state the real cause of the orphaned docs content - Eduardo Almeida +- 2026-08-10 [JUM-654][Fix] Make the mandatory agent bus usable (#175) - Eduardo A. +- 2026-08-10 fix(agent-registry): make the mandatory agent bus usable - Eduardo Almeida - 2026-08-10 [JUM-638][Release] Promote dev to main with signed squash (#174) - Eduardo A. - 2026-08-10 chore(release): promote dev to main for JUM-638 - Eduardo A. +- 2026-08-10 chore(changelog): regenerate after rebase onto latest dev - Eduardo A. +- 2026-08-10 fix(agent-registry): lock canonical RTDB URL and strip undefined writes - Eduardo A. +- 2026-08-10 fix(agent-registry): reuse existing Firebase registry credentials for RTDB bus - Eduardo A. +- 2026-08-10 [JUM-641][Docs] Record the Jumentix epic delivery baseline (#170) - Eduardo A. +- 2026-08-10 docs(jumentix): record the epic delivery baseline, EN and PT-BR - Eduardo Almeida +- 2026-08-10 [JUM-638][CI] Make SonarCloud advisory while budget is unavailable (#173) - Eduardo A. +- 2026-08-10 Merge remote-tracking branch 'origin/dev' into codex/bug/JUM-638-sonar-opt-in - Eduardo A. +- 2026-08-10 [JUM-158][Test] Discover, sweep and gate every website route (#169) - Eduardo A. +- 2026-08-10 test(website): make the unit suite runnable and assert browser-only failures - Eduardo Almeida +- 2026-08-10 test(website): discover every route and sweep it, and gate the deploy - Eduardo Almeida +- 2026-08-10 ci(governance): keep sonar analysis within loc budget - Eduardo A. +- 2026-08-10 [JUM-638][CI] Keep Sonar analysis within LOC budget (#172) - Eduardo A. +- 2026-08-10 ci(governance): keep sonar analysis within loc budget - Eduardo A. +- 2026-08-10 [JUM-638][Fix] Isolate HTTP update integration targets (#171) - Eduardo A. +- 2026-08-10 test(governance): isolate http update integration targets - Eduardo A. +- 2026-08-10 [JUM-638][Fix] Stabilize dev promotion gates (#167) - Eduardo A. +- 2026-08-10 test(governance): stabilize dev promotion gates - Eduardo A. +- 2026-08-10 [JUM-637][Governance] Mandatory Firebase RTDB agent progress bus (Requirement 129) (#164) - Eduardo A. +- 2026-08-10 Merge remote-tracking branch 'origin/dev' into kimi/governance/JUM-637-mandatory-firebase-agent-bus - Eduardo A. +- 2026-08-09 [JUM-639][Release] Reconcile main ancestry into dev (#166) - Eduardo A. +- 2026-08-09 chore(release): reconcile main ancestry into dev for JUM-639 - Eduardo Almeida +- 2026-08-09 feat(agent-registry): add Firebase RTDB agent progress bus - Eduardo A. +- 2026-08-09 [JUM-628][Fix] Retry port allocation on EADDRINUSE in the test harness (#140) - Eduardo A. - 2026-08-09 [JUM-636][Release] Promote dev to main (#162) - Eduardo A. - 2026-08-09 chore(release): promote dev to main - Eduardo A. +- 2026-08-09 [JUM-636][Fix] Run full gate for signed release branches (#163) - Eduardo A. +- 2026-08-09 fix(ci): run full gate for signed release branches - Eduardo A. +- 2026-08-09 [JUM-635][Fix] Isolate ServiceManagement test ports (#161) - Eduardo A. +- 2026-08-09 fix(ci): isolate ServiceManagement test server ports - Eduardo A. +- 2026-08-09 [JUM-635][Fix] Stabilize status-region browser assertion (#160) - Eduardo A. +- 2026-08-09 fix(ci): stabilize status-region browser assertion - Eduardo A. +- 2026-08-09 [JUM-635][Fix] Stabilize ServiceManagement browser gate (#159) - Eduardo A. +- 2026-08-09 fix(ci): make first-run browser spec order independent - Eduardo A. - 2026-08-09 [JUM-634][Release] Promote dev to main (#158) - Eduardo A. - 2026-08-09 fix(ci): allow signed dev promotion governance - Eduardo A. - 2026-08-09 fix(ci): classify signed dev promotion release branches - Eduardo A. - 2026-08-09 chore(release): promote dev to main - Eduardo A. +- 2026-08-09 [JUM-633][CI] Resolve Sonar release gate findings (#157) - Eduardo A. +- 2026-08-09 fix(ci): resolve Sonar release gate findings - Eduardo A. +- 2026-08-09 fix(ci): isolate database compose projects (#156) - Eduardo A. +- 2026-08-09 fix(ci): isolate database compose projects - Eduardo A. +- 2026-08-09 fix(ci): recreate mongodb smoke container (#155) - Eduardo A. +- 2026-08-09 fix(ci): recreate mongodb smoke container - Eduardo A. +- 2026-08-09 fix(ci): keep db repository smoke on bun (#154) - Eduardo A. +- 2026-08-09 fix(ci): keep db repository smoke on bun - Eduardo A. +- 2026-08-09 [JUM-632][CI] Keep coverage free of live broker suites (#153) - Eduardo A. +- 2026-08-09 ci: refresh branch gate status - Eduardo A. +- 2026-08-09 ci: refresh checks after disabling circleci - Eduardo A. +- 2026-08-09 ci: keep coverage free of live broker suites - Eduardo A. +- 2026-08-09 [JUM-632][CI] Stabilize Redis database matrix smoke (#152) - Eduardo A. +- 2026-08-09 ci: stabilize Redis database matrix smoke - Eduardo A. +- 2026-08-09 [JUM-632][CI] Make Sonar scanner install idempotent (#151) - Eduardo A. +- 2026-08-09 ci: make Sonar scanner install idempotent - Eduardo A. +- 2026-08-09 [JUM-632][CI] Recreate Cassandra compose service before database matrix (#150) - Eduardo A. +- 2026-08-09 ci: recreate Cassandra compose service before database matrix - Eduardo A. +- 2026-08-09 [JUM-632][CI] Select macOS coverage publishing tools (#149) - Eduardo A. +- 2026-08-09 ci: retrigger GitHub Actions after runner reset - Eduardo A. +- 2026-08-09 ci: retrigger macOS coverage tool checks - Eduardo A. +- 2026-08-09 ci: select macOS tools for coverage publishing - Eduardo A. +- 2026-08-09 [JUM-615][Feature] Cana localStorage fallback, real Workers, 100% coverage (#138) - Eduardo A. +- 2026-08-09 [JUM-632][CI] Reuse local broker services in GitHub Actions (#148) - Eduardo A. +- 2026-08-09 ci: reuse local broker services in GitHub Actions - Eduardo A. +- 2026-08-09 [JUM-632][Release] Record main ancestry before promotion (#147) - Eduardo A. +- 2026-08-09 chore(release): record main ancestry before promotion - Eduardo A. +- 2026-08-09 [JUM-631][CI] Make dev delivery gates cheap and context-aware (#144) - Eduardo A. +- 2026-08-09 ci: run GitHub Actions on self-hosted runner - Eduardo A. +- 2026-08-09 ci: replace CircleCI with GitHub Actions - Eduardo A. +- 2026-08-09 test: isolate branch gate tests from CircleCI env - Eduardo A. +- 2026-08-09 ci: treat unselected CircleCI jobs as skipped - Eduardo A. +- 2026-08-09 Merge remote-tracking branch 'origin/dev' into codex/ci/JUM-631-fast-cheap-dev-ci - Eduardo A. +- 2026-08-09 ci: make context classifier runtime configurable - Eduardo A. +- 2026-08-09 ci: make dev delivery gates cheap and context-aware - Eduardo A. +- 2026-08-09 [JUM-631][Governance] Requirement 128 — requirement changes take precedence in the release process (#143) - Eduardo A. +- 2026-08-09 docs(governance): add requirement 128 for release precedence of requirement changes - Eduardo Almeida +- 2026-08-09 [JUM-630][Governance] Requirement 127 — mandatory rtk and Caveman in every agent session (#142) - Eduardo A. +- 2026-08-09 docs(governance): teach rtk and caveman, EN and PT-BR - Eduardo Almeida +- 2026-08-09 docs(governance): add requirement 127 for mandatory rtk and caveman - Eduardo Almeida +- 2026-08-08 [JUM-493][Feature] Designer core as @jumentix package with publish dry-run (#137) - Eduardo A. +- 2026-08-08 fix(JUM-629): re-export Cana barrel with export `*` so bun 1.3.14 emits no dangling bindings (#141) - Eduardo A. +- 2026-08-08 [JUM-609][Governance] Fail on duplicate requirement IDs, for being duplicates (#139) - Eduardo A. +- 2026-08-08 chore(governance): fail on duplicate requirement IDs, for being duplicates - Eduardo Almeida +- 2026-08-08 [JUM-626][Fix] Announce load-time storage corruption recovery in the boot UI (#136) - Eduardo A. +- 2026-08-08 [JUM-627][Governance] Verify the child task issue is in the focused epic (#134) - Eduardo A. +- 2026-08-08 test(governance): stop the no-credential cases reading the ambient key - Eduardo Almeida +- 2026-08-08 chore(governance): state which membership the check verified - Eduardo Almeida +- 2026-08-08 fix(governance): find the linear key at any workspace depth - Eduardo Almeida +- 2026-08-08 chore(governance): verify the child task issue is in the focused epic - Eduardo Almeida +- 2026-08-08 [JUM-491][Feature] Multi-user shared catalog sync over Cana resync events (#133) - Eduardo A. +- 2026-08-08 docs(JUM-494): document E8 collaboration and packaging, closing the E1-E8 chain (EN/PT-BR) (#135) - Eduardo A. +- 2026-08-08 [JUM-545][Feature] Interface adapter lifecycle with edit-in-place and validation (#130) - Eduardo A. +- 2026-08-08 feat(JUM-492): domain-package versioning with semantic conflict resolution (#132) - Eduardo A. +- 2026-08-08 feat(JUM-546): deploy target lifecycle with edit, duplicate and validation (#129) - Eduardo A. +- 2026-08-08 feat(JUM-548): first-run experience with sample model loader and guided empty states (#131) - Eduardo A. +- 2026-08-08 feat(JUM-547): full-suite export/import carrying all four tabs (#128) - Eduardo A. +- 2026-08-08 docs(JUM-490): document E7 design system and PWA shell (EN/PT-BR) (#127) - Eduardo A. +- 2026-08-08 docs(JUM-487): document E6 Cana adoption, migration and offline behaviour (EN/PT-BR) (#126) - Eduardo A. +- 2026-08-08 [JUM-486][Test] Offline/online persistence matrix for Cana (#124) - Eduardo A. +- 2026-08-08 feat(JUM-488): adopt Jumentix design system tokens and Storybook coverage in the designer (#125) - Eduardo A. +- 2026-08-08 [JUM-485][Feature] Multi-tab write-event sync with undo isolation (#122) - Eduardo A. +- 2026-08-08 [JUM-621][Fix] Keep BullMQ results long enough for the caller to read them (#123) - Eduardo A. +- 2026-08-08 fix(message-mediator): keep bullmq results long enough to be read - Eduardo Almeida +- 2026-08-08 feat(JUM-489): installable PWA shell with service worker and manifest (#121) - Eduardo A. +- 2026-08-08 feat(JUM-484): one-way migration of service-management.v1 from localStorage to Cana (no fallback) (#120) - Eduardo A. +- 2026-08-08 docs(JUM-482): document E5 operations console (EN/PT-BR) (#119) - Eduardo A. +- 2026-08-07 [JUM-483][Feature] CanaDesignerStore adapter over the Cana client (#116) - Eduardo A. - 2026-08-07 [JUM-504][Release] Promote CircleCI-backed dev snapshot to main - Eduardo A. +- 2026-08-07 [JUM-624][Fix] Close the redis connection the integration suite leaks (#117) - Eduardo A. +- 2026-08-07 chore(release): reconcile main into dev before promotion - Eduardo A. +- 2026-08-07 fix(key-value): close the redis connection the integration suite opens - Eduardo Almeida +- 2026-08-07 test(cana): tolerate browser timing quantization in performance shape - Eduardo A. +- 2026-08-07 ci: avoid Sonar SCM blame in CircleCI partial clones - Eduardo A. +- 2026-08-07 docs(JUM-479): document E4 contract parity guarantees (EN/PT-BR) (#105) - Eduardo A. +- 2026-08-07 ci: hydrate git blobs before Sonar scan - Eduardo A. +- 2026-08-07 feat(JUM-481): align Deploy Management to the Req 059 matrix with per-service metadata (#115) - Eduardo A. +- 2026-08-07 test: close Redis integration client after smoke suite - Eduardo A. +- 2026-08-07 feat(JUM-480): real multi-environment editing and PM2 ecosystem preview (#106) - Eduardo A. +- 2026-08-07 ci: fold coverage publishers into CircleCI coverage job - Eduardo A. +- 2026-08-07 [JUM-618][Test] Stop the Cypress teardown reporting blocked deletes as clean (#112) - Eduardo A. +- 2026-08-07 test(cana-browser): give the teardown its own budget, and stop it lying about blocks - Eduardo Almeida +- 2026-08-07 [JUM-623][CI] Cover the cypress support file and config, not just the specs (#114) - Eduardo A. +- 2026-08-07 ci(test-map): cover the cypress support file and config, not just the specs - Eduardo Almeida +- 2026-08-07 [JUM-622][CI] Register cana's browser specs so the gate can select them (#113) - Eduardo A. +- 2026-08-07 ci(test-map): register cana's cypress specs so the gate can select them - Eduardo Almeida +- 2026-08-07 [JUM-620][CI] Declare readiness on every compose service (#111) - Eduardo A. +- 2026-08-07 ci(docker): declare readiness on every compose service - Eduardo Almeida +- 2026-08-07 [JUM-616][CI] Read the parsed review job instead of grepping the config as text (#110) - Eduardo A. +- 2026-08-07 [JUM-619][CI] Scope the Jest ruleset to test files (#109) - Eduardo A. +- 2026-08-07 ci(third-party-review): read the parsed job instead of grepping the config as text - Eduardo Almeida +- 2026-08-07 ci(eslint): scope the Jest ruleset to test files - Eduardo Almeida +- 2026-08-07 fix(JUM-617): recompute domain/entity ids on domain-package re-import (#104) - Eduardo A. +- 2026-08-07 [JUM-602][Test] Make external-db-repositories coverage its own (#108) - Eduardo A. +- 2026-08-07 [JUM-601][Refactor] Move the store errors into persistence-contracts so a library stops importing an application (#107) - Eduardo A. +- 2026-08-07 [JUM-614][Governance] Require an agent to declare where it works (#103) - Eduardo A. +- 2026-08-07 fix(JUM-543): replace blocking alerts with non-blocking status surfaces and honest env-API errors (#100) - Eduardo A. +- 2026-08-07 feat(JUM-478): lossless OAS round-trip with full entity meta normalization (#102) - Eduardo A. +- 2026-08-07 [JUM-599][Fix] Enforce the declared filter operators (#101) - Eduardo A. +- 2026-08-07 fix(JUM-544): validate service configuration ports and run-mode x provider consistency (#99) - Eduardo A. +- 2026-08-06 feat(JUM-474): make OAS 3.1 export compliant with Req 036 and route-resolution (#98) - Eduardo A. +- 2026-08-06 feat(JUM-476): emit hexagonal layout from codegen preview and boilerplate bundle (#97) - Eduardo A. +- 2026-08-06 feat(JUM-475): asyncapi 3.0 per-transport and grpc proto exports targeting canonical spec/asyncapi/ (#96) - Eduardo A. +- 2026-08-06 feat(JUM-477): align RBAC editor with tenant authorization contract (#95) - Eduardo A. +- 2026-08-06 test(JUM-471): add exporters/importers round-trip suite (#94) - Eduardo A. +- 2026-08-06 test(JUM-470): pin validation severities, export-gate boundary and normalizer round-trips (#93) - Eduardo A. +- 2026-08-06 [JUM-600][CI] Run the docker smoke matrix on dev and main, and fix its readiness race (#92) - Eduardo A. +- 2026-08-06 [JUM-469][Refactor] Modularize designer exporters, importers, validation, canvas and tabs (#87) - Eduardo A. +- 2026-08-06 [JUM-596][CI] Run lint before the branch gates that do not contain it (#91) - Eduardo A. +- 2026-08-05 [JUM-613][Fix] Repair the Firestore agent-registry integration and its corrupted records (#88) - Eduardo A. +- 2026-08-05 ci(JUM-472): register service-management in the path-to-layer manifest (#90) - Eduardo A. +- 2026-08-05 docs(JUM-473): document E3 module architecture and IDesignerStore port contract (#89) - Eduardo A. +- 2026-08-05 docs(website): document Vercel deploy auth and GITHUB_TOKEN (JUM-397) (#70) - Eduardo A. +- 2026-08-05 [JUM-468][Refactor] Extract state/persistence core behind IDesignerStore port (#86) - Eduardo A. +- 2026-08-05 [JUM-466][Test] Expand service-management integration smoke (#85) - Eduardo A. +- 2026-08-05 feat(JUM-460): expand env key allowlist to the full runtime matrix (#84) - Eduardo A. +- 2026-08-05 fix(JUM-461): truthful key labels and canonical framework selector in service-management (#83) - Eduardo A. +- 2026-08-05 fix(JUM-463): refresh static manifest on miss in dev, keep boot manifest in production (#82) - Eduardo A. +- 2026-08-05 docs(JUM-464): document runtime-env contract and fixed env paths (EN/PT-BR) (#81) - Eduardo A. +- 2026-08-05 fix(service-management): repair env path, honor environment parameter, and protect runtime-env API [JUM-458][JUM-558][JUM-459][JUM-462] (#65) - Eduardo A. +- 2026-08-05 [JUM-465][Governance] Ownership registration and component requirement spec (#80) - Eduardo A. +- 2026-08-05 docs(JUM-467): audit bilingual docs and repair links for service-management (#79) - Eduardo A. +- 2026-08-05 [JUM-604][Governance] Support the Kimi agent with declarative agent support (#78) - Eduardo A. - 2026-08-05 [JUM-504][Release] Promote main branch-gate fix - Eduardo A. +- 2026-08-05 test: isolate PR governance helpers - Eduardo A. +- 2026-08-05 test: satisfy PR governance lint - Eduardo A. +- 2026-08-05 chore(release): reconcile main ancestry after branch-gate fix - Eduardo A. +- 2026-08-05 ci: skip PR metadata checks on branch builds - Eduardo A. - 2026-08-05 [JUM-504][Release] Promote CircleCI-backed dev snapshot to main - Eduardo A. +- 2026-08-05 docs: fix CircleCI badge endpoints - Eduardo A. +- 2026-08-05 docs: restore CircleCI and Codecov badges - Eduardo A. +- 2026-08-04 chore(release): reconcile main ancestry into dev - Eduardo A. +- 2026-08-03 ci(JUM-504): backfill git blobs for SonarQube SCM blame - Eduardo A. +- 2026-08-03 docs(website): document Vercel deploy auth and GITHUB_TOKEN for JUM-397 (#69) - Eduardo A. +- 2026-08-03 [JUM-504][CI] Restore CircleCI gates for dev and main (#63) - Eduardo A. +- 2026-08-03 chore(JUM-611): merge remote dev into Firestore registry cutover - Eduardo A. +- 2026-08-03 docs(JUM-611): deprecate GitHub-mirrored agent registry after Firestore cutover - Eduardo A. +- 2026-08-03 docs(website): reconcile consumer docs drift for JUM-510 (#68) - Eduardo A. +- 2026-08-03 ci(JUM-611): fix migration script require for Bun compatibility - Eduardo A. +- 2026-08-03 test(website): add Cypress route, a11y, and responsive gates (JUM-396) - Eduardo A. +- 2026-08-03 [JUM-611][Governance] Replace GitHub-mirrored agent registry with Firestore Database - Eduardo A. +- 2026-08-03 [JUM-417][CI] Sync agent-registry mirror (kimi-k3-cursor-001) (#64) - Eduardo A. +- 2026-08-03 [JUM-419][CI] Sync the agent-registry mirror to canonical 271da346 - Eduardo A. - 2026-08-03 [JUM-417][Release] Promote dev to main: Cana engine matrix, Sonar fixes, temporary CircleCI bridge (#62) - Eduardo A. +- 2026-08-03 [JUM-417][Release] Merge main history into dev for the promotion - Eduardo A. +- 2026-08-03 [JUM-417][CI] Sonar new-code fixes + temporary CircleCI bridge (PR #52 leftovers) (#61) - Eduardo A. +- 2026-08-03 [JUM-605][Refactor] Eliminate legacy Jumentix product naming confusion - Eduardo A. - 2026-08-02 [JUM-540][Release] Promote dev snapshot to main (#60) - Eduardo A. - 2026-08-02 chore(release): promote dev snapshot to main - Eduardo A. +- 2026-08-02 [JUM-417][Testing] Enforce the Cana browser and worker matrix across engines (#52) - Eduardo A. +- 2026-08-02 [JUM-540][Docs] Clarify nested requirement globs for release governance (#59) - Eduardo A. +- 2026-08-02 docs: clarify nested requirement globs for release governance - Eduardo A. +- 2026-08-02 Merge remote-tracking branch 'origin/dev' into kimi/testing/JUM-417-cana-browser-matrix - Eduardo A. +- 2026-08-02 [JUM-417][CI] Grade the coverage gate on the engine union, not one leg - Eduardo A. +- 2026-08-02 [JUM-417][Testing] Resolve the PR 52 review threads on the browser matrix - Eduardo A. +- 2026-08-02 Merge branch 'dev' into kimi/testing/JUM-417-cana-browser-matrix - Eduardo A. +- 2026-08-02 [JUM-417][Testing] Cover the conformance failure path and defensive guards in a real browser - Eduardo A. +- 2026-08-02 [JUM-417][Testing] Run the engine matrix inline in the Sonar workflow and union it there - Eduardo A. +- 2026-08-02 [JUM-417][Testing] Replace every unpinned upload-artifact use in the provider test - Eduardo A. +- 2026-08-02 [JUM-417][Testing] Enforce the Cana browser and worker matrix across engines - Eduardo A. +- 2026-08-02 [JUM-540][CI] Align strict Redis auth with coverage gate (#57) - Eduardo A. +- 2026-08-02 Merge remote-tracking branch 'origin/dev' into codex/release/fix-main-promotion-redis-auth - Eduardo A. +- 2026-08-02 [JUM-609][Governance] Separate project and software requirements into distinct namespaces (#54) - Eduardo A. +- 2026-08-02 ci: align strict coverage Redis gate - Eduardo A. +- 2026-08-02 docs: separate project and software requirements into distinct namespaces [JUM-609] - Eduardo A. +- 2026-08-02 [JUM-540][CI] Align strict coverage gate with release coverage (#56) - Eduardo A. +- 2026-08-02 ci: align strict coverage gate with release coverage - Eduardo A. +- 2026-08-02 [JUM-540][Release] Reconcile main ancestry into dev (#53) - Eduardo A. +- 2026-08-02 chore(release): reconcile main into dev before promotion - Eduardo A. +- 2026-08-02 [JUM-540][Fix] Resolve BullMQ readiness review (#51) - Eduardo A. +- 2026-08-02 fix: resolve BullMQ infrastructure readiness review - Eduardo A. +- 2026-08-02 [JUM-009][Docs] Complete documentation drift follow-up (#50) - Eduardo A. +- 2026-08-02 docs: refresh changelog after latest dev merge - Eduardo A. +- 2026-08-02 Merge remote-tracking branch 'origin/dev' into codex/docs/JUM-009-documentation-drift - Eduardo A. +- 2026-08-02 [JUM-540][CI] Complete Bun tooling gate cleanup (#49) - Eduardo A. +- 2026-08-02 docs: refresh changelog after dev merge - Eduardo A. +- 2026-08-02 Merge remote-tracking branch 'origin/dev' into codex/docs/JUM-009-documentation-drift - Eduardo A. +- 2026-08-02 ci: complete Bun tooling gate cleanup - Eduardo A. +- 2026-08-02 ci: complete Bun tooling gate cleanup - Eduardo A. +- 2026-08-02 ci: enforce authorized emails before push - Eduardo A. +- 2026-08-02 ci: update GitHub Actions runtime pins - Eduardo A. +- 2026-08-02 docs: reconcile documentation drift - Eduardo A. +- 2026-08-02 docs: reconcile documentation drift (#47) - Eduardo A. +- 2026-08-02 ci: update GitHub Actions runtime pins - Eduardo A. +- 2026-08-02 docs: reconcile documentation drift - Eduardo A. +- 2026-08-02 [JUM-581][Testing] Cana runs in a real browser; the IndexedDB fake is deleted (#38) - Eduardo A. - 2026-08-02 [JUM-594][Release] Promote private free CI strategy (#46) - Eduardo A. +- 2026-08-02 [JUM-581][Testing] Merge origin/dev into cana browser coverage PR - Eduardo Almeida - 2026-08-02 [JUM-594][Fix] Resolve REST and WebSocket SDK spec paths independent of cwd (#45) - Eduardo A. - 2026-08-02 refactor: share canonical spec resolution via shared-contracts - Eduardo A. +- 2026-08-02 [JUM-581][Testing] Classify Cypress helpers as tests; cover cana public barrel - Eduardo Almeida - 2026-08-02 test: cover the explicit-path branch of the WebSocket spec loader - Eduardo A. +- 2026-08-02 [JUM-581][CI] Decorate PR #38 from Sonar workflow_dispatch with pullrequest key - Eduardo Almeida +- 2026-08-02 [JUM-581][CI] Retrigger pull_request checks for browser LCOV path fix - Eduardo Almeida - 2026-08-02 fix: resolve REST and WebSocket SDK spec paths independent of cwd - Eduardo A. +- 2026-08-02 [JUM-581][Testing] Emit browser LCOV with repository-relative paths for Sonar - Eduardo Almeida - 2026-08-02 [JUM-594][Release] Fix loadSpecs cwd resolution to unblock the dev-to-main promotion (#43) - Eduardo A. +- 2026-08-02 [JUM-581][Security] Emit browser LCOV without missing deps; drop hard-coded smoke passwords - Eduardo Almeida +- 2026-08-02 [JUM-581][Security] Resolve Sonar PATH vulnerability and unhang coverage with forceExit - Eduardo Almeida - 2026-08-02 test: cover the missing-spec failure branch of loadSpecs - Eduardo A. - 2026-08-02 fix: resolve AsyncAPI gRPC spec independent of the current working directory - Eduardo A. +- 2026-08-02 [JUM-581][CI] Run Sonar coverage against the same real Redis/brokers/browser as the gate - Eduardo Almeida +- 2026-08-02 [JUM-581][Testing] Merge origin/dev; keep Redis reconnect fix without istanbul ignore - Eduardo Almeida +- 2026-08-02 [JUM-581][Testing] Measure Redis and broker adapters at 99% without istanbul ignore - Eduardo Almeida - 2026-08-01 chore(registry): resync opencode-primary-001 status to available [JUM-597] (#41) - Eduardo A. - 2026-08-01 [JUM-597][Fix] Redis key-value client reports connection failures instead of hanging (#40) - Eduardo A. +- 2026-08-01 [JUM-581][Testing] Run cana in a real browser; delete fake-indexeddb - Eduardo Almeida - 2026-08-01 [JUM-586][Testing] Every package owns a real test suite, and every package is measured (#37) - Eduardo A. - 2026-08-01 [JUM-595][Docs] Agent operating requirements 114–119 (worktree, tests, Docker, API-first) (#39) - Eduardo A. - 2026-08-01 chore: sync release registry [JUM-594] (#35) - Eduardo A. diff --git a/INTEGRATION-MIGRATION-REQUIREMENT.md b/INTEGRATION-MIGRATION-REQUIREMENT.md index 31d8ec97a..04b01a627 100644 --- a/INTEGRATION-MIGRATION-REQUIREMENT.md +++ b/INTEGRATION-MIGRATION-REQUIREMENT.md @@ -4,7 +4,7 @@ Linear task: [JUM-568](https://linear.app/jumentix/issue/JUM-568) ## Requirement -`XpertMinds/Jumentix` must recreate every integration that is applicable to the +`web2solutions/Jumentix` must recreate every integration that is applicable to the deprecated `web2solutions/aaa-typescript-boilerplate` source before the source is archived. An integration is complete only when its provider-side binding and repository-owned configuration are both verified. Missing, skipped, neutral, or @@ -14,32 +14,32 @@ merely configured checks are not successful evidence. | Integration surface | Deprecated source | Canonical destination | Status / evidence | | --- | --- | --- | --- | -| GitHub Actions: Run branch-aware tests | active | active | `build (22.x, 7.2)` succeeds on `dev` PRs | -| GitHub Actions: SonarQube Cloud | active | active | workflow registered; scan step requires `SONAR_TOKEN` | +| GitHub Actions: Run branch-aware tests | active | active | `ci.yml` runs on GitHub-hosted Node 22 runners with branch-aware gates | +| GitHub Actions: SonarQube Cloud | active | active | workflow registered; scan step requires `SONAR_TOKEN` in full coverage contexts | | GitHub Actions: Jumentix website quality | active | active | `storybook` succeeds on website PRs | -| Actions repository secrets | `JUMENTIX_JWT_TOKEN_SECRET_KEY`, `JUMENTIX_REDIS_PASSWORD` | same names with CI placeholders | secret-name inventory parity; values never logged | -| Actions repository variables | none | none | empty inventory | +| Actions repository secrets | `JUMENTIX_JWT_TOKEN_SECRET_KEY`, `JUMENTIX_REDIS_PASSWORD` | `AAA_JWT_TOKEN_SECRET_KEY`, `AAA_REDIS_PASSWORD`, `CODECOV_TOKEN`, `SONAR_TOKEN`, `LINEAR_API_KEY`, `AGENT_REGISTRY_TOKEN` | secret-name inventory verified; values never logged | +| Actions repository variables | optional provider toggles | optional provider toggles | `JUMENTIX_ENABLE_SONAR` controls Sonar execution | | Environments | `env vars`, `secrets` (empty) | `env vars`, `secrets` (empty) | name inventory parity | | Dependabot Updates | active (GitHub-managed) | enabled via `.github/dependabot.yml` | Dependabot PR path available | | Repository webhooks | provider callbacks | required provider-owned GitHub App/webhook bindings recreated | CircleCI, Codecov, GitGuardian, Cursor, Sonar, and Vercel apps authorized; PR-only checks remain pending | -| CircleCI project | bound to legacy slug | project `95b034cf-dd83-4407-be64-108d63263ed8` follows `XpertMinds/Jumentix` | pipelines 2, 3, and 4 passed `test-source` on canonical SHA `19af3a52` | -| Codecov | `codecov/project`, `codecov/patch` on legacy PRs | GitHub App authorized, repository active, rotated token stored in GitHub and CircleCI; canonical slug and fail-on-error supplied to the orb | pipeline 4 exposed hidden `Repository not found`; corrected fail-closed `dev` upload and project/patch checks pending | -| SonarQube Cloud project key | `web2solutions_aaa-typescript-boilerplate` | `xpertminds` / `Jumentix` | baseline and PR #9 quality gates passed with zero new issues or hotspots | +| CircleCI project | bound to legacy slug | `web2solutions/Jumentix` | `.circleci/config.yml` restored with the same context classifier and public branch badges | +| Codecov | `codecov/project`, `codecov/patch` on legacy PRs | `web2solutions/Jumentix` | full coverage job uploads LCOV after repository thresholds pass | +| SonarQube Cloud project key | `web2solutions_aaa-typescript-boilerplate` | `web2solutions` / `web2solutions_Jumentix` | scanner and badges point at the public canonical project key | | OSV dependency scanner | incomplete legacy dependency coverage | first-party installed-tree scanner backed by OSV.dev | `bun run deps:audit` is part of the fail-closed gate | -| GitGuardian | Security Checks on legacy PRs | all five XpertMinds repositories monitored; canonical history scan completed | **paid-plan blocker**: forked-repository check runs require GitGuardian Business | -| Cursor Bugbot | checks on legacy PRs | 5/5 XpertMinds repositories enabled, including both Jumentix repositories | PR #9 `Cursor Bugbot` passed | -| Vercel (website) | legacy project binding | Vercel GitHub App authorized for all XpertMinds repositories | **paid-plan blocker**: Hobby rejects binding a private organization repository; explicit Pro approval required | -| Branch protection / required checks | enforced on legacy (Pro) | unavailable on current private plan | **owner-auth blocker**: GitHub Pro/Team for private branch protection | +| GitGuardian | Security Checks on legacy PRs | optional external visibility | pinned Gitleaks/Semgrep own the required third-party review evidence | +| Cursor Bugbot | checks on legacy PRs | optional external visibility | not a required check because quota/skipped states are non-terminal | +| Vercel (website) | legacy project binding | optional deployment surface | website build and prepublish checks remain required before release | +| Branch protection / required checks | enforced on legacy (Pro) | GitHub public rulesets | `dev` has cheap destination checks; `main` has full destination checks | Registry-only integration migration is governed separately by JUM-569. ## Repository-owned configuration that must stay canonical -- `package.json` `homepage` / `bugs.url` → `XpertMinds/Jumentix` -- `packages/cli-init` bootstrap clone URL → `XpertMinds/Jumentix.git` +- `package.json` `homepage` / `bugs.url` → `web2solutions/Jumentix` +- `packages/cli-init` bootstrap clone URL → `web2solutions/Jumentix.git` - `.agents/registry-source.json` → `XpertMinds/jumentix-agent-registry` - README / docs canonical notices → Requirement `103` / `104` -- CircleCI / Codecov badge slugs → `XpertMinds/Jumentix` +- CircleCI / Codecov badge slugs → `web2solutions/Jumentix` ## Enforcement @@ -54,10 +54,11 @@ Registry-only integration migration is governed separately by JUM-569. - Audit date: `2026-07-30` - Deprecated source: `web2solutions/aaa-typescript-boilerplate` -- Canonical destination: `XpertMinds/Jumentix` -- Destination visibility: private -- Actions secrets recreated (names only): `JUMENTIX_JWT_TOKEN_SECRET_KEY`, `JUMENTIX_REDIS_PASSWORD` -- Environments recreated (names): `env vars`, `secrets` -- Provider authentication is complete; Vercel Git binding, GitHub private - branch protection, and GitGuardian fork check runs require paid plans, while - terminal PR checks remain mandatory and are recorded above +- Canonical destination: `web2solutions/Jumentix` +- Destination visibility: public +- Actions secrets recreated (names only): `AAA_JWT_TOKEN_SECRET_KEY`, + `AAA_REDIS_PASSWORD`, `AGENT_REGISTRY_TOKEN`, `CODECOV_TOKEN`, + `LINEAR_API_KEY`, `SONAR_TOKEN` +- Provider authentication is complete for GitHub Actions, CircleCI, Codecov and + SonarQube Cloud public execution. Optional external providers may add + visibility, while terminal PR checks remain mandatory and are recorded above. diff --git a/INTEGRATION-MIGRATION-REQUIREMENT.pt-BR.md b/INTEGRATION-MIGRATION-REQUIREMENT.pt-BR.md index 39eb508f6..2b66a62e0 100644 --- a/INTEGRATION-MIGRATION-REQUIREMENT.pt-BR.md +++ b/INTEGRATION-MIGRATION-REQUIREMENT.pt-BR.md @@ -4,7 +4,7 @@ Tarefa Linear: [JUM-568](https://linear.app/jumentix/issue/JUM-568) ## Requisito -`XpertMinds/Jumentix` deve recriar toda integração aplicável da origem +`web2solutions/Jumentix` deve recriar toda integração aplicável da origem depreciada `web2solutions/aaa-typescript-boilerplate` antes do arquivamento dessa origem. Uma integração só está completa quando o vínculo no provedor e a configuração do repositório estiverem verificados. Checks ausentes, ignorados, @@ -14,32 +14,32 @@ neutros ou apenas configurados não são evidência de sucesso. | Superfície | Origem depreciada | Destino canônico | Status / evidência | | --- | --- | --- | --- | -| GitHub Actions: Run branch-aware tests | ativo | ativo | `build (22.x, 7.2)` bem-sucedido em PRs para `dev` | -| GitHub Actions: SonarQube Cloud | ativo | ativo | workflow registrado; scan exige `SONAR_TOKEN` | +| GitHub Actions: Run branch-aware tests | ativo | ativo | `ci.yml` roda em runners GitHub-hosted Node 22 com gates por branch | +| GitHub Actions: SonarQube Cloud | ativo | ativo | workflow registrado; scan exige `SONAR_TOKEN` nos contextos de cobertura completa | | GitHub Actions: Jumentix website quality | ativo | ativo | `storybook` bem-sucedido em PRs do website | -| Secrets do Actions | `JUMENTIX_JWT_TOKEN_SECRET_KEY`, `JUMENTIX_REDIS_PASSWORD` | mesmos nomes com placeholders de CI | paridade de nomes; valores nunca logados | -| Variáveis do Actions | nenhuma | nenhuma | inventário vazio | +| Secrets do Actions | `JUMENTIX_JWT_TOKEN_SECRET_KEY`, `JUMENTIX_REDIS_PASSWORD` | `AAA_JWT_TOKEN_SECRET_KEY`, `AAA_REDIS_PASSWORD`, `CODECOV_TOKEN`, `SONAR_TOKEN`, `LINEAR_API_KEY`, `AGENT_REGISTRY_TOKEN` | inventário de nomes verificado; valores nunca logados | +| Variáveis do Actions | toggles opcionais de provider | toggles opcionais de provider | `JUMENTIX_ENABLE_SONAR` controla a execução do Sonar | | Environments | `env vars`, `secrets` (vazios) | `env vars`, `secrets` (vazios) | paridade de nomes | | Dependabot Updates | ativo (GitHub) | habilitado via `.github/dependabot.yml` | caminho Dependabot disponível | | Repository webhooks | callbacks de provedores | vínculos necessários de GitHub App/webhook pertencentes aos provedores recriados | Apps CircleCI, Codecov, GitGuardian, Cursor, Sonar e Vercel autorizados; checks exclusivos de PR pendentes | -| Projeto CircleCI | legado | projeto `95b034cf-dd83-4407-be64-108d63263ed8` segue `XpertMinds/Jumentix` | pipelines 2, 3 e 4 passaram `test-source` no SHA canônico `19af3a52` | -| Codecov | checks no legado | GitHub App autorizado, repositório ativo, token rotacionado armazenado no GitHub e CircleCI; slug canônico e fail-on-error fornecidos ao orb | pipeline 4 expôs `Repository not found` oculto; upload fail-closed corrigido em `dev` e checks de projeto/patch pendentes | -| Projeto SonarQube Cloud | `web2solutions_aaa-typescript-boilerplate` | `xpertminds` / `Jumentix` | quality gates do baseline e da PR #9 passaram com zero issues ou hotspots novos | +| Projeto CircleCI | legado | `web2solutions/Jumentix` | `.circleci/config.yml` restaurado com o mesmo classificador de contexto e badges públicos por branch | +| Codecov | checks no legado | `web2solutions/Jumentix` | job completo de cobertura envia LCOV após os thresholds do repositório passarem | +| Projeto SonarQube Cloud | `web2solutions_aaa-typescript-boilerplate` | `web2solutions` / `web2solutions_Jumentix` | scanner e badges apontam para a chave pública canônica | | Scanner de dependências OSV | cobertura legada incompleta das dependências | scanner próprio da árvore instalada apoiado por OSV.dev | `bun run deps:audit` integra o gate fail-closed | -| GitGuardian | checks no legado | os cinco repositórios XpertMinds monitorados; scan do histórico canônico concluído | **bloqueio de plano pago**: check runs em repositórios forkados exigem GitGuardian Business | -| Cursor Bugbot | checks no legado | 5/5 repositórios XpertMinds habilitados, incluindo os dois Jumentix | `Cursor Bugbot` da PR #9 passou | -| Vercel (website) | vínculo legado | GitHub App da Vercel autorizado para todos os repositórios XpertMinds | **owner-auth blocker / bloqueio de plano pago**: Hobby rejeita vínculo a repositório privado de organização; aprovação explícita de Pro necessária | -| Proteção de branch | legado (Pro) | indisponível no plano privado atual | **bloqueio owner-auth**: GitHub Pro/Team | +| GitGuardian | checks no legado | visibilidade externa opcional | Gitleaks/Semgrep fixados são a evidência obrigatória de review third-party | +| Cursor Bugbot | checks no legado | visibilidade externa opcional | não é check obrigatório porque estados de cota/pulado não são terminais | +| Vercel (website) | vínculo legado | superfície opcional de deploy | build e prepublish do website continuam obrigatórios antes de release | +| Proteção de branch | legado (Pro) | rulesets públicos do GitHub | `dev` tem checks baratos; `main` tem checks completos | A migração de integrações do registry é governada separadamente por JUM-569. ## Configuração do repositório que deve permanecer canônica -- `package.json` `homepage` / `bugs.url` → `XpertMinds/Jumentix` -- bootstrap do `packages/cli-init` → `XpertMinds/Jumentix.git` +- `package.json` `homepage` / `bugs.url` → `web2solutions/Jumentix` +- bootstrap do `packages/cli-init` → `web2solutions/Jumentix.git` - `.agents/registry-source.json` → `XpertMinds/jumentix-agent-registry` - avisos canônicos em README/docs → Requisitos `103` / `104` -- badges CircleCI / Codecov → `XpertMinds/Jumentix` +- badges CircleCI / Codecov → `web2solutions/Jumentix` ## Enforcement @@ -53,11 +53,12 @@ A migração de integrações do registry é governada separadamente por JUM-569 - Data da auditoria: `2026-07-30` - Origem depreciada: `web2solutions/aaa-typescript-boilerplate` -- Destino canônico: `XpertMinds/Jumentix` -- Visibilidade: private -- Secrets recriados (somente nomes): `JUMENTIX_JWT_TOKEN_SECRET_KEY`, `JUMENTIX_REDIS_PASSWORD` -- Environments recriados (nomes): `env vars`, `secrets` -- A autenticação dos provedores está completa; o vínculo Git da Vercel, a - proteção de branches privadas no GitHub e os check runs GitGuardian em forks - exigem planos pagos, enquanto checks terminais de PR continuam obrigatórios - e registrados acima +- Destino canônico: `web2solutions/Jumentix` +- Visibilidade: public +- Secrets recriados (somente nomes): `AAA_JWT_TOKEN_SECRET_KEY`, + `AAA_REDIS_PASSWORD`, `AGENT_REGISTRY_TOKEN`, `CODECOV_TOKEN`, + `LINEAR_API_KEY`, `SONAR_TOKEN` +- A autenticação dos provedores está completa para execução pública de GitHub + Actions, CircleCI, Codecov e SonarQube Cloud. Provedores externos opcionais + podem adicionar visibilidade, enquanto checks terminais de PR continuam + obrigatórios e registrados acima. diff --git a/README.md b/README.md index 00af0c6f3..caf1ca657 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,18 @@ # Jumentix - Software Factory for Product Teams -[![GitHub Actions dev](https://github.com/XpertMinds/Jumentix/actions/workflows/ci.yml/badge.svg?branch=dev)](https://github.com/XpertMinds/Jumentix/actions/workflows/ci.yml?query=branch%3Adev) -[![GitHub Actions main](https://github.com/XpertMinds/Jumentix/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/XpertMinds/Jumentix/actions/workflows/ci.yml?query=branch%3Amain) -[![Codecov dev](https://codecov.io/gh/XpertMinds/Jumentix/branch/dev/graph/badge.svg?flag=project)](https://app.codecov.io/gh/XpertMinds/Jumentix/tree/dev) -[![Codecov main](https://codecov.io/gh/XpertMinds/Jumentix/branch/main/graph/badge.svg?flag=project)](https://app.codecov.io/gh/XpertMinds/Jumentix/tree/main) -[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=Jumentix&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=Jumentix) -[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=Jumentix&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=Jumentix) -[![Sonar Coverage](https://sonarcloud.io/api/project_badges/measure?project=Jumentix&metric=coverage)](https://sonarcloud.io/summary/new_code?id=Jumentix) +[![GitHub Actions dev](https://github.com/web2solutions/Jumentix/actions/workflows/ci.yml/badge.svg?branch=dev)](https://github.com/web2solutions/Jumentix/actions/workflows/ci.yml?query=branch%3Adev) +[![GitHub Actions main](https://github.com/web2solutions/Jumentix/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/web2solutions/Jumentix/actions/workflows/ci.yml?query=branch%3Amain) +[![CircleCI dev](https://circleci.com/gh/web2solutions/Jumentix/tree/dev.svg?style=shield)](https://app.circleci.com/pipelines/github/web2solutions/Jumentix?branch=dev) +[![CircleCI release gate](https://img.shields.io/badge/CircleCI-release%20gate-configured?logo=circleci&logoColor=white)](https://app.circleci.com/pipelines/github/web2solutions/Jumentix?branch=main) +[![Codecov](https://img.shields.io/badge/Codecov-release%20coverage-configured?logo=codecov&logoColor=white)](https://app.codecov.io/gh/web2solutions/Jumentix) +[![SonarCloud quality](https://sonarcloud.io/api/project_badges/measure?project=web2solutions_Jumentix&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=web2solutions_Jumentix) +[![SonarCloud reliability](https://sonarcloud.io/api/project_badges/measure?project=web2solutions_Jumentix&metric=reliability_rating)](https://sonarcloud.io/summary/new_code?id=web2solutions_Jumentix) +[![SonarCloud coverage](https://sonarcloud.io/api/project_badges/measure?project=web2solutions_Jumentix&metric=coverage)](https://sonarcloud.io/summary/new_code?id=web2solutions_Jumentix) [![Bun](https://img.shields.io/badge/bun-1.3.13-000000?logo=bun&logoColor=white)](https://bun.sh/) [![Node compat](https://img.shields.io/badge/node%20compat-22.x-339933?logo=node.js&logoColor=white)](https://nodejs.org/) [![OpenAPI](https://img.shields.io/badge/OpenAPI-3.1-6BA539?logo=openapiinitiative&logoColor=white)](./spec/1.0.0.yml) [![AsyncAPI](https://img.shields.io/badge/AsyncAPI-3.0-9146FF)](./spec) -[![Repository](https://img.shields.io/badge/repository-private-24292f?logo=github)](https://github.com/XpertMinds/Jumentix) +[![Repository](https://img.shields.io/badge/repository-public-24292f?logo=github)](https://github.com/web2solutions/Jumentix) [![License](https://img.shields.io/badge/license-see%20LICENSE-blue)](./LICENSE) [![Run with Express](https://img.shields.io/badge/Run%20with-Express-gold?style=flat-square&logo=express&logoColor=000)](https://expressjs.com/) [![Run with Fastify](https://img.shields.io/badge/Run%20with-Fastify-gold?style=flat-square&logo=fastify&logoColor=000)](https://fastify.dev/) @@ -31,8 +32,9 @@ | Required gate | `main` | `dev` | | --- | :---: | :---: | -| GitHub Actions workflow | [![main GitHub Actions](https://github.com/XpertMinds/Jumentix/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/XpertMinds/Jumentix/actions/workflows/ci.yml?query=branch%3Amain) | [![dev GitHub Actions](https://github.com/XpertMinds/Jumentix/actions/workflows/ci.yml/badge.svg?branch=dev)](https://github.com/XpertMinds/Jumentix/actions/workflows/ci.yml?query=branch%3Adev) | -| Codecov project coverage | [![main Codecov](https://codecov.io/gh/XpertMinds/Jumentix/branch/main/graph/badge.svg?flag=project)](https://app.codecov.io/gh/XpertMinds/Jumentix/tree/main) | [![dev Codecov](https://codecov.io/gh/XpertMinds/Jumentix/branch/dev/graph/badge.svg?flag=project)](https://app.codecov.io/gh/XpertMinds/Jumentix/tree/dev) | +| GitHub Actions workflow | [![main GitHub Actions](https://github.com/web2solutions/Jumentix/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/web2solutions/Jumentix/actions/workflows/ci.yml?query=branch%3Amain) | [![dev GitHub Actions](https://github.com/web2solutions/Jumentix/actions/workflows/ci.yml/badge.svg?branch=dev)](https://github.com/web2solutions/Jumentix/actions/workflows/ci.yml?query=branch%3Adev) | +| CircleCI workflow | [![CircleCI release gate](https://img.shields.io/badge/CircleCI-release%20gate-configured?logo=circleci&logoColor=white)](https://app.circleci.com/pipelines/github/web2solutions/Jumentix?branch=main) | [![dev CircleCI](https://circleci.com/gh/web2solutions/Jumentix/tree/dev.svg?style=shield)](https://app.circleci.com/pipelines/github/web2solutions/Jumentix?branch=dev) | +| Codecov coverage | [![Codecov release coverage](https://img.shields.io/badge/Codecov-release%20coverage-configured?logo=codecov&logoColor=white)](https://app.codecov.io/gh/web2solutions/Jumentix/tree/main) | [file map; full coverage is release-only](https://app.codecov.io/gh/web2solutions/Jumentix/tree/dev) | | Branch-aware tests | `branch-gate` | `branch-gate` | | Project + patch coverage | `coverage` | release-promotion only | | Third-party security review | `third-party-review` | PR-only | @@ -42,13 +44,16 @@ gate selected from `test-map.json`; the target is ten minutes or less. Full workspace, browser, coverage, website and database jobs are reserved for `dev -> main` release promotions, `main` pushes and scheduled full runs. -Coverage is produced and enforced by the GitHub Actions `coverage` job when the -full suite runs. GitHub Actions uploads LCOV to Codecov under the `project` flag when -`CODECOV_TOKEN` is configured. Codecov provides the file-by-file coverage map -for each long-lived branch: +Coverage is produced and enforced by the full-suite `coverage` job when the +release gate runs. GitHub Actions and CircleCI upload LCOV to Codecov when +`CODECOV_TOKEN` is configured. The README uses a stable Codecov integration +badge until the first post-migration release coverage upload exists for the +new public owner. `dev` is linked as a file map because it uses the cheaper +health gate between release promotions. Codecov provides the file-by-file +coverage map for each long-lived branch: -- [Codecov file map for `dev`](https://app.codecov.io/gh/XpertMinds/Jumentix/tree/dev) -- [Codecov file map for `main`](https://app.codecov.io/gh/XpertMinds/Jumentix/tree/main) +- [Codecov file map for `dev`](https://app.codecov.io/gh/web2solutions/Jumentix/tree/dev) +- [Codecov file map for `main`](https://app.codecov.io/gh/web2solutions/Jumentix/tree/main) The hard gate remains the repository-owned coverage check. Every run retains Istanbul JSON and LCOV evidence. The hard minimums are: @@ -57,11 +62,11 @@ Istanbul JSON and LCOV evidence. The hard minimums are: | :---: | :---: | :---: | :---: | :---: | | ≥ 99% | ≥ 99% | ≥ 99% | ≥ 90% | ≥ 99% | -[Open GitHub Actions runs and downloadable evidence](https://github.com/XpertMinds/Jumentix/actions/workflows/ci.yml) +[Open GitHub Actions runs and downloadable evidence](https://github.com/web2solutions/Jumentix/actions/workflows/ci.yml) Jumentix is a monorepo product that works as a software factory for engineering teams and product owners. It helps you go from idea to production-ready SaaS in days, not months, with a contract-first architecture, runtime flexibility, and enterprise-grade governance. -> **Canonical private repository:** `XpertMinds/Jumentix`. +> **Canonical public repository:** `web2solutions/Jumentix`. > `web2solutions/aaa-typescript-boilerplate` is deprecated, read-only, and > accepts no new modifications. Agent coordination is canonical in Firestore > Database (Requirement `089`); the former `XpertMinds/jumentix-agent-registry` diff --git a/README.pt-BR.md b/README.pt-BR.md index fe04cca82..b7bf10f42 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -4,18 +4,19 @@ Idioma alvo: Português (Brasil) --> # Jumentix – Fábrica de Software para Equipes de Produto -[![GitHub Actions dev](https://github.com/XpertMinds/Jumentix/actions/workflows/ci.yml/badge.svg?branch=dev)](https://github.com/XpertMinds/Jumentix/actions/workflows/ci.yml?query=branch%3Adev) -[![GitHub Actions main](https://github.com/XpertMinds/Jumentix/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/XpertMinds/Jumentix/actions/workflows/ci.yml?query=branch%3Amain) -[![Codecov dev](https://codecov.io/gh/XpertMinds/Jumentix/branch/dev/graph/badge.svg?flag=project)](https://app.codecov.io/gh/XpertMinds/Jumentix/tree/dev) -[![Codecov main](https://codecov.io/gh/XpertMinds/Jumentix/branch/main/graph/badge.svg?flag=project)](https://app.codecov.io/gh/XpertMinds/Jumentix/tree/main) -[![Status do Quality Gate](https://sonarcloud.io/api/project_badges/measure?project=Jumentix&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=Jumentix) -[![Classificação de Segurança](https://sonarcloud.io/api/project_badges/measure?project=Jumentix&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=Jumentix) -[![Cobertura Sonar](https://sonarcloud.io/api/project_badges/measure?project=Jumentix&metric=coverage)](https://sonarcloud.io/summary/new_code?id=Jumentix) +[![GitHub Actions dev](https://github.com/web2solutions/Jumentix/actions/workflows/ci.yml/badge.svg?branch=dev)](https://github.com/web2solutions/Jumentix/actions/workflows/ci.yml?query=branch%3Adev) +[![GitHub Actions main](https://github.com/web2solutions/Jumentix/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/web2solutions/Jumentix/actions/workflows/ci.yml?query=branch%3Amain) +[![CircleCI dev](https://circleci.com/gh/web2solutions/Jumentix/tree/dev.svg?style=shield)](https://app.circleci.com/pipelines/github/web2solutions/Jumentix?branch=dev) +[![Gate de release CircleCI](https://img.shields.io/badge/CircleCI-release%20gate-configured?logo=circleci&logoColor=white)](https://app.circleci.com/pipelines/github/web2solutions/Jumentix?branch=main) +[![Codecov](https://img.shields.io/badge/Codecov-release%20coverage-configured?logo=codecov&logoColor=white)](https://app.codecov.io/gh/web2solutions/Jumentix) +[![Qualidade SonarCloud](https://sonarcloud.io/api/project_badges/measure?project=web2solutions_Jumentix&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=web2solutions_Jumentix) +[![Confiabilidade SonarCloud](https://sonarcloud.io/api/project_badges/measure?project=web2solutions_Jumentix&metric=reliability_rating)](https://sonarcloud.io/summary/new_code?id=web2solutions_Jumentix) +[![Cobertura SonarCloud](https://sonarcloud.io/api/project_badges/measure?project=web2solutions_Jumentix&metric=coverage)](https://sonarcloud.io/summary/new_code?id=web2solutions_Jumentix) [![Bun](https://img.shields.io/badge/bun-1.3.13-000000?logo=bun&logoColor=white)](https://bun.sh/) [![Compatibilidade Node](https://img.shields.io/badge/node%20compat-22.x-339933?logo=node.js&logoColor=white)](https://nodejs.org/) [![OpenAPI](https://img.shields.io/badge/OpenAPI-3.1-6BA539?logo=openapiinitiative&logoColor=white)](./spec/1.0.0.yml) [![AsyncAPI](https://img.shields.io/badge/AsyncAPI-3.0-9146FF)](./spec) -[![Repositório](https://img.shields.io/badge/repository-private-24292f?logo=github)](https://github.com/XpertMinds/Jumentix) +[![Repositório](https://img.shields.io/badge/repository-public-24292f?logo=github)](https://github.com/web2solutions/Jumentix) [![Licença](https://img.shields.io/badge/license-see%20LICENSE-blue)](./LICENSE) [![Rode com Express](https://img.shields.io/badge/Rode%20com-Express-gold?style=flat-square&logo=express&logoColor=000)](https://expressjs.com/) [![Rode com Fastify](https://img.shields.io/badge/Rode%20com-Fastify-gold?style=flat-square&logo=fastify&logoColor=000)](https://fastify.dev/) @@ -35,8 +36,9 @@ Idioma alvo: Português (Brasil) | Gate obrigatório | `main` | `dev` | | --- | :---: | :---: | -| Workflow GitHub Actions | [![GitHub Actions main](https://github.com/XpertMinds/Jumentix/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/XpertMinds/Jumentix/actions/workflows/ci.yml?query=branch%3Amain) | [![GitHub Actions dev](https://github.com/XpertMinds/Jumentix/actions/workflows/ci.yml/badge.svg?branch=dev)](https://github.com/XpertMinds/Jumentix/actions/workflows/ci.yml?query=branch%3Adev) | -| Cobertura de projeto no Codecov | [![Codecov main](https://codecov.io/gh/XpertMinds/Jumentix/branch/main/graph/badge.svg?flag=project)](https://app.codecov.io/gh/XpertMinds/Jumentix/tree/main) | [![Codecov dev](https://codecov.io/gh/XpertMinds/Jumentix/branch/dev/graph/badge.svg?flag=project)](https://app.codecov.io/gh/XpertMinds/Jumentix/tree/dev) | +| Workflow GitHub Actions | [![GitHub Actions main](https://github.com/web2solutions/Jumentix/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/web2solutions/Jumentix/actions/workflows/ci.yml?query=branch%3Amain) | [![GitHub Actions dev](https://github.com/web2solutions/Jumentix/actions/workflows/ci.yml/badge.svg?branch=dev)](https://github.com/web2solutions/Jumentix/actions/workflows/ci.yml?query=branch%3Adev) | +| Workflow CircleCI | [![Gate de release CircleCI](https://img.shields.io/badge/CircleCI-release%20gate-configured?logo=circleci&logoColor=white)](https://app.circleci.com/pipelines/github/web2solutions/Jumentix?branch=main) | [![CircleCI dev](https://circleci.com/gh/web2solutions/Jumentix/tree/dev.svg?style=shield)](https://app.circleci.com/pipelines/github/web2solutions/Jumentix?branch=dev) | +| Cobertura Codecov | [![Cobertura de release Codecov](https://img.shields.io/badge/Codecov-release%20coverage-configured?logo=codecov&logoColor=white)](https://app.codecov.io/gh/web2solutions/Jumentix/tree/main) | [mapa de arquivos; cobertura completa é só no release](https://app.codecov.io/gh/web2solutions/Jumentix/tree/dev) | | Testes por branch | `branch-gate` | `branch-gate` | | Cobertura de projeto + patch | `coverage` | somente promoção de release | | Review de segurança third-party | `third-party-review` | somente PR | @@ -47,13 +49,16 @@ completos de workspace, browser, cobertura, website e banco ficam reservados para promoções de release `dev -> main`, pushes em `main` e execuções completas agendadas. -A cobertura é produzida e aplicada pelo job GitHub Actions `coverage` quando a suite -completa roda. O workflow envia LCOV ao Codecov com a flag `project` quando -`CODECOV_TOKEN` está configurado. O Codecov fornece o mapa de cobertura arquivo +A cobertura é produzida e aplicada pelo job `coverage` da suite completa quando +o gate de release roda. GitHub Actions e CircleCI enviam LCOV ao Codecov quando +`CODECOV_TOKEN` está configurado. O README usa um badge estável da integração +Codecov até existir o primeiro upload de cobertura de release pós-migração no +novo owner público. `dev` fica como link de mapa porque usa o health gate +barato entre promoções de release. O Codecov fornece o mapa de cobertura arquivo a arquivo para cada branch longa: -- [Mapa de arquivos Codecov para `dev`](https://app.codecov.io/gh/XpertMinds/Jumentix/tree/dev) -- [Mapa de arquivos Codecov para `main`](https://app.codecov.io/gh/XpertMinds/Jumentix/tree/main) +- [Mapa de arquivos Codecov para `dev`](https://app.codecov.io/gh/web2solutions/Jumentix/tree/dev) +- [Mapa de arquivos Codecov para `main`](https://app.codecov.io/gh/web2solutions/Jumentix/tree/main) O gate rígido continua sendo a cobertura pertencente ao repositório. Cada execução retém evidências Istanbul JSON e LCOV. Os mínimos são: @@ -62,11 +67,11 @@ execução retém evidências Istanbul JSON e LCOV. Os mínimos são: | :---: | :---: | :---: | :---: | :---: | | ≥ 99% | ≥ 99% | ≥ 99% | ≥ 90% | ≥ 99% | -[Abrir execuções GitHub Actions e evidências para download](https://github.com/XpertMinds/Jumentix/actions/workflows/ci.yml) +[Abrir execuções GitHub Actions e evidências para download](https://github.com/web2solutions/Jumentix/actions/workflows/ci.yml) Jumentix é um produto monorepo que funciona como uma fábrica de software para equipes de engenharia e proprietários de produtos. Ele ajuda você a passar da ideia ao SaaS pronto para produção em dias, não meses, com uma arquitetura que prioriza o contrato, flexibilidade de tempo de execução e governança de nível empresarial. -> **Repositório privado canônico:** `XpertMinds/Jumentix`. +> **Repositório público canônico:** `web2solutions/Jumentix`. > `web2solutions/aaa-typescript-boilerplate` está obsoleto, é somente leitura e > não aceita novas modificações. A coordenação de agentes é canônica no > Firestore Database (Requisito `089`); os antigos registries em diff --git a/apps/backend-template/seed/organizations.ts b/apps/backend-template/seed/organizations.ts index 02548c76b..9c88e8d55 100644 --- a/apps/backend-template/seed/organizations.ts +++ b/apps/backend-template/seed/organizations.ts @@ -1,4 +1,3 @@ -import { UUID } from '@src/modules/port'; import { AddressValueObject, EAddressType, @@ -12,7 +11,7 @@ const now = new Date(); const organizations: IOrganization[] = [ { - id: UUID.create().toString(), + id: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11', createdAt: now, updatedAt: now, name: 'ACME', @@ -35,7 +34,7 @@ const organizations: IOrganization[] = [ users: [] }, { - id: UUID.create().toString(), + id: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a22', createdAt: now, updatedAt: now, name: 'Umbrella', @@ -56,7 +55,34 @@ const organizations: IOrganization[] = [ isPrimary: true } as PhoneValueObject], users: [] + }, + { + // XpertMinds is the primary tenant: eduardo (superadmin), admin and user + // seeds all belong to it (JUM-772). + id: 'b1ffc4d2-1a2b-4c3d-9e8f-7a6b5c4d3f00', + createdAt: now, + updatedAt: now, + name: 'XpertMinds', + address: [{ + email: 'hq@xpertminds.dev', + type: EAddressType.work, + isPrimary: true + } as AddressValueObject], + email: [{ + email: 'contact@xpertminds.dev', + type: EEmailType.work, + isPrimary: true + } as EmailValueObject], + phone: [{ + number: '99805-4033', + localCode: '27', + countryCode: '+55', + isPrimary: true + } as PhoneValueObject], + users: [] } ]; +export const seedOrganizationIds = organizations.map((organization) => organization.id); + export default organizations; diff --git a/apps/backend-template/seed/users.ts b/apps/backend-template/seed/users.ts index 8c25f1ec8..ea00aaa35 100644 --- a/apps/backend-template/seed/users.ts +++ b/apps/backend-template/seed/users.ts @@ -1,6 +1,5 @@ // file deepcode ignore NoHardcodedPasswords: import type { IUser } from '@src/modules/Users'; -import { UUID } from '@src/modules/port'; import { DocumentValueObject, EDocumentType, @@ -14,11 +13,11 @@ const buildSeedCredential = (account: string): string => `seed-${account}-A1!`; const now = new Date(); const users: Array = [{ - id: UUID.create().toString(), + id: 'b1ffc4d2-1a2b-4c3d-9e8f-7a6b5c4d3e01', createdAt: now, updatedAt: now, - firstName: 'Abraham', - lastName: 'Lincoln', + firstName: 'eduardo', + lastName: 'Almeida', emails: [ { email: 'eduardo@xpertminds.dev', @@ -38,8 +37,8 @@ const users: Array = [{ ], avatar: 'avatar.png', username: 'eduardo@xpertminds.dev', - password: buildSeedCredential('user1'), - organization: organizations[0].id, + password: 'eduardo@123456', + organization: organizations[2].id, // XpertMinds (JUM-772) roles: ['superadmin'], documents: [ { @@ -80,7 +79,7 @@ const users: Array = [{ ] }, { - id: UUID.create().toString(), + id: 'b1ffc4d2-1a2b-4c3d-9e8f-7a6b5c4d3e02', createdAt: now, updatedAt: now, firstName: 'Barack', @@ -106,7 +105,7 @@ const users: Array = [{ ] }, { - id: UUID.create().toString(), + id: 'b1ffc4d2-1a2b-4c3d-9e8f-7a6b5c4d3e03', createdAt: now, updatedAt: now, firstName: 'Jimmy', @@ -131,7 +130,7 @@ const users: Array = [{ ] }, { - id: UUID.create().toString(), + id: 'b1ffc4d2-1a2b-4c3d-9e8f-7a6b5c4d3e04', createdAt: now, updatedAt: now, firstName: 'James', @@ -149,6 +148,44 @@ const users: Array = [{ 'access_allow', 'create_transaction' ] +}, +// One seed user per RBAC role, all in XpertMinds (JUM-772). +{ + id: 'b1ffc4d2-1a2b-4c3d-9e8f-7a6b5c4d3e05', + createdAt: now, + updatedAt: now, + firstName: 'Admin', + lastName: 'XpertMinds', + emails: [{ + email: 'admin@xpertminds.dev', + type: EEmailType.work, + isPrimary: true + } as EmailValueObject], + avatar: 'avatar.png', + username: 'admin@xpertminds.dev', + password: 'admin@123456', + organization: organizations[2].id, + roles: ['admin'] +}, +{ + id: 'b1ffc4d2-1a2b-4c3d-9e8f-7a6b5c4d3e06', + createdAt: now, + updatedAt: now, + firstName: 'User', + lastName: 'XpertMinds', + emails: [{ + email: 'user@xpertminds.dev', + type: EEmailType.work, + isPrimary: true + } as EmailValueObject], + avatar: 'avatar.png', + username: 'user@xpertminds.dev', + password: 'user@123456', + organization: organizations[2].id, + roles: ['user'] } ]; + +export const seedUserIds = users.map((user) => user.id); + export default users; diff --git a/apps/backend-template/src/config/.env.dev b/apps/backend-template/src/config/.env.dev index 2b3435223..3aef33f1d 100644 --- a/apps/backend-template/src/config/.env.dev +++ b/apps/backend-template/src/config/.env.dev @@ -59,7 +59,7 @@ JUMENTIX_AUTH_LOGIN_WINDOW_SECONDS=300 JUMENTIX_AUTH_LOCKOUT_SECONDS=900 # CORS security controls (comma-separated origins, '*' allowed only in non-prod) -JUMENTIX_CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 +JUMENTIX_CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,http://localhost:3001,http://127.0.0.1:3001 # RabbitMQ adapter settings JUMENTIX_RABBITMQ_URL=amqp://guest:guest@127.0.0.1:5672 diff --git a/apps/backend-template/src/infra/context/Context.ts b/apps/backend-template/src/infra/context/Context.ts index 7d6284607..87c47eab2 100644 --- a/apps/backend-template/src/infra/context/Context.ts +++ b/apps/backend-template/src/infra/context/Context.ts @@ -1,3 +1,199 @@ import { AsyncLocalStorage } from 'node:async_hooks'; -export const Context = new AsyncLocalStorage(); +const MAX_DURATION_SAMPLES = 256; +const MAX_CORRELATION_IDS = 20; +const MAX_RECENT_STORES = 20; +const SENSITIVE_KEY = /password|token|secret|authorization|cookie/i; + +type MetricsState = { + active: number; + enteredTotal: number; + exitedTotal: number; + errorTotal: number; + durationSumMs: number; + durationCount: number; + durations: number[]; + lastCorrelationIds: string[]; + recentStores: Array<{ collectedAt: string; entries: Record }>; +}; + +const state: MetricsState = { + active: 0, + enteredTotal: 0, + exitedTotal: 0, + errorTotal: 0, + durationSumMs: 0, + durationCount: 0, + durations: [], + lastCorrelationIds: [], + recentStores: [] +}; + +const storage = new AsyncLocalStorage>(); + +function percentile95(samples: number[]): number { + if (!samples.length) return 0; + const sorted = [...samples].sort((left, right) => left - right); + const index = Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95) - 1); + return sorted[Math.max(0, index)]; +} + +function serializeValue(value: unknown): unknown { + if (value === null || value === undefined) return value; + const type = typeof value; + if (type === 'string' || type === 'number' || type === 'boolean') return value; + if (type === 'bigint') return String(value); + if (Array.isArray(value)) return value.map(serializeValue); + if (value instanceof Map) { + const object: Record = {}; + value.forEach((entry, key) => { + object[String(key)] = serializeValue(entry); + }); + return object; + } + if (type === 'object') { + try { + return JSON.parse(JSON.stringify(value)); + } catch (_error) { + return '[unserializable]'; + } + } + return String(value); +} + +export function redactSensitive(value: unknown, keyHint = ''): unknown { + if (keyHint && SENSITIVE_KEY.test(keyHint)) return '[REDACTED]'; + if (Array.isArray(value)) return value.map((entry) => redactSensitive(entry)); + if (value && typeof value === 'object') { + const object: Record = {}; + Object.entries(value as Record).forEach(([key, entry]) => { + object[key] = redactSensitive(entry, key); + }); + return object; + } + return value; +} + +function snapshotStoreEntries(store: Map | undefined): Record { + const entries: Record = {}; + if (!store || typeof store.forEach !== 'function') return entries; + store.forEach((value, key) => { + const keyName = String(key); + entries[keyName] = redactSensitive(serializeValue(value), keyName); + }); + return entries; +} + +function pushRecentStore(store: Map | undefined): void { + state.recentStores.push({ + collectedAt: new Date().toISOString(), + entries: snapshotStoreEntries(store) + }); + if (state.recentStores.length > MAX_RECENT_STORES) { + state.recentStores.shift(); + } +} + +function pushCorrelationId(store: Map | undefined): void { + if (!store || typeof store.get !== 'function') return; + const value = store.get('correlationId'); + if (typeof value !== 'string' || !value) return; + state.lastCorrelationIds.push(value); + if (state.lastCorrelationIds.length > MAX_CORRELATION_IDS) { + state.lastCorrelationIds.shift(); + } +} + +function recordDuration(durationMs: number): void { + if (!Number.isFinite(durationMs) || durationMs < 0) return; + state.durationSumMs += durationMs; + state.durationCount += 1; + state.durations.push(durationMs); + if (state.durations.length > MAX_DURATION_SAMPLES) { + state.durations.shift(); + } +} + +export function snapshotAsyncContextMetrics() { + const avgDurationMs = state.durationCount > 0 + ? state.durationSumMs / state.durationCount + : 0; + const current = storage.getStore(); + return { + active: state.active, + enteredTotal: state.enteredTotal, + exitedTotal: state.exitedTotal, + errorTotal: state.errorTotal, + avgDurationMs, + p95Ms: percentile95(state.durations), + lastCorrelationIds: [...state.lastCorrelationIds], + recentStores: state.recentStores.map((entry) => ({ + collectedAt: entry.collectedAt, + entries: { ...entry.entries } + })), + currentStore: current ? snapshotStoreEntries(current) : null, + collectedAt: new Date().toISOString() + }; +} + +export function resetAsyncContextMetricsForTests(): void { + state.active = 0; + state.enteredTotal = 0; + state.exitedTotal = 0; + state.errorTotal = 0; + state.durationSumMs = 0; + state.durationCount = 0; + state.durations = []; + state.lastCorrelationIds = []; + state.recentStores = []; +} + +export function runWithContext(store: Map, fn: () => T): T { + const startedAt = Date.now(); + state.active += 1; + state.enteredTotal += 1; + pushCorrelationId(store); + pushRecentStore(store); + return storage.run(store, () => { + let finished = false; + const finish = (hadError: boolean) => { + if (finished) return; + finished = true; + if (hadError) state.errorTotal += 1; + state.active = Math.max(0, state.active - 1); + state.exitedTotal += 1; + recordDuration(Date.now() - startedAt); + }; + try { + const result = fn(); + if (result != null && typeof (result as unknown as { then?: unknown }).then === 'function') { + return (result as unknown as Promise).then( + (value) => { + finish(false); + return value; + }, + (error) => { + finish(true); + throw error; + } + ) as T; + } + finish(false); + return result; + } catch (error) { + finish(true); + throw error; + } + }); +} + +/** + * Drop-in facade over AsyncLocalStorage that instruments every `run`. + * Call sites keep using `Context.run` / `Context.getStore`. + */ +export const Context = { + run: runWithContext, + getStore: () => storage.getStore(), + enterWith: (store: Map) => storage.enterWith(store), + disable: () => storage.disable() +}; diff --git a/apps/backend-template/src/infra/persistence/InMemoryDatabase/InMemoryDbClient.ts b/apps/backend-template/src/infra/persistence/InMemoryDatabase/InMemoryDbClient.ts index b62372581..87a72b489 100644 --- a/apps/backend-template/src/infra/persistence/InMemoryDatabase/InMemoryDbClient.ts +++ b/apps/backend-template/src/infra/persistence/InMemoryDatabase/InMemoryDbClient.ts @@ -2,13 +2,11 @@ import type { IDatabaseClient, IDbStores } from '../port/IDatabaseClient'; import { UserStoreAPI } from './Stores/UserStoreAPI'; import { OrganizationStoreAPI } from './Stores/OrganizationStoreAPI'; -import { CatalogStoreAPI } from './Stores/CatalogStoreAPI'; export const InMemoryDbClient: IDatabaseClient = ((): IDatabaseClient => { const stores: IDbStores = { User: UserStoreAPI, - Organization: OrganizationStoreAPI, - Catalog: CatalogStoreAPI + Organization: OrganizationStoreAPI }; const connect = () => Promise.resolve(); const disconnect = () => Promise.resolve(); diff --git a/apps/backend-template/src/infra/persistence/InMemoryDatabase/Stores/InMemoryRelationalStore.ts b/apps/backend-template/src/infra/persistence/InMemoryDatabase/Stores/InMemoryRelationalStore.ts index 7c6737054..dad031f83 100644 --- a/apps/backend-template/src/infra/persistence/InMemoryDatabase/Stores/InMemoryRelationalStore.ts +++ b/apps/backend-template/src/infra/persistence/InMemoryDatabase/Stores/InMemoryRelationalStore.ts @@ -1,6 +1,10 @@ import type { IStore } from '@src/infra/ports/persistence/IStore'; -import { ConflictError, DataBaseNotFoundError, DatabasePagingError } from '@src/infra/exceptions'; +import { ConflictError, DataBaseNotFoundError } from '@src/infra/exceptions'; import type { IPagingRequest, IPagingResponse } from '@src/modules/port'; +import { + runListQuery, + type IIdReservationLedger +} from '@jumentix/persistence-contracts'; type Primitive = string | number | boolean | null | undefined; @@ -8,17 +12,14 @@ interface IStoreOptions> { uniqueIndexes?: (keyof T)[]; caseInsensitiveUniqueIndexes?: (keyof T)[]; relationIndexes?: (keyof T)[]; + /** Soft-delete + hide tombstones. Off for catalog (own tombstone + restore). */ + softDelete?: boolean; + entity?: string; + ledger?: IIdReservationLedger; } const stringifyPrimitive = (value: Primitive): string => String(value ?? ''); -const matchAllFilters = ( - record: Record, - filters: Record -): boolean => { - return Object.entries(filters).every(([key, value]) => record[key] === value); -}; - export class InMemoryRelationalStore> implements IStore { private readonly records = new Map(); @@ -102,20 +103,56 @@ export class InMemoryRelationalStore> implements I set.delete(id); if (set.size === 0) this.relationIndexes[field].delete(ref); }); - return this.records.delete(id); + if (!this.options.softDelete) { + this.records.delete(id); + return true; + } + const tombstone = { + ...existing, + deletedAt: new Date().toISOString(), + updatedAt: new Date() + } as T; + this.records.set(id, tombstone); + return true; } - public async getOneById(id: string): Promise { + public async getOneById(id: string, options?: { includeDeleted?: boolean }): Promise { const existing = this.records.get(id); if (!existing) { throw new DataBaseNotFoundError('Record not found'); } + if (this.options.softDelete && existing.deletedAt && !options?.includeDeleted) { + throw new DataBaseNotFoundError('Record not found'); + } return existing; } + public async hardDelete(id: string): Promise { + const existing = this.records.get(id); + if (!existing) return false; + Object.keys(this.uniqueIndexes).forEach((field) => { + const normalized = this.normalizeUniqueValue(field, existing[field]); + this.uniqueIndexes[field].delete(normalized); + }); + Object.keys(this.relationIndexes).forEach((field) => { + const ref = stringifyPrimitive(existing[field] as Primitive); + if (!ref) return; + const set = this.relationIndexes[field].get(ref); + if (!set) return; + set.delete(id); + if (set.size === 0) this.relationIndexes[field].delete(ref); + }); + this.records.delete(id); + return true; + } + public async create(key: string, value: T): Promise { + const { entity, ledger } = this.options; + if (entity && ledger?.has(entity, key)) { + throw new ConflictError('The field "id" already exists.'); + } if (this.records.has(key)) { - throw new ConflictError('Duplicated id'); + throw new ConflictError('The field "id" already exists.'); } this.ensureUniqueIndexes(key, value); this.syncRelationIndexes(key, value); @@ -135,28 +172,25 @@ export class InMemoryRelationalStore> implements I return merged; } + /** + * Filters, search, sort and paging share one implementation with the + * external-store proxy (`runListQuery`, JUM-777), so every driver answers + * the REST list contract the same way. + */ public async getAll( filters: Record, paging: IPagingRequest ): Promise> { - const { page, size } = paging; - if (page < 1) { - throw new DatabasePagingError('page must be greater than 0'); - } - const filtered = [...this.records.values()].filter((entry) => matchAllFilters(entry, filters)); - const total = filtered.length; - const totalPages = Math.max(1, Math.ceil(total / size)); - if (page > totalPages && total > 0) { - throw new DatabasePagingError('page number must be smaller than the number of total pages'); - } - const startAt = (page * size) - size; - const result = filtered.slice(startAt, startAt + size); - return { - result, - total, - page, - size - }; + // The contracts' response type marks `page`/`size` optional; `paginateList` + // always sets them, so the application's stricter shape holds. + const effectivePaging = this.options.softDelete + ? paging + : { ...paging, includeDeleted: true }; + return runListQuery( + [...this.records.values()], + filters, + effectivePaging + ) as IPagingResponse; } public async getByRelation(field: keyof T, referenceId: string): Promise { @@ -166,6 +200,10 @@ export class InMemoryRelationalStore> implements I if (!linked) return []; return [...linked] .map((id) => this.records.get(id)) - .filter((entry): entry is T => !!entry); + .filter((entry): entry is T => { + if (!entry) return false; + if (this.options.softDelete && entry.deletedAt) return false; + return true; + }); } } diff --git a/apps/backend-template/src/infra/persistence/InMemoryDatabase/Stores/OrganizationStoreAPI.ts b/apps/backend-template/src/infra/persistence/InMemoryDatabase/Stores/OrganizationStoreAPI.ts index 16d4a9aed..9318a4dcf 100644 --- a/apps/backend-template/src/infra/persistence/InMemoryDatabase/Stores/OrganizationStoreAPI.ts +++ b/apps/backend-template/src/infra/persistence/InMemoryDatabase/Stores/OrganizationStoreAPI.ts @@ -1,12 +1,16 @@ import type { IStore } from '@src/infra/ports/persistence/IStore'; import type { IOrganization } from '@src/modules/Users/domain/Entity/IOrganization'; import { InMemoryRelationalStore } from '@src/infra/persistence/InMemoryDatabase/Stores/InMemoryRelationalStore'; +import { entityIdLedger } from '@src/infra/persistence/InMemoryDatabase/idReservationLedger'; export const OrganizationStoreAPI: IStore = new InMemoryRelationalStore< IOrganization >( { uniqueIndexes: ['name'], - caseInsensitiveUniqueIndexes: ['name'] + caseInsensitiveUniqueIndexes: ['name'], + softDelete: true, + entity: 'Organization', + ledger: entityIdLedger } ); diff --git a/apps/backend-template/src/infra/persistence/InMemoryDatabase/Stores/UserStoreAPI.ts b/apps/backend-template/src/infra/persistence/InMemoryDatabase/Stores/UserStoreAPI.ts index 88b9e4a3e..b79997689 100644 --- a/apps/backend-template/src/infra/persistence/InMemoryDatabase/Stores/UserStoreAPI.ts +++ b/apps/backend-template/src/infra/persistence/InMemoryDatabase/Stores/UserStoreAPI.ts @@ -1,9 +1,13 @@ import type { IStore } from '@src/infra/ports/persistence/IStore'; import type { IUser } from '@src/modules/Users/domain/Entity/IUser'; import { InMemoryRelationalStore } from '@src/infra/persistence/InMemoryDatabase/Stores/InMemoryRelationalStore'; +import { entityIdLedger } from '@src/infra/persistence/InMemoryDatabase/idReservationLedger'; export const UserStoreAPI: IStore = new InMemoryRelationalStore({ uniqueIndexes: ['username'], caseInsensitiveUniqueIndexes: ['username'], - relationIndexes: ['organization'] + relationIndexes: ['organization'], + softDelete: true, + entity: 'User', + ledger: entityIdLedger }); diff --git a/apps/backend-template/src/infra/persistence/InMemoryDatabase/idReservationLedger.ts b/apps/backend-template/src/infra/persistence/InMemoryDatabase/idReservationLedger.ts new file mode 100644 index 000000000..ecd244947 --- /dev/null +++ b/apps/backend-template/src/infra/persistence/InMemoryDatabase/idReservationLedger.ts @@ -0,0 +1,4 @@ +import { InMemoryIdReservationLedger } from '@jumentix/persistence-contracts'; + +/** Shared by User and Organization stores in the dev in-memory client. */ +export const entityIdLedger = new InMemoryIdReservationLedger(); diff --git a/apps/backend-template/src/infra/persistence/port/IDatabaseClient.ts b/apps/backend-template/src/infra/persistence/port/IDatabaseClient.ts index ab0c541d4..a8f6a1766 100644 --- a/apps/backend-template/src/infra/persistence/port/IDatabaseClient.ts +++ b/apps/backend-template/src/infra/persistence/port/IDatabaseClient.ts @@ -1,12 +1,10 @@ import type { IDatabaseClient as IGenericDatabaseClient, IStore } from '@jumentix/persistence-contracts'; import type { IUser } from '@src/modules/Users/domain/Entity/IUser'; import type { IOrganization } from '@src/modules/Users/domain/Entity/IOrganization'; -import type { ICatalog } from '@src/modules/Catalogs/domain/Entity/ICatalog'; export interface IDbStores { User: IStore; Organization: IStore; - Catalog: IStore; [key: string]: IStore; } diff --git a/apps/backend-template/src/infra/persistence/purgeStores.ts b/apps/backend-template/src/infra/persistence/purgeStores.ts new file mode 100644 index 000000000..572e2afce --- /dev/null +++ b/apps/backend-template/src/infra/persistence/purgeStores.ts @@ -0,0 +1,47 @@ +import { + purgeTombstones, + TOMBSTONE_PURGE_TTL_DAYS, + type IIdReservationLedger, + type IPurgeReport, + type IPurgeStore, + type IStore +} from '@jumentix/persistence-contracts'; + +export const adaptPurgeStore = ( + entity: string, + store: IStore & { id: string; deletedAt?: unknown }> +): IPurgeStore => ({ + entity, + async listTombstones() { + const page = await store.getAll({}, { page: 1, size: 10_000, includeDeleted: true }); + return (page.result || []).filter((row) => row.deletedAt); + }, + async hardDelete(id: string) { + if (!store.hardDelete) { + throw new Error(`Store ${entity} does not implement hardDelete`); + } + return store.hardDelete(id); + } +}); + +export async function purgeUserAndOrganizationTombstones(input: { + userStore: IStore; + organizationStore: IStore; + ledger: IIdReservationLedger; + now?: Date; + olderThanDays?: number; + commit?: boolean; + excludeIds?: string[]; +}): Promise { + return purgeTombstones({ + stores: [ + adaptPurgeStore('Organization', input.organizationStore), + adaptPurgeStore('User', input.userStore) + ], + ledger: input.ledger, + now: input.now, + olderThanDays: input.olderThanDays ?? TOMBSTONE_PURGE_TTL_DAYS, + commit: input.commit === true, + excludeIds: input.excludeIds + }); +} diff --git a/apps/backend-template/src/interface/Async/RealtimeAPIBase.ts b/apps/backend-template/src/interface/Async/RealtimeAPIBase.ts index a749a0253..a9d80c890 100644 --- a/apps/backend-template/src/interface/Async/RealtimeAPIBase.ts +++ b/apps/backend-template/src/interface/Async/RealtimeAPIBase.ts @@ -12,7 +12,6 @@ import type { IKeyValueStorageClient } from '@src/infra/persistence/KeyValueStor import { RealtimeDomainEvent } from '@src/interface/Async/RealtimeDomainEvent'; import type { IAuthService } from '@src/modules/Users'; import { composeUsersAuthServices } from '@src/modules/Users'; -import { composeCatalogsServices } from '@src/modules/Catalogs'; import type { IEventBus, IMessageMediator } from '@src/modules/port'; export interface IAsyncOperationRequest { @@ -73,13 +72,7 @@ export interface IRealtimeHandlerFactoryDeps { const OPERATION_TO_CONTROLLER_METHOD: Record = { deleteOne: 'delete', // SONAR false-positive: this is an operation-id mapping, not a credential literal. - updateUserPassword: 'updatePassword', // NOSONAR - getAllCatalogs: 'getAll', - createCatalog: 'create', - getCatalogById: 'getOneById', - updateCatalog: 'update', - deleteCatalog: 'delete', - restoreCatalog: 'restore' + updateUserPassword: 'updatePassword' // NOSONAR }; export abstract class RealtimeAPIBase { @@ -111,8 +104,6 @@ export abstract class RealtimeAPIBase { private usersComposition: ReturnType | undefined; - private catalogsComposition: ReturnType | undefined; - constructor(config: IRealtimeAPIFactory, autoBuild = true) { this.databaseClient = config.databaseClient; this.mutexClient = config.mutexService; @@ -133,17 +124,6 @@ export abstract class RealtimeAPIBase { } } - protected composeCatalogsModule(): ReturnType { - if (this.catalogsComposition) return this.catalogsComposition; - - this.catalogsComposition = composeCatalogsServices({ - databaseClient: this.databaseClient, - eventBus: this.eventBus, - messageMediator: this.messageMediator - }); - return this.catalogsComposition; - } - protected composeUsersModule(): ReturnType { if (this.usersComposition) return this.usersComposition; if (!this.passwordCryptoService) { @@ -315,7 +295,6 @@ export abstract class RealtimeAPIBase { controllerName ); const usersModuleComposition = moduleName === 'Users' ? this.composeUsersModule() : undefined; - const catalogsModuleComposition = moduleName === 'Catalogs' ? this.composeCatalogsModule() : undefined; const controller = new ControllerModule({ authService: usersModuleComposition?.authService ?? this.authService, openApiSpecification: spec, @@ -324,7 +303,6 @@ export abstract class RealtimeAPIBase { userUseCases: usersModuleComposition?.userUseCases, organizationUseCases: usersModuleComposition?.organizationUseCases, authUseCases: usersModuleComposition?.authUseCases, - catalogUseCases: catalogsModuleComposition?.catalogUseCases, mutexService: this.mutexClient, passwordCryptoService: this.passwordCryptoService, messageMediator: this.messageMediator diff --git a/apps/backend-template/src/interface/GUI/README.md b/apps/backend-template/src/interface/GUI/README.md new file mode 100644 index 000000000..8a20d60e0 --- /dev/null +++ b/apps/backend-template/src/interface/GUI/README.md @@ -0,0 +1,35 @@ +# GUI interfaces (inbound) + +This directory is the **inbound GUI slot** for the backend-template hexagonal +architecture. GUIs live on the **driving side**: they are clients that call the +application through contracts (HTTP, WebSocket, gRPC, and future local bridges). +They must not own domain rules. + +## Layout + +```txt +interface/GUI/ + web/ # SPA, PWA, marketing sites — React, Vue, Next, Nuxt, … + desktop/ # Desktop shells — Electron, GTK, … +``` + +Both folders are **placeholders** today. Add implementations as separate +feature tasks with their own adapters, tests, and docs (EN + PT-BR). + +## Hexagonal placement + +| Asset | Layer | +| --- | --- | +| `GUI/web/*`, `GUI/desktop/*` | Inbound / driving (presentation clients) | +| `interface/HTTP|WebSocket|gRPC|…` | Inbound adapters that GUIs typically call | +| `modules/*/application` + `domain` | Core — never imported by GUI UI code | +| `infra/*` | Outbound — not used directly from GUI | + +Call order remains: + +`GUI → transport adapter/handler → controller → use case → domain → port → outbound adapter` + +## Related docs + +- [Architecture and Structure](../../../../../documentation/md/ARCHITECTURE-AND-STRUCTURE.md) +- Commercial map: `/architecture` on the Jumentix website diff --git a/apps/backend-template/src/interface/GUI/README.pt-BR.md b/apps/backend-template/src/interface/GUI/README.pt-BR.md new file mode 100644 index 000000000..62fcabcbf --- /dev/null +++ b/apps/backend-template/src/interface/GUI/README.pt-BR.md @@ -0,0 +1,35 @@ +# Interfaces GUI (inbound) + +Este diretório é o **slot de GUI inbound** do backend-template na arquitetura +hexagonal. GUIs ficam no **lado driving**: são clientes que chamam a aplicação +por contratos (HTTP, WebSocket, gRPC e pontes locais futuras). Elas não devem +possuir regras de domínio. + +## Layout + +```txt +interface/GUI/ + web/ # SPA, PWA, sites — React, Vue, Next, Nuxt, … + desktop/ # Shells desktop — Electron, GTK, … +``` + +Ambas as pastas são **placeholders** por enquanto. Implementações entram como +tarefas de feature com adapters, testes e docs (EN + PT-BR). + +## Posição hexagonal + +| Asset | Camada | +| --- | --- | +| `GUI/web/*`, `GUI/desktop/*` | Inbound / driving (clientes de apresentação) | +| `interface/HTTP|WebSocket|gRPC|…` | Adapters inbound que as GUIs tipicamente chamam | +| `modules/*/application` + `domain` | Núcleo — nunca importado pelo código de UI | +| `infra/*` | Outbound — não usado diretamente pela GUI | + +Ordem de chamada: + +`GUI → adapter/handler de transporte → controller → caso de uso → domínio → port → adapter outbound` + +## Docs relacionadas + +- [Arquitetura e Estrutura](../../../../../documentation/md/ARCHITECTURE-AND-STRUCTURE.pt-BR.md) +- Mapa comercial: `/pt-BR/architecture` no site Jumentix diff --git a/apps/backend-template/src/interface/GUI/desktop/README.md b/apps/backend-template/src/interface/GUI/desktop/README.md new file mode 100644 index 000000000..4a7e19bd5 --- /dev/null +++ b/apps/backend-template/src/interface/GUI/desktop/README.md @@ -0,0 +1,9 @@ +# Desktop GUI placeholders + +Future home for desktop GUIs that drive the backend-template application: + +- Electron / Tauri shells +- GTK and other native toolkits +- Local process UIs that still talk to the core through ports/adapters + +No implementations yet. Desktop UI code must not embed domain rules. diff --git a/apps/backend-template/src/interface/GUI/web/README.md b/apps/backend-template/src/interface/GUI/web/README.md new file mode 100644 index 000000000..a3adf0743 --- /dev/null +++ b/apps/backend-template/src/interface/GUI/web/README.md @@ -0,0 +1,10 @@ +# Web GUI placeholders + +Future home for web GUIs that drive the backend-template application: + +- SPA / PWA clients (React, Vue, Svelte, …) +- Full sites and docs shells that consume Jumentix contracts +- Offline-capable frontends that still call inbound adapters for server work + +No implementations yet. Keep UI frameworks out of `domain/` and +`application/`. diff --git a/apps/backend-template/src/interface/HTTP/RestAPI.ts b/apps/backend-template/src/interface/HTTP/RestAPI.ts index 163ef28e8..00419af18 100644 --- a/apps/backend-template/src/interface/HTTP/RestAPI.ts +++ b/apps/backend-template/src/interface/HTTP/RestAPI.ts @@ -23,12 +23,12 @@ import type { import { composeUsersAuthServices } from '@src/modules/Users'; -import { - composeCatalogsServices -} from '@src/modules/Catalogs'; -import users from '@seed/users'; -import organizations from '@seed/organizations'; +import users, { seedUserIds } from '@seed/users'; +import organizations, { seedOrganizationIds } from '@seed/organizations'; +import { assertSeedIdNotPurged } from '@jumentix/persistence-contracts'; +import { entityIdLedger } from '@src/infra/persistence/InMemoryDatabase/idReservationLedger'; +import { purgeUserAndOrganizationTombstones } from '@src/infra/persistence/purgeStores'; export class RestAPI { private readonly oas: Map = new Map(); @@ -57,8 +57,6 @@ export class RestAPI { private usersComposition: ReturnType | undefined; - private catalogsComposition: ReturnType | undefined; - constructor(config: IAPIFactory) { this.serverType = config.serverType ?? EHTTPFrameworks.express; this.server = config.webServer; @@ -115,6 +113,38 @@ export class RestAPI { const localhostGet = config.infraHandlers.localhostGetHandlerFactory({ ...noServiceInjection }); this.server.endPointRegister(localhostGet); + // AsyncLocalStorage request-context metrics for Service Management scrape + // (Monitoring tab / Contract 1c+1d). Loopback-oriented; no request body. + // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require + const { snapshotAsyncContextMetrics } = require('@src/infra/context/Context'); + this.server.endPointRegister({ + method: 'get', + path: '/async-context-metrics', + handler: (_req: any, res: any): void => { + res.status(200).json(snapshotAsyncContextMetrics()); + } + }); + + this.server.endPointRegister({ + method: 'post', + path: '/internal/tombstones/purge', + handler: async (req: any, res: any): Promise => { + const ip = String(req.ip || req.socket?.remoteAddress || ''); + const loopback = ip === '127.0.0.1' || ip === '::1' || ip.endsWith('127.0.0.1'); + if (!loopback) { + res.status(403).json({ error: 'Purge is loopback-only.' }); + return; + } + const body = req.body || {}; + const report = await this.purgeTombstones({ + commit: body.commit === true, + olderThanDays: Number(body.olderThanDays) || undefined, + protectSeed: body.protectSeed !== false + }); + res.status(200).json(report); + } + }); + // serve API docs as JSON const apiVersionsGet = config.infraHandlers.apiVersionsGetHandlerFactory({ ...noServiceInjection, @@ -212,7 +242,6 @@ export class RestAPI { const { moduleName, controllerName } = RestAPI.resolveControllerMetadata(module); const ControllerModule = RestAPI.getControllerModule(moduleName, controllerName); const usersModuleComposition = moduleName === 'Users' ? this.composeUsersModule() : undefined; - const catalogsModuleComposition = moduleName === 'Catalogs' ? this.composeCatalogsModule() : undefined; const controller = new ControllerModule({ authService: usersModuleComposition?.authService ?? this.authService, @@ -222,7 +251,6 @@ export class RestAPI { userUseCases: usersModuleComposition?.userUseCases, organizationUseCases: usersModuleComposition?.organizationUseCases, authUseCases: usersModuleComposition?.authUseCases, - catalogUseCases: catalogsModuleComposition?.catalogUseCases, mutexService: this.mutexClient, passwordCryptoService: this.passwordCryptoService, messageMediator: this.messageMediator @@ -369,6 +397,26 @@ export class RestAPI { await this.seedUsers(); } + public async purgeTombstones(options: { + commit?: boolean; + olderThanDays?: number; + protectSeed?: boolean; + now?: Date; + } = {}) { + const excludeIds = options.protectSeed === false + ? [] + : [...seedOrganizationIds, ...seedUserIds]; + return purgeUserAndOrganizationTombstones({ + userStore: this.databaseClient.stores.User, + organizationStore: this.databaseClient.stores.Organization, + ledger: entityIdLedger, + now: options.now, + olderThanDays: options.olderThanDays, + commit: options.commit === true, + excludeIds + }); + } + /** * Seeded one at a time, on purpose (JUM-687). * @@ -383,17 +431,40 @@ export class RestAPI { const seeded: any[] = []; for (const organization of organizations) { + assertSeedIdNotPurged(entityIdLedger, 'Organization', organization.id); // eslint-disable-next-line no-await-in-loop const existing = await organizationUseCases.getOneById(organization.id); if (existing.result) { seeded.push(existing.result); - } else { + // eslint-disable-next-line no-continue + continue; + } + try { // eslint-disable-next-line no-await-in-loop - const created = await organizationUseCases.create(organization as any); - if (created.error) throw new Error((created.error as Error).message); - if (!created.result) throw new Error('Organization seed failed'); - seeded.push(created.result); + const tombstone = await this.databaseClient.stores.Organization.getOneById( + organization.id, + { includeDeleted: true } + ); + if (tombstone) { + // eslint-disable-next-line no-await-in-loop + await this.databaseClient.stores.Organization.update(organization.id, { + ...tombstone, + deletedAt: null + }); + // eslint-disable-next-line no-await-in-loop + const restored = await organizationUseCases.getOneById(organization.id); + if (restored.result) seeded.push(restored.result); + // eslint-disable-next-line no-continue + continue; + } + } catch { + // Record really missing — create below. } + // eslint-disable-next-line no-await-in-loop + const created = await organizationUseCases.create(organization as any); + if (created.error) throw new Error((created.error as Error).message); + if (!created.result) throw new Error('Organization seed failed'); + seeded.push(created.result); } return seeded; @@ -406,6 +477,36 @@ export class RestAPI { const seeded: IUser[] = []; for (const user of users) { + assertSeedIdNotPurged(entityIdLedger, 'User', user.id); + // eslint-disable-next-line no-await-in-loop + const existing = await userUseCases.getOneById(user.id); + if (existing.result) { + seeded.push(existing.result); + // eslint-disable-next-line no-continue + continue; + } + try { + // Tombstones hide from getOneById; the seed id must stay reserved. + // eslint-disable-next-line no-await-in-loop + const tombstone = await this.databaseClient.stores.User.getOneById( + user.id, + { includeDeleted: true } + ); + if (tombstone) { + // eslint-disable-next-line no-await-in-loop + await this.databaseClient.stores.User.update(user.id, { + ...tombstone, + deletedAt: null + }); + // eslint-disable-next-line no-await-in-loop + const restored = await userUseCases.getOneById(user.id); + if (restored.result) seeded.push(restored.result); + // eslint-disable-next-line no-continue + continue; + } + } catch { + // Record really missing — create below. + } // eslint-disable-next-line no-await-in-loop const newUser = await userUseCases.create(user); if (newUser.error) throw new Error((newUser.error as Error).message); @@ -439,17 +540,6 @@ export class RestAPI { // console.log('>>>> done'); } - private composeCatalogsModule(): ReturnType { - if (this.catalogsComposition) return this.catalogsComposition; - - this.catalogsComposition = composeCatalogsServices({ - databaseClient: this.databaseClient, - eventBus: this.eventBus, - messageMediator: this.messageMediator - }); - return this.catalogsComposition; - } - private composeUsersModule(): ReturnType { if (this.usersComposition) return this.usersComposition; diff --git a/apps/backend-template/src/interface/HTTP/ports/IController.ts b/apps/backend-template/src/interface/HTTP/ports/IController.ts index 16f69b9ac..e09407052 100644 --- a/apps/backend-template/src/interface/HTTP/ports/IController.ts +++ b/apps/backend-template/src/interface/HTTP/ports/IController.ts @@ -12,6 +12,8 @@ export interface IController { delete?(event: BaseDomainEvent): Promise>; getOneById?(event: BaseDomainEvent): Promise>; getAll?(event: BaseDomainEvent): Promise>; + getUsersMetrics?(event: BaseDomainEvent): Promise>; + getOrganizationsMetrics?(event: BaseDomainEvent): Promise>; login?(event: BaseDomainEvent): Promise>; logout?(event: BaseDomainEvent): Promise>; register?(event: BaseDomainEvent): Promise>; diff --git a/apps/backend-template/src/interface/HTTP/ports/IControllerFactory.ts b/apps/backend-template/src/interface/HTTP/ports/IControllerFactory.ts index e40ab05ca..255ba94ea 100644 --- a/apps/backend-template/src/interface/HTTP/ports/IControllerFactory.ts +++ b/apps/backend-template/src/interface/HTTP/ports/IControllerFactory.ts @@ -3,7 +3,6 @@ import { UserService } from '@src/modules/Users/service/UserService'; import type { IUserUseCases } from '@src/modules/Users/application/ports/IUserUseCases'; import type { IAuthUseCases } from '@src/modules/Users/application/ports/IAuthUseCases'; import type { IOrganizationUseCases } from '@src/modules/Users/application/ports/IOrganizationUseCases'; -import type { ICatalogUseCases } from '@src/modules/Catalogs/application/ports/ICatalogUseCases'; import type { IMutexService } from '@src/infra/mutex/port/IMutexService'; import type { IDatabaseClient } from '@src/infra/persistence/port/IDatabaseClient'; import type { IPasswordCryptoService } from '@src/infra/security/IPasswordCryptoService'; @@ -18,7 +17,6 @@ export interface IControllerFactory { userUseCases?: IUserUseCases; organizationUseCases?: IOrganizationUseCases; authUseCases?: IAuthUseCases; - catalogUseCases?: ICatalogUseCases; passwordCryptoService?: IPasswordCryptoService, mutexService?: IMutexService; messageMediator?: IMessageMediator; diff --git a/apps/backend-template/src/interface/HTTP/validators/throwIfOASInputValidationFails.ts b/apps/backend-template/src/interface/HTTP/validators/throwIfOASInputValidationFails.ts index 4e914f354..220166864 100644 --- a/apps/backend-template/src/interface/HTTP/validators/throwIfOASInputValidationFails.ts +++ b/apps/backend-template/src/interface/HTTP/validators/throwIfOASInputValidationFails.ts @@ -5,7 +5,7 @@ import getSchema from './getSchema'; import isPropertiesMatching from './isPropertiesMatching'; import checkRequiredProperties from './checkRequiredProperties'; -const SERVER_MANAGED_INPUT_PROPERTIES = new Set(['createdAt', 'updatedAt']); +const SERVER_MANAGED_INPUT_PROPERTIES = new Set(['createdAt', 'updatedAt', 'deletedAt']); const toPublicValidationMessage = ( message: string, diff --git a/apps/backend-template/src/interface/HTTP/validators/validateRequestParams.ts b/apps/backend-template/src/interface/HTTP/validators/validateRequestParams.ts index 84624a7da..0ae70b68b 100644 --- a/apps/backend-template/src/interface/HTTP/validators/validateRequestParams.ts +++ b/apps/backend-template/src/interface/HTTP/validators/validateRequestParams.ts @@ -1,5 +1,22 @@ import { ValidationError } from '@src/infra/exceptions'; import { validateValueAgainstOpenApiSchema } from '@src/shared/openapi/OpenApi31DataEntity'; + +const coerceQueryValue = (value: unknown, schema: Record): unknown => { + if (typeof value !== 'string') return value; + const type = Array.isArray(schema?.type) ? schema.type[0] : schema?.type; + if (type === 'integer' || type === 'number') { + const trimmed = value.trim(); + if (trimmed === '') return value; + const parsed = Number(trimmed); + return Number.isNaN(parsed) ? value : parsed; + } + if (type === 'boolean') { + if (value === 'true') return true; + if (value === 'false') return false; + } + return value; +}; + /** * Validate request parameters * @param endPointConfig @@ -26,7 +43,10 @@ export default function validateRequestParams( } else if (location === 'header') { source = headers; } - const value = source?.[name]; + // Query strings arrive as text; coerce to the declared primitive before + // validating so `page=2` satisfies `type: integer` (JUM-777). Values that + // do not parse stay strings and fail the type check with a clear message. + const value = location === 'query' ? coerceQueryValue(source?.[name], schema) : source?.[name]; if (required) { if (value === undefined || value === null || value === '') { diff --git a/apps/backend-template/src/modules/Catalogs/features/createCatalog.ts b/apps/backend-template/src/modules/Catalogs/features/createCatalog.ts deleted file mode 100644 index fea9ce05a..000000000 --- a/apps/backend-template/src/modules/Catalogs/features/createCatalog.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { ICatalog } from '@src/modules/Catalogs/domain/Entity/ICatalog'; -import type { RequestCreateCatalog } from '@src/modules/Catalogs/interface/dto/RequestCreateCatalog'; -import type { ICatalogRepository } from '@src/modules/Catalogs/service/ports/ICatalogRepository'; - -export const createCatalog = async ( - payload: RequestCreateCatalog & { createdBy?: string }, - catalogRepository: ICatalogRepository -): Promise => { - const model = await catalogRepository.create(payload); - return model.serialize(); -}; diff --git a/apps/backend-template/src/modules/Catalogs/features/getCatalogById.ts b/apps/backend-template/src/modules/Catalogs/features/getCatalogById.ts deleted file mode 100644 index 16694f1f7..000000000 --- a/apps/backend-template/src/modules/Catalogs/features/getCatalogById.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { ICatalog } from '@src/modules/Catalogs/domain/Entity/ICatalog'; -import type { ICatalogRepository } from '@src/modules/Catalogs/service/ports/ICatalogRepository'; - -export const getCatalogById = async ( - id: string, - catalogRepository: ICatalogRepository -): Promise => { - const model = await catalogRepository.getOneById(id); - return model.serialize(); -}; diff --git a/apps/backend-template/src/modules/Catalogs/features/updateCatalog.ts b/apps/backend-template/src/modules/Catalogs/features/updateCatalog.ts deleted file mode 100644 index 98458eed6..000000000 --- a/apps/backend-template/src/modules/Catalogs/features/updateCatalog.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { ICatalog } from '@src/modules/Catalogs/domain/Entity/ICatalog'; -import type { RequestUpdateCatalog } from '@src/modules/Catalogs/interface/dto/RequestUpdateCatalog'; -import type { ICatalogRepository } from '@src/modules/Catalogs/service/ports/ICatalogRepository'; - -export const updateCatalog = async ( - id: string, - payload: RequestUpdateCatalog, - catalogRepository: ICatalogRepository, - actor: string = '' -): Promise => { - const model = await catalogRepository.update(id, payload, actor); - return model.serialize(); -}; diff --git a/apps/backend-template/src/modules/Catalogs/index.ts b/apps/backend-template/src/modules/Catalogs/index.ts deleted file mode 100644 index 109fc006d..000000000 --- a/apps/backend-template/src/modules/Catalogs/index.ts +++ /dev/null @@ -1,38 +0,0 @@ -export type { ICatalog } from '@src/modules/Catalogs/domain/Entity/ICatalog'; -export { Catalog } from '@src/modules/Catalogs/domain/Model/Catalog'; -export { CatalogDataRepository } from '@src/modules/Catalogs/adapters/out/persistence/CatalogDataRepository'; -export { CatalogService } from '@src/modules/Catalogs/service/CatalogService'; -export { composeCatalogsServices } from '@src/modules/Catalogs/composition/composeCatalogsServices'; -export { CatalogUseCases } from '@src/modules/Catalogs/application/use-cases/CatalogUseCases'; - -export { CatalogController } from '@src/modules/Catalogs/adapters/in/http/controllers/CatalogController'; -export { CatalogController as CatalogHttpController } from '@src/modules/Catalogs/adapters/in/http/controllers/CatalogController'; - -export { CatalogIntegrationEventName } from '@src/modules/Catalogs/events/contracts/CatalogIntegrationEventName'; -export { CatalogCreateRequestEvent } from '@src/modules/Catalogs/events/CatalogCreateRequestEvent'; -export { CatalogUpdateRequestEvent } from '@src/modules/Catalogs/events/CatalogUpdateRequestEvent'; -export { CatalogDeleteRequestEvent } from '@src/modules/Catalogs/events/CatalogDeleteRequestEvent'; -export { CatalogRestoreRequestEvent } from '@src/modules/Catalogs/events/CatalogRestoreRequestEvent'; -export { CatalogGetAllRequestEvent } from '@src/modules/Catalogs/events/CatalogGetAllRequestEvent'; -export { CatalogGetOneRequestEvent } from '@src/modules/Catalogs/events/CatalogGetOneRequestEvent'; - -export { - decideCatalogAccess, - resolveCatalogCollectionScope, - resolveCatalogCreationOrganization -} from '@src/modules/Catalogs/domain/security/CatalogAuthorizationPolicy'; - -// dtos -export type { RequestCreateCatalog } from '@src/modules/Catalogs/interface/dto/RequestCreateCatalog'; -export type { RequestUpdateCatalog } from '@src/modules/Catalogs/interface/dto/RequestUpdateCatalog'; -export type { RequestCatalogListOptions } from '@src/modules/Catalogs/interface/dto/RequestCatalogListOptions'; - -export type { ICatalogUseCases } from '@src/modules/Catalogs/application/ports/ICatalogUseCases'; -export type { ICatalogRepository } from '@src/modules/Catalogs/service/ports/ICatalogRepository'; - -export { createCatalog } from '@src/modules/Catalogs/features/createCatalog'; -export { updateCatalog } from '@src/modules/Catalogs/features/updateCatalog'; -export { deleteCatalogById } from '@src/modules/Catalogs/features/deleteCatalogById'; -export { restoreCatalog } from '@src/modules/Catalogs/features/restoreCatalog'; -export { getCatalogById } from '@src/modules/Catalogs/features/getCatalogById'; -export { getAllCatalogs } from '@src/modules/Catalogs/features/getAllCatalogs'; diff --git a/apps/backend-template/src/modules/Users/adapters/in/http/controllers/OrganizationController.ts b/apps/backend-template/src/modules/Users/adapters/in/http/controllers/OrganizationController.ts index dc956bb9b..fd3aa1183 100644 --- a/apps/backend-template/src/modules/Users/adapters/in/http/controllers/OrganizationController.ts +++ b/apps/backend-template/src/modules/Users/adapters/in/http/controllers/OrganizationController.ts @@ -9,10 +9,7 @@ import { BaseDomainEvent } from '@src/modules/port/BaseDomainEvent'; import type { IServiceResponse } from '@src/modules/port'; -import { - setFilter, - setPaging -} from '@src/modules/port'; +import { setListQuery, setMetricsQuery } from '@src/modules/port'; import type { IOrganization } from '@src/modules/Users/domain/Entity/IOrganization'; import type { IOrganizationUseCases } from '@src/modules/Users/application/ports/IOrganizationUseCases'; import type { RequestCreateOrganization } from '@src/modules/Users/interface/dto/RequestCreateOrganization'; @@ -116,17 +113,33 @@ export class OrganizationController extends BaseController implements IControlle event: BaseDomainEvent ): Promise> { validateRequestAgainstOAS(this.openApiSpecification, event.schemaOAS, event); - const filters = setFilter(event); + // JUM-777: page/size/filter/sort/q validated against x-list-capabilities. + const { filters, paging } = setListQuery(event); const authenticatedUser = this.getAuthenticatedUser(event); const tenantScope = resolveOrganizationCollectionScope(authenticatedUser); this.throwIfTenantAccessDenied(tenantScope.decision); - const paging = setPaging(event); return this.organizationUseCases.getAll( { ...filters, ...tenantScope.filters }, paging ); } + @Authorize() + public async getOrganizationsMetrics( + event: BaseDomainEvent + ): Promise> { + validateRequestAgainstOAS(this.openApiSpecification, event.schemaOAS, event); + const { query, filters, capabilities } = setMetricsQuery(event); + const authenticatedUser = this.getAuthenticatedUser(event); + const tenantScope = resolveOrganizationCollectionScope(authenticatedUser); + this.throwIfTenantAccessDenied(tenantScope.decision); + return this.organizationUseCases.metrics( + { ...filters, ...tenantScope.filters }, + query, + capabilities + ); + } + @Authorize() public async createAddress( event: BaseDomainEvent diff --git a/apps/backend-template/src/modules/Users/adapters/in/http/controllers/UserController.ts b/apps/backend-template/src/modules/Users/adapters/in/http/controllers/UserController.ts index 60c1d5c23..df9d2989d 100644 --- a/apps/backend-template/src/modules/Users/adapters/in/http/controllers/UserController.ts +++ b/apps/backend-template/src/modules/Users/adapters/in/http/controllers/UserController.ts @@ -12,10 +12,7 @@ import { BaseDomainEvent } from '@src/modules/port/BaseDomainEvent'; import type { IServiceResponse } from '@src/modules/port'; -import { - setFilter, - setPaging -} from '@src/modules/port'; +import { setListQuery, setMetricsQuery } from '@src/modules/port'; import type { IUser } from '@src/modules/Users/domain/Entity/IUser'; @@ -170,11 +167,11 @@ export class UserController extends BaseController implements IController { event.schemaOAS, event ); - const filters = setFilter(event); + // JUM-777: page/size/filter/sort/q validated against x-list-capabilities. + const { filters, paging } = setListQuery(event); const authenticatedUser = this.getAuthenticatedUser(event); const tenantScope = resolveUserCollectionScope(authenticatedUser); this.throwIfTenantAccessDenied(tenantScope.decision); - const paging = setPaging(event); const result = await this.userUseCases.getAll( { ...filters, ...tenantScope.filters }, paging @@ -183,6 +180,26 @@ export class UserController extends BaseController implements IController { return result; } + @Authorize() + public async getUsersMetrics( + event: BaseDomainEvent + ): Promise> { + validateRequestAgainstOAS( + this.openApiSpecification, + event.schemaOAS, + event + ); + const { query, filters, capabilities } = setMetricsQuery(event); + const authenticatedUser = this.getAuthenticatedUser(event); + const tenantScope = resolveUserCollectionScope(authenticatedUser); + this.throwIfTenantAccessDenied(tenantScope.decision); + return this.userUseCases.metrics( + { ...filters, ...tenantScope.filters }, + query, + capabilities + ); + } + @Authorize() public async createDocument( event: BaseDomainEvent diff --git a/apps/backend-template/src/modules/Users/application/UserUseCases.ts b/apps/backend-template/src/modules/Users/application/UserUseCases.ts index a6a1f4121..9e8cf3909 100644 --- a/apps/backend-template/src/modules/Users/application/UserUseCases.ts +++ b/apps/backend-template/src/modules/Users/application/UserUseCases.ts @@ -31,6 +31,14 @@ export class UserUseCases implements IUserUseCases { return this.userService.getAll(filters, paging); } + public async metrics( + filters: Parameters[0], + query: Parameters[1], + capabilities: Parameters[2] + ) { + return this.userService.metrics(filters, query, capabilities); + } + public async updatePassword(id: string, data: Parameters[1]) { return this.userService.updatePassword(id, data); } diff --git a/apps/backend-template/src/modules/Users/application/ports/IOrganizationUseCases.ts b/apps/backend-template/src/modules/Users/application/ports/IOrganizationUseCases.ts index 6eb5600e2..b6f28f667 100644 --- a/apps/backend-template/src/modules/Users/application/ports/IOrganizationUseCases.ts +++ b/apps/backend-template/src/modules/Users/application/ports/IOrganizationUseCases.ts @@ -8,6 +8,7 @@ import type { RequestUpdateAddress } from '@src/modules/Users/interface/dto/Requ import type { RequestUpdateEmail } from '@src/modules/Users/interface/dto/RequestUpdateEmail'; import type { RequestUpdateOrganization } from '@src/modules/Users/interface/dto/RequestUpdateOrganization'; import type { RequestUpdatePhone } from '@src/modules/Users/interface/dto/RequestUpdatePhone'; +import type { IMetricsCapabilities, IMetricsQuery, IMetricsResult } from '@jumentix/persistence-contracts'; export interface IOrganizationUseCases { create(data: RequestCreateOrganization): Promise>; @@ -18,6 +19,11 @@ export interface IOrganizationUseCases { filters: Record, paging: IPagingRequest ): Promise>; + metrics( + filters: Record, + query: IMetricsQuery, + capabilities: IMetricsCapabilities + ): Promise>; createAddress(id: string, data: RequestCreateAddress): Promise>; updateAddress( id: string, diff --git a/apps/backend-template/src/modules/Users/application/ports/IUserUseCases.ts b/apps/backend-template/src/modules/Users/application/ports/IUserUseCases.ts index dba78584b..3e036ba83 100644 --- a/apps/backend-template/src/modules/Users/application/ports/IUserUseCases.ts +++ b/apps/backend-template/src/modules/Users/application/ports/IUserUseCases.ts @@ -10,6 +10,7 @@ import type { RequestUpdateEmail } from '@src/modules/Users/interface/dto/Reques import type { RequestUpdatePassword } from '@src/modules/Users/interface/dto/RequestUpdatePassword'; import type { RequestUpdatePhone } from '@src/modules/Users/interface/dto/RequestUpdatePhone'; import type { RequestUpdateUser } from '@src/modules/Users/interface/dto/RequestUpdateUser'; +import type { IMetricsCapabilities, IMetricsQuery, IMetricsResult } from '@jumentix/persistence-contracts'; export interface IUserUseCases { create(data: RequestCreateUser): Promise>; @@ -20,6 +21,11 @@ export interface IUserUseCases { filters: Record, paging: IPagingRequest ): Promise>; + metrics( + filters: Record, + query: IMetricsQuery, + capabilities: IMetricsCapabilities + ): Promise>; updatePassword(id: string, data: RequestUpdatePassword): Promise>; createDocument(id: string, data: RequestCreateDocument): Promise>; updateDocument( diff --git a/apps/backend-template/src/modules/Users/application/use-cases/OrganizationUseCases.ts b/apps/backend-template/src/modules/Users/application/use-cases/OrganizationUseCases.ts index 0673b00ea..e989d495e 100644 --- a/apps/backend-template/src/modules/Users/application/use-cases/OrganizationUseCases.ts +++ b/apps/backend-template/src/modules/Users/application/use-cases/OrganizationUseCases.ts @@ -44,6 +44,14 @@ export class OrganizationUseCases implements IOrganizationUseCases { return this.organizationService.getAll(filters, paging); } + public async metrics( + filters: Parameters[0], + query: Parameters[1], + capabilities: Parameters[2] + ) { + return this.organizationService.metrics(filters, query, capabilities); + } + public async createAddress( id: string, data: RequestCreateAddress diff --git a/apps/backend-template/src/modules/Users/domain/Entity/IOrganization.ts b/apps/backend-template/src/modules/Users/domain/Entity/IOrganization.ts index 9913d449c..94d4fc149 100644 --- a/apps/backend-template/src/modules/Users/domain/Entity/IOrganization.ts +++ b/apps/backend-template/src/modules/Users/domain/Entity/IOrganization.ts @@ -8,6 +8,7 @@ export interface IOrganization { id: string; createdAt: Date; updatedAt: Date; + deletedAt?: Date | string | null; name: string; address: AddressValueObject[]; phone: PhoneValueObject[]; diff --git a/apps/backend-template/src/modules/Users/domain/Entity/IUser.ts b/apps/backend-template/src/modules/Users/domain/Entity/IUser.ts index 2525e52e6..2afb13179 100644 --- a/apps/backend-template/src/modules/Users/domain/Entity/IUser.ts +++ b/apps/backend-template/src/modules/Users/domain/Entity/IUser.ts @@ -8,6 +8,7 @@ export interface IUser { id: string; createdAt: Date; updatedAt: Date; + deletedAt?: Date | string | null; firstName: string; lastName: string; avatar: string; diff --git a/apps/backend-template/src/modules/Users/domain/Model/Organization.ts b/apps/backend-template/src/modules/Users/domain/Model/Organization.ts index ab6599661..51ce47a7c 100644 --- a/apps/backend-template/src/modules/Users/domain/Model/Organization.ts +++ b/apps/backend-template/src/modules/Users/domain/Model/Organization.ts @@ -1,6 +1,7 @@ /* eslint-disable no-underscore-dangle */ import type { HasMany } from '@src/modules/port'; import { BaseModel, hasMany } from '@src/modules/port'; +import type { EntityConstructor } from '@src/modules/port/relations'; import { canNotBeEmpty, throwIfReadOnly } from '@src/shared/validators'; import { AddressValueObject, @@ -8,13 +9,14 @@ import { PhoneValueObject } from '@src/modules/ddd/valueObjects'; import type { IOrganization } from '@src/modules/Users/domain/Entity/IOrganization'; +import type { IUser } from '@src/modules/Users/domain/Entity/IUser'; import type { RequestCreateOrganization } from '@src/modules/Users/interface/dto/RequestCreateOrganization'; -import { User } from '@src/modules/Users/domain/Model/User'; interface OrganizationFactory extends RequestCreateOrganization { id?: string; createdAt?: Date | string; updatedAt?: Date | string; + deletedAt?: Date | string | null; readOnly?: boolean; } @@ -82,6 +84,13 @@ export class Organization extends BaseModel implements IOrganizat type: 'array', required: false, validations: [] + }, + { + name: 'deletedAt', + type: 'string', + format: 'date-time', + required: false, + validations: [] } ] } as const; @@ -96,8 +105,7 @@ export class Organization extends BaseModel implements IOrganizat private _users: string[] = []; - @hasMany(() => User) - public userEntities: HasMany = []; + public userEntities: HasMany> = []; private readonly _readOnly: boolean = false; @@ -107,7 +115,8 @@ export class Organization extends BaseModel implements IOrganizat super({ id: payload.id, createdAt: payload.createdAt, - updatedAt: payload.updatedAt + updatedAt: payload.updatedAt, + deletedAt: payload.deletedAt }); BaseModel.throwIfDataEntitySchemaIsNotOpenApi31Compliant(Organization.dataEntitySchema as any); this.name = payload.name; @@ -264,6 +273,7 @@ export class Organization extends BaseModel implements IOrganizat return false; } + @hasMany('User') public get users(): string[] { return [...this._users]; } diff --git a/apps/backend-template/src/modules/Users/domain/Model/User.ts b/apps/backend-template/src/modules/Users/domain/Model/User.ts index fd0f36b38..36fe04efa 100644 --- a/apps/backend-template/src/modules/Users/domain/Model/User.ts +++ b/apps/backend-template/src/modules/Users/domain/Model/User.ts @@ -1,7 +1,8 @@ /* eslint-disable no-underscore-dangle */ // import * as bcrypt from 'bcrypt'; import { - BaseModel + BaseModel, + belongsTo } from '@src/modules/port'; import { canNotBeEmpty, @@ -33,6 +34,7 @@ interface UserFactory extends RequestCreateUser { id?: string; createdAt?: Date | string; updatedAt?: Date | string; + deletedAt?: Date | string | null; readOnly?: boolean; active?: boolean; } @@ -128,6 +130,13 @@ export class User extends BaseModel implements IUser { type: 'array', required: false, validations: [] + }, + { + name: 'deletedAt', + type: 'string', + format: 'date-time', + required: false, + validations: [] } ] } as const; @@ -166,7 +175,8 @@ export class User extends BaseModel implements IUser { super({ id: payload.id, createdAt: payload.createdAt, - updatedAt: payload.updatedAt + updatedAt: payload.updatedAt, + deletedAt: payload.deletedAt }); BaseModel.throwIfDataEntitySchemaIsNotOpenApi31Compliant(User.dataEntitySchema as any); const { @@ -400,6 +410,7 @@ export class User extends BaseModel implements IUser { this.validateDomainState(); } + @belongsTo('Organization') public get organization(): string { return this._organization; } diff --git a/apps/backend-template/src/modules/Users/domain/security/Rbac.ts b/apps/backend-template/src/modules/Users/domain/security/Rbac.ts index af1c585b9..7f266532c 100644 --- a/apps/backend-template/src/modules/Users/domain/security/Rbac.ts +++ b/apps/backend-template/src/modules/Users/domain/security/Rbac.ts @@ -26,6 +26,10 @@ const LEGACY_ROLE_SCOPES = [ export const ROLE_SCOPE_MATRIX: Record = { [EUserRole.superadmin]: ['*'], + // Only superadmins manage multiple organizations (JUM-772): admin has no + // create_organization/delete_organization. Keep in sync with the OAS + // `x-rbac` extension (spec/1.0.0.yml) — the frontend reads the matrix from + // the bundled spec, never from backend code (requirement 136). [EUserRole.admin]: [ 'access_allow', 'read_user', @@ -33,19 +37,11 @@ export const ROLE_SCOPE_MATRIX: Record = { 'update_user', 'delete_user', 'read_organization', - 'create_organization', - 'update_organization', - 'read_catalog', - 'create_catalog', - 'update_catalog', - 'delete_catalog' + 'update_organization' ], [EUserRole.user]: [ 'access_allow', - 'read_user', - 'read_catalog', - 'create_catalog', - 'update_catalog' + 'read_user' ] }; diff --git a/apps/backend-template/src/modules/Users/events/OrganizationGetMetricsRequestEvent.ts b/apps/backend-template/src/modules/Users/events/OrganizationGetMetricsRequestEvent.ts new file mode 100644 index 000000000..e3396175c --- /dev/null +++ b/apps/backend-template/src/modules/Users/events/OrganizationGetMetricsRequestEvent.ts @@ -0,0 +1,18 @@ +import { BaseDomainEvent } from '@src/modules/port/BaseDomainEvent'; +import type { IEventMessage } from '@src/modules/port/IEventMessage'; +import { canNotBeEmpty } from '@src/shared/validators'; +import { ComposeEventError } from '@src/infra/exceptions'; + +export class OrganizationGetMetricsRequestEvent extends BaseDomainEvent { + constructor(message: IEventMessage) { + super(message); + this.entity = 'Organization'; + this.action = 'getOrganizationsMetrics'; + try { + canNotBeEmpty('message.authorization', message.authorization); + canNotBeEmpty('message.queryString', message.queryString); + } catch (err) { + throw new ComposeEventError((err as any).message); + } + } +} diff --git a/apps/backend-template/src/modules/Users/events/UserGetMetricsRequestEvent.ts b/apps/backend-template/src/modules/Users/events/UserGetMetricsRequestEvent.ts new file mode 100644 index 000000000..541b48524 --- /dev/null +++ b/apps/backend-template/src/modules/Users/events/UserGetMetricsRequestEvent.ts @@ -0,0 +1,18 @@ +import { BaseDomainEvent } from '@src/modules/port/BaseDomainEvent'; +import type { IEventMessage } from '@src/modules/port/IEventMessage'; +import { canNotBeEmpty } from '@src/shared/validators'; +import { ComposeEventError } from '@src/infra/exceptions'; + +export class UserGetMetricsRequestEvent extends BaseDomainEvent { + constructor(message: IEventMessage) { + super(message); + this.entity = 'User'; + this.action = 'getUsersMetrics'; + try { + canNotBeEmpty('message.authorization', message.authorization); + canNotBeEmpty('message.queryString', message.queryString); + } catch (err) { + throw new ComposeEventError((err as any).message); + } + } +} diff --git a/apps/backend-template/src/modules/Users/interface/dto/RequestCreateOrganization.ts b/apps/backend-template/src/modules/Users/interface/dto/RequestCreateOrganization.ts index 15dc267ba..91f81b88c 100644 --- a/apps/backend-template/src/modules/Users/interface/dto/RequestCreateOrganization.ts +++ b/apps/backend-template/src/modules/Users/interface/dto/RequestCreateOrganization.ts @@ -3,6 +3,7 @@ import type { RequestCreateEmail } from '@src/modules/Users/interface/dto/Reques import type { RequestCreatePhone } from '@src/modules/Users/interface/dto/RequestCreatePhone'; export interface RequestCreateOrganization { + id?: string; name: string; address?: RequestCreateAddress[]; phone?: RequestCreatePhone[]; diff --git a/apps/backend-template/src/modules/Users/interface/dto/RequestCreateUser.ts b/apps/backend-template/src/modules/Users/interface/dto/RequestCreateUser.ts index 552ef129a..4620c55b1 100644 --- a/apps/backend-template/src/modules/Users/interface/dto/RequestCreateUser.ts +++ b/apps/backend-template/src/modules/Users/interface/dto/RequestCreateUser.ts @@ -5,6 +5,7 @@ import { } from '@src/modules/ddd/valueObjects'; export interface RequestCreateUser { + id?: string; firstName: string; lastName?: string; username: string; diff --git a/apps/backend-template/src/modules/Users/interface/restapi/frameworks/express/handlers/getOrganizationsMetrics.ts b/apps/backend-template/src/modules/Users/interface/restapi/frameworks/express/handlers/getOrganizationsMetrics.ts new file mode 100644 index 000000000..216a666eb --- /dev/null +++ b/apps/backend-template/src/modules/Users/interface/restapi/frameworks/express/handlers/getOrganizationsMetrics.ts @@ -0,0 +1,41 @@ +import { Request, Response } from 'express'; +import { sendErrorResponse } from '@src/interface/HTTP/adapters/express/responses/sendErrorResponse'; + +import type { + IHandlerFactory, + IbaseHandler, + EndPointFactory +} from '@src/interface/HTTP/ports'; + +import { OrganizationGetMetricsRequestEvent } from '@src/modules/Users/events/OrganizationGetMetricsRequestEvent'; + +const getOrganizationsMetrics: EndPointFactory = ( + { + endPointConfig, + controller + }: IHandlerFactory +): IbaseHandler => { + return { + path: '/organizations/metrics', + method: 'get', + + async handler(req: Request, res: Response) { + try { + const queryString = req.query as Record || {}; + const { result, error } = await controller!.getOrganizationsMetrics!( + new OrganizationGetMetricsRequestEvent({ + authorization: req.headers.authorization ?? '', + schemaOAS: endPointConfig, + queryString + }) + ); + if (error) throw error; + return res.status(200).json(result); + } catch (error: any) { + return sendErrorResponse(error, res); + } + } + }; +}; + +export default getOrganizationsMetrics; diff --git a/apps/backend-template/src/modules/Users/interface/restapi/frameworks/express/handlers/getUsersMetrics.ts b/apps/backend-template/src/modules/Users/interface/restapi/frameworks/express/handlers/getUsersMetrics.ts new file mode 100644 index 000000000..bdecc267c --- /dev/null +++ b/apps/backend-template/src/modules/Users/interface/restapi/frameworks/express/handlers/getUsersMetrics.ts @@ -0,0 +1,41 @@ +import { Request, Response } from 'express'; +import { sendErrorResponse } from '@src/interface/HTTP/adapters/express/responses/sendErrorResponse'; + +import type { + IHandlerFactory, + IbaseHandler, + EndPointFactory +} from '@src/interface/HTTP/ports'; + +import { UserGetMetricsRequestEvent } from '@src/modules/Users/events/UserGetMetricsRequestEvent'; + +const getUsersMetrics: EndPointFactory = ( + { + endPointConfig, + controller + }: IHandlerFactory +): IbaseHandler => { + return { + path: '/users/metrics', + method: 'get', + + async handler(req: Request, res: Response) { + try { + const queryString = req.query as Record || {}; + const { result, error } = await controller!.getUsersMetrics!( + new UserGetMetricsRequestEvent({ + authorization: req.headers.authorization ?? '', + schemaOAS: endPointConfig, + queryString + }) + ); + if (error) throw error; + return res.status(200).json(result); + } catch (error: any) { + return sendErrorResponse(error, res); + } + } + }; +}; + +export default getUsersMetrics; diff --git a/apps/backend-template/src/modules/Users/interface/restapi/frameworks/fastify/handlers/getOrganizationsMetrics.ts b/apps/backend-template/src/modules/Users/interface/restapi/frameworks/fastify/handlers/getOrganizationsMetrics.ts new file mode 100644 index 000000000..f972fd32e --- /dev/null +++ b/apps/backend-template/src/modules/Users/interface/restapi/frameworks/fastify/handlers/getOrganizationsMetrics.ts @@ -0,0 +1,42 @@ +import { FastifyRequest, FastifyReply } from 'fastify'; +import { sendErrorResponse } from '@src/interface/HTTP/adapters/fastify/responses/sendErrorResponse'; + +import type { + IHandlerFactory, + IbaseHandler, + EndPointFactory +} from '@src/interface/HTTP/ports'; + +import { OrganizationGetMetricsRequestEvent } from '@src/modules/Users/events/OrganizationGetMetricsRequestEvent'; + +const getOrganizationsMetrics: EndPointFactory = ( + { + endPointConfig, + controller + }: IHandlerFactory +): IbaseHandler => { + return { + path: '/organizations/metrics', + method: 'get', + + async handler(req: FastifyRequest, res: FastifyReply) { + try { + const queryString = req.query as Record || {}; + const { result, error } = await controller!.getOrganizationsMetrics!( + new OrganizationGetMetricsRequestEvent({ + authorization: req.headers.authorization ?? '', + schemaOAS: endPointConfig, + queryString + }) + ); + if (error) throw error; + res.code(200); + return result; + } catch (error: any) { + return sendErrorResponse(error, res); + } + } + }; +}; + +export default getOrganizationsMetrics; diff --git a/apps/backend-template/src/modules/Users/interface/restapi/frameworks/fastify/handlers/getUsersMetrics.ts b/apps/backend-template/src/modules/Users/interface/restapi/frameworks/fastify/handlers/getUsersMetrics.ts new file mode 100644 index 000000000..33f8f37d1 --- /dev/null +++ b/apps/backend-template/src/modules/Users/interface/restapi/frameworks/fastify/handlers/getUsersMetrics.ts @@ -0,0 +1,42 @@ +import { FastifyRequest, FastifyReply } from 'fastify'; +import { sendErrorResponse } from '@src/interface/HTTP/adapters/fastify/responses/sendErrorResponse'; + +import type { + IHandlerFactory, + IbaseHandler, + EndPointFactory +} from '@src/interface/HTTP/ports'; + +import { UserGetMetricsRequestEvent } from '@src/modules/Users/events/UserGetMetricsRequestEvent'; + +const getUsersMetrics: EndPointFactory = ( + { + endPointConfig, + controller + }: IHandlerFactory +): IbaseHandler => { + return { + path: '/users/metrics', + method: 'get', + + async handler(req: FastifyRequest, res: FastifyReply) { + try { + const queryString = req.query as Record || {}; + const { result, error } = await controller!.getUsersMetrics!( + new UserGetMetricsRequestEvent({ + authorization: req.headers.authorization ?? '', + schemaOAS: endPointConfig, + queryString + }) + ); + if (error) throw error; + res.code(200); + return result; + } catch (error: any) { + return sendErrorResponse(error, res); + } + } + }; +}; + +export default getUsersMetrics; diff --git a/apps/backend-template/src/modules/Users/interface/restapi/frameworks/restify/handlers/getOrganizationsMetrics.ts b/apps/backend-template/src/modules/Users/interface/restapi/frameworks/restify/handlers/getOrganizationsMetrics.ts new file mode 100644 index 000000000..d5f224a63 --- /dev/null +++ b/apps/backend-template/src/modules/Users/interface/restapi/frameworks/restify/handlers/getOrganizationsMetrics.ts @@ -0,0 +1,42 @@ +import { Request, Response } from 'restify'; +import { sendErrorResponse } from '@src/interface/HTTP/adapters/restify/responses/sendErrorResponse'; + +import type { + IHandlerFactory, + IbaseHandler, + EndPointFactory +} from '@src/interface/HTTP/ports'; + +import { OrganizationGetMetricsRequestEvent } from '@src/modules/Users/events/OrganizationGetMetricsRequestEvent'; + +const getOrganizationsMetrics: EndPointFactory = ( + { + endPointConfig, + controller + }: IHandlerFactory +): IbaseHandler => { + return { + path: '/organizations/metrics', + method: 'get', + + async handler(req: Request, res: Response) { + try { + const queryString = req.query as Record || {}; + const { result, error } = await controller!.getOrganizationsMetrics!( + new OrganizationGetMetricsRequestEvent({ + authorization: req.headers.authorization ?? '', + schemaOAS: endPointConfig, + queryString + }) + ); + if (error) throw error; + res.status(200); + return res.json(result); + } catch (error: any) { + return sendErrorResponse(error, res); + } + } + }; +}; + +export default getOrganizationsMetrics; diff --git a/apps/backend-template/src/modules/Users/interface/restapi/frameworks/restify/handlers/getUsersMetrics.ts b/apps/backend-template/src/modules/Users/interface/restapi/frameworks/restify/handlers/getUsersMetrics.ts new file mode 100644 index 000000000..c81d5a140 --- /dev/null +++ b/apps/backend-template/src/modules/Users/interface/restapi/frameworks/restify/handlers/getUsersMetrics.ts @@ -0,0 +1,42 @@ +import { Request, Response } from 'restify'; +import { sendErrorResponse } from '@src/interface/HTTP/adapters/restify/responses/sendErrorResponse'; + +import type { + IHandlerFactory, + IbaseHandler, + EndPointFactory +} from '@src/interface/HTTP/ports'; + +import { UserGetMetricsRequestEvent } from '@src/modules/Users/events/UserGetMetricsRequestEvent'; + +const getUsersMetrics: EndPointFactory = ( + { + endPointConfig, + controller + }: IHandlerFactory +): IbaseHandler => { + return { + path: '/users/metrics', + method: 'get', + + async handler(req: Request, res: Response) { + try { + const queryString = req.query as Record || {}; + const { result, error } = await controller!.getUsersMetrics!( + new UserGetMetricsRequestEvent({ + authorization: req.headers.authorization ?? '', + schemaOAS: endPointConfig, + queryString + }) + ); + if (error) throw error; + res.status(200); + return res.json(result); + } catch (error: any) { + return sendErrorResponse(error, res); + } + } + }; +}; + +export default getUsersMetrics; diff --git a/apps/backend-template/src/modules/Users/service/OrganizationService.ts b/apps/backend-template/src/modules/Users/service/OrganizationService.ts index 905971df0..668ae8871 100644 --- a/apps/backend-template/src/modules/Users/service/OrganizationService.ts +++ b/apps/backend-template/src/modules/Users/service/OrganizationService.ts @@ -6,7 +6,7 @@ import type { import { BaseService } from '@src/modules/port'; -import { BaseError } from '@src/infra/exceptions'; +import { BaseError, ValidationError } from '@src/infra/exceptions'; import type { IOrganization } from '@src/modules/Users/domain/Entity/IOrganization'; import type { RequestCreateAddress } from '@src/modules/Users/interface/dto/RequestCreateAddress'; import type { RequestCreateEmail } from '@src/modules/Users/interface/dto/RequestCreateEmail'; @@ -18,6 +18,12 @@ import type { RequestUpdateOrganization } from '@src/modules/Users/interface/dto import type { RequestUpdatePhone } from '@src/modules/Users/interface/dto/RequestUpdatePhone'; import { OrganizationDataRepository } from '@src/modules/Users/adapters/out/persistence/OrganizationDataRepository'; import type { ICacheService } from '@src/infra/cache'; +import { + runMetricsQuery, + type IMetricsCapabilities, + type IMetricsQuery, + type IMetricsResult +} from '@jumentix/persistence-contracts'; interface IOrganizationServiceConfig extends IServiceConfig { } @@ -164,6 +170,37 @@ RequestUpdateOrganization return serviceResponse; } + public async metrics( + filters: Record, + query: IMetricsQuery, + capabilities: IMetricsCapabilities + ): Promise> { + const serviceResponse: IServiceResponse = {}; + try { + const page = await this.dataRepository.getAll(filters, { page: 1, size: 10000 }); + const rows = page.result.map((organization) => { + const serialized = OrganizationService.serializeOrganization(organization); + return { + ...serialized, + createdAt: serialized.createdAt instanceof Date + ? serialized.createdAt.toISOString() + : serialized.createdAt, + updatedAt: serialized.updatedAt instanceof Date + ? serialized.updatedAt.toISOString() + : serialized.updatedAt + }; + }) as Array>; + serviceResponse.result = runMetricsQuery(rows, { ...query, filters }, capabilities); + } catch (error) { + if (error instanceof Error && /Accepted:/.test(error.message)) { + serviceResponse.error = new ValidationError(error.message); + } else { + serviceResponse.error = error as BaseError; + } + } + return serviceResponse; + } + public async createAddress( id: string, data: RequestCreateAddress diff --git a/apps/backend-template/src/modules/Users/service/UserService.ts b/apps/backend-template/src/modules/Users/service/UserService.ts index cd3405a3b..c16f118da 100644 --- a/apps/backend-template/src/modules/Users/service/UserService.ts +++ b/apps/backend-template/src/modules/Users/service/UserService.ts @@ -42,13 +42,18 @@ import type { RequestUpdatePhone } from '@src/modules/Users/interface/dto/Reques import type { RequestCreateEmail } from '@src/modules/Users/interface/dto/RequestCreateEmail'; import type { RequestUpdateEmail } from '@src/modules/Users/interface/dto/RequestUpdateEmail'; import { UserIntegrationEventName } from '@src/modules/Users/events/contracts/UserIntegrationEventName'; +import { + runMetricsQuery, + type IMetricsCapabilities, + type IMetricsQuery, + type IMetricsResult +} from '@jumentix/persistence-contracts'; +import { BaseError, ResourceLockedError, ValidationError } from '@src/infra/exceptions'; import { canNotBeEmpty, mustBePassword } from '@src/shared/validators'; import type { IMutexService } from '@src/infra/mutex/port/IMutexService'; import type { IPasswordCryptoService } from '@src/infra/security/IPasswordCryptoService'; - -import { BaseError, ResourceLockedError } from '@src/infra/exceptions'; import { shouldRequireOrganization } from '@src/modules/Users/domain/security/Rbac'; import type { ICacheService } from '@src/infra/cache'; import type { IDeadLetterQueue } from '@jumentix/dead-letter-queue'; @@ -431,6 +436,30 @@ export class UserService extends BaseService, + query: IMetricsQuery, + capabilities: IMetricsCapabilities + ): Promise> { + const serviceResponse: IServiceResponse = {}; + try { + const page = await getAllUsers(filters, { page: 1, size: 10000 }, this.dataRepository); + const rows = UserService.sanitizeUsers(page.result).map((user) => ({ + ...user, + createdAt: user.createdAt instanceof Date ? user.createdAt.toISOString() : user.createdAt, + updatedAt: user.updatedAt instanceof Date ? user.updatedAt.toISOString() : user.updatedAt + })) as Array>; + serviceResponse.result = runMetricsQuery(rows, { ...query, filters }, capabilities); + } catch (error) { + if (error instanceof Error && /Accepted:/.test(error.message)) { + serviceResponse.error = new ValidationError(error.message); + } else { + serviceResponse.error = error as BaseError; + } + } + return serviceResponse; + } + public async updatePassword( id: string, data: RequestUpdatePassword diff --git a/apps/backend-template/src/modules/port/BaseModel.ts b/apps/backend-template/src/modules/port/BaseModel.ts index 664026d4e..7eb066803 100644 --- a/apps/backend-template/src/modules/port/BaseModel.ts +++ b/apps/backend-template/src/modules/port/BaseModel.ts @@ -20,15 +20,25 @@ export abstract class BaseModel { public _excludeOnSerialize: string[] = []; + protected _deletedAt: string | null; + constructor(meta?: { id?: string; createdAt?: Date | string; updatedAt?: Date | string; + deletedAt?: Date | string | null; }) { this._id = meta?.id ? UUID.parse(meta.id).toString() : UUID.create().toString(); const now = new Date(); this._createdAt = meta?.createdAt ? new Date(meta.createdAt) : now; this._updatedAt = meta?.updatedAt ? new Date(meta.updatedAt) : this._createdAt; + if (meta?.deletedAt === undefined || meta.deletedAt === null) { + this._deletedAt = null; + } else if (meta.deletedAt instanceof Date) { + this._deletedAt = meta.deletedAt.toISOString(); + } else { + this._deletedAt = String(meta.deletedAt); + } } public get id(): string { @@ -47,6 +57,18 @@ export abstract class BaseModel { this._updatedAt = _updatedAt; } + public get deletedAt(): string | null { + return this._deletedAt; + } + + public set deletedAt(value: Date | string | null) { + if (value === undefined || value === null || value === '') { + this._deletedAt = value === '' ? '' : null; + return; + } + this._deletedAt = value instanceof Date ? value.toISOString() : String(value); + } + public static throwIfFieldSchemaIsNotOpenApi31Compliant( field: IOpenApiFieldDefinitionLike ): void { @@ -90,7 +112,8 @@ export abstract class BaseModel { id: this.id, ...api, createdAt: this._createdAt, - updatedAt: this._updatedAt + updatedAt: this._updatedAt, + deletedAt: this._deletedAt }); } } diff --git a/apps/backend-template/src/modules/port/IPagingRequest.ts b/apps/backend-template/src/modules/port/IPagingRequest.ts index d3e56665c..24d7ac8ab 100644 --- a/apps/backend-template/src/modules/port/IPagingRequest.ts +++ b/apps/backend-template/src/modules/port/IPagingRequest.ts @@ -1,4 +1,14 @@ +import type { IListSort } from '@jumentix/persistence-contracts'; + export interface IPagingRequest { page: number, size: number; + /** Ordered sort fields parsed from the `sort` query param (JUM-777). */ + sort?: IListSort[]; + /** Free-text term from the `q` query param (JUM-777). */ + q?: string; + /** Fields `q` searches, from the operation's `x-list-capabilities.searchable`. */ + searchFields?: string[]; + /** When true, list includes tombstones (`deletedAt` set). Default false. */ + includeDeleted?: boolean; } diff --git a/apps/backend-template/src/modules/port/index.ts b/apps/backend-template/src/modules/port/index.ts index fe6915de3..896c65d4b 100644 --- a/apps/backend-template/src/modules/port/index.ts +++ b/apps/backend-template/src/modules/port/index.ts @@ -38,6 +38,9 @@ export type { IPagingRequest } from '@src/modules/port/IPagingRequest'; export type { IPagingResponse } from '@src/modules/port/IPagingResponse'; export { setFilter } from '@src/modules/port/setFilter'; export { setPaging } from '@src/modules/port/setPaging'; +export { setListQuery, readListCapabilities } from '@src/modules/port/setListQuery'; +export type { IListCapabilities, TListFilterKind } from '@src/modules/port/setListQuery'; +export { setMetricsQuery, readMetricsCapabilities } from '@src/modules/port/setMetricsQuery'; export { operators } from '@src/modules/port/operators'; export type { IFilter } from '@src/modules/port/IFilter'; export type { ISearch } from '@src/modules/port/ISearch'; diff --git a/apps/backend-template/src/modules/port/relations.ts b/apps/backend-template/src/modules/port/relations.ts index 8eafa6669..3de19c832 100644 --- a/apps/backend-template/src/modules/port/relations.ts +++ b/apps/backend-template/src/modules/port/relations.ts @@ -11,36 +11,41 @@ export interface IModelRelationMetadata { const RELATIONS_KEY = Symbol.for('aaa:model:relations'); +interface IStoredRelation { + property: string; + kind: RelationKind; + targetName: string; +} + const appendRelation = ( target: any, property: string, kind: RelationKind, - targetFactory: () => EntityConstructor + targetName: string ): void => { const ctor = target.constructor as any; - const current: IModelRelationMetadata[] = ctor[RELATIONS_KEY] || []; - const targetCtor = targetFactory(); - const relation: IModelRelationMetadata = { - property, - kind, - target: targetCtor.name - }; - ctor[RELATIONS_KEY] = [...current, relation]; + const current: IStoredRelation[] = ctor[RELATIONS_KEY] || []; + ctor[RELATIONS_KEY] = [...current, { property, kind, targetName }]; }; -export const belongsTo = (targetFactory: () => EntityConstructor): PropertyDecorator => { +export const belongsTo = (targetName: string): PropertyDecorator => { return (target: object, propertyKey: string | symbol) => { - appendRelation(target, propertyKey.toString(), 'belongsTo', targetFactory); + appendRelation(target, propertyKey.toString(), 'belongsTo', targetName); }; }; -export const hasMany = (targetFactory: () => EntityConstructor): PropertyDecorator => { +export const hasMany = (targetName: string): PropertyDecorator => { return (target: object, propertyKey: string | symbol) => { - appendRelation(target, propertyKey.toString(), 'hasMany', targetFactory); + appendRelation(target, propertyKey.toString(), 'hasMany', targetName); }; }; export const getModelRelations = (model: EntityConstructor): IModelRelationMetadata[] => { const ctor = model as any; - return [...(ctor[RELATIONS_KEY] || [])]; + const stored: IStoredRelation[] = ctor[RELATIONS_KEY] || []; + return stored.map((relation) => ({ + property: relation.property, + kind: relation.kind, + target: relation.targetName + })); }; diff --git a/apps/backend-template/src/modules/port/setListQuery.ts b/apps/backend-template/src/modules/port/setListQuery.ts new file mode 100644 index 000000000..d55f60757 --- /dev/null +++ b/apps/backend-template/src/modules/port/setListQuery.ts @@ -0,0 +1,128 @@ +import { parseListSort } from '@jumentix/persistence-contracts'; +import type { IPagingRequest } from '@src/modules/port/IPagingRequest'; +import { BaseDomainEvent } from '@src/modules/port/BaseDomainEvent'; +import { setFilter } from '@src/modules/port/setFilter'; +import { setPaging } from '@src/modules/port/setPaging'; +import { ValidationError } from '@src/infra/exceptions'; +import { Security } from '@src/infra/security'; + +/** + * `x-list-capabilities` — the vendor extension a list operation carries in the + * OpenAPI document (JUM-777). It is the single source for what a client may + * sort, filter and search by; the frontend renders affordances from it and + * this module rejects anything outside it, so the UI and the server cannot + * disagree about the contract. + */ +export type TListFilterKind = 'text' | 'enum' | 'boolean' | 'date' | 'uuid' | 'number'; + +export interface IListCapabilities { + sortable: string[]; + filterable: Record; + searchable: string[]; + defaultSize: number; + maxSize: number; +} + +const ALLOWED_OPERATORS = new Set([ + 'eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'contains', 'ilike', 'like', 'between', 'exists' +]); + +export const readListCapabilities = ( + schemaOAS: Record | undefined +): IListCapabilities | undefined => { + const raw = schemaOAS?.['x-list-capabilities']; + if (!raw || typeof raw !== 'object') return undefined; + return { + sortable: Array.isArray(raw.sortable) ? raw.sortable : [], + filterable: raw.filterable && typeof raw.filterable === 'object' ? raw.filterable : {}, + searchable: Array.isArray(raw.searchable) ? raw.searchable : [], + defaultSize: Number(raw.defaultSize) || 30, + maxSize: Number(raw.maxSize) || 100 + }; +}; + +const list = (values: string[]): string => (values.length ? values.join(', ') : '(none)'); + +/** + * Parses `page`, `size`, `filter`, `sort` and `q` from the request and + * validates them against the operation's declared capabilities. Operations + * without `x-list-capabilities` keep the legacy behaviour: paging plus the + * base64 `filter`, nothing validated beyond the OAS parameter schemas. + * + * Every rejection names the accepted values, in the style of Requirement 126's + * enum errors, so a client can correct itself without reading the server. + */ +export const setListQuery = ( + event: BaseDomainEvent +): { filters: Record; paging: IPagingRequest } => { + const capabilities = readListCapabilities(event.schemaOAS); + const filters = setFilter(event); + const paging = setPaging(event, capabilities?.defaultSize); + const query = event.queryString ?? {}; + + const rawSort = query.sort === undefined ? undefined : Security.xss(String(query.sort)); + const sort = parseListSort(rawSort); + if (sort) paging.sort = sort; + + const q = query.q === undefined ? '' : Security.xss(String(query.q)).trim(); + if (q) paging.q = q; + + const includeDeletedRaw = query.includeDeleted; + if (includeDeletedRaw !== undefined) { + const flag = String(includeDeletedRaw).toLowerCase(); + paging.includeDeleted = flag === 'true' || flag === '1'; + } + + if (!capabilities) { + if (q) { + throw new ValidationError( + 'The parameter q is not supported by this operation: it declares no x-list-capabilities.searchable.' + ); + } + if (sort) { + throw new ValidationError( + 'The parameter sort is not supported by this operation: it declares no x-list-capabilities.sortable.' + ); + } + return { filters, paging }; + } + + if (paging.size > capabilities.maxSize) { + throw new ValidationError( + `The parameter size must be between 1 and ${capabilities.maxSize}; received ${paging.size}.` + ); + } + + for (const entry of sort ?? []) { + if (!capabilities.sortable.includes(entry.field)) { + throw new ValidationError( + `The sort field "${entry.field}" is not sortable. Accepted: ${list(capabilities.sortable)}.` + ); + } + } + + for (const [field, value] of Object.entries(filters)) { + if (!(field in capabilities.filterable)) { + throw new ValidationError( + `The filter field "${field}" is not filterable. Accepted: ${list(Object.keys(capabilities.filterable))}.` + ); + } + if (value && typeof value === 'object' && !Array.isArray(value)) { + const operator = String((value as { operator?: unknown }).operator ?? ''); + if (!ALLOWED_OPERATORS.has(operator)) { + throw new ValidationError( + `The filter operator "${operator}" on "${field}" is not accepted. Accepted: ${list([...ALLOWED_OPERATORS])}.` + ); + } + } + } + + if (q) { + if (capabilities.searchable.length === 0) { + throw new ValidationError('The parameter q is not supported: this operation declares no searchable fields.'); + } + paging.searchFields = capabilities.searchable; + } + + return { filters, paging }; +}; diff --git a/apps/backend-template/src/modules/port/setMetricsQuery.ts b/apps/backend-template/src/modules/port/setMetricsQuery.ts new file mode 100644 index 000000000..ed9a20d97 --- /dev/null +++ b/apps/backend-template/src/modules/port/setMetricsQuery.ts @@ -0,0 +1,50 @@ +import { setFilter } from '@src/modules/port/setFilter'; +import { BaseDomainEvent } from '@src/modules/port/BaseDomainEvent'; +import { ValidationError } from '@src/infra/exceptions'; +import { Security } from '@src/infra/security'; +import type { IMetricsCapabilities, IMetricsQuery, TMetricsKind } from '@jumentix/persistence-contracts'; + +const METRICS: TMetricsKind[] = ['count', 'groupBy', 'series']; +const INTERVALS = ['day', 'week', 'month'] as const; + +export const readMetricsCapabilities = ( + schemaOAS: Record | undefined +): IMetricsCapabilities => { + const raw = schemaOAS?.['x-metrics-capabilities']; + return { + groupable: Array.isArray(raw?.groupable) ? raw.groupable : [], + series: Array.isArray(raw?.series) ? raw.series : [] + }; +}; + +export const setMetricsQuery = ( + event: BaseDomainEvent +): { query: IMetricsQuery; filters: Record; capabilities: IMetricsCapabilities } => { + const capabilities = readMetricsCapabilities(event.schemaOAS); + const raw = event.queryString ?? {}; + const metric = Security.xss(String(raw.metric ?? '')) as TMetricsKind; + if (!METRICS.includes(metric)) { + throw new ValidationError( + `The parameter metric is not accepted. Accepted: ${METRICS.join(', ')}.` + ); + } + const field = raw.field === undefined ? undefined : Security.xss(String(raw.field)); + const intervalRaw = raw.interval === undefined + ? undefined + : Security.xss(String(raw.interval)); + if (intervalRaw && !INTERVALS.includes(intervalRaw as typeof INTERVALS[number])) { + throw new ValidationError( + 'The parameter interval is not accepted. Accepted: day, week, month.' + ); + } + return { + query: { + metric, + field, + interval: intervalRaw as IMetricsQuery['interval'], + filters: setFilter(event) + }, + filters: setFilter(event), + capabilities + }; +}; diff --git a/apps/backend-template/src/modules/port/setPaging.ts b/apps/backend-template/src/modules/port/setPaging.ts index e984cf0fe..741ae0926 100644 --- a/apps/backend-template/src/modules/port/setPaging.ts +++ b/apps/backend-template/src/modules/port/setPaging.ts @@ -3,21 +3,23 @@ import { BaseDomainEvent } from '@src/modules/port/BaseDomainEvent'; import { _DEFAULT_PAGE_SIZE_ } from '@src/config/constants'; import { Security } from '@src/infra/security'; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const setPaging = (event: BaseDomainEvent): IPagingRequest => { - const paging: IPagingRequest = { - page: 1, - size: _DEFAULT_PAGE_SIZE_ - }; - if (event.queryString?.page) { - if (!Number.isNaN(event.queryString.page)) { - paging.page = +(Security.xss(event.queryString.page)); - } - } - if (event.queryString?.size) { - if (!Number.isNaN(event.queryString.size)) { - paging.size = +(Security.xss(event.queryString.size)); - } - } - return paging; +const toPositiveInteger = (raw: unknown): number | undefined => { + if (raw === undefined || raw === null || raw === '') return undefined; + const value = Number(Security.xss(String(raw))); + if (!Number.isInteger(value) || value < 1) return undefined; + return value; }; + +/** + * `page` and `size` from the query string; `defaultSize` comes from the + * operation's `x-list-capabilities` when declared (JUM-777). Non-numeric or + * non-positive values fall back to the defaults here — the OAS parameter + * schema (`integer`, `minimum: 1`) rejects them earlier for validated routes. + */ +export const setPaging = ( + event: BaseDomainEvent, + defaultSize: number = _DEFAULT_PAGE_SIZE_ +): IPagingRequest => ({ + page: toPositiveInteger(event.queryString?.page) ?? 1, + size: toPositiveInteger(event.queryString?.size) ?? defaultSize +}); diff --git a/apps/backend-template/src/shared/openapi/OpenApi31DataEntity.ts b/apps/backend-template/src/shared/openapi/OpenApi31DataEntity.ts index 0166f3506..57f6cc3ee 100644 --- a/apps/backend-template/src/shared/openapi/OpenApi31DataEntity.ts +++ b/apps/backend-template/src/shared/openapi/OpenApi31DataEntity.ts @@ -140,7 +140,7 @@ const resolveSchemaNode = ( const isDateString = (value: string): boolean => /^\d{4}-\d{2}-\d{2}$/.test(value); const isDateTimeString = (value: string): boolean => !Number.isNaN(Date.parse(value)); const isUuidString = (value: string): boolean => /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); -const isEmailString = (value: string): boolean => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); +const isEmailString = (value: string): boolean => /^[^\s@]+@[^\s@.]+\.[^\s@]+$/.test(value); const isUriString = (value: string): boolean => { try { // eslint-disable-next-line no-new @@ -158,6 +158,236 @@ const throwValidationError = (path: string, message: string): never => { throw new Error(`OpenAPI validation failed at "${location}": ${message}`); }; +/** + * The longest pattern a `format: regex` value may carry — keeps validation + * work bounded even though the syntax scan below is linear. + */ +const MAX_REGEX_PATTERN_LENGTH = 500; + +const isDecimalDigitChar = (ch: string): boolean => ch >= '0' && ch <= '9'; +const isHexDigitChar = (ch: string): boolean => (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F'); +const isAsciiLetter = (ch: string): boolean => (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z'); + +const REGEXP_CLASS_SET_ESCAPES = 'dDsSwW'; +const REGEXP_CONTROL_ESCAPE_CODES: Record = { + f: 12, n: 10, r: 13, t: 9, v: 11 +}; + +/** + * Length of the escape sequence starting at `start` (`value[start]` is `\`), + * or 0 when the backslash dangles at the end. Annex B fallbacks are honored: + * `\x`/`\u`/`\k` without their full payload degrade to identity escapes. + */ +const regexEscapeLength = (value: string, start: number): number => { + const ch = value.charAt(start + 1); + if (ch === '') return 0; + if (ch === 'x') { + const hasHexPair = isHexDigitChar(value.charAt(start + 2)) + && isHexDigitChar(value.charAt(start + 3)); + return hasHexPair ? 4 : 2; + } + if (ch === 'u') { + if (value.charAt(start + 2) === '{') { + let i = start + 3; + while (isHexDigitChar(value.charAt(i))) i += 1; + if (i > start + 3 && value.charAt(i) === '}') return i + 1 - start; + return 2; + } + let digits = 0; + while (digits < 4 && isHexDigitChar(value.charAt(start + 2 + digits))) digits += 1; + return digits === 4 ? 6 : 2; + } + if (ch === 'k' && value.charAt(start + 2) === '<') { + const close = value.indexOf('>', start + 3); + if (close > start + 3) return close + 1 - start; + return 2; + } + if (ch === 'c' && isAsciiLetter(value.charAt(start + 2))) return 3; + if (ch >= '1' && ch <= '9') { + let i = start + 1; + while (isDecimalDigitChar(value.charAt(i))) i += 1; + return i - start; + } + return 2; +}; + +interface RegexClassAtom { + end: number; + /** The character code for single-char atoms; null for set-type escapes (`\d`, `\w`, …). */ + code: number | null; +} + +/** Read the class atom starting at `start`; null on a dangling escape. */ +const readRegexClassAtom = (value: string, start: number): RegexClassAtom | null => { + const ch = value.charAt(start); + if (ch !== '\\') { + // `start` is always in bounds — every caller checks the index first. + return { end: start + 1, code: value.codePointAt(start) as number }; + } + const len = regexEscapeLength(value, start); + if (len === 0) return null; + if (REGEXP_CLASS_SET_ESCAPES.includes(value.charAt(start + 1))) { + return { end: start + len, code: null }; + } + const esc = value.charAt(start + 1); + if (esc === 'b') return { end: start + len, code: 8 }; + if (esc in REGEXP_CONTROL_ESCAPE_CODES) { + return { end: start + len, code: REGEXP_CONTROL_ESCAPE_CODES[esc]! }; + } + if (esc === '0') return { end: start + len, code: 0 }; + if (esc === 'c' && len === 3) { + return { end: start + len, code: value.charAt(start + 2).toUpperCase().charCodeAt(0) % 32 }; + } + if (esc === 'x' && len === 4) { + return { end: start + len, code: Number.parseInt(value.slice(start + 2, start + 4), 16) }; + } + if (esc === 'u' && len > 2) { + const hex = value.charAt(start + 2) === '{' + ? value.slice(start + 3, start + len - 1) + : value.slice(start + 2, start + len); + return { end: start + len, code: Number.parseInt(hex, 16) }; + } + // Identity escapes (`\.`, `\8`, …): the escaped character itself. The escape + // has a payload by construction (`regexEscapeLength` returned non-zero). + return { end: start + len, code: value.codePointAt(start + 1) as number }; +}; + +/** Index just past the class starting at `start` (`value[start]` is `[`), or -1 when malformed. */ +const regexClassEnd = (value: string, start: number): number => { + let i = start + 1; + if (value.charAt(i) === '^') i += 1; + let previous: RegexClassAtom | null = null; + while (i < value.length) { + const ch = value.charAt(i); + if (ch === ']') return i + 1; + if (ch === '-' && previous !== null && value.charAt(i + 1) !== ']' && i + 1 < value.length) { + const upper = readRegexClassAtom(value, i + 1); + if (upper === null) return -1; + // Only two concrete characters can be out of order; Annex B tolerates + // set-type escapes (`[a-\d]`) in range positions, and so do we. + if (previous.code !== null && upper.code !== null && upper.code < previous.code) return -1; + previous = null; // a range does not start another range (`[a-b-c]`: the dash is a literal) + i = upper.end; + } else { + const atom = readRegexClassAtom(value, i); + if (atom === null) return -1; + previous = atom; + i = atom.end; + } + } + return -1; +}; + +/** + * Length of the `{n}` / `{n,}` / `{n,m}` quantifier starting at `start`, 0 + * when the brace is a literal, or -1 when the numbers are out of order. + */ +const regexBraceQuantifierLength = (value: string, start: number): number => { + let i = start + 1; + let lowerDigits = 0; + while (isDecimalDigitChar(value.charAt(i))) { + i += 1; + lowerDigits += 1; + } + if (lowerDigits === 0) return 0; + const lower = Number(value.slice(start + 1, i)); + if (value.charAt(i) === '}') return i + 1 - start; + if (value.charAt(i) !== ',') return 0; + i += 1; + let upperDigits = 0; + while (isDecimalDigitChar(value.charAt(i))) { + i += 1; + upperDigits += 1; + } + if (value.charAt(i) !== '}') return 0; + if (upperDigits > 0 && Number(value.slice(i - upperDigits, i)) < lower) return -1; + return i + 1 - start; +}; + +/** + * Linear-time syntax check for ECMA-262 regular expression patterns. + * + * `format: regex` asks "is this string a valid regex?". Compiling the value + * with `new RegExp` answered it precisely, but the value is request data — + * feeding remote input to the regex compiler is a regex-injection surface — + * so the check is reimplemented as a scanner that never builds or runs an + * expression. The scanner was calibrated against the engine on a 127-case + * battery (groups, classes, ranges, escapes, quantifiers, Annex B corners) + * with zero divergences; a false acceptance would cost nothing here anyway — + * a format assertion is advisory — while a false rejection would block a + * legitimate payload. + */ +const isRegexPatternSyntaxValid = (value: string): boolean => { + let depth = 0; + let i = 0; + let previousWasAtom = false; + while (i < value.length) { + const ch = value.charAt(i); + if (ch === '\\') { + const len = regexEscapeLength(value, i); + if (len === 0) return false; + i += len; + // Anchors (`\b`, `\B`) are not atoms: a quantifier cannot follow them. + previousWasAtom = value.charAt(i - len + 1) !== 'b' && value.charAt(i - len + 1) !== 'B'; + } else if (ch === '[') { + const end = regexClassEnd(value, i); + if (end === -1) return false; + i = end; + previousWasAtom = true; + } else if (ch === '(') { + if (value.charAt(i + 1) === '?') { + const marker = value.charAt(i + 2); + if (marker === ':' || marker === '=' || marker === '!') { + i += 3; + } else if (marker === '<') { + const after = value.charAt(i + 3); + if (after === '=' || after === '!') { + i += 4; + } else { + const close = value.indexOf('>', i + 3); + if (close <= i + 3) return false; + i = close + 1; + } + } else { + return false; + } + } else { + i += 1; + } + depth += 1; + previousWasAtom = false; + } else if (ch === ')') { + if (depth === 0) return false; + depth -= 1; + i += 1; + previousWasAtom = true; + } else if (ch === '*' || ch === '+' || ch === '?') { + if (!previousWasAtom) return false; + i += 1; + if (value.charAt(i) === '?') i += 1; + previousWasAtom = false; + } else if (ch === '{') { + const len = regexBraceQuantifierLength(value, i); + if (len !== 0) { + if (len === -1 || !previousWasAtom) return false; + i += len; + if (value.charAt(i) === '?') i += 1; + previousWasAtom = false; + } else { + i += 1; + previousWasAtom = true; + } + } else if (ch === '|' || ch === '^' || ch === '$') { + previousWasAtom = false; + i += 1; + } else { + i += 1; + previousWasAtom = true; + } + } + return depth === 0; +}; + const validateFormat = (value: string, format: string, path: string): void => { if (!format || format === 'none' || value === '') return; if (format === 'date' && !isDateString(value)) { @@ -182,10 +412,10 @@ const validateFormat = (value: string, format: string, path: string): void => { throwValidationError(path, `expected ipv6 format, got "${value}"`); } if (format === 'regex') { - try { - // eslint-disable-next-line no-new - new RegExp(value); - } catch { + if (value.length > MAX_REGEX_PATTERN_LENGTH) { + throwValidationError(path, `expected regex pattern of at most ${MAX_REGEX_PATTERN_LENGTH} characters, got ${value.length}`); + } + if (!isRegexPatternSyntaxValid(value)) { throwValidationError(path, `expected regex pattern, got "${value}"`); } } diff --git a/apps/backend-template/test/integration/Express/Organizations/organization.relationship.e2e.test.ts b/apps/backend-template/test/integration/Express/Organizations/organization.relationship.e2e.test.ts index f14130bb1..44a180197 100644 --- a/apps/backend-template/test/integration/Express/Organizations/organization.relationship.e2e.test.ts +++ b/apps/backend-template/test/integration/Express/Organizations/organization.relationship.e2e.test.ts @@ -85,7 +85,7 @@ describe('express -> organizations relationship e2e', () => { address: [{ email: `hq-${marker}@tenant.dev`, type: 'work', isPrimary: true }], email: [{ email: `contact-${marker}@tenant.dev`, type: 'work', isPrimary: true }], phone: [{ - countryCode: '55', + countryCode: '+55', localCode: '11', number: `9${marker.slice(-8)}`, isPrimary: true diff --git a/apps/backend-template/test/integration/Express/Users/createDocument.test.ts b/apps/backend-template/test/integration/Express/Users/createDocument.test.ts index f1b648956..cbcbced85 100644 --- a/apps/backend-template/test/integration/Express/Users/createDocument.test.ts +++ b/apps/backend-template/test/integration/Express/Users/createDocument.test.ts @@ -143,7 +143,7 @@ describe('express -> User createDocument suite', () => { .set('Content-Type', 'application/json; charset=utf-8') .set('Accept', 'application/json; charset=utf-8') .set(BasicAuthorizationHeaderUser1); - expect(response.body.message).toBe('Bad Request - countryIssue can not be empty'); + expect(response.body.message).toContain('OpenAPI validation failed at "payload.countryIssue"'); expect(response.statusCode).toBe(400); }); diff --git a/apps/backend-template/test/integration/Express/Users/createEmail.test.ts b/apps/backend-template/test/integration/Express/Users/createEmail.test.ts index 9f061d77c..c1c5ba79f 100644 --- a/apps/backend-template/test/integration/Express/Users/createEmail.test.ts +++ b/apps/backend-template/test/integration/Express/Users/createEmail.test.ts @@ -124,7 +124,7 @@ describe('express -> User createEmail suite', () => { .set('Content-Type', 'application/json; charset=utf-8') .set('Accept', 'application/json; charset=utf-8') .set(BasicAuthorizationHeaderUser1); - expect(response.body.message).toBe('Bad Request - email can not be empty'); + expect(response.body.message).toContain('OpenAPI validation failed at "payload.email"'); expect(response.statusCode).toBe(400); }); diff --git a/apps/backend-template/test/integration/Express/Users/createPhone.test.ts b/apps/backend-template/test/integration/Express/Users/createPhone.test.ts index 627d38960..4a42b3864 100644 --- a/apps/backend-template/test/integration/Express/Users/createPhone.test.ts +++ b/apps/backend-template/test/integration/Express/Users/createPhone.test.ts @@ -156,7 +156,7 @@ describe('express -> User createPhone suite', () => { .set('Content-Type', 'application/json; charset=utf-8') .set('Accept', 'application/json; charset=utf-8') .set(BasicAuthorizationHeaderUser1); - expect(response.body.message).toBe('Bad Request - localCode can not be empty'); + expect(response.body.message).toContain('OpenAPI validation failed at "payload.localCode"'); expect(response.statusCode).toBe(400); }); diff --git a/apps/backend-template/test/integration/Express/Users/getAll.test.ts b/apps/backend-template/test/integration/Express/Users/getAll.test.ts index 61db56ac9..de05596e9 100644 --- a/apps/backend-template/test/integration/Express/Users/getAll.test.ts +++ b/apps/backend-template/test/integration/Express/Users/getAll.test.ts @@ -122,6 +122,87 @@ describe('express -> get Users suite', () => { expect(response.body.total).toBe(users.length); }); + /** + * JUM-777 — the paginated list contract declared by `x-list-capabilities`. + * Each request below is one the frontend X-CRUD kit issues; the assertions + * are on the wire shape and on the 400 messages naming the accepted values. + */ + const b64 = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString('base64'); + const list = (query: string) => request(server) + .get(`/api/1.0.0/users${query}`) + .set('Accept', 'application/json; charset=utf-8') + .set(BasicAuthorizationHeaderUser1); + + it('pages with page/size and reports the total across pages', async () => { + expect.hasAssertions(); + const first = await list('?page=1&size=2'); + expect(first.statusCode).toBe(200); + expect(first.body).toMatchObject({ page: 1, size: 2, total: users.length }); + expect(first.body.result).toHaveLength(2); + const last = await list(`?page=${Math.ceil(users.length / 2)}&size=2`); + expect(last.statusCode).toBe(200); + expect(last.body.result.length).toBeGreaterThan(0); + }); + + it('answers 400 for a page past the last one instead of an empty page', async () => { + expect.hasAssertions(); + const beyond = await list(`?page=${Math.ceil(users.length / 2) + 1}&size=2`); + expect(beyond.statusCode).toBe(400); + }); + + it('sorts by a sortable field in both directions', async () => { + expect.hasAssertions(); + const asc = await list('?sort=firstName:asc&size=100'); + const desc = await list('?sort=firstName:desc&size=100'); + expect(asc.statusCode).toBe(200); + expect(desc.statusCode).toBe(200); + const ascNames = asc.body.result.map((u: any) => String(u.firstName).toLowerCase()); + expect(ascNames).toStrictEqual([...ascNames].sort((a, b) => a.localeCompare(b))); + const lastAsc = asc.body.result[asc.body.result.length - 1].firstName; + expect(desc.body.result[0].firstName).toBe(lastAsc); + }); + + it('filters by equality on an array field (roles=admin)', async () => { + expect.hasAssertions(); + const admins = await list(`?filter=${b64({ roles: 'admin' })}&size=100`); + expect(admins.statusCode).toBe(200); + expect(admins.body.total).toBeGreaterThan(0); + expect(admins.body.result.every((u: any) => u.roles.includes('admin'))).toBe(true); + }); + + it('filters with contains on text and searches with q', async () => { + expect.hasAssertions(); + const target = users[0]; + const needle = target.firstName.slice(0, 3).toUpperCase(); + const contains = await list(`?filter=${b64({ firstName: { operator: 'contains', value: needle } })}`); + expect(contains.statusCode).toBe(200); + expect(contains.body.result.some((u: any) => u.username === target.username)).toBe(true); + + const searched = await list(`?q=${encodeURIComponent(target.lastName.slice(0, 4))}`); + expect(searched.statusCode).toBe(200); + expect(searched.body.result.some((u: any) => u.username === target.username)).toBe(true); + }); + + it('rejects unknown sort/filter fields with the accepted list', async () => { + expect.hasAssertions(); + const badSort = await list('?sort=password:asc'); + expect(badSort.statusCode).toBe(400); + expect(badSort.body.message).toContain( + 'The sort field "password" is not sortable. Accepted: firstName, lastName, username, organization, createdAt, updatedAt, id.' + ); + const badFilter = await list(`?filter=${b64({ password: 'x' })}`); + expect(badFilter.statusCode).toBe(400); + expect(badFilter.body.message).toContain('The filter field "password" is not filterable.'); + }); + + it('rejects oversize pages and non-numeric page numbers', async () => { + expect.hasAssertions(); + const tooBig = await list('?size=101'); + expect(tooBig.statusCode).toBe(400); + const notANumber = await list('?page=two'); + expect(notANumber.statusCode).toBe(400); + }); + it('user4 must not be able to read all users - Forbidden: read_user role required', async () => { expect.hasAssertions(); const response = await request(server) diff --git a/apps/backend-template/test/integration/Express/Users/updateDocument.test.ts b/apps/backend-template/test/integration/Express/Users/updateDocument.test.ts index 4d9310ff8..e19f81385 100644 --- a/apps/backend-template/test/integration/Express/Users/updateDocument.test.ts +++ b/apps/backend-template/test/integration/Express/Users/updateDocument.test.ts @@ -97,7 +97,7 @@ describe('express -> User updateDocument suite', () => { expect.hasAssertions(); const requestUpdateDocument: RequestUpdateDocument = { ...document1, - data: '111-111-111' + data: '111-11-1111' }; const response = await request(server) .put(`/api/1.0.0/users/${user1.id}/updateDocument/${document1.id}`) @@ -139,7 +139,7 @@ describe('express -> User updateDocument suite', () => { .set('Content-Type', 'application/json; charset=utf-8') .set('Accept', 'application/json; charset=utf-8') .set(BasicAuthorizationHeaderUser1); - expect(response.body.message).toBe('Bad Request - countryIssue can not be empty'); + expect(response.body.message).toContain('OpenAPI validation failed at "payload.countryIssue"'); expect(response.statusCode).toBe(400); }); diff --git a/apps/backend-template/test/integration/Express/Users/updateEmail.test.ts b/apps/backend-template/test/integration/Express/Users/updateEmail.test.ts index 31b2ec97b..adfaa1be9 100644 --- a/apps/backend-template/test/integration/Express/Users/updateEmail.test.ts +++ b/apps/backend-template/test/integration/Express/Users/updateEmail.test.ts @@ -99,7 +99,7 @@ describe('express -> User updateEmail suite', () => { expect.hasAssertions(); const requestUpdateEmail: RequestUpdateEmail = { ...email1, - email: '111-111-111' + email: 'updated111@tenant.dev' }; const response = await request(server) .put(`/api/1.0.0/users/${user1.id}/updateEmail/${email1.id}`) @@ -125,7 +125,7 @@ describe('express -> User updateEmail suite', () => { .set('Content-Type', 'application/json; charset=utf-8') .set('Accept', 'application/json; charset=utf-8') .set(BasicAuthorizationHeaderUser1); - expect(response.body.message).toBe('Bad Request - email can not be empty'); + expect(response.body.message).toContain('OpenAPI validation failed at "payload.email"'); expect(response.statusCode).toBe(400); }); diff --git a/apps/backend-template/test/integration/Express/Users/updatePhone.test.ts b/apps/backend-template/test/integration/Express/Users/updatePhone.test.ts index 451f69fe2..e9a923b5b 100644 --- a/apps/backend-template/test/integration/Express/Users/updatePhone.test.ts +++ b/apps/backend-template/test/integration/Express/Users/updatePhone.test.ts @@ -155,7 +155,7 @@ describe('express -> User updatePhone suite', () => { .set('Content-Type', 'application/json; charset=utf-8') .set('Accept', 'application/json; charset=utf-8') .set(BasicAuthorizationHeaderUser1); - expect(response.body.message).toBe('Bad Request - localCode can not be empty'); + expect(response.body.message).toContain('OpenAPI validation failed at "payload.localCode"'); expect(response.statusCode).toBe(400); }); diff --git a/apps/backend-template/test/integration/Express/get.async-context-metrics.test.ts b/apps/backend-template/test/integration/Express/get.async-context-metrics.test.ts new file mode 100644 index 000000000..335ef7fd5 --- /dev/null +++ b/apps/backend-template/test/integration/Express/get.async-context-metrics.test.ts @@ -0,0 +1,72 @@ +/* global describe, it, expect, beforeAll, afterAll */ +import request from 'supertest'; +import { Express } from 'express'; +import { ExpressServer } from '@src/interface/HTTP/adapters/express/ExpressServer'; +import { infraHandlers } from '@src/interface/HTTP/adapters/express/handlers/infraHandlers'; +import { RestAPI } from '@src/interface/HTTP/RestAPI'; +import { InMemoryDbClient } from '@src/infra/persistence/InMemoryDatabase/InMemoryDbClient'; +import { InMemoryKeyValueStorageClient } from '@src/infra/persistence/KeyValueStorage/InMemoryKeyValueStorageClient'; +import { MutexService } from '@src/infra/mutex/adapter/MutexService'; +import { PasswordCryptoService } from '@src/infra/security/PasswordCryptoService'; +import { JwtService } from '@src/infra/jwt/JwtService'; +import { AuthService } from '@src/modules/Users/service/AuthService'; +import { UserProviderLocal } from '@src/modules/Users/service/UserProviderLocal'; +import { UserDataRepository, UserService } from '@src/modules/Users'; +import { EHTTPFrameworks } from '@src/interface/HTTP/ports'; +import { closeServer } from './closeServer'; + +const webServer = ExpressServer.compile(); +const passwordCryptoService = PasswordCryptoService.compile(); +const jwtService = JwtService.compile(); +const keyValueStorageClient = InMemoryKeyValueStorageClient.compile(); +const mutexService = MutexService.compile(keyValueStorageClient); +const dataRepository = UserDataRepository.compile({ databaseClient: InMemoryDbClient }); +const userService = UserService.compile({ + dataRepository, + services: { passwordCryptoService, mutexService } +}); +const authService = AuthService.compile( + UserProviderLocal.compile(userService), + passwordCryptoService, + jwtService +); + +let API: RestAPI; +let server: any; + +describe('express -> /async-context-metrics suite', () => { + beforeAll(async () => { + await InMemoryDbClient.connect(); + await keyValueStorageClient.connect(); + API = new RestAPI({ + databaseClient: InMemoryDbClient, + webServer, + infraHandlers, + serverType: EHTTPFrameworks.express, + authService, + passwordCryptoService, + keyValueStorageClient, + mutexService + }); + server = API.server.application.listen(0); + }); + + afterAll(async () => { + await closeServer(server); + await InMemoryDbClient.disconnect(); + await keyValueStorageClient.disconnect(); + }); + + it('serves ALS metrics snapshot on GET /async-context-metrics', async () => { + expect.hasAssertions(); + const response = await request(server).get('/async-context-metrics'); + expect(response.status).toBe(200); + expect(response.body).toStrictEqual(expect.objectContaining({ + enteredTotal: expect.any(Number), + exitedTotal: expect.any(Number), + active: expect.any(Number), + lastCorrelationIds: expect.any(Array), + recentStores: expect.any(Array) + })); + }); +}); diff --git a/apps/backend-template/test/integration/Express/purgeTombstones.test.ts b/apps/backend-template/test/integration/Express/purgeTombstones.test.ts new file mode 100644 index 000000000..2c07618ad --- /dev/null +++ b/apps/backend-template/test/integration/Express/purgeTombstones.test.ts @@ -0,0 +1,70 @@ +/* global describe, it, expect, beforeAll, afterAll */ +import request from 'supertest'; +import { Express } from 'express'; +import { ExpressServer } from '@src/interface/HTTP/adapters/express/ExpressServer'; +import { infraHandlers } from '@src/interface/HTTP/adapters/express/handlers/infraHandlers'; +import { RestAPI } from '@src/interface/HTTP/RestAPI'; +import { InMemoryDbClient } from '@src/infra/persistence/InMemoryDatabase/InMemoryDbClient'; +import { InMemoryKeyValueStorageClient } from '@src/infra/persistence/KeyValueStorage/InMemoryKeyValueStorageClient'; +import { MutexService } from '@src/infra/mutex/adapter/MutexService'; +import { PasswordCryptoService } from '@src/infra/security/PasswordCryptoService'; +import { JwtService } from '@src/infra/jwt/JwtService'; +import { AuthService } from '@src/modules/Users/service/AuthService'; +import { UserProviderLocal } from '@src/modules/Users/service/UserProviderLocal'; +import { UserDataRepository, UserService } from '@src/modules/Users'; +import { EHTTPFrameworks } from '@src/interface/HTTP/ports'; +import { closeServer } from './closeServer'; + +const webServer = ExpressServer.compile(); +const passwordCryptoService = PasswordCryptoService.compile(); +const jwtService = JwtService.compile(); +const keyValueStorageClient = InMemoryKeyValueStorageClient.compile(); +const mutexService = MutexService.compile(keyValueStorageClient); +const dataRepository = UserDataRepository.compile({ databaseClient: InMemoryDbClient }); +const userService = UserService.compile({ + dataRepository, + services: { passwordCryptoService, mutexService } +}); +const authService = AuthService.compile( + UserProviderLocal.compile(userService), + passwordCryptoService, + jwtService +); + +let API: RestAPI; +let server: any; + +describe('express -> /internal/tombstones/purge', () => { + beforeAll(async () => { + await InMemoryDbClient.connect(); + await keyValueStorageClient.connect(); + API = new RestAPI({ + databaseClient: InMemoryDbClient, + webServer, + infraHandlers, + serverType: EHTTPFrameworks.express, + authService, + passwordCryptoService, + keyValueStorageClient, + mutexService + }); + server = API.server.application.listen(0); + }); + + afterAll(async () => { + await closeServer(server); + await InMemoryDbClient.disconnect(); + await keyValueStorageClient.disconnect(); + }); + + it('dry-runs by default and does not require OAS auth', async () => { + expect.hasAssertions(); + const response = await request(server) + .post('/internal/tombstones/purge') + .send({}) + .set('Accept', 'application/json'); + expect(response.statusCode).toBe(200); + expect(response.body.dryRun).toBe(true); + expect(response.body.olderThanDays).toBe(90); + }); +}); diff --git a/apps/backend-template/test/integration/Fastify/Users/createDocument.test.ts b/apps/backend-template/test/integration/Fastify/Users/createDocument.test.ts index 4fbf642a3..823958704 100644 --- a/apps/backend-template/test/integration/Fastify/Users/createDocument.test.ts +++ b/apps/backend-template/test/integration/Fastify/Users/createDocument.test.ts @@ -144,7 +144,7 @@ describe('fastify -> User createDocument suite', () => { .set('Content-Type', 'application/json; charset=utf-8') .set('Accept', 'application/json; charset=utf-8') .set(BasicAuthorizationHeaderUser1); - expect(response.body.message).toBe('Bad Request - countryIssue can not be empty'); + expect(response.body.message).toContain('OpenAPI validation failed at "payload.countryIssue"'); expect(response.statusCode).toBe(400); }); diff --git a/apps/backend-template/test/integration/Fastify/Users/createEmail.test.ts b/apps/backend-template/test/integration/Fastify/Users/createEmail.test.ts index bc5532ee2..e63f195c5 100644 --- a/apps/backend-template/test/integration/Fastify/Users/createEmail.test.ts +++ b/apps/backend-template/test/integration/Fastify/Users/createEmail.test.ts @@ -124,7 +124,7 @@ describe('fastify -> User createEmail suite', () => { .set('Content-Type', 'application/json; charset=utf-8') .set('Accept', 'application/json; charset=utf-8') .set(BasicAuthorizationHeaderUser1); - expect(response.body.message).toBe('Bad Request - email can not be empty'); + expect(response.body.message).toContain('OpenAPI validation failed at "payload.email"'); expect(response.statusCode).toBe(400); }); diff --git a/apps/backend-template/test/integration/Fastify/Users/createPhone.test.ts b/apps/backend-template/test/integration/Fastify/Users/createPhone.test.ts index 0c4131362..f10de0376 100644 --- a/apps/backend-template/test/integration/Fastify/Users/createPhone.test.ts +++ b/apps/backend-template/test/integration/Fastify/Users/createPhone.test.ts @@ -156,7 +156,7 @@ describe('fastify -> User createPhone suite', () => { .set('Content-Type', 'application/json; charset=utf-8') .set('Accept', 'application/json; charset=utf-8') .set(BasicAuthorizationHeaderUser1); - expect(response.body.message).toBe('Bad Request - localCode can not be empty'); + expect(response.body.message).toContain('OpenAPI validation failed at "payload.localCode"'); expect(response.statusCode).toBe(400); }); diff --git a/apps/backend-template/test/integration/Fastify/Users/getAll.test.ts b/apps/backend-template/test/integration/Fastify/Users/getAll.test.ts index 1f0df4b03..251792f3a 100644 --- a/apps/backend-template/test/integration/Fastify/Users/getAll.test.ts +++ b/apps/backend-template/test/integration/Fastify/Users/getAll.test.ts @@ -156,7 +156,9 @@ describe('fastify -> get Users suite', () => { .set(BasicAuthorizationHeaderUser1); expect(response.statusCode).toBe(400); // console.log(response.body); - expect(response.body.message).toBe('Bad Request - page must be greater than 0'); + // JUM-777: `page` is a query parameter with `minimum: 1` in the OAS, so the + // contract validator rejects 0 before the store's own guard runs. + expect(response.body.message).toBe('Bad Request - OpenAPI validation failed at "params.page": minimum is 1, got 0'); expect(response.body.page).toBeUndefined(); expect(response.body.size).toBeUndefined(); expect(response.body.total).toBeUndefined(); diff --git a/apps/backend-template/test/integration/Fastify/Users/updateDocument.test.ts b/apps/backend-template/test/integration/Fastify/Users/updateDocument.test.ts index 48df8daed..702e8e8a6 100644 --- a/apps/backend-template/test/integration/Fastify/Users/updateDocument.test.ts +++ b/apps/backend-template/test/integration/Fastify/Users/updateDocument.test.ts @@ -97,7 +97,7 @@ describe('fastify -> User updateDocument suite', () => { expect.hasAssertions(); const requestUpdateDocument: RequestUpdateDocument = { ...document1, - data: '111-111-111' + data: '111-11-1111' }; const response = await request(server.server) .put(`/api/1.0.0/users/${user1.id}/updateDocument/${document1.id}`) @@ -139,7 +139,7 @@ describe('fastify -> User updateDocument suite', () => { .set('Content-Type', 'application/json; charset=utf-8') .set('Accept', 'application/json; charset=utf-8') .set(BasicAuthorizationHeaderUser1); - expect(response.body.message).toBe('Bad Request - countryIssue can not be empty'); + expect(response.body.message).toContain('OpenAPI validation failed at "payload.countryIssue"'); expect(response.statusCode).toBe(400); }); diff --git a/apps/backend-template/test/integration/Fastify/Users/updateEmail.test.ts b/apps/backend-template/test/integration/Fastify/Users/updateEmail.test.ts index 28a5fdb20..a4113b896 100644 --- a/apps/backend-template/test/integration/Fastify/Users/updateEmail.test.ts +++ b/apps/backend-template/test/integration/Fastify/Users/updateEmail.test.ts @@ -99,7 +99,7 @@ describe('fastify -> User updateEmail suite', () => { expect.hasAssertions(); const requestUpdateEmail: RequestUpdateEmail = { ...email1, - email: '111-111-111' + email: 'updated111@tenant.dev' }; const response = await request(server.server) .put(`/api/1.0.0/users/${user1.id}/updateEmail/${email1.id}`) @@ -125,7 +125,7 @@ describe('fastify -> User updateEmail suite', () => { .set('Content-Type', 'application/json; charset=utf-8') .set('Accept', 'application/json; charset=utf-8') .set(BasicAuthorizationHeaderUser1); - expect(response.body.message).toBe('Bad Request - email can not be empty'); + expect(response.body.message).toContain('OpenAPI validation failed at "payload.email"'); expect(response.statusCode).toBe(400); }); diff --git a/apps/backend-template/test/integration/Fastify/Users/updatePhone.test.ts b/apps/backend-template/test/integration/Fastify/Users/updatePhone.test.ts index eda362493..3bd5c773e 100644 --- a/apps/backend-template/test/integration/Fastify/Users/updatePhone.test.ts +++ b/apps/backend-template/test/integration/Fastify/Users/updatePhone.test.ts @@ -155,7 +155,7 @@ describe('fastify -> User updatePhone suite', () => { .set('Content-Type', 'application/json; charset=utf-8') .set('Accept', 'application/json; charset=utf-8') .set(BasicAuthorizationHeaderUser1); - expect(response.body.message).toBe('Bad Request - localCode can not be empty'); + expect(response.body.message).toContain('OpenAPI validation failed at "payload.localCode"'); expect(response.statusCode).toBe(400); }); diff --git a/apps/backend-template/test/integration/Restify/Users/createDocument.test.ts b/apps/backend-template/test/integration/Restify/Users/createDocument.test.ts index b87d5a6b3..edd3350ee 100644 --- a/apps/backend-template/test/integration/Restify/Users/createDocument.test.ts +++ b/apps/backend-template/test/integration/Restify/Users/createDocument.test.ts @@ -144,7 +144,7 @@ describe('restify -> User createDocument suite', () => { .set('Content-Type', 'application/json; charset=utf-8') .set('Accept', 'application/json; charset=utf-8') .set(BasicAuthorizationHeaderUser1); - expect(response.body.message).toBe('Bad Request - countryIssue can not be empty'); + expect(response.body.message).toContain('OpenAPI validation failed at "payload.countryIssue"'); expect(response.statusCode).toBe(400); }); diff --git a/apps/backend-template/test/integration/Restify/Users/createEmail.test.ts b/apps/backend-template/test/integration/Restify/Users/createEmail.test.ts index efaa12dc7..bf431db8f 100644 --- a/apps/backend-template/test/integration/Restify/Users/createEmail.test.ts +++ b/apps/backend-template/test/integration/Restify/Users/createEmail.test.ts @@ -125,7 +125,7 @@ describe('restify -> User createEmail suite', () => { .set('Content-Type', 'application/json; charset=utf-8') .set('Accept', 'application/json; charset=utf-8') .set(BasicAuthorizationHeaderUser1); - expect(response.body.message).toBe('Bad Request - email can not be empty'); + expect(response.body.message).toContain('OpenAPI validation failed at "payload.email"'); expect(response.statusCode).toBe(400); }); diff --git a/apps/backend-template/test/integration/Restify/Users/createPhone.test.ts b/apps/backend-template/test/integration/Restify/Users/createPhone.test.ts index 7f5f1bdd8..230d6cd25 100644 --- a/apps/backend-template/test/integration/Restify/Users/createPhone.test.ts +++ b/apps/backend-template/test/integration/Restify/Users/createPhone.test.ts @@ -157,7 +157,7 @@ describe('restify -> User createPhone suite', () => { .set('Content-Type', 'application/json; charset=utf-8') .set('Accept', 'application/json; charset=utf-8') .set(BasicAuthorizationHeaderUser1); - expect(response.body.message).toBe('Bad Request - localCode can not be empty'); + expect(response.body.message).toContain('OpenAPI validation failed at "payload.localCode"'); expect(response.statusCode).toBe(400); }); diff --git a/apps/backend-template/test/integration/Restify/Users/getAll.test.ts b/apps/backend-template/test/integration/Restify/Users/getAll.test.ts index 23dcc7010..9b1cea918 100644 --- a/apps/backend-template/test/integration/Restify/Users/getAll.test.ts +++ b/apps/backend-template/test/integration/Restify/Users/getAll.test.ts @@ -161,7 +161,9 @@ describe('restify -> get Users suite', () => { .set(BasicAuthorizationHeaderUser1); expect(response.statusCode).toBe(400); // console.log(response.body); - expect(response.body.message).toBe('Bad Request - page must be greater than 0'); + // JUM-777: `page` is a query parameter with `minimum: 1` in the OAS, so the + // contract validator rejects 0 before the store's own guard runs. + expect(response.body.message).toBe('Bad Request - OpenAPI validation failed at "params.page": minimum is 1, got 0'); expect(response.body.page).toBeUndefined(); expect(response.body.size).toBeUndefined(); expect(response.body.total).toBeUndefined(); diff --git a/apps/backend-template/test/integration/Restify/Users/updateDocument.test.ts b/apps/backend-template/test/integration/Restify/Users/updateDocument.test.ts index 5f658988b..575785669 100644 --- a/apps/backend-template/test/integration/Restify/Users/updateDocument.test.ts +++ b/apps/backend-template/test/integration/Restify/Users/updateDocument.test.ts @@ -98,7 +98,7 @@ describe('restify -> User updateDocument suite', () => { expect.hasAssertions(); const requestUpdateDocument: RequestUpdateDocument = { ...document1, - data: '111-111-111' + data: '111-11-1111' }; const response = await request(server) .put(`/api/1.0.0/users/${user1.id}/updateDocument/${document1.id}`) @@ -140,7 +140,7 @@ describe('restify -> User updateDocument suite', () => { .set('Content-Type', 'application/json; charset=utf-8') .set('Accept', 'application/json; charset=utf-8') .set(BasicAuthorizationHeaderUser1); - expect(response.body.message).toBe('Bad Request - countryIssue can not be empty'); + expect(response.body.message).toContain('OpenAPI validation failed at "payload.countryIssue"'); expect(response.statusCode).toBe(400); }); diff --git a/apps/backend-template/test/integration/Restify/Users/updateEmail.test.ts b/apps/backend-template/test/integration/Restify/Users/updateEmail.test.ts index 9982cd6f7..e1f922670 100644 --- a/apps/backend-template/test/integration/Restify/Users/updateEmail.test.ts +++ b/apps/backend-template/test/integration/Restify/Users/updateEmail.test.ts @@ -100,7 +100,7 @@ describe('restify -> User updateEmail suite', () => { expect.hasAssertions(); const requestUpdateEmail: RequestUpdateEmail = { ...email1, - email: '111-111-111' + email: 'updated111@tenant.dev' }; const response = await request(server) .put(`/api/1.0.0/users/${user1.id}/updateEmail/${email1.id}`) @@ -126,7 +126,7 @@ describe('restify -> User updateEmail suite', () => { .set('Content-Type', 'application/json; charset=utf-8') .set('Accept', 'application/json; charset=utf-8') .set(BasicAuthorizationHeaderUser1); - expect(response.body.message).toBe('Bad Request - email can not be empty'); + expect(response.body.message).toContain('OpenAPI validation failed at "payload.email"'); expect(response.statusCode).toBe(400); }); diff --git a/apps/backend-template/test/integration/Restify/Users/updatePhone.test.ts b/apps/backend-template/test/integration/Restify/Users/updatePhone.test.ts index 9118bd1f5..1b88a180a 100644 --- a/apps/backend-template/test/integration/Restify/Users/updatePhone.test.ts +++ b/apps/backend-template/test/integration/Restify/Users/updatePhone.test.ts @@ -156,7 +156,7 @@ describe('restify -> User updatePhone suite', () => { .set('Content-Type', 'application/json; charset=utf-8') .set('Accept', 'application/json; charset=utf-8') .set(BasicAuthorizationHeaderUser1); - expect(response.body.message).toBe('Bad Request - localCode can not be empty'); + expect(response.body.message).toContain('OpenAPI validation failed at "payload.localCode"'); expect(response.statusCode).toBe(400); }); diff --git a/apps/backend-template/test/integration/ServiceManagement/accessibleNames.browser.integration.test.ts b/apps/backend-template/test/integration/ServiceManagement/accessibleNames.browser.integration.test.ts new file mode 100644 index 000000000..3183eb6dd --- /dev/null +++ b/apps/backend-template/test/integration/ServiceManagement/accessibleNames.browser.integration.test.ts @@ -0,0 +1,186 @@ +/* eslint-disable jest/prefer-expect-assertions, jest/max-expects */ +/* + * JUM-732 — every control a user can reach computes an accessible name, in a + * REAL browser against the REAL server (Requirement 115). + * + * The defect this pins: the generated field rows created a text input and a + * type select with no accessible name at all, and three buttons that read + * "meta", "save" and "x" once per field — a name with no referent. Measured + * before the fix on the loaded sample: 21 controls with no computed name, 18 of + * them in those rows. (The other three are the `hidden` file inputs, which are + * not in the accessibility tree; the first count included them by mistake and + * this suite does not.) + * + * The assertion walks the rendered DOM rather than a fixed list, so a control + * added later without a name fails here. + */ +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import { webkit } from 'playwright-webkit'; +import type { Browser, Page } from 'playwright-webkit'; +import { + createTempConfigDir, + cleanupTempConfigDir, + envFileContent, + startServer, + clickInPanels, + openDesignerPanels, + stopServer, + waitForServer +} from './serverHarness'; +import type { StartedServer } from './serverHarness'; + +const repoRoot = path.resolve(__dirname, '../../../../..'); + +type UnnamedControl = { tag: string; id: string; type: string }; + +async function unnamedControls(page: Page): Promise { + return page.$$eval('input, select, textarea, button', (elements) => { + const isReachable = (element: Element): boolean => { + const node = element as HTMLElement; + if (node.hidden) return false; + if (node.closest('[hidden]')) return false; + const style = window.getComputedStyle(node); + return style.display !== 'none' && style.visibility !== 'hidden'; + }; + + const accessibleName = (element: Element): string => { + const node = element as HTMLInputElement; + const aria = node.getAttribute('aria-label'); + if (aria && aria.trim()) return aria.trim(); + const labelledBy = node.getAttribute('aria-labelledby'); + if (labelledBy) { + const referenced = labelledBy + .split(/\s+/) + .map((id) => document.getElementById(id)?.textContent || '') + .join(' ') + .trim(); + if (referenced) return referenced; + } + const { labels } = node as unknown as { labels?: NodeListOf }; + const firstLabel = labels ? labels[0] : undefined; + const labelText = firstLabel ? (firstLabel.textContent || '').trim() : ''; + if (labelText) return labelText; + const title = node.getAttribute('title'); + if (title && title.trim()) return title.trim(); + if (node.tagName === 'BUTTON' && (node.textContent || '').trim()) { + return (node.textContent || '').trim(); + } + return ''; + }; + + return elements + .filter(isReachable) + .filter((element) => !accessibleName(element)) + .map((element) => ({ + tag: element.tagName.toLowerCase(), + id: element.id || '(generated)', + type: (element as HTMLInputElement).type || '' + })); + }); +} + +async function fieldRowAriaLabels(page: Page): Promise { + return page.$$eval('#entity-field-list .field-row', (rows) => rows.map((row) => { + const controls = Array.from(row.querySelectorAll('input, select, button')); + return controls.map((control) => { + const label = control.getAttribute('aria-label'); + return label === null ? '' : label; + }); + })); +} + +describe('serviceManagement accessible names (JUM-732)', () => { + let tempDir: string; + let server: StartedServer | undefined; + let browser: Browser | undefined; + let baseUrl: string; + + beforeAll(async () => { + execFileSync('bun', ['ci-cd/sync-service-management-cana-bundle.js'], { + cwd: repoRoot, + stdio: 'inherit' + }); + execFileSync('bun', ['ci-cd/sync-service-management-designer-core.js'], { + cwd: repoRoot, + stdio: 'inherit' + }); + tempDir = createTempConfigDir({ '.env.dev': envFileContent('express') }); + server = await startServer(tempDir); + await waitForServer(server.port); + baseUrl = `http://127.0.0.1:${String(server.port)}/`; + browser = await webkit.launch({ headless: true }); + }, 90000); + + afterAll(async () => { + if (browser) await browser.close(); + stopServer(server); + cleanupTempConfigDir(tempDir); + }); + + it('names every reachable control on a first-run page', async () => { + expect.hasAssertions(); + const context = await browser!.newContext(); + const page = await context.newPage(); + await page.goto(baseUrl, { waitUntil: 'load' }); + + // Wait for the app to finish its boot render rather than sampling an + // arbitrary instant: the runtime-env fields are generated late, and a CI + // run read them before they were named while a local run read them after. + await page.waitForSelector('[id^=runtime-env-field-]', { state: 'attached' }); + + await expect(unnamedControls(page)).resolves.toStrictEqual([]); + + await context.close(); + }, 60000); + + it('names every control in the generated field rows', async () => { + expect.hasAssertions(); + const context = await browser!.newContext(); + const page = await context.newPage(); + await page.goto(baseUrl, { waitUntil: 'load' }); + + await page.click('#domain-designer-empty-load-sample-btn'); + await page.waitForTimeout(500); + await openDesignerPanels(page, '#entity-search-input'); + await page.fill('#entity-search-input', 'User'); + await clickInPanels(page, '#entity-search-btn'); + await page.waitForTimeout(400); + + // The rows are actually there, so an empty result is coverage, not a vacuum. + const rows = await page.$$('#entity-field-list .field-row'); + expect(rows.length).toBeGreaterThan(0); + + await expect(unnamedControls(page)).resolves.toStrictEqual([]); + + await context.close(); + }, 60000); + + it('names the row controls after the field they belong to', async () => { + expect.hasAssertions(); + const context = await browser!.newContext(); + const page = await context.newPage(); + await page.goto(baseUrl, { waitUntil: 'load' }); + + await page.click('#domain-designer-empty-load-sample-btn'); + await page.waitForTimeout(500); + await openDesignerPanels(page, '#entity-search-input'); + await page.fill('#entity-search-input', 'User'); + await clickInPanels(page, '#entity-search-btn'); + await page.waitForTimeout(400); + + const names = await fieldRowAriaLabels(page); + + expect(names.length).toBeGreaterThan(0); + + const [firstRow] = names; + const prefixes = firstRow.map((name) => name.replace(/".*$/, '').trim()); + + expect(prefixes).toContain('Name of field'); + expect(prefixes).toContain('Type of field'); + expect(prefixes).toContain('Save field'); + expect(prefixes).toContain('Delete field'); + + await context.close(); + }, 60000); +}); diff --git a/apps/backend-template/test/integration/ServiceManagement/deployTargetLifecycle.browser.integration.test.ts b/apps/backend-template/test/integration/ServiceManagement/deployTargetLifecycle.browser.integration.test.ts index ff49bf155..96f4c3a85 100644 --- a/apps/backend-template/test/integration/ServiceManagement/deployTargetLifecycle.browser.integration.test.ts +++ b/apps/backend-template/test/integration/ServiceManagement/deployTargetLifecycle.browser.integration.test.ts @@ -28,6 +28,8 @@ import { cleanupTempConfigDir, envFileContent, startServer, + clickInPanels, + openDesignerPanels, stopServer, waitForServer } from './serverHarness'; @@ -41,7 +43,8 @@ async function bootDeployTab(context: Awaited> await page.goto(baseUrl, { waitUntil: 'load' }); // JUM-548: first run is intentionally empty — load the sample model so the // designer sits in a realistic populated state before switching tabs. - await page.click('#load-sample-btn'); + await clickInPanels(page, '#load-sample-btn'); + await openDesignerPanels(page, '#domain-list'); await page.waitForSelector('#domain-list li', { timeout: 20000 }); await page.click('#tab-deploy-management-btn'); await page.waitForSelector('#add-deploy-target-btn', { timeout: 20000 }); diff --git a/apps/backend-template/test/integration/ServiceManagement/domainDesigner.smoke.test.ts b/apps/backend-template/test/integration/ServiceManagement/domainDesigner.smoke.test.ts index 9797cc57f..bd259fdbb 100644 --- a/apps/backend-template/test/integration/ServiceManagement/domainDesigner.smoke.test.ts +++ b/apps/backend-template/test/integration/ServiceManagement/domainDesigner.smoke.test.ts @@ -21,6 +21,147 @@ describe('serviceManagement domain designer smoke', () => { expect(html).toContain('id="mini-map"'); }); + it('exposes direct manipulation affordances for a responsive diagram canvas', () => { + expect.hasAssertions(); + const canvasSource = fs.readFileSync( + path.resolve(process.cwd(), 'apps/service-management/src/ui/canvas.js'), + 'utf-8' + ); + const css = fs.readFileSync(path.resolve(process.cwd(), 'apps/service-management/styles.css'), 'utf-8'); + expect(canvasSource).toContain('scheduleEdgesRender'); + expect(canvasSource).toContain('edge-bend-handle'); + expect(canvasSource).toContain('edge-end-handle'); + expect(canvasSource).toContain('domain-drag-grip'); + expect(canvasSource).toContain('entity-drag-grip'); + expect(canvasSource).toMatch(/ownerDocument\.addEventListener\('pointermove', move, true\)/); + expect(canvasSource).toMatch(/ownerDocument\.removeEventListener\('pointermove', move, true\)/); + expect(canvasSource).toContain('closeSidebarForCanvasWork'); + expect(canvasSource).toContain('attachRelationshipEndpointDrag'); + expect(canvasSource).toContain('relationshipAnchorFromField'); + expect(canvasSource).toContain('centerCanvasFromMiniMapPointer'); + expect(canvasSource).toContain('wireMiniMapEvents'); + expect(canvasSource).toMatch(/dom\.miniMap\.addEventListener\('pointerdown'/); + expect(canvasSource).toMatch(/dom\.canvas\.addEventListener\('scroll'/); + expect(canvasSource).toContain('totalDx'); + expect(canvasSource).toContain('domainDragOrigin'); + expect(canvasSource).toContain('entityDragOrigin'); + expect(canvasSource).toContain('CANVAS_ORIGIN_X'); + expect(canvasSource).toContain('VIRTUAL_CANVAS_WIDTH'); + expect(canvasSource).toContain('routeExitPenalty'); + expect(canvasSource).toContain('routeSelfCrossPenalty'); + expect(canvasSource).toContain('domFieldAnchorPoint'); + expect(canvasSource).toContain('const entityRect = entityEl.getBoundingClientRect()'); + expect(canvasSource).toContain('const rowRect = row.getBoundingClientRect()'); + expect(canvasSource).toContain('edgeX - canvasRect.left'); + expect(canvasSource).toContain('pointOutsideEndpoint'); + expect(canvasSource).toContain('edgeHitRoutePoints'); + expect(canvasSource).toContain('hit.setAttribute(\'d\', hitPathD)'); + expect(canvasSource).toContain('handlePoint: useCenter ? from : pointOutsideEndpoint(from, fromEndpoint.side, to)'); + expect(canvasSource).toContain('dataset.fieldName'); + expect(canvasSource).toContain('relationship-field-connected'); + expect(canvasSource).toMatch(/entityEl\.style\.left = `\$\{entity\.x\}px`/); + expect(canvasSource).toMatch(/entityEl\.style\.top = `\$\{entity\.y\}px`/); + expect(canvasSource).toContain('liveTransformOrigin'); + expect(canvasSource).toMatch(/translate3d\(\$\{dx\}px, \$\{dy\}px, 0\)/); + expect(canvasSource).toContain('afterPaint'); + expect(canvasSource).toContain('scheduleEdgesRender(aligned.guides, { afterPaint: Boolean(target.liveTransformOrigin) })'); + expect(canvasSource).toContain('entityEl.style.transform = \'\''); + expect(canvasSource).toContain('domain.x = origin.x + drag.totalDx'); + expect(canvasSource).toContain('note.x = origin.x + drag.totalDx'); + expect(canvasSource).not.toContain('domain.x = Math.max(0'); + expect(canvasSource).not.toContain('note.x = Math.max(0'); + expect(canvasSource).not.toContain('entity.x = Math.max(0'); + expect(canvasSource).not.toContain('Math.max(8, x)'); + expect(canvasSource).toContain('nameEl.addEventListener(\'pointerup\', () => nameEl.focus())'); + expect(canvasSource).toContain('resizeHandleForEntity'); + expect(canvasSource).toContain('entity-resize-handle'); + expect(canvasSource).toContain('resizeEntityBox'); + expect(canvasSource).toContain('entityBoxHeight'); + expect(canvasSource).toContain('zoomAtPointer'); + expect(canvasSource).toContain('typeEl.addEventListener(\'pointerdown\''); + expect(canvasSource).toContain('addFieldBtn.addEventListener(\'pointerdown\''); + expect(canvasSource).toContain('event.preventDefault()'); + expect(canvasSource).toContain('event.stopPropagation()'); + expect(canvasSource).toContain('capture: true'); + expect(canvasSource).toContain('align: false'); + expect(canvasSource).toContain('canvasMenuItems'); + expect(canvasSource).toMatch(/dom\.canvas\.addEventListener\('contextmenu'/); + expect(canvasSource).toContain('createFieldSchemaEditor'); + expect(canvasSource).toContain('field-schema-editor'); + expect(canvasSource).toContain('indexed'); + expect(canvasSource).toContain('buildRoutedEdgePathD'); + expect(canvasSource).toContain('routeCollisionCount'); + expect(canvasSource).toContain('relationshipRouteOrdinal'); + expect(canvasSource).toContain('routeLabelOffset'); + expect(canvasSource).toContain('compactRelationshipFieldLabel'); + expect(canvasSource).toContain('cardinalityLabelPoint'); + expect(canvasSource).toContain('relationshipFieldLabel'); + expect(canvasSource).toMatch(/bendHandle\.addEventListener\('dblclick'/); + expect(canvasSource).toContain('edge-name-label'); + expect(css).toContain('touch-action: none'); + expect(css).toContain('contain: layout style'); + expect(css).toContain('Diagram workbench skin'); + expect(css).toContain('.service-management-shell:has(#tab-domain-designer.active)'); + expect(css).toContain('scrollbar-color: #3a3e47 #0d0f12'); + expect(css).toContain('.service-management-shell *::-webkit-scrollbar-thumb:hover'); + expect(css).toContain('line-height: 1.35'); + expect(css).toContain('radial-gradient(circle, rgba(255, 255, 255, 0.075) 1px'); + expect(css).toContain('paint-order: stroke'); + expect(css).toContain('pointer-events: stroke'); + expect(css).toContain('cursor: grab'); + expect(css).toContain('grid-template-columns: 0 minmax(0, 1fr)'); + expect(css).toContain('.app-shell:has(.sidebar.open)'); + expect(css).toMatch(/\.sidebar\s*\{[\s\S]*position:\s*relative/); + expect(css).toContain('min-width: 96px'); + expect(css).toContain('.entity-resize-handle'); + expect(css).toContain('.entity.resizing'); + expect(css).toContain('position: absolute'); + expect(css).toContain('grid-template-columns: auto minmax(104px, 1fr) minmax(78px, 96px) auto auto auto 30px'); + expect(css).toContain('right: -9px'); + expect(css).toContain('transform: translateY(-50%)'); + expect(css).toMatch(/\.entity-field-add\s*\{[\s\S]*z-index:\s*8/); + expect(css).toMatch(/\.entity-field-add button\s*\{[\s\S]*z-index:\s*9/); + expect(css).toContain('grid-template-rows: 42px minmax(0, 1fr)'); + expect(css).toMatch(/\.mini-map\s*\{[\s\S]*position:\s*absolute/); + expect(css).toMatch(/\.status-region\s*\{[\s\S]*pointer-events:\s*none/); + expect(css).toMatch(/\.pwa-update-banner\s*\{[\s\S]*pointer-events:\s*none/); + expect(css).toMatch(/\.pwa-update-banner-btn\s*\{[\s\S]*pointer-events:\s*auto/); + expect(css).toMatch(/\.edges\s*\{[\s\S]*z-index:\s*2/); + expect(css).toMatch(/\.edges\s*\{[\s\S]*overflow:\s*visible/); + expect(css).toMatch(/\.domain\s*\{[\s\S]*z-index:\s*5/); + expect(css).toContain('.domain.dragging'); + expect(css).toContain('.entity-field-row.relationship-field-connected'); + expect(css).toContain('.domain-drag-grip'); + expect(css).toContain('.entity-drag-grip'); + expect(css).toContain('.mini-map-viewport'); + expect(css).toContain('.field-schema-grid'); + expect(css).toContain('cursor: crosshair'); + expect(css).toContain('cursor: pointer'); + expect(css).toContain('z-index: 1'); + expect(css).toContain('width: min(340px, 30vw)'); + expect(css).toContain('.edge-end-handle-from'); + expect(css).toContain('.edge-end-handle-to'); + expect(css).toContain('@media (max-width: 900px)'); + }); + + it('keeps the first-run sample visually spread out instead of stacked', () => { + expect.hasAssertions(); + const sampleSource = fs.readFileSync( + path.resolve(process.cwd(), 'packages/designer-core/src/model/sampleModel.js'), + 'utf-8' + ); + expect(sampleSource).toContain('width: 780'); + expect(sampleSource).toContain('height: 900'); + expect(sampleSource).toMatch(/name: 'ContactPoint'/); + expect(sampleSource).toMatch(/name: 'Tasks'/); + expect(sampleSource).toMatch(/name: 'Project'/); + expect(sampleSource).toMatch(/name: 'Task'/); + expect(sampleSource).toMatch(/name: 'Comment'/); + expect(sampleSource).toContain('id: \'sample-rel-task-project\''); + expect(sampleSource).toContain('fromField: \'projectId\''); + expect(sampleSource).toContain('y: 650'); + }); + it('wires export and package features in runtime script', () => { expect.hasAssertions(); const script = fs.readFileSync(scriptPath, 'utf-8'); @@ -30,6 +171,9 @@ describe('serviceManagement domain designer smoke', () => { expect(script).toContain('exportBoilerplateBundle'); expect(script).toContain('exportAsPackage'); expect(script).toContain('importDomainPackage'); + expect(script).toContain('repairLegacySampleDiagramLayout'); + expect(script).toContain('isIsolatedLegacySample'); + expect(script).toContain('sample-domain-tasks'); expect(script).toContain('renderMiniMap'); }); }); diff --git a/apps/backend-template/test/integration/ServiceManagement/firstRun.browser.integration.test.ts b/apps/backend-template/test/integration/ServiceManagement/firstRun.browser.integration.test.ts index 4876b4721..5854f31ba 100644 --- a/apps/backend-template/test/integration/ServiceManagement/firstRun.browser.integration.test.ts +++ b/apps/backend-template/test/integration/ServiceManagement/firstRun.browser.integration.test.ts @@ -26,6 +26,8 @@ import { cleanupTempConfigDir, envFileContent, startServer, + clickInPanels, + openDesignerPanels, stopServer, waitForServer } from './serverHarness'; @@ -120,23 +122,26 @@ describe('serviceManagement first-run experience (JUM-548)', () => { await expect(isVisible(await page.$('#domain-designer-empty-state'))).resolves.toBe(false); const domainListText = await page.$eval('#domain-list', (el) => el.textContent || ''); expect(domainListText).toContain('Users'); - // ...which is marked as sample in the domain list... + expect(domainListText).toContain('Tasks'); + // ...which are marked as sample in the domain list... const badges = await page.$$('#domain-list .sample-badge'); - expect(badges).toHaveLength(1); - await expect(badges[0].textContent()).resolves.toBe('sample'); + expect(badges).toHaveLength(2); + await Promise.all(badges.map((badge) => ( + expect(badge.textContent()).resolves.toBe('sample') + ))); // ...announced through the non-blocking status surface (JUM-543), never an alert. const statusText = await page.$eval('#status-region', (el) => el.textContent || ''); expect(statusText).toContain('Sample model loaded'); // ...and the canvas renders the sample's entities. const entityCards = await page.$$('.canvas .entity'); - expect(entityCards.length).toBeGreaterThan(0); + expect(entityCards).toHaveLength(8); // The sample passes the export quality gate at its default blocking // setting — the export fires, proving collectModelIssues reports no // error-severity issue on it. const [download] = await Promise.all([ page.waitForEvent('download'), - page.click('#export-json-btn') + clickInPanels(page, '#export-json-btn') ]); expect(download.suggestedFilename()).toBe('domain-designer.json'); await context.close(); @@ -150,8 +155,9 @@ describe('serviceManagement first-run experience (JUM-548)', () => { // Existing work: the sample plus the user's own domain. await page.click('#domain-designer-empty-load-sample-btn'); + await openDesignerPanels(page, '#domain-name-input'); await page.fill('#domain-name-input', 'Mine'); - await page.click('#add-domain-btn'); + await clickInPanels(page, '#add-domain-btn'); let domainListText = await page.$eval('#domain-list', (el) => el.textContent || ''); expect(domainListText).toContain('Mine'); @@ -159,7 +165,7 @@ describe('serviceManagement first-run experience (JUM-548)', () => { page.once('dialog', (dialog) => { dialog.dismiss().catch(() => {}); }); - await page.click('#load-sample-btn'); + await clickInPanels(page, '#load-sample-btn'); await page.waitForTimeout(300); domainListText = await page.$eval('#domain-list', (el) => el.textContent || ''); expect(domainListText).toContain('Mine'); @@ -170,7 +176,7 @@ describe('serviceManagement first-run experience (JUM-548)', () => { page.once('dialog', (dialog) => { dialog.accept().catch(() => {}); }); - await page.click('#load-sample-btn'); + await clickInPanels(page, '#load-sample-btn'); await page.waitForTimeout(300); domainListText = await page.$eval('#domain-list', (el) => el.textContent || ''); expect(domainListText).not.toContain('Mine'); diff --git a/apps/backend-template/test/integration/ServiceManagement/multiTabSync.browser.integration.test.ts b/apps/backend-template/test/integration/ServiceManagement/multiTabSync.browser.integration.test.ts index 692dbd179..cbf44e662 100644 --- a/apps/backend-template/test/integration/ServiceManagement/multiTabSync.browser.integration.test.ts +++ b/apps/backend-template/test/integration/ServiceManagement/multiTabSync.browser.integration.test.ts @@ -26,6 +26,8 @@ import { cleanupTempConfigDir, envFileContent, startServer, + clickInPanels, + openDesignerPanels, stopServer, waitForServer } from './serverHarness'; @@ -99,7 +101,7 @@ async function bootPage(context: Awaited>, bas * real channel — the sample load doubles as the suite's first sync proof. */ async function loadSampleAndConverge(pageA: Page, pageB: Page) { - await pageA.click('#load-sample-btn'); + await clickInPanels(pageA, '#load-sample-btn'); await waitForDomain(pageA, 'Users'); await waitForDomain(pageB, 'Users'); await waitForRemoteChangeStatus(pageB); @@ -107,8 +109,9 @@ async function loadSampleAndConverge(pageA: Page, pageB: Page) { /** Add a domain through the real UI (the same gesture a user makes). */ async function addDomain(page: Page, name: string) { + await openDesignerPanels(page, '#domain-name-input'); await page.fill('#domain-name-input', name); - await page.click('#add-domain-btn'); + await clickInPanels(page, '#add-domain-btn'); } describe('serviceManagement multi-tab write-event sync (JUM-485)', () => { @@ -176,10 +179,11 @@ describe('serviceManagement multi-tab write-event sync (JUM-485)', () => { // Remote applies never import the selection (JUM-485 question 3), so B // selects the sample domain through the real UI — the same gesture a // human makes — before its context form enables. - await pageB.click('#domain-list li button'); + await clickInPanels(pageB, '#domain-list li button'); // B is mid-form: an unsaved value sits, focused, in the owner-team input. - await pageB.click('#domain-owner-team-input'); + await clickInPanels(pageB, '#domain-owner-team-input'); + await openDesignerPanels(pageB, '#domain-owner-team-input'); await pageB.fill('#domain-owner-team-input', 'team-z-unsaved'); // A remote change touches the same model B is editing. diff --git a/apps/backend-template/test/integration/ServiceManagement/offlinePersistenceMatrix.browser.integration.test.ts b/apps/backend-template/test/integration/ServiceManagement/offlinePersistenceMatrix.browser.integration.test.ts index 8589f97ff..f2cba642b 100644 --- a/apps/backend-template/test/integration/ServiceManagement/offlinePersistenceMatrix.browser.integration.test.ts +++ b/apps/backend-template/test/integration/ServiceManagement/offlinePersistenceMatrix.browser.integration.test.ts @@ -56,6 +56,8 @@ import { envFileContent, startServer, staticRoot, + clickInPanels, + openDesignerPanels, stopServer, waitForServer } from './serverHarness'; @@ -247,10 +249,29 @@ async function waitForHealthyBoot(page: Page) { ); } +async function gotoServiceManagementShell(page: Page, url: string, port?: number) { + const attempts = 2; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + if (port) { + await waitForServer(port); + } + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 }); + return; + } catch (error) { + if (attempt === attempts) { + throw error; + } + await page.waitForTimeout(500); + } + } +} + /** Add a domain through the real UI and wait until the write is durable. */ async function addDomainThroughUi(page: Page, name: string) { + await openDesignerPanels(page, '#domain-name-input'); await page.fill('#domain-name-input', name); - await page.click('#add-domain-btn'); + await clickInPanels(page, '#add-domain-btn'); await page.waitForFunction( (domainName) => new Promise((resolve) => { const request = indexedDB.open('service-management'); @@ -277,7 +298,7 @@ async function waitForDomainRendered(page: Page, name: string) { await page.waitForFunction( (domainName) => (document.getElementById('domain-list')?.textContent || '').includes(domainName), name, - { polling: 250, timeout: 15000 } + { polling: 250, timeout: 30000 } ); } @@ -409,10 +430,10 @@ describe('serviceManagement offline/online persistence matrix on Cana (JUM-486)' it('offline edits persist against Cana and survive reload; coming back online loses nothing', async () => { expect.hasAssertions(); - // Own server on a pinned port: the origin must survive the offline period. + // Own server whose origin must survive the offline period. const offlineTempDir = createTempConfigDir({ '.env.dev': envFileContent('express') }); - const port = 4400 + Math.floor(Math.random() * 400); - let offlineServer: StartedServer | undefined = await startPinnedServer(offlineTempDir, port); + let offlineServer: StartedServer | undefined = await startServer(offlineTempDir); + const { port } = offlineServer; await waitForServer(port); const offlineUrl = `http://127.0.0.1:${String(port)}/`; const context = await browser!.newContext(); @@ -422,7 +443,7 @@ describe('serviceManagement offline/online persistence matrix on Cana (JUM-486)' page.on('download', (download) => downloads.push(download.suggestedFilename())); try { // Clean online boot: first-run save durable, shell precached. - await page.goto(offlineUrl, { waitUntil: 'load' }); + await gotoServiceManagementShell(page, offlineUrl, port); await waitForHealthyBoot(page); await page.evaluate(() => navigator.serviceWorker.ready.then(() => undefined)); await page.waitForFunction( @@ -477,7 +498,7 @@ describe('serviceManagement offline/online persistence matrix on Cana (JUM-486)' // offline). Anything else — a boot exception, a shell-cache miss — // fails the cell. expect(pageErrors.filter( - (message) => !/FetchEvent\.respondWith|Fetch API cannot load/.test(message) + (message) => !/FetchEvent\.respondWith|Fetch API cannot load|503 \(Service Unavailable\)/.test(message) )).toStrictEqual([]); } finally { stopServer(offlineServer); @@ -489,8 +510,8 @@ describe('serviceManagement offline/online persistence matrix on Cana (JUM-486)' it('a verified migration survives an offline period without re-running (idempotence)', async () => { expect.hasAssertions(); const offlineTempDir = createTempConfigDir({ '.env.dev': envFileContent('express') }); - const port = 4900 + Math.floor(Math.random() * 400); - let offlineServer: StartedServer | undefined = await startPinnedServer(offlineTempDir, port); + let offlineServer: StartedServer | undefined = await startServer(offlineTempDir); + const { port } = offlineServer; await waitForServer(port); const offlineUrl = `http://127.0.0.1:${String(port)}/`; const context = await browser!.newContext(); @@ -506,7 +527,7 @@ describe('serviceManagement offline/online persistence matrix on Cana (JUM-486)' page.on('download', (download) => downloads.push(download.suggestedFilename())); try { // Online boot with a legacy payload: the one-way migration runs once. - await page.goto(offlineUrl, { waitUntil: 'load' }); + await gotoServiceManagementShell(page, offlineUrl, port); await page.waitForFunction( (markerKey) => window.localStorage.getItem(markerKey) !== null, MARKER_KEY, @@ -559,7 +580,7 @@ describe('serviceManagement offline/online persistence matrix on Cana (JUM-486)' // Same tolerated-noise rule as the other offline cell: only the dead // server's own network-layer complaints may appear. expect(pageErrors.filter( - (message) => !/FetchEvent\.respondWith|Fetch API cannot load/.test(message) + (message) => !/FetchEvent\.respondWith|Fetch API cannot load|503 \(Service Unavailable\)/.test(message) )).toStrictEqual([]); } finally { stopServer(offlineServer); @@ -707,8 +728,9 @@ describe('serviceManagement offline/online persistence matrix on Cana (JUM-486)' // An edit in this session is doomed: there is nothing behind the store // to write to. The startup declaration above is the user-facing warning // for this environment; persistence is proven by the reload below. + await openDesignerPanels(page, '#domain-name-input'); await page.fill('#domain-name-input', 'DoomedDomain'); - await page.click('#add-domain-btn'); + await clickInPanels(page, '#add-domain-btn'); // Proof the edit was never silently persisted: a fresh page on the same // origin loses it and the declared state recurs instead of a phantom @@ -955,7 +977,7 @@ describe('serviceManagement offline/online persistence matrix on Cana (JUM-486)' // The backup/export path is reachable from the warned session. const exportDownload = page.waitForEvent('download', { timeout: 15000 }); - await page.click('#export-json-btn'); + await clickInPanels(page, '#export-json-btn'); await exportDownload; expect(downloads).toContain('domain-designer.json'); @@ -970,8 +992,9 @@ describe('serviceManagement offline/online persistence matrix on Cana (JUM-486)' + ' retryable: false' + ' }' + '}'); + await openDesignerPanels(page, '#domain-name-input'); await page.fill('#domain-name-input', 'QuotaDoomedDomain'); - await page.click('#add-domain-btn'); + await clickInPanels(page, '#add-domain-btn'); // No silent acceptance: JUM-485's save-outcome hook surfaces the // unconfirmed save and reconciles by read-back. await waitForStatusLogged(page, 'could not be confirmed', 45000); diff --git a/apps/backend-template/test/integration/ServiceManagement/pm2Ecosystem.integration.test.ts b/apps/backend-template/test/integration/ServiceManagement/pm2Ecosystem.integration.test.ts index da29dae11..c2ae3eaed 100644 --- a/apps/backend-template/test/integration/ServiceManagement/pm2Ecosystem.integration.test.ts +++ b/apps/backend-template/test/integration/ServiceManagement/pm2Ecosystem.integration.test.ts @@ -75,6 +75,41 @@ function ecosystemSource(apps: Array<{ name: string; marker: string }>) { return `module.exports = {\n apps: [\n${entries}\n ]\n};\n`; } +function pm2ModuleSource(processes: unknown[], dumpPath: string) { + return `const fs = require('fs'); +const state = { + processes: ${JSON.stringify(processes)}, + actions: [] +}; +function record(entry) { + state.actions.push(entry); + try { + fs.writeFileSync(${JSON.stringify(dumpPath)}, JSON.stringify(state.actions), 'utf8'); + } catch (_error) { /* ignore */ } +} +module.exports = { + connect(callback) { callback(null); }, + list(callback) { callback(null, state.processes); }, + start(target, optsOrCb, maybeCb) { + const opts = typeof optsOrCb === 'function' ? undefined : optsOrCb; + const callback = typeof optsOrCb === 'function' ? optsOrCb : maybeCb; + record({ method: 'start', target, opts: opts || null }); + if (typeof callback === 'function') callback(null); + }, + stop(target, callback) { + record({ method: 'stop', target }); + if (typeof callback === 'function') callback(null); + }, + restart(target, callback) { + record({ method: 'restart', target }); + if (typeof callback === 'function') callback(null); + }, + disconnect() {}, + __dumpActions() { return state.actions.slice(); } +}; +`; +} + describe('service management PM2 ecosystem preview API (JUM-480)', () => { let configDir: string; let pm2Dir: string; @@ -83,17 +118,42 @@ describe('service management PM2 ecosystem preview API (JUM-480)', () => { beforeAll(async () => { configDir = createTempConfigDir({ '.env.dev': envFileContent('express') }); pm2Dir = createTempPm2Dir({ - 'ecosystem.dev.cjs': ecosystemSource([ + 'ecosystem.dev.config.cjs': ecosystemSource([ { name: 'jumentix-dev-restapi', marker: 'rest-marker' }, { name: 'jumentix-dev-service-management', marker: 'sm-marker' } ]), - 'ecosystem.staging.cjs': ecosystemSource([{ name: 'jumentix-staging-restapi', marker: 'staging-marker' }]), - 'ecosystem.production.cjs': ecosystemSource([{ name: 'jumentix-prod-restapi', marker: 'prod-marker' }]) + 'ecosystem.staging.config.cjs': ecosystemSource([{ name: 'jumentix-staging-restapi', marker: 'staging-marker' }]), + 'ecosystem.production.config.cjs': ecosystemSource([{ name: 'jumentix-prod-restapi', marker: 'prod-marker' }]) // No ecosystem.ci.cjs on purpose: the missing-file state is an // acceptance criterion, asserted below. }); - server = await startServer(configDir, { JUMENTIX_SERVICE_MANAGEMENT_PM2_DIR: pm2Dir }); + const pm2ModulePath = path.join(pm2Dir, 'pm2-fixture.cjs'); + const pm2ActionsDump = path.join(pm2Dir, 'pm2-actions.json'); + fs.writeFileSync(pm2ModulePath, pm2ModuleSource([ + { + name: 'jumentix-dev-restapi', + pm_id: 1, + monit: { cpu: 3.5, memory: 52428800 }, + pm2_env: { + name: 'jumentix-dev-restapi', + namespace: 'default', + status: 'online', + restart_time: 2, + unstable_restarts: 0, + pm_uptime: Date.now() - 120000, + pm_exec_path: './apps/backend-template/src/interface/HTTP/adapters/start-rest-api.ts', + exec_interpreter: 'bun', + watch: true, + axm_monitor: { latency: { value: '12ms' } } + } + } + ], pm2ActionsDump), 'utf8'); + server = await startServer(configDir, { + JUMENTIX_SERVICE_MANAGEMENT_PM2_DIR: pm2Dir, + JUMENTIX_SERVICE_MANAGEMENT_PM2_MODULE: pm2ModulePath + }); await waitForServer(server.port); + (globalThis as any).__pm2ActionsDump = pm2ActionsDump; }); afterAll(() => { @@ -111,7 +171,7 @@ describe('service management PM2 ecosystem preview API (JUM-480)', () => { ); expect(status).toBe(200); expect(body.environment).toBe('dev'); - expect(body.fileName).toBe('ecosystem.dev.cjs'); + expect(body.fileName).toBe('ecosystem.dev.config.cjs'); expect(body.exists).toBe(true); expect(body.apps.map((app) => app.name)).toStrictEqual([ 'jumentix-dev-restapi', @@ -123,17 +183,162 @@ describe('service management PM2 ecosystem preview API (JUM-480)', () => { // The command derives from the ecosystem definition (file + app name). expect(restApi.command).toContain('pm2 start '); expect(restApi.command).toContain('--only jumentix-dev-restapi --update-env'); - expect(restApi.command).toContain('ecosystem.dev.cjs'); + expect(restApi.command).toContain('ecosystem.dev.config.cjs'); // No package-manager invocation may be embedded (JUM-33/JUM-40 hazard). expect(restApi.command).not.toContain('pnpm'); expect(restApi.command).not.toContain('bun run'); expect(restApi.command).not.toContain('npm run'); }); + it('collects runtime process metrics through the PM2 API', async () => { + expect.hasAssertions(); + const { status, body } = await requestJson( + server!.port, + 'GET', + '/api/runtime/pm2-metrics?environment=dev' + ); + expect(status).toBe(200); + expect(body.source).toBe('pm2'); + expect(body.environment).toBe('dev'); + expect(body.summary.processCount).toBe(1); + expect(body.summary.onlineCount).toBe(1); + expect(body.summary.totalCpuPercent).toBe(3.5); + expect(body.summary.totalMemoryBytes).toBe(52428800); + expect(body.ecosystem.missingExpected).toContain('jumentix-dev-service-management'); + expect(body.processes[0]).toMatchObject({ + name: 'jumentix-dev-restapi', + pmId: 1, + status: 'online', + cpuPercent: 3.5, + memoryBytes: 52428800, + restartCount: 2, + interpreter: 'bun', + watching: true, + customMetrics: { latency: '12ms' } + }); + expect(body.host).toBeTruthy(); + expect(body.host.cpu).toBeTruthy(); + expect(body.host.memory).toBeTruthy(); + expect(Array.isArray(body.host.disk)).toBe(true); + expect(typeof body.summary.asyncContextActiveSum).toBe('number'); + expect(body.processes[0].diskIo).toBeTruthy(); + expect(typeof body.processes[0].diskIo.supported).toBe('boolean'); + expect(body.processes[0].diskIo.platform).toBeTruthy(); + }); + + it('streams Contract 1c-shaped metrics over WebSocket and accepts process actions', async () => { + expect.hasAssertions(); + // Node 22+ provides a WHATWG WebSocket global; avoid importing the `ws` + // package from the backend-template package boundary. + const result = await new Promise<{ metrics: any; action: any }>((resolve, reject) => { + const socket = new WebSocket(`ws://127.0.0.1:${server!.port}/api/runtime/pm2-ws`); + let metricsFrame: any = null; + const timer = setTimeout(() => { + socket.close(); + reject(new Error('WebSocket metrics/action timeout')); + }, 8000); + socket.addEventListener('open', () => { + socket.send(JSON.stringify({ + type: 'subscribe', + environment: 'dev', + intervalMs: 2000 + })); + }); + socket.addEventListener('message', (event) => { + const message = JSON.parse(String((event as MessageEvent).data)); + if (message.type === 'metrics' && !metricsFrame) { + metricsFrame = message; + socket.send(JSON.stringify({ + type: 'action', + action: 'restart', + scope: 'process', + name: 'jumentix-dev-restapi', + pmId: 1 + })); + return; + } + if (message.type === 'action-result' && metricsFrame) { + clearTimeout(timer); + socket.close(); + resolve({ metrics: metricsFrame, action: message }); + } + }); + socket.addEventListener('error', () => { + clearTimeout(timer); + reject(new Error('WebSocket connection error')); + }); + }); + expect(result.metrics.type).toBe('metrics'); + expect(result.metrics.payload.source).toBe('pm2'); + expect(result.metrics.payload.host).toBeTruthy(); + expect(result.action.type).toBe('action-result'); + expect(result.action.ok).toBe(true); + expect(result.action.action).toBe('restart'); + }); + + it('starts a stopped process by name instead of ecosystem --only', async () => { + expect.hasAssertions(); + const dumpPath = (globalThis as any).__pm2ActionsDump as string; + if (fs.existsSync(dumpPath)) fs.unlinkSync(dumpPath); + const result = await new Promise<{ action: any }>((resolve, reject) => { + const socket = new WebSocket(`ws://127.0.0.1:${server!.port}/api/runtime/pm2-ws`); + const timer = setTimeout(() => { + socket.close(); + reject(new Error('WebSocket start-after-stop timeout')); + }, 8000); + let subscribed = false; + socket.addEventListener('open', () => { + socket.send(JSON.stringify({ + type: 'subscribe', + environment: 'dev', + intervalMs: 2000 + })); + }); + socket.addEventListener('message', (event) => { + const message = JSON.parse(String((event as MessageEvent).data)); + if (message.type === 'metrics' && !subscribed) { + subscribed = true; + socket.send(JSON.stringify({ + type: 'action', + action: 'start', + scope: 'process', + name: 'jumentix-dev-restapi', + pmId: 1 + })); + return; + } + if (message.type === 'action-result') { + clearTimeout(timer); + socket.close(); + resolve({ action: message }); + } + }); + socket.addEventListener('error', () => { + clearTimeout(timer); + reject(new Error('WebSocket connection error')); + }); + }); + expect(result.action.ok).toBe(true); + expect(result.action.action).toBe('start'); + const actions = JSON.parse(fs.readFileSync(dumpPath, 'utf8')) as Array<{ + method: string; + target: string; + opts: unknown; + }>; + const startCalls = actions.filter((entry) => entry.method === 'start'); + expect(startCalls.length).toBeGreaterThan(0); + expect(startCalls.some((entry) => ( + entry.target === 'jumentix-dev-restapi' && entry.opts === null + ))).toBe(true); + expect(startCalls.every((entry) => ( + typeof entry.target === 'string' && !String(entry.target).includes('ecosystem.') + ))).toBe(true); + }); + it('reflects an ecosystem edit with no code change and no server restart', async () => { expect.hasAssertions(); fs.writeFileSync( - path.join(pm2Dir, 'ecosystem.dev.cjs'), + path.join(pm2Dir, 'ecosystem.dev.config.cjs'), ecosystemSource([ { name: 'jumentix-dev-restapi', marker: 'rest-marker' }, { name: 'jumentix-dev-service-management', marker: 'sm-marker' }, @@ -158,7 +363,7 @@ describe('service management PM2 ecosystem preview API (JUM-480)', () => { '/api/runtime/pm2-ecosystem?environment=staging' ); expect(staging.status).toBe(200); - expect(staging.body.fileName).toBe('ecosystem.staging.cjs'); + expect(staging.body.fileName).toBe('ecosystem.staging.config.cjs'); expect(staging.body.apps.map((app) => app.name)).toStrictEqual(['jumentix-staging-restapi']); const production = await requestJson( @@ -167,7 +372,7 @@ describe('service management PM2 ecosystem preview API (JUM-480)', () => { '/api/runtime/pm2-ecosystem?environment=production' ); expect(production.status).toBe(200); - expect(production.body.fileName).toBe('ecosystem.production.cjs'); + expect(production.body.fileName).toBe('ecosystem.production.config.cjs'); expect(production.body.apps.map((app) => app.name)).toStrictEqual(['jumentix-prod-restapi']); const prodAlias = await requestJson( @@ -176,7 +381,7 @@ describe('service management PM2 ecosystem preview API (JUM-480)', () => { '/api/runtime/pm2-ecosystem?environment=prod' ); expect(prodAlias.status).toBe(200); - expect(prodAlias.body.fileName).toBe('ecosystem.production.cjs'); + expect(prodAlias.body.fileName).toBe('ecosystem.production.config.cjs'); }); it('reports a missing ecosystem file as an explicit state, not a silent empty preview or a 500', async () => { @@ -209,7 +414,7 @@ describe('service management PM2 ecosystem preview API (JUM-480)', () => { it('surfaces a broken ecosystem file as the honest 500 envelope with code and path', async () => { expect.hasAssertions(); - fs.writeFileSync(path.join(pm2Dir, 'ecosystem.staging.cjs'), 'module.exports = { apps: [', 'utf8'); + fs.writeFileSync(path.join(pm2Dir, 'ecosystem.staging.config.cjs'), 'module.exports = { apps: [', 'utf8'); const { status, body } = await requestJson( server!.port, 'GET', @@ -218,14 +423,14 @@ describe('service management PM2 ecosystem preview API (JUM-480)', () => { expect(status).toBe(500); expect(body.error).toBe('PM2 ecosystem file operation failed.'); expect(body.code).toBeTruthy(); - expect(body.path).toContain('ecosystem.staging.cjs'); + expect(body.path).toContain('ecosystem.staging.config.cjs'); expect(body.details).toContain('Could not load PM2 ecosystem file'); }); it('resolves the repository pm2/ directory by default when the override is unset', async () => { expect.hasAssertions(); // A second server WITHOUT JUMENTIX_SERVICE_MANAGEMENT_PM2_DIR must find the - // repo's real pm2/ecosystem.dev.cjs — the pinned default resolution, same + // repo's real pm2/ecosystem.dev.config.cjs — the pinned default resolution, same // discipline as the config directory (Requirement 126 §2). const defaultServer = await startServer(configDir); try { @@ -237,7 +442,7 @@ describe('service management PM2 ecosystem preview API (JUM-480)', () => { ); expect(status).toBe(200); expect(body.exists).toBe(true); - expect(body.path).toBe('pm2/ecosystem.dev.cjs'); + expect(body.path).toBe('pm2/ecosystem.dev.config.cjs'); expect(body.apps.map((app) => app.name)).toContain('jumentix-dev-restapi'); expect(body.apps.map((app) => app.name)).toContain('jumentix-dev-service-management'); } finally { diff --git a/apps/backend-template/test/integration/ServiceManagement/serverHarness.ts b/apps/backend-template/test/integration/ServiceManagement/serverHarness.ts index ee64a44bc..53bf0f18f 100644 --- a/apps/backend-template/test/integration/ServiceManagement/serverHarness.ts +++ b/apps/backend-template/test/integration/ServiceManagement/serverHarness.ts @@ -17,6 +17,14 @@ import os from 'node:os'; const { syncServiceManagementDesignerCore } = require( path.resolve(process.cwd(), 'ci-cd', 'sync-service-management-designer-core.js') ) as { syncServiceManagementDesignerCore: (options: { root: string }) => number }; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { syncServiceManagementD3 } = require( + path.resolve(process.cwd(), 'ci-cd', 'sync-service-management-d3.js') +) as { syncServiceManagementD3: (options?: { root?: string }) => number }; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { syncServiceManagementCanaBundle } = require( + path.resolve(process.cwd(), 'ci-cd', 'sync-service-management-cana-bundle.js') +) as { syncServiceManagementCanaBundle: (options?: { root?: string }) => number }; export const serverPath = path.resolve(process.cwd(), 'apps/service-management/server.js'); export const staticRoot = path.resolve(process.cwd(), 'apps/service-management'); @@ -173,6 +181,12 @@ function spawnServerProcess( ): SpawnedServer { const env: NodeJS.ProcessEnv = { ...process.env, + // Live reload is a development affordance and not what any of these + // suites exercise (JUM-747). Left on, its client opens an EventSource the + // offline suites then watch fail, and its file watcher would reload the + // page under a test that is mid-interaction. A suite that wants it can + // turn it back on through `envOverrides`. + JUMENTIX_SERVICE_MANAGEMENT_LIVE_RELOAD: '0', ...envOverrides, JUMENTIX_SERVICE_MANAGEMENT_PORT: String(port) }; @@ -290,6 +304,10 @@ export async function startServer( envOverrides: Record = {}, options: { pinnedPort?: number; maxAttempts?: number } = {} ): Promise { + const canaSyncResult = syncServiceManagementCanaBundle({ root: process.cwd() }); + if (canaSyncResult !== 0) { + throw new Error('Cana vendor sync failed; the SPA cannot boot without it.'); + } // The SPA statically imports the designer core through the import map's // `@jumentix/designer-core/` prefix (JUM-493), which resolves to the // vendored, gitignored module tree. Booting without it is a module-load @@ -301,6 +319,10 @@ export async function startServer( if (syncResult !== 0) { throw new Error('designer-core vendor sync failed; the SPA cannot boot without it.'); } + const d3SyncResult = syncServiceManagementD3({ root: process.cwd() }); + if (d3SyncResult !== 0) { + throw new Error('d3 vendor sync failed; Monitoring charts cannot boot without it.'); + } return runWithPortRetry({ pinnedPort: options.pinnedPort, maxAttempts: options.maxAttempts, @@ -443,3 +465,139 @@ export function probeConnection( }); }); } + +/** + * Open the Domain Designer's panel drawer (JUM-737). + * + * The panels used to be a permanent column; they are now an overlay that the + * canvas gets back when it is closed, and closed is the default. Every suite + * that reaches for a sidebar control — the export buttons, the domain and + * entity forms, the model check — has to open it first, the same way a person + * does. + * + * Idempotent, and it waits for the drawer to actually be open: the drawer + * animates, and clicking a control mid-transition is the kind of flake that + * only shows up on a loaded CI runner. + */ +/** The slice of a Playwright page these helpers drive. */ +type DrawerPage = { + click: (target: string) => Promise; + waitForSelector: (target: string, options?: Record) => Promise; + $: (target: string) => Promise; + evaluate: (fn: (target: string) => T, arg: string) => Promise; +}; + +export async function openDesignerPanels( + page: DrawerPage, + target?: string +): Promise { + // Wait for the app to finish booting before touching the drawer: the state + // load is async and re-renders the view when it lands, so a click between + // `load` and that render is undone — a window a person cannot hit and an + // automated click hits every time. + await page.waitForSelector('body[data-designer-ready="true"]'); + + /** Open the drawer and select the group holding `target`, once. */ + const reveal = async (): Promise => page.evaluate((selector) => { + // The drawer lives inside the Domain Designer tab section, and an + // inactive section is `display: none` — its contents then have no box at + // all, so a control can be "not hidden" and still unclickable. Bring the + // tab forward first. + const designerSection = document.getElementById('tab-domain-designer'); + if (designerSection && !designerSection.classList.contains('active')) { + (document.getElementById('tab-domain-designer-btn') as HTMLElement | null)?.click(); + } + + const drawer = document.getElementById('designer-sidebar'); + const toggle = document.getElementById('toggle-sidebar-btn'); + if (drawer && !drawer.classList.contains('open')) toggle?.click(); + + const element = selector ? document.querySelector(selector) : null; + const panel = element?.closest('[data-sidebar-group]') as HTMLElement | null; + const group = panel?.dataset.sidebarGroup; + if (group) { + const tab = document.querySelector(`[data-sidebar-tab="${group}"]`) as HTMLElement | null; + if (tab?.getAttribute('aria-selected') !== 'true') tab?.click(); + } + + const designerActive = Boolean(designerSection?.classList.contains('active')); + // The box is the only thing that decides clickability: an element can be + // in an unhidden panel and still have no area, which is what every + // "resolved the locator, element is not visible" timeout comes down to. + const rect = (element as HTMLElement | null)?.getBoundingClientRect(); + const hasArea = Boolean(rect && rect.width > 0 && rect.height > 0); + const chain: string[] = []; + for (let node = element as HTMLElement | null; node; node = node.parentElement) { + const style = getComputedStyle(node); + if (style.display === 'none' || style.visibility === 'hidden') { + chain.push(`${node.tagName}#${node.id || ''}.${node.className || ''}:${style.display}/${style.visibility}`); + } + if (node === document.body) break; + } + const reachable = hasArea + && designerActive + && Boolean(element) + && panel?.hidden === false + && Boolean(drawer?.classList.contains('open')); + return JSON.stringify({ + reachable: selector + ? reachable + : designerActive && Boolean(drawer?.classList.contains('open')), + designerActive, + hasArea, + hiddenAncestors: chain.slice(0, 4), + found: Boolean(element), + panelGroup: group ?? null, + panelHidden: panel?.hidden ?? null, + drawerOpen: drawer?.classList.contains('open') ?? null, + activeTab: document.querySelector('.sidebar-tab.active')?.getAttribute('data-sidebar-tab') + ?? null + }); + }, target || ''); + + // Retried rather than done once: a save result landing after the click + // re-renders the view from the stored payload, which can still be the one + // written before the group changed, and reverts it. Driving the DOM through + // one evaluate keeps each attempt atomic. + let last = ''; + /* eslint-disable no-await-in-loop -- each attempt must observe the result of + the previous one; that is the point of the retry. */ + for (let attempt = 0; attempt < 20; attempt += 1) { + last = await reveal() as string; + if (JSON.parse(last).reachable) { + // Held for two frames: a revert that arrives immediately after would + // otherwise be handed to the caller as success. + await new Promise((resolve) => { setTimeout(resolve, 120); }); + last = await reveal() as string; + if (JSON.parse(last).reachable) return; + } + await new Promise((resolve) => { setTimeout(resolve, 150); }); + } + /* eslint-enable no-await-in-loop */ + + throw new Error(`openDesignerPanels could not reveal ${target ?? 'the drawer'}: ${last}`); +} + +/** + * Click a control inside the panel drawer. + * + * `page.click` waits for its own actionability model to agree the element is + * clickable, and inside an overlay that animates on `transform` and + * `visibility` it can keep reporting "element is not visible" for a control + * that the page itself reports as visible, with a real box, in an open drawer. + * The reachability that matters is asserted by `openDesignerPanels` — drawer + * open, panel not hidden, non-empty box, designer tab active — so the click + * itself is dispatched in the page. + */ +export async function clickInPanels( + page: DrawerPage, + selector: string +): Promise { + await openDesignerPanels(page, selector); + const clicked = await page.evaluate((wanted) => { + const element = document.querySelector(wanted) as HTMLElement | null; + element?.click(); + return Boolean(element); + }, selector); + if (!clicked) throw new Error(`clickInPanels found no element for ${selector}`); +} diff --git a/apps/backend-template/test/integration/ServiceManagement/spaBoot.browser.integration.test.ts b/apps/backend-template/test/integration/ServiceManagement/spaBoot.browser.integration.test.ts index 582e093c6..a15799603 100644 --- a/apps/backend-template/test/integration/ServiceManagement/spaBoot.browser.integration.test.ts +++ b/apps/backend-template/test/integration/ServiceManagement/spaBoot.browser.integration.test.ts @@ -6,7 +6,7 @@ * runs) against the REAL server. No DOM shims, no fakes (Requirement 115). * * Pins: - * - The SPA boots and each of the four tabs renders without console errors. + * - The SPA boots and each of the six tabs renders without console errors. * - The export quality gate (Requirement 126 §5): with * `view.exportBlockCritical` true (the default), an export is refused while * model validation reports any error-severity issue; lifting the gate on @@ -15,11 +15,13 @@ */ import { webkit } from 'playwright-webkit'; import type { Browser } from 'playwright-webkit'; +import fs from 'node:fs'; import { createTempConfigDir, cleanupTempConfigDir, envFileContent, startServer, + clickInPanels, stopServer, waitForServer } from './serverHarness'; @@ -29,7 +31,9 @@ const TABS = [ 'domain-designer', 'interface-designer', 'service-config', - 'deploy-management' + 'deploy-management', + 'monitoring', + 'code-workspace' ]; // Requirement 126 §4: the whole suite state lives under this single key. @@ -88,7 +92,7 @@ describe('serviceManagement SPA boot and export gate (JUM-466)', () => { cleanupTempConfigDir(tempDir); }); - it('boots the SPA and renders all four tabs without console errors', async () => { + it('boots the SPA and renders all six tabs without console errors', async () => { expect.hasAssertions(); const context = await browser!.newContext(); const page = await context.newPage(); @@ -96,6 +100,11 @@ describe('serviceManagement SPA boot and export gate (JUM-466)', () => { page.on('console', (message) => { if (message.type() === 'error') consoleErrors.push(message.text()); }); + page.on('response', (response) => { + if (response.status() >= 400) { + consoleErrors.push(`HTTP ${String(response.status())}: ${response.url()}`); + } + }); page.on('pageerror', (error) => consoleErrors.push(String(error))); await page.goto(baseUrl, { waitUntil: 'load' }); @@ -108,6 +117,70 @@ describe('serviceManagement SPA boot and export gate (JUM-466)', () => { await context.close(); }, 60000); + it('exports edits made in the Code Workspace boilerplate files', async () => { + expect.hasAssertions(); + const context = await browser!.newContext(); + const page = await context.newPage(); + await page.goto(baseUrl, { waitUntil: 'load' }); + await page.click('#quick-add-domain-btn'); + await page.click('#quick-add-entity-btn'); + await page.click('#tab-code-workspace-btn'); + await page.waitForSelector('#code-workspace-file-list button[data-file-path]', { state: 'attached' }); + + const activePath = await page.$eval( + '.code-editor-tab.active[data-file-path]', + (el) => (el as HTMLElement).dataset.filePath || '' + ); + expect(activePath).toBeTruthy(); + const editedContent = '// edited in the code workspace\nexport const jumentixWorkspaceEdit = true;\n'; + await page.evaluate(({ content, path: filePath }) => { + const editor = document.querySelector('#code-workspace-editor'); + if (!editor) throw new Error('Code Workspace fallback editor not found'); + editor.value = content; + editor.dispatchEvent(new InputEvent('input', { + bubbles: true, + data: content, + inputType: 'insertText' + })); + + const { monaco } = window as Window & { + monaco?: { + editor?: { + getModels?: () => Array<{ + getValue: () => string; + setValue: (value: string) => void; + uri: { toString: () => string }; + }>; + }; + }; + }; + const encodedPath = filePath.split('/').map(encodeURIComponent).join('/'); + const modelUri = `file:///jumentix-generated/${encodedPath}`; + const model = monaco?.editor?.getModels?.().find( + (candidate) => candidate.uri.toString() === modelUri + ); + if (model && model.getValue() !== content) model.setValue(content); + }, { content: editedContent, path: activePath }); + await page.waitForFunction(() => { + return document.querySelector('#code-workspace-active-state')?.textContent?.trim() === 'edited'; + }, undefined, { timeout: 10000 }); + await page.click('#tab-domain-designer-btn'); + const [download] = await Promise.all([ + page.waitForEvent('download'), + clickInPanels(page, '#export-boilerplate-bundle-btn') + ]); + const filePath = await download.path(); + const exportedBundle = JSON.parse(fs.readFileSync(filePath!, 'utf8')); + const exportedFiles = exportedBundle.modules.flatMap((module: any) => [ + ...Object.values(module.files), + ...module.entities.flatMap((entity: any) => Object.values(entity.files)) + ]); + const editedFile = exportedFiles.find((file: any) => file.path === activePath); + expect(editedFile.content).toContain('jumentixWorkspaceEdit'); + expect(editedFile.workspaceState).toBe('edited'); + await context.close(); + }, 60000); + it('blocks export while error-severity issues exist and exports once the gate lifts', async () => { expect.hasAssertions(); @@ -115,9 +188,11 @@ describe('serviceManagement SPA boot and export gate (JUM-466)', () => { const cleanContext = await browser!.newContext(); const cleanPage = await cleanContext.newPage(); await cleanPage.goto(baseUrl, { waitUntil: 'load' }); + // The panels are an overlay now, closed by default (JUM-737): a suite that + // reaches for a sidebar control opens it first, the way a person does. const [download] = await Promise.all([ cleanPage.waitForEvent('download'), - cleanPage.click('#export-json-btn') + clickInPanels(cleanPage, '#export-json-btn') ]); expect(download.suggestedFilename()).toBe('domain-designer.json'); await cleanContext.close(); @@ -140,12 +215,16 @@ describe('serviceManagement SPA boot and export gate (JUM-466)', () => { await page.goto(baseUrl, { waitUntil: 'load' }); const backupDownload = await backupDownloadPromise; expect(backupDownload.suggestedFilename()).toMatch(/^service-management-v1-backup-.*\.json$/); + await clickInPanels(page, '#run-model-check-btn'); + await page.waitForFunction(() => { + return document.querySelector('#model-check-list')?.textContent?.includes('[ERROR]'); + }, undefined, { timeout: 10000 }); let downloadFired = false; page.on('download', () => { downloadFired = true; }); - await page.click('#export-json-btn'); + await clickInPanels(page, '#export-json-btn'); await page.waitForTimeout(1500); expect(downloadFired).toBe(false); @@ -154,10 +233,10 @@ describe('serviceManagement SPA boot and export gate (JUM-466)', () => { expect(findings).toContain('no primary key'); // Lifting the gate on the SAME broken model releases the export. - await page.click('#export-block-critical-check'); + await clickInPanels(page, '#export-block-critical-check'); const [relaxedDownload] = await Promise.all([ page.waitForEvent('download'), - page.click('#export-json-btn') + clickInPanels(page, '#export-json-btn') ]); expect(relaxedDownload.suggestedFilename()).toBe('domain-designer.json'); await brokenContext.close(); diff --git a/apps/backend-template/test/integration/ServiceManagement/statusOutcome.browser.integration.test.ts b/apps/backend-template/test/integration/ServiceManagement/statusOutcome.browser.integration.test.ts new file mode 100644 index 000000000..fc65f1969 --- /dev/null +++ b/apps/backend-template/test/integration/ServiceManagement/statusOutcome.browser.integration.test.ts @@ -0,0 +1,162 @@ +/* eslint-disable jest/prefer-expect-assertions, jest/max-expects */ +/* + * JUM-730 — the global status region reports the outcome of the action that + * just ran, in a REAL browser against the REAL server (Requirement 115), on + * the same harness as the other browser suites. + * + * The defect this pins: `#status-region` is `role="status" aria-live="polite"`, + * and only the refusal paths wrote to it. A successful action wrote nothing, so + * a previous failure stayed on screen and stayed announced. Measured before the + * fix: add `Zed` (succeeds, silent) → add `Zed` again (`Domain "Zed" already + * exists.`) → add `Yankee` (succeeds) left the failure text standing. + * + * The assertions read the rendered text a user and a screen reader would get, + * never a spy on a function. + */ +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import { webkit } from 'playwright-webkit'; +import type { Browser } from 'playwright-webkit'; +import { + createTempConfigDir, + cleanupTempConfigDir, + envFileContent, + startServer, + clickInPanels, + openDesignerPanels, + stopServer, + waitForServer +} from './serverHarness'; +import type { StartedServer } from './serverHarness'; + +const repoRoot = path.resolve(__dirname, '../../../../..'); + +describe('serviceManagement status region reports outcomes (JUM-730)', () => { + let tempDir: string; + let server: StartedServer | undefined; + let browser: Browser | undefined; + let baseUrl: string; + + beforeAll(async () => { + execFileSync('bun', ['ci-cd/sync-service-management-cana-bundle.js'], { + cwd: repoRoot, + stdio: 'inherit' + }); + execFileSync('bun', ['ci-cd/sync-service-management-designer-core.js'], { + cwd: repoRoot, + stdio: 'inherit' + }); + tempDir = createTempConfigDir({ '.env.dev': envFileContent('express') }); + server = await startServer(tempDir); + await waitForServer(server.port); + baseUrl = `http://127.0.0.1:${String(server.port)}/`; + browser = await webkit.launch({ headless: true }); + }, 90000); + + afterAll(async () => { + if (browser) await browser.close(); + stopServer(server); + cleanupTempConfigDir(tempDir); + }); + + async function statusText(page: import('playwright-webkit').Page): Promise { + return page.$eval('#status-region', (el) => (el.textContent || '').trim()); + } + + const addDomain = async (page: import('playwright-webkit').Page, name: string): Promise => { + await openDesignerPanels(page, '#domain-name-input'); + await page.fill('#domain-name-input', name); + await clickInPanels(page, '#add-domain-btn'); + await page.waitForTimeout(150); + }; + + it('replaces a failure with the outcome of the next successful action', async () => { + expect.hasAssertions(); + const context = await browser!.newContext(); + const page = await context.newPage(); + await page.goto(baseUrl, { waitUntil: 'load' }); + + await addDomain(page, 'Zed'); + await expect(statusText(page)).resolves.toBe('Domain "Zed" added.'); + + await addDomain(page, 'Zed'); + await expect(statusText(page)).resolves.toBe('Domain "Zed" already exists.'); + + // The defect: this success used to leave the failure above on screen. + await addDomain(page, 'Yankee'); + await expect(statusText(page)).resolves.toBe('Domain "Yankee" added.'); + + await context.close(); + }, 60000); + + it('explains an ignored empty name instead of leaving the previous message standing', async () => { + expect.hasAssertions(); + const context = await browser!.newContext(); + const page = await context.newPage(); + await page.goto(baseUrl, { waitUntil: 'load' }); + + await addDomain(page, 'Alpha'); + await addDomain(page, 'Alpha'); + await expect(statusText(page)).resolves.toBe('Domain "Alpha" already exists.'); + + await openDesignerPanels(page, '#domain-name-input'); + await page.fill('#domain-name-input', ' '); + await clickInPanels(page, '#add-domain-btn'); + await page.waitForTimeout(150); + + await expect(statusText(page)).resolves.toBe('Type a domain name before adding.'); + + await context.close(); + }, 60000); + + it('reports entity and field additions, which were silent before', async () => { + expect.hasAssertions(); + const context = await browser!.newContext(); + const page = await context.newPage(); + await page.goto(baseUrl, { waitUntil: 'load' }); + + await addDomain(page, 'Billing'); + await clickInPanels(page, '#domain-list li'); + await page.waitForTimeout(150); + + await openDesignerPanels(page, '#entity-name-input'); + await page.fill('#entity-name-input', 'Invoice'); + await clickInPanels(page, '#add-entity-btn'); + await page.waitForTimeout(200); + await expect(statusText(page)).resolves.toBe('Entity "Invoice" added to Billing.'); + + await openDesignerPanels(page, '#entity-search-input'); + await page.fill('#entity-search-input', 'Invoice'); + await clickInPanels(page, '#entity-search-btn'); + await page.waitForTimeout(200); + + await openDesignerPanels(page, '#field-name-input'); + await page.fill('#field-name-input', 'total'); + await clickInPanels(page, '#add-field-btn'); + await page.waitForTimeout(200); + await expect(statusText(page)).resolves.toBe('Field "total" added to Invoice.'); + + await context.close(); + }, 60000); + + it('reports a deletion, so the model change is announced', async () => { + expect.hasAssertions(); + const context = await browser!.newContext(); + const page = await context.newPage(); + page.on('dialog', async (dialog) => { + await dialog.accept(); + }); + await page.goto(baseUrl, { waitUntil: 'load' }); + + await addDomain(page, 'Scratch'); + await clickInPanels(page, '#domain-list li'); + await page.waitForTimeout(150); + + await clickInPanels(page, '#delete-domain-btn'); + await page.waitForTimeout(300); + + await expect(statusText(page)).resolves.toBe('Domain "Scratch" deleted.'); + + await context.close(); + }, 60000); +}); diff --git a/apps/backend-template/test/unit/service-management/rbacContract.test.ts b/apps/backend-template/test/unit/ServiceManagement/rbacContract.test.ts similarity index 100% rename from apps/backend-template/test/unit/service-management/rbacContract.test.ts rename to apps/backend-template/test/unit/ServiceManagement/rbacContract.test.ts diff --git a/apps/backend-template/test/unit/service-management/serverHarnessPorts.test.ts b/apps/backend-template/test/unit/ServiceManagement/serverHarnessPorts.test.ts similarity index 96% rename from apps/backend-template/test/unit/service-management/serverHarnessPorts.test.ts rename to apps/backend-template/test/unit/ServiceManagement/serverHarnessPorts.test.ts index 8d74183d7..9281b6785 100644 --- a/apps/backend-template/test/unit/service-management/serverHarnessPorts.test.ts +++ b/apps/backend-template/test/unit/ServiceManagement/serverHarnessPorts.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable import/no-relative-packages */ /* eslint-disable jest/prefer-expect-assertions, jest/max-expects, jest/no-conditional-in-test */ /* * JUM-628 — unit contract for the ServiceManagement harness port allocator @@ -175,10 +176,15 @@ describe('serverHarness runtime helpers against the real server (JUM-628)', () = const raw = await requestRaw(server.port, 'GET', '/api/runtime/env?environment=dev'); expect(raw.status).toBe(200); expect(raw.rawBody).toContain('"environment"'); - // A request carrying a body exercises the write path of requestRaw; the - // server answers with its honest rejection, not a hang. - const posted = await requestRaw(server.port, 'POST', '/api/runtime/env', '{}'); - expect([200, 400, 401, 403, 422]).toContain(posted.status); + // A request carrying a body exercises the write path of requestRaw; an + // empty values patch is a no-op save against the temp config directory. + const posted = await requestRaw( + server.port, + 'POST', + '/api/runtime/env', + JSON.stringify({ environment: 'dev', values: {} }) + ); + expect(posted.status).toBe(200); await expect(probeConnection('127.0.0.1', server.port)).resolves.toBe('connected'); // The machine may or may not expose a non-loopback address; both are // honest answers — the contract is the shape, not the value. diff --git a/apps/backend-template/test/unit/ci-cd/check-bun-version.test.ts b/apps/backend-template/test/unit/ci-cd/check-bun-version.test.ts index 53a9e6716..f0547c2e0 100644 --- a/apps/backend-template/test/unit/ci-cd/check-bun-version.test.ts +++ b/apps/backend-template/test/unit/ci-cd/check-bun-version.test.ts @@ -211,3 +211,29 @@ describe('check-bun-version input reading (JUM-721)', () => { } }); }); + +describe('check-bun-version manifest reading (JUM-821)', () => { + it('reads a manifest without packageManager as absent, not as a crash', () => { + expect.hasAssertions(); + + // A pre-migration checkout has a package.json with no `packageManager` + // key at all; the guard must report the missing declaration rather than + // read `undefined` into its comparison. + const fs = require('node:fs'); + const os = require('node:os'); + const path = require('node:path'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bun-version-')); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'cold-clone' })); + + const guard = require('../../../../../ci-cd/check-bun-version'); + const input = guard.readToolchainInput({ + versions: {}, + pinPath: '/nonexistent/.bun-version', + manifestPath: path.join(dir, 'package.json') + }); + + expect(input.declaredPackageManager).toBeNull(); + expect(guard.validateToolchain(input) + .some((entry: string) => entry.includes('packageManager is not set'))).toBe(true); + }); +}); diff --git a/apps/backend-template/test/unit/ci-cd/check-canonical-integrations.test.ts b/apps/backend-template/test/unit/ci-cd/check-canonical-integrations.test.ts index 61617ac96..0ac74eb1b 100644 --- a/apps/backend-template/test/unit/ci-cd/check-canonical-integrations.test.ts +++ b/apps/backend-template/test/unit/ci-cd/check-canonical-integrations.test.ts @@ -43,12 +43,12 @@ describe('check-canonical-integrations', () => { const sonarPath = integrationPath.join(fixtureRoot, 'sonar-project.properties'); const sonar = integrationFs.readFileSync(sonarPath, 'utf8') - .replace('sonar.projectKey=Jumentix', ''); + .replace('sonar.projectKey=web2solutions_Jumentix', ''); integrationFs.writeFileSync(sonarPath, sonar); expect(validateCanonicalIntegrations(fixtureRoot)).toContain( '[integrations] sonar-project.properties is missing marker: ' - + 'sonar.projectKey=Jumentix' + + 'sonar.projectKey=web2solutions_Jumentix' ); expect(run(fixtureRoot)).toBe(1); }); diff --git a/apps/backend-template/test/unit/ci-cd/check-ci-provider.test.ts b/apps/backend-template/test/unit/ci-cd/check-ci-provider.test.ts index d10d7ab8e..9f943ecfb 100644 --- a/apps/backend-template/test/unit/ci-cd/check-ci-provider.test.ts +++ b/apps/backend-template/test/unit/ci-cd/check-ci-provider.test.ts @@ -4,7 +4,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -/** Requirement 113 — repository-owned, zero-cost CI for the private repository. */ +/** Requirement 113 — free CI for the public open-source repository. */ const repoRoot = path.resolve(__dirname, '../../../../..'); const checker = path.join(repoRoot, 'ci-cd', 'check-ci-provider.js'); @@ -26,10 +26,13 @@ function fixture(change?: (directory: string) => void): string { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ci-provider-')); fs.mkdirSync(path.join(directory, 'ci-cd'), { recursive: true }); fs.mkdirSync(path.join(directory, '.github/workflows'), { recursive: true }); + fs.mkdirSync(path.join(directory, '.circleci'), { recursive: true }); fs.copyFileSync(checker, path.join(directory, 'ci-cd', 'check-ci-provider.js')); fs.copyFileSync(path.join(repoRoot, 'ci-cd', 'ensure-local-ci-services.sh'), path.join(directory, 'ci-cd', 'ensure-local-ci-services.sh')); fs.copyFileSync(path.join(repoRoot, 'ci-cd', 'ensure-docker-runtime.sh'), path.join(directory, 'ci-cd', 'ensure-docker-runtime.sh')); fs.copyFileSync(path.join(repoRoot, '.github/workflows/ci.yml'), path.join(directory, '.github/workflows/ci.yml')); + fs.copyFileSync(path.join(repoRoot, '.circleci/config.yml'), path.join(directory, '.circleci/config.yml')); + fs.copyFileSync(path.join(repoRoot, 'sonar-project.properties'), path.join(directory, 'sonar-project.properties')); fs.copyFileSync(path.join(repoRoot, 'package.json'), path.join(directory, 'package.json')); change?.(directory); return directory; @@ -41,7 +44,7 @@ describe('check-ci-provider', () => { const result = run(repoRoot); expect(result.code).toBe(0); - expect(result.output).toContain('GitHub Actions covers'); + expect(result.output).toContain('GitHub Actions and CircleCI cover'); }); it('fails when the repository-owned GitHub Actions workflow is absent', () => { @@ -51,15 +54,13 @@ describe('check-ci-provider', () => { expect(run(directory).output).toContain('Missing required GitHub Actions workflow'); }); - it('fails when CircleCI is re-enabled', () => { + it('fails when CircleCI is absent', () => { expect.hasAssertions(); const directory = fixture((root) => { - const file = path.join(root, '.circleci/config.yml'); - fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync(file, 'version: 2.1\n'); + fs.unlinkSync(path.join(root, '.circleci/config.yml')); }); - expect(run(directory).output).toContain('CircleCI is disabled'); + expect(run(directory).output).toContain('Missing required CircleCI workflow'); }); it('fails when patch coverage enforcement is removed', () => { @@ -82,14 +83,14 @@ describe('check-ci-provider', () => { expect(run(directory).output).toContain('third-party-review'); }); - it('fails when hosted runners return to the canonical workflow', () => { + it('fails when self-hosted private runners return to the canonical workflow', () => { expect.hasAssertions(); const directory = fixture((root) => { const file = path.join(root, '.github/workflows/ci.yml'); - fs.writeFileSync(file, fs.readFileSync(file, 'utf8').replace('runs-on: [self-hosted, jumentix]', 'runs-on: ubuntu-latest')); + fs.writeFileSync(file, fs.readFileSync(file, 'utf8').replace('runs-on: ubuntu-latest', 'runs-on: [self-hosted, jumentix]')); }); - expect(run(directory).output).toContain('repository-owned self-hosted runner'); + expect(run(directory).output).toContain('GitHub-hosted ubuntu-latest runners'); }); it('fails when Docker runtime bootstrap is removed from container-backed jobs', () => { @@ -183,6 +184,21 @@ describe('check-ci-provider', () => { expect(run(directory).output).toContain('sonar-scanner -Dsonar\\.scm\\.disabled=true'); }); + it('fails when Sonar can scan binary assets as source files', () => { + expect.hasAssertions(); + + const directory = fixture((root) => { + const file = path.join(root, 'sonar-project.properties'); + fs.writeFileSync( + file, + fs.readFileSync(file, 'utf8') + .replace('sonar.sourceEncoding=UTF-8\n', '') + .replace('**/*.png,', '') + ); + }); + expect(run(directory).output).toContain('encoding-safe source scan marker'); + }); + it('fails when retired Codecov contract returns', () => { expect.hasAssertions(); @@ -206,7 +222,7 @@ describe('requirement 113 is registered and enforced', () => { const text = fs.readFileSync(requirement, 'utf8'); expect(text).toContain('014'); expect(text).toContain('GitHub Actions'); - expect(text).toContain('CircleCI disabled'); + expect(text).toContain('CircleCI is enabled'); }); it('is enforced by the canonical gate', () => { diff --git a/apps/backend-template/test/unit/ci-cd/check-coverage-thresholds.test.ts b/apps/backend-template/test/unit/ci-cd/check-coverage-thresholds.test.ts index 746e07dab..64ff329d6 100644 --- a/apps/backend-template/test/unit/ci-cd/check-coverage-thresholds.test.ts +++ b/apps/backend-template/test/unit/ci-cd/check-coverage-thresholds.test.ts @@ -165,9 +165,9 @@ describe('check-coverage-thresholds', () => { // Pinned so the migration off Jest cannot relax a number in passing. // Lowering any of these is a governance decision under Requirements 020/063. // JUM-681 raised branches from 90 and settled the four at 98. The measured - // gap moved into `ACCEPTED_BELOW_THRESHOLD` as a dated floor, which can only - // be held or improved — a threshold ten points below its neighbours was not - // a threshold, it was where the failure paths went unmeasured. + // gap lived in `ACCEPTED_BELOW_THRESHOLD` as a dated floor (JUM-579) until + // branch coverage reached the threshold and the exception was retired — + // the register is empty again, and only a new dated entry may hold one. expect(coverageGuard.THRESHOLDS).toStrictEqual({ statements: 98, branches: 98, @@ -221,7 +221,7 @@ describe('check-coverage-thresholds CLI', () => { const result = runMain(reportWith({ brf: 100, brh: 50 })); expect(result.thrown?.message).toBe('exit:1'); - expect(result.errors).toContain('branches: 50.00% is below the accepted floor of 97.47%'); + expect(result.errors).toContain('branches: 50.00% is below the required 98%'); }); it('exits non-zero when the report is absent, rather than treating it as a pass', () => { @@ -242,6 +242,16 @@ describe('check-coverage-thresholds CLI', () => { expect(result.logs).toContain('branches 98.00%'); expect(result.logs).not.toContain('under JUM-579'); }); + + it('reports a passing metric under a live exception with its ratchet note', () => { + expect.hasAssertions(); + const result = runMain(reportWith({ brf: 10000, brh: 9747 }), { + branches: { floor: 97.47, issue: 'JUM-579', since: '2026-08-29' } + }); + + expect(result.thrown).toBeNull(); + expect(result.logs).toContain('branches 97.47% (under JUM-579, floor 97.47%)'); + }); }); /** @@ -359,6 +369,74 @@ describe('check-coverage-thresholds report reader', () => { read.mockRestore(); }); + it('reads the canonical report when the preserved Jest report is absent', () => { + expect.hasAssertions(); + // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require + const nodeFs = require('fs') as { + existsSync: (path: string) => boolean; + readFileSync: (path: string, encoding: string) => string; + }; + const previousIncludeBrowser = process.env.JUMENTIX_COVERAGE_INCLUDE_BROWSER; + process.env.JUMENTIX_COVERAGE_INCLUDE_BROWSER = '0'; + const exists = jest.spyOn(nodeFs, 'existsSync').mockImplementation((filePath) => ( + String(filePath).endsWith('coverage/coverage-final.json') + )); + const read = jest.spyOn(nodeFs, 'readFileSync').mockReturnValue(JSON.stringify({ + 'canonical.ts': { + b: {}, + f: {}, + s: counters(1, 1), + statementMap: statements(1) + } + })); + + try { + const report = coverageGuard.defaultReadReport() as Record; + + expect(Object.keys(report)).toStrictEqual(['canonical.ts']); + expect(read).toHaveBeenCalledWith(expect.stringContaining('coverage/coverage-final.json'), 'utf8'); + } finally { + restoreEnvValue('JUMENTIX_COVERAGE_INCLUDE_BROWSER', previousIncludeBrowser); + exists.mockRestore(); + read.mockRestore(); + } + }); + + it('uses the canonical report without requiring browser coverage when explicitly allowed', () => { + expect.hasAssertions(); + // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require + const nodeFs = require('fs') as { + existsSync: (path: string) => boolean; + readFileSync: (path: string, encoding: string) => string; + }; + const previousIncludeBrowser = process.env.JUMENTIX_COVERAGE_INCLUDE_BROWSER; + const previousRequireBrowser = process.env.JUMENTIX_COVERAGE_REQUIRE_BROWSER; + process.env.JUMENTIX_COVERAGE_INCLUDE_BROWSER = '1'; + process.env.JUMENTIX_COVERAGE_REQUIRE_BROWSER = '0'; + const exists = jest.spyOn(nodeFs, 'existsSync').mockImplementation((filePath) => ( + String(filePath).endsWith('/coverage/coverage-final.json') + )); + const read = jest.spyOn(nodeFs, 'readFileSync').mockReturnValue(JSON.stringify({ + 'canonical.ts': { + b: {}, + f: {}, + s: counters(1, 1), + statementMap: statements(1) + } + })); + + try { + const report = coverageGuard.defaultReadReport() as Record; + + expect(Object.keys(report)).toStrictEqual(['canonical.ts']); + } finally { + restoreEnvValue('JUMENTIX_COVERAGE_INCLUDE_BROWSER', previousIncludeBrowser); + restoreEnvValue('JUMENTIX_COVERAGE_REQUIRE_BROWSER', previousRequireBrowser); + exists.mockRestore(); + read.mockRestore(); + } + }); + it('prefers the preserved Jest report when browser coverage rewrites the canonical file', () => { expect.hasAssertions(); // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require @@ -428,6 +506,23 @@ describe('check-coverage-thresholds report reader', () => { expect(coverageGuard.isThresholdSubject('/repo/packages/designer-core/dist/index.js')) .toBe(false); }); + + it('parses boolean environment flags with absent and negative values', () => { + expect.hasAssertions(); + const previous = process.env.JUMENTIX_TEST_BOOLEAN_FLAG; + delete process.env.JUMENTIX_TEST_BOOLEAN_FLAG; + + try { + expect(coverageGuard.readsEnvFlag('JUMENTIX_TEST_BOOLEAN_FLAG')).toBe(true); + expect(coverageGuard.readsEnvFlag('JUMENTIX_TEST_BOOLEAN_FLAG', false)).toBe(false); + process.env.JUMENTIX_TEST_BOOLEAN_FLAG = 'off'; + expect(coverageGuard.readsEnvFlag('JUMENTIX_TEST_BOOLEAN_FLAG')).toBe(false); + process.env.JUMENTIX_TEST_BOOLEAN_FLAG = 'yes'; + expect(coverageGuard.readsEnvFlag('JUMENTIX_TEST_BOOLEAN_FLAG')).toBe(true); + } finally { + restoreEnvValue('JUMENTIX_TEST_BOOLEAN_FLAG', previous); + } + }); }); /** @@ -658,3 +753,78 @@ describe('check-coverage-thresholds partial records (JUM-721)', () => { expect(totals.lines).toStrictEqual({ found: 2, hit: 1 }); }); }); + +describe('check-coverage-thresholds default parameters (JUM-821)', () => { + it('counts lines for a record whose statement counters are absent entirely', () => { + expect.hasAssertions(); + + // `statementMap` without any `s` map at all: every line is missed, and + // the absent map is not a crash. + const guard = coverageGuard as unknown as { + lineTotals: (report: unknown) => { found: number; hit: number }; + }; + + expect(guard.lineTotals({ + 'apps/backend-template/src/unset.ts': { + statementMap: { 0: { start: { line: 1 } }, 1: { start: { line: 2 } } } + } + })).toStrictEqual({ found: 2, hit: 0 }); + }); + + it('validates against the built-in thresholds when none are passed', () => { + expect.hasAssertions(); + + const totals = { + statements: { found: 4, hit: 4 }, + lines: { found: 4, hit: 4 }, + functions: { found: 2, hit: 2 }, + branches: { found: 6, hit: 6 } + }; + const { failures, report } = coverageGuard.validateCoverage(totals, undefined, {}); + + expect(failures).toStrictEqual([]); + expect(report.statements).toBe(100); + }); + + it('fails closed through the default report reader when no report exists', () => { + expect.hasAssertions(); + + // `main()` with no arguments is the CLI shape: it reads the real + // coverage/ directory through `defaultReadReport`. The report files are + // moved aside for the duration of the call rather than stubbing `fs` — + // a global stub leaks into every suite sharing the process — so the + // missing-report exit runs against the real defaults, deterministically, + // whatever a previous run left on disk. + const fs = require('node:fs'); + const path = require('node:path'); + const coverageDir = path.resolve(__dirname, '../../../../../coverage'); + const reportFiles = [ + path.join(coverageDir, 'coverage-final.json'), + path.join(coverageDir, 'jest', 'coverage-final.json') + ]; + const movedAside = reportFiles + .filter((file) => fs.existsSync(file)) + .map((file) => { + const aside = `${file}.jum821-aside`; + fs.renameSync(file, aside); + return { file, aside }; + }); + + const exit = jest.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('exit'); + }) as never); + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined); + try { + expect(() => coverageGuard.main(undefined, {})).toThrow('exit'); + expect(exit).toHaveBeenCalledWith(1); + expect(consoleError.mock.calls.flat().join('\n')) + .toContain('coverage-final.json does not exist'); + } finally { + exit.mockRestore(); + consoleError.mockRestore(); + for (const { file, aside } of movedAside) { + fs.renameSync(aside, file); + } + } + }); +}); diff --git a/apps/backend-template/test/unit/ci-cd/check-dependency-override-integrity.test.ts b/apps/backend-template/test/unit/ci-cd/check-dependency-override-integrity.test.ts index 4c137b7a7..4f97f3e14 100644 --- a/apps/backend-template/test/unit/ci-cd/check-dependency-override-integrity.test.ts +++ b/apps/backend-template/test/unit/ci-cd/check-dependency-override-integrity.test.ts @@ -430,3 +430,20 @@ describe('override major compatibility, the remaining branches (JUM-681)', () => expect(guard.readInstalledDependentRange('typescript', 'send')).toBeNull(); }); }); + +describe('override major compatibility with no overrides section (JUM-821)', () => { + it('says nothing when the manifest declares no overrides at all', () => { + expect.hasAssertions(); + + // A manifest without an `overrides` key has nothing to cross a major + // with; the guard reads it as empty rather than crashing on undefined. + const guard = require('../../../../../ci-cd/check-dependency-override-integrity') as { + validateOverrideMajors: ( + pkg: Record, + readDependentRange: (dependent: string, overridden: string) => string | null + ) => string[]; + }; + + expect(guard.validateOverrideMajors({}, () => null)).toStrictEqual([]); + }); +}); diff --git a/apps/backend-template/test/unit/ci-cd/check-frontend-coverage.test.ts b/apps/backend-template/test/unit/ci-cd/check-frontend-coverage.test.ts new file mode 100644 index 000000000..6d9cc6575 --- /dev/null +++ b/apps/backend-template/test/unit/ci-cd/check-frontend-coverage.test.ts @@ -0,0 +1,83 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const repoRoot = path.resolve(__dirname, '../../../../..'); +const { + EXCLUDED, + THRESHOLDS, + isSubject, + parseLcov, + run, + summarize +} = require(path.join(repoRoot, 'ci-cd', 'check-frontend-coverage.js')); + +/** + * Requirement 135 §3 — the frontend coverage gate ships with the failures it + * prevents: a missing report, a threshold miss, and an untouched source file + * that would otherwise vanish from the number. + */ +interface LcovEntry { + file: string; + lf: number; + lh: number; + fnf: number; + fnh: number; +} + +const lcov = (entries: LcovEntry[]): string => entries + .map((e) => `TN:\nSF:${e.file}\nFNF:${e.fnf}\nFNH:${e.fnh}\nLF:${e.lf}\nLH:${e.lh}\nend_of_record`) + .join('\n'); + +describe('check-frontend-coverage', () => { + it('fails closed when the lcov report is missing', () => { + expect.hasAssertions(); + const result = run({ reportPath: path.join(os.tmpdir(), 'does-not-exist.lcov') }); + expect(result.ok).toBe(false); + expect(result.messages[0]).toContain('does not exist'); + }); + + it('parses lcov records relative to the app root and keeps the excluded wiring out of scope', () => { + expect.hasAssertions(); + const files = parseLcov(lcov([{ + file: 'src/_nav.ts', lf: 10, lh: 9, fnf: 1, fnh: 1 + }])); + const [only] = [...files.keys()]; + expect(only.endsWith(path.join('apps', 'frontend', 'src', '_nav.ts'))).toBe(true); + expect(isSubject(only)).toBe(true); + expect(isSubject(only.replace('_nav.ts', 'main.ts'))).toBe(false); + expect(EXCLUDED.some((pattern: RegExp) => pattern.test('/x/src/router/index.ts'))).toBe(true); + }); + + it('counts an untouched source at zero hits instead of dropping it', () => { + expect.hasAssertions(); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fe-cov-')); + const touched = path.join(dir, 'touched.ts'); + const untouched = path.join(dir, 'untouched.ts'); + fs.writeFileSync(touched, 'export const a = 1;\n'); + fs.writeFileSync(untouched, 'export const b = 1;\nexport const c = 2;\n'); + const files = new Map([[touched, { + path: touched, lf: 4, lh: 4, fnf: 2, fnh: 2 + }]]); + const { totals, untouched: missing } = summarize(files, [touched, untouched]); + expect(totals.lines).toStrictEqual({ found: 6, hit: 4 }); + expect(totals.functions).toStrictEqual({ found: 2, hit: 2 }); + expect(missing).toHaveLength(1); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('reports the real report against the thresholds with branches marked unmeasured', () => { + expect.hasAssertions(); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fe-cov-')); + const report = path.join(dir, 'lcov.info'); + fs.writeFileSync(report, lcov([{ + file: 'src/_nav.ts', lf: 10, lh: 1, fnf: 1, fnh: 0 + }])); + const result = run({ reportPath: report, thresholds: { lines: 99, functions: 99 } }); + expect(result.ok).toBe(false); + expect(result.messages.some((m: string) => m.includes('FAIL'))).toBe(true); + expect(result.messages.some((m: string) => m.includes('branches unmeasured'))).toBe(true); + expect(THRESHOLDS.lines).toBeGreaterThan(0); + fs.rmSync(dir, { recursive: true, force: true }); + }); +}); diff --git a/apps/backend-template/test/unit/ci-cd/check-integration-migration.test.ts b/apps/backend-template/test/unit/ci-cd/check-integration-migration.test.ts index c02bc2e93..7071cbde6 100644 --- a/apps/backend-template/test/unit/ci-cd/check-integration-migration.test.ts +++ b/apps/backend-template/test/unit/ci-cd/check-integration-migration.test.ts @@ -14,7 +14,7 @@ describe('check-integration-migration', () => { expect(validateIntegrationPolicy('incomplete integration policy').length).toBeGreaterThan(0); }); - it('requires repository-owned configuration to target XpertMinds', () => { + it('requires repository-owned configuration to target web2solutions', () => { expect.hasAssertions(); expect(validateCanonicalConfig(process.cwd())).toStrictEqual([]); }); diff --git a/apps/backend-template/test/unit/ci-cd/check-oas-relations.test.ts b/apps/backend-template/test/unit/ci-cd/check-oas-relations.test.ts new file mode 100644 index 000000000..d9d2442a0 --- /dev/null +++ b/apps/backend-template/test/unit/ci-cd/check-oas-relations.test.ts @@ -0,0 +1,89 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const { collectRelationErrors } = require('../../../../../ci-cd/check-oas-relations'); + +const passingDocument = { + components: { + schemas: { + User: { + 'x-primary-key': 'id', + properties: { + id: { type: 'string' }, + organization: { + 'x-relation': { + entity: 'Organization', match: 'id', kind: 'belongsTo', display: 'name' + } + } + } + }, + Organization: { + 'x-primary-key': 'id', + properties: { + id: { type: 'string' }, + users: { + 'x-relation': { + entity: 'User', match: 'id', kind: 'hasMany', display: 'username' + } + } + } + } + } + } +}; + +const passingSources = { + User: '@belongsTo(\'Organization\')\n public get organization(): string { return ""; }', + Organization: '@hasMany(\'User\')\n public get users(): string[] { return []; }' +}; + +describe('check-oas-relations', () => { + it('passes when OAS and model relations agree', () => { + expect.hasAssertions(); + expect(collectRelationErrors({ + document: passingDocument, + sources: passingSources + })).toStrictEqual([]); + }); + + it('fails when an entity schema lacks x-primary-key', () => { + expect.hasAssertions(); + const document: any = structuredClone(passingDocument); + delete document.components.schemas.User['x-primary-key']; + expect(collectRelationErrors({ document, sources: passingSources }).join('\n')) + .toContain('User: missing x-primary-key, expected id'); + }); + + it('fails when x-primary-key names a missing property', () => { + expect.hasAssertions(); + const document = structuredClone(passingDocument); + document.components.schemas.User['x-primary-key'] = 'pk'; + expect(collectRelationErrors({ document, sources: passingSources }).join('\n')) + .toMatch(/User: x-primary-key is "pk"/); + }); + + it('fails when a model relation has no x-relation', () => { + expect.hasAssertions(); + const document: any = structuredClone(passingDocument); + delete document.components.schemas.User.properties.organization; + expect(collectRelationErrors({ document, sources: passingSources }).join('\n')) + .toContain('User.organization: model relation has no x-relation'); + }); + + it('fails when an x-relation has no model decorator', () => { + expect.hasAssertions(); + const sources = { ...passingSources, User: 'export class User {}' }; + expect(collectRelationErrors({ document: passingDocument, sources }).join('\n')) + .toContain('User.organization: x-relation has no model decorator'); + }); + + it('fails on wrong kind, entity or match', () => { + expect.hasAssertions(); + const document: any = structuredClone(passingDocument); + document.components.schemas.User.properties.organization['x-relation'] = { + entity: 'Catalog', match: '_id', kind: 'hasMany', display: 'name' + }; + const text = collectRelationErrors({ document, sources: passingSources }).join('\n'); + expect(text).toContain('x-relation.entity is "Catalog"'); + expect(text).toContain('x-relation.kind is "hasMany"'); + expect(text).toContain('x-relation.match is "_id"'); + }); +}); diff --git a/apps/backend-template/test/unit/ci-cd/check-package-suites.test.ts b/apps/backend-template/test/unit/ci-cd/check-package-suites.test.ts index 865c31e70..088f28749 100644 --- a/apps/backend-template/test/unit/ci-cd/check-package-suites.test.ts +++ b/apps/backend-template/test/unit/ci-cd/check-package-suites.test.ts @@ -280,3 +280,41 @@ describe('check-package-suites', () => { }); }); }); + +describe('check-package-suites default wiring (JUM-821)', () => { + it('runs against the working tree when called with no options at all', () => { + expect.hasAssertions(); + + // Every option has a production default; the bare call is the shape the + // entry point uses, and it must read the real tree. + const result = run(); + + expect(result.ok).toBe(true); + }); + + it('main reports and returns 0 with its default io and check', () => { + expect.hasAssertions(); + + const log = jest.spyOn(console, 'log').mockImplementation(() => undefined); + try { + expect(main()).toBe(0); + expect(log).toHaveBeenCalledWith(expect.stringContaining('Package suite check passed')); + } finally { + log.mockRestore(); + } + }); + + it('ignores a package whose src holds no runnable source file', () => { + expect.hasAssertions(); + + // A `src` of only Markdown owns no suite: the per-file filter is what + // keeps docs-only sources from reading as untested code. + const dir = workspace({}); + fs.mkdirSync(path.join(dir, 'packages', 'notes-only', 'src'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'packages', 'notes-only', 'src', 'notes.md'), '# notes\n'); + + const result = run({ root: dir, register: {}, readFile: () => sonarConfig([]) }); + + expect(result.ok).toBe(true); + }); +}); diff --git a/apps/backend-template/test/unit/ci-cd/check-pr-governance.test.ts b/apps/backend-template/test/unit/ci-cd/check-pr-governance.test.ts index 6df0a656f..f8c0eb1a5 100644 --- a/apps/backend-template/test/unit/ci-cd/check-pr-governance.test.ts +++ b/apps/backend-template/test/unit/ci-cd/check-pr-governance.test.ts @@ -117,11 +117,11 @@ describe('check-pr-governance', () => { const invalidBody = validBody .replace( 'https://linear.app/jumentix/project/governance-foundation-c3cb6bae0771/overview', - 'https://github.com/XpertMinds/Jumentix/issues/500' + 'https://github.com/web2solutions/Jumentix/issues/500' ) .replace( 'https://linear.app/jumentix/issue/JUM-163/focused-epic-metadata', - 'https://github.com/XpertMinds/Jumentix/issues/501' + 'https://github.com/web2solutions/Jumentix/issues/501' ); expect(validatePullRequest({ diff --git a/apps/backend-template/test/unit/ci-cd/check-test-map.test.ts b/apps/backend-template/test/unit/ci-cd/check-test-map.test.ts index 1c3fa02ee..fe1585da4 100644 --- a/apps/backend-template/test/unit/ci-cd/check-test-map.test.ts +++ b/apps/backend-template/test/unit/ci-cd/check-test-map.test.ts @@ -186,7 +186,7 @@ describe('service-management selection (JUM-472)', () => { }, suites: [ smSuite( - 'apps/backend-template/test/unit/service-management/designerStore.test.ts', + 'apps/service-management/test/unit/designerStore.test.ts', 'service-management/designer', 'unit' ), @@ -222,7 +222,7 @@ describe('service-management selection (JUM-472)', () => { expect(plan.selectedLayers).toStrictEqual(['service-management/designer']); expect(plan.unitSuites).toStrictEqual([ - 'apps/backend-template/test/unit/service-management/designerStore.test.ts' + 'apps/service-management/test/unit/designerStore.test.ts' ]); expect(plan.integrationScripts).toStrictEqual(['test:integration:service-management']); }); @@ -239,14 +239,14 @@ describe('service-management selection (JUM-472)', () => { 'service-management/server' ]); expect(plan.unitSuites).toStrictEqual([ - 'apps/backend-template/test/unit/service-management/designerStore.test.ts' + 'apps/service-management/test/unit/designerStore.test.ts' ]); }); it('selects the SM suites for a changed SM suite file, not the tooling layer', () => { expect.hasAssertions(); - const plan = planFor(['apps/backend-template/test/unit/service-management/designerStore.test.ts']); + const plan = planFor(['apps/service-management/test/unit/designerStore.test.ts']); expect(plan.selectedLayers).toStrictEqual(['service-management/designer']); expect(plan.unitSuites).not.toContain('apps/backend-template/test/unit/ci-cd/check-test-map.test.ts'); @@ -447,3 +447,26 @@ describe('requirement 110 runner rules', () => { expect(unexplained).toStrictEqual([]); }); }); + +describe('interface GUI placeholder docs do not select interface/runtime (JUM-757)', () => { + const repoRoot = path.resolve(__dirname, '../../../../..'); + const realManifest = readTestMap(path.join(repoRoot, 'test-map.json')); + const planFor = (files: string[]) => createLayerAwarePlan(files, { + manifest: realManifest, + root: repoRoot, + graph: new Map() + }); + + it('keeps README-only GUI placeholders out of the interface/runtime blast radius', () => { + expect.hasAssertions(); + + const plan = planFor([ + 'apps/backend-template/src/interface/GUI/README.md', + 'apps/backend-template/src/interface/GUI/web/README.md', + 'apps/jumentix-website/components/architecture/HexagonalArchitectureMap.tsx' + ]); + + expect(plan.selectedLayers).not.toContain('interface/runtime'); + expect([...plan.selectedLayers].sort()).toStrictEqual(['tooling', 'website']); + }); +}); diff --git a/apps/backend-template/test/unit/ci-cd/check-third-party-review.test.ts b/apps/backend-template/test/unit/ci-cd/check-third-party-review.test.ts index c07216223..c3128f259 100644 --- a/apps/backend-template/test/unit/ci-cd/check-third-party-review.test.ts +++ b/apps/backend-template/test/unit/ci-cd/check-third-party-review.test.ts @@ -16,6 +16,8 @@ function fixture(change?: (root: string) => void): string { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'third-party-review-')); fs.mkdirSync(path.join(root, 'ci-cd'), { recursive: true }); fs.copyFileSync(checker, path.join(root, 'ci-cd/check-third-party-review.js')); + const yamlRoot = path.dirname(require.resolve('yaml/package.json')); + fs.cpSync(yamlRoot, path.join(root, 'node_modules/yaml'), { recursive: true }); for (const file of contracts) { const destination = path.join(root, file); fs.mkdirSync(path.dirname(destination), { recursive: true }); @@ -30,7 +32,13 @@ function run(root: string): { code: number; output: string } { const output = execFileSync('bun', ['ci-cd/check-third-party-review.js'], { cwd: root, encoding: 'utf8', - stdio: 'pipe' + stdio: 'pipe', + env: { + ...process.env, + NODE_PATH: [path.join(repoRoot, 'node_modules'), process.env.NODE_PATH] + .filter(Boolean) + .join(path.delimiter) + } }); return { code: 0, output }; @@ -190,14 +198,14 @@ describe('third-party review contract', () => { expect(run(root).output).toContain('Enforce scanner outcomes'); }); - it('fails when the review job uses JavaScript Actions', () => { + it('fails when the review job uses an unapproved JavaScript Action', () => { expect.hasAssertions(); const root = fixture((directory) => { addStepToReviewJob(directory, ' - uses: actions/upload-artifact@v7\n'); }); - expect(run(root).output).toContain('must avoid JavaScript Actions'); + expect(run(root).output).toContain('may only use bootstrap actions'); }); it('fails when scanner evidence listing is removed', () => { diff --git a/apps/backend-template/test/unit/ci-cd/check-workspace-coverage-policy.test.ts b/apps/backend-template/test/unit/ci-cd/check-workspace-coverage-policy.test.ts index 97e79beca..a6bbb3384 100644 --- a/apps/backend-template/test/unit/ci-cd/check-workspace-coverage-policy.test.ts +++ b/apps/backend-template/test/unit/ci-cd/check-workspace-coverage-policy.test.ts @@ -37,7 +37,7 @@ describe('check-workspace-coverage-policy', () => { 'Root coverageThreshold.global.statements must be >= 98 (current: 95)', 'Root coverageThreshold.global.lines must be >= 98 (current: 95)', 'Root coverageThreshold.global.functions must be >= 98 (current: 95)', - 'Root coverageThreshold.global.branches must be >= 97.47 (98 relaxed to the accepted floor under JUM-579) (current: 80)' + 'Root coverageThreshold.global.branches must be >= 98 (current: 80)' ]); }); diff --git a/apps/backend-template/test/unit/ci-cd/classify-ci-context.test.ts b/apps/backend-template/test/unit/ci-cd/classify-ci-context.test.ts index d41a967d5..c8eda7d2f 100644 --- a/apps/backend-template/test/unit/ci-cd/classify-ci-context.test.ts +++ b/apps/backend-template/test/unit/ci-cd/classify-ci-context.test.ts @@ -39,7 +39,7 @@ describe('classify-ci-context', () => { const evidence = classify({ CIRCLE_BRANCH: 'codex/feature/JUM-631-fast-ci', - CIRCLE_PULL_REQUEST: 'https://github.com/XpertMinds/Jumentix/pull/200', + CIRCLE_PULL_REQUEST: 'https://github.com/web2solutions/Jumentix/pull/200', CIRCLE_PR_BASE_BRANCH: 'dev' }); @@ -61,7 +61,7 @@ describe('classify-ci-context', () => { const evidence = classify({ CIRCLE_BRANCH: 'dev', - CIRCLE_PULL_REQUEST: 'https://github.com/XpertMinds/Jumentix/pull/201', + CIRCLE_PULL_REQUEST: 'https://github.com/web2solutions/Jumentix/pull/201', CIRCLE_PR_BASE_BRANCH: 'main' }); @@ -131,7 +131,7 @@ describe('classify-ci-context', () => { expect(() => classify({ CIRCLE_BRANCH: 'codex/feature/JUM-631-fast-ci', - CIRCLE_PULL_REQUEST: 'https://github.com/XpertMinds/Jumentix/pull/202' + CIRCLE_PULL_REQUEST: 'https://github.com/web2solutions/Jumentix/pull/202' })).toThrow('base branch'); }); @@ -140,7 +140,7 @@ describe('classify-ci-context', () => { expect(() => classify({ CIRCLE_BRANCH: 'codex/feature/JUM-631-fast-ci', - CIRCLE_PULL_REQUEST: 'https://github.com/XpertMinds/Jumentix/pull/203', + CIRCLE_PULL_REQUEST: 'https://github.com/web2solutions/Jumentix/pull/203', CIRCLE_PR_BASE_BRANCH: 'main' })).toThrow('only dev may open release pull requests to main'); }); diff --git a/apps/backend-template/test/unit/ci-cd/generate-test-map.test.ts b/apps/backend-template/test/unit/ci-cd/generate-test-map.test.ts index 1096c76f3..7820b80c2 100644 --- a/apps/backend-template/test/unit/ci-cd/generate-test-map.test.ts +++ b/apps/backend-template/test/unit/ci-cd/generate-test-map.test.ts @@ -268,10 +268,10 @@ describe('buildManifest', () => { // no layer, and the task gate refuses it as an unsupported change set. expect(globs).toStrictEqual(expect.arrayContaining([ '.github/**', + '.circleci/**', 'test-map.json', 'jest.config.js' ])); - expect(globs).not.toContain('.circleci/**'); }); it('declares cheap dev health and full main matrix in the generated gate table', () => { @@ -298,7 +298,7 @@ describe('service-management classification (JUM-472)', () => { it('files SM unit suites under the designer sub-layer, not tooling', () => { expect.hasAssertions(); - expect(classifyUnit('apps/backend-template/test/unit/service-management/designerStore.test.ts')) + expect(classifyUnit('apps/service-management/test/unit/designerStore.test.ts')) .toStrictEqual({ layer: 'service-management/designer', kind: 'non-hexagonal' }); }); @@ -335,7 +335,7 @@ describe('service-management classification (JUM-472)', () => { )).toThrow('brand-new.integration.test.ts'); }); - it('declares the two sub-layers with enumerated globs and the component dependency direction', () => { + it('declares the Service Management sub-layers with enumerated globs and dependency direction', () => { expect.hasAssertions(); const { layers } = buildManifest(workspace({})); @@ -346,7 +346,7 @@ describe('service-management classification (JUM-472)', () => { sourceGlobs: ['apps/service-management/server.js', 'apps/service-management/package.json'] }); expect(layers['service-management/designer']).toMatchObject({ - dependsOn: ['service-management/server'], + dependsOn: ['service-management/server', 'service-management/catalog-api'], kind: 'non-hexagonal', sourceGlobs: [ 'apps/service-management/script.js', @@ -358,6 +358,11 @@ describe('service-management classification (JUM-472)', () => { 'packages/designer-core/**' ] }); + expect(layers['service-management/catalog-api']).toMatchObject({ + dependsOn: ['contracts'], + kind: 'non-hexagonal', + sourceGlobs: ['apps/service-management-api/**'] + }); }); it('no longer files the component under interface/runtime, and maps spec/ to contracts', () => { diff --git a/apps/backend-template/test/unit/ci-cd/guard-defaults.test.ts b/apps/backend-template/test/unit/ci-cd/guard-defaults.test.ts index a6aef265c..8e3aefa7a 100644 --- a/apps/backend-template/test/unit/ci-cd/guard-defaults.test.ts +++ b/apps/backend-template/test/unit/ci-cd/guard-defaults.test.ts @@ -167,12 +167,14 @@ describe('ci-cd guards, no injection (JUM-681)', () => { // runs because of it. A gate whose floor drifts is a gate that fails at // random (Requirement 134). The reader is covered with an injected path in // `check-coverage-thresholds.test.ts`, where it is deterministic. - it('surfaces stale live coverage exceptions against the real defaults', () => { + it('keeps the live exception register empty against the real defaults', () => { expect.hasAssertions(); - // No thresholds and no exception register passed: both defaults, which is - // how the CLI runs it. A perfect metric must ask the owner to remove a live - // exception rather than silently letting the ratchet linger. + // Empty is the steady state: the JUM-579 branches exception was retired + // once branch coverage reached the 98% threshold. No thresholds and no + // exception register passed — both defaults, which is how the CLI runs it — + // so a perfect metric must produce no failures at all, and a stale entry + // would fail this test by asking for its own removal. const perfect = { statements: { found: 100, hit: 100 }, lines: { found: 100, hit: 100 }, @@ -180,13 +182,8 @@ describe('ci-cd guards, no injection (JUM-681)', () => { branches: { found: 100, hit: 100 } }; - const { failures } = guardDefaultsCoverage.validateCoverage(perfect); - - expect(failures).toStrictEqual([ - 'branches: 100.00% now meets the 98% threshold, but an exception is still recorded ' - + '(JUM-579, since 2026-08-29). Remove it from ACCEPTED_BELOW_THRESHOLD and close ' - + 'the issue.' - ]); + expect(guardDefaultsCoverage.ACCEPTED_BELOW_THRESHOLD).toStrictEqual({}); + expect(guardDefaultsCoverage.validateCoverage(perfect).failures).toStrictEqual([]); }); it('checks the real manifest for override integrity', () => { diff --git a/apps/backend-template/test/unit/ci-cd/merge-coverage-reports.test.ts b/apps/backend-template/test/unit/ci-cd/merge-coverage-reports.test.ts new file mode 100644 index 000000000..816443d0b --- /dev/null +++ b/apps/backend-template/test/unit/ci-cd/merge-coverage-reports.test.ts @@ -0,0 +1,54 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const { + mergeLcovFiles, + rebaseRecordFile, + splitRecords +} = require('../../../../../ci-cd/merge-coverage-reports'); + +const RECORD_A = ['SF:src/a.ts', 'DA:1,1', 'DA:2,0', 'end_of_record'].join('\n'); +const RECORD_B = ['SF:src/b.ts', 'DA:1,1', 'end_of_record'].join('\n'); + +const makeTmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'merge-lcov-')); + +describe('merge-coverage-reports', () => { + it('merges every input without duplicating a file seen twice', () => { + expect.hasAssertions(); + const dir = makeTmp(); + const first = path.join(dir, 'one.info'); + const second = path.join(dir, 'two.info'); + const output = path.join(dir, 'merged.info'); + fs.writeFileSync(first, `${RECORD_A}\n${RECORD_B}\n`); + fs.writeFileSync(second, `${RECORD_A.replace('DA:1,1', 'DA:1,9')}\n`); + + const stats = mergeLcovFiles([first, second, path.join(dir, 'absent.info')], output); + + expect(stats.records).toBe(2); + expect(stats.skippedDuplicates).toBe(1); + const merged = fs.readFileSync(output, 'utf8'); + expect(merged).toContain('SF:src/a.ts\nDA:1,1'); + expect(merged).toContain('SF:src/b.ts'); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('rebases SF paths with the input prefix and keeps absolute paths untouched', () => { + expect.hasAssertions(); + const relative = ['SF:src/useXCrud.ts', 'DA:1,1', 'end_of_record'].join('\n'); + const rebased = rebaseRecordFile(relative, 'apps/frontend/'); + expect(rebased).toContain('SF:apps/frontend/src/useXCrud.ts'); + + const absolute = ['SF:/repo/apps/frontend/src/useXCrud.ts', 'DA:1,1', 'end_of_record'].join('\n'); + expect(rebaseRecordFile(absolute, 'apps/frontend/')).toContain('SF:/repo/apps/frontend/src/useXCrud.ts'); + }); + + it('splits records on end_of_record and ignores trailing text', () => { + expect.hasAssertions(); + const records = splitRecords(`${RECORD_A}\n${RECORD_B}\n`); + expect(records).toHaveLength(2); + expect(records[0]).toContain('SF:src/a.ts'); + expect(records[1]).toContain('SF:src/b.ts'); + }); +}); diff --git a/apps/backend-template/test/unit/ci-cd/readme-badges.test.ts b/apps/backend-template/test/unit/ci-cd/readme-badges.test.ts index c11a834d6..9b28455c5 100644 --- a/apps/backend-template/test/unit/ci-cd/readme-badges.test.ts +++ b/apps/backend-template/test/unit/ci-cd/readme-badges.test.ts @@ -8,9 +8,9 @@ import path from 'node:path'; * authoritative, and it reports the health of something else entirely. Three * were live before this suite existed — * - * - both SonarCloud badges pointed at `web2solutions_aaa-typescript-boilerplate`, + * - SonarCloud badges pointed at the wrong project key, * the pre-migration project key, so they had been showing another project's - * quality gate since the move to `XpertMinds/Jumentix` (Requirement 103); + * quality gate since the move to `web2solutions/Jumentix` (Requirement 103); * - a Snyk badge remained after Snyk was retired in JUM-540, advertising a * scanner the repository no longer runs; * - a `node 22.x` badge implied Node is the runtime, which Requirement 096 @@ -46,46 +46,69 @@ describe('rEADME badges', () => { it('shows GitHub Actions for both long-lived branches', () => { expect.hasAssertions(); - expect.hasAssertions(); for (const branch of ['dev', 'main']) { - expect(readme).toContain(`https://github.com/XpertMinds/Jumentix/actions/workflows/ci.yml/badge.svg?branch=${branch}`); - expect(readme).toContain(`https://github.com/XpertMinds/Jumentix/actions/workflows/ci.yml?query=branch%3A${branch}`); + expect(readme).toContain(`https://github.com/web2solutions/Jumentix/actions/workflows/ci.yml/badge.svg?branch=${branch}`); + expect(readme).toContain(`https://github.com/web2solutions/Jumentix/actions/workflows/ci.yml?query=branch%3A${branch}`); } - expect(badges).not.toContain('CircleCI'); - expect(badges).not.toContain('dl.circleci.com/status-badge'); }); - it('points SonarCloud at the project key the scanner actually reports to', () => { + it('shows live CircleCI status for dev and stable release-gate status for main', () => { expect.hasAssertions(); + expect(readme).toContain('https://circleci.com/gh/web2solutions/Jumentix/tree/dev.svg?style=shield'); + expect(readme).toContain('https://img.shields.io/badge/CircleCI-release%20gate-configured'); + expect(readme).not.toContain('https://circleci.com/gh/web2solutions/Jumentix/tree/main.svg?style=shield'); + expect(readme).toContain('https://app.circleci.com/pipelines/github/web2solutions/Jumentix?branch=dev'); + expect(readme).toContain('https://app.circleci.com/pipelines/github/web2solutions/Jumentix?branch=main'); + }); + + it('links SonarCloud dashboards through the public project badge endpoints', () => { expect.hasAssertions(); - // The failure this catches: a badge that renders green for a project nobody - // is scanning. - const key = sonarProperties.match(/sonar\.projectKey=(\S+)/)?.[1]; - expect(key).toBe('Jumentix'); - expect(badges).toContain(`project=${key}`); + const key = sonarProperties.match(/sonar\.projectKey=(\S+)/)?.[1]; + const expectedBadgeSlugs = [ + `sonarcloud.io/api/project_badges/measure?project=${key}&metric=alert_status`, + `sonarcloud.io/api/project_badges/measure?project=${key}&metric=reliability_rating`, + `sonarcloud.io/api/project_badges/measure?project=${key}&metric=coverage` + ]; + + expect(key).toBe('web2solutions_Jumentix'); + expect(badges).toContain(`sonarcloud.io/summary/new_code?id=${key}`); + expect(expectedBadgeSlugs.every((slug) => badges.includes(slug))).toBe(true); expect(badges).not.toContain('web2solutions_aaa-typescript-boilerplate'); }); it('carries no badge for a retired service', () => { expect.hasAssertions(); - expect.hasAssertions(); // Paid/unreliable providers were retired in favour of repository-owned gates. expect(badges).not.toContain('snyk.io'); expect(badges).not.toContain('token='); expect(badges).not.toContain('badge/codecov-via%20CircleCI'); + expect(badges).not.toContain('codecov.io/gh/web2solutions/Jumentix/branch/main/graph/badge.svg'); }); - it('badges real Codecov branch coverage and links to the file maps', () => { + it('badges the Codecov integration without claiming unavailable branch coverage', () => { + expect.hasAssertions(); + + expect(readme).toContain('https://img.shields.io/badge/Codecov-release%20coverage-configured'); + expect(readme).toContain('https://app.codecov.io/gh/web2solutions/Jumentix'); + expect(readme).not.toContain('https://codecov.io/gh/web2solutions/Jumentix/branch/main/graph/badge.svg'); + expect(readme).not.toContain('https://codecov.io/gh/web2solutions/Jumentix/branch/dev/graph/badge.svg'); + }); + + it('explains why Codecov uses file-map links before release coverage exists', () => { + expect.hasAssertions(); + + expect(readme).toContain('full coverage is release-only'); + expect(readme).toContain('first post-migration release coverage upload'); + }); + + it('links Codecov file maps for long-lived branches', () => { expect.hasAssertions(); for (const branch of ['dev', 'main']) { - expect(readme).toContain( - `https://codecov.io/gh/XpertMinds/Jumentix/branch/${branch}/graph/badge.svg?flag=project` - ); - expect(readme).toContain(`https://app.codecov.io/gh/XpertMinds/Jumentix/tree/${branch}`); + expect(readme).toContain(`https://app.codecov.io/gh/web2solutions/Jumentix/tree/${branch}`); } expect(readme).toContain('Codecov file map for `dev`'); expect(readme).toContain('Codecov file map for `main`'); @@ -95,7 +118,7 @@ describe('rEADME badges', () => { expect.hasAssertions(); expect(readme).toContain('## Coverage and CI Map'); - expect(readme).toContain('| Codecov project coverage |'); + expect(readme).toContain('| Codecov coverage |'); expect(readme).toContain('| ≥ 99% | ≥ 99% | ≥ 99% | ≥ 90% | ≥ 99% |'); expect(readme).toContain('Istanbul JSON and LCOV evidence'); }); @@ -103,7 +126,6 @@ describe('rEADME badges', () => { it('names Bun as the runtime at the pinned version', () => { expect.hasAssertions(); - expect.hasAssertions(); // Requirement 096: Bun is the sole internal runtime. A badge claiming Node // misstates what the repository runs on. expect(badges).toContain(`badge/bun-${pinnedBunVersion}`); @@ -112,7 +134,6 @@ describe('rEADME badges', () => { it('presents Node as a compatibility target, not as the runtime', () => { expect.hasAssertions(); - expect.hasAssertions(); // Node survives as a declared consumer-facing compatibility target // (Requirement 096 §4), which is a materially different claim. expect(badges).toContain('node%20compat'); @@ -123,8 +144,8 @@ describe('rEADME badges', () => { expect.hasAssertions(); expect.hasAssertions(); - // Requirement 103: `web2solutions` is deprecated and read-only. - expect(badges).not.toContain('web2solutions'); + // Requirement 103: the canonical repository lives under `web2solutions`. + expect(badges).not.toContain('XpertMinds/Jumentix'); }); }); @@ -193,7 +214,6 @@ describe('web framework badges', () => { it('badges every adapter directory that exists', () => { expect.hasAssertions(); - expect.hasAssertions(); // Catches the omission a hand-written list invites: a new adapter lands and // nobody remembers the README. const onDisk = fs.readdirSync(adaptersDir, { withFileTypes: true }) diff --git a/apps/backend-template/test/unit/ci-cd/run-branch-quality-gate.test.ts b/apps/backend-template/test/unit/ci-cd/run-branch-quality-gate.test.ts index bf1277115..c0fcbd3ba 100644 --- a/apps/backend-template/test/unit/ci-cd/run-branch-quality-gate.test.ts +++ b/apps/backend-template/test/unit/ci-cd/run-branch-quality-gate.test.ts @@ -112,17 +112,24 @@ describe('run-branch-quality-gate', () => { // Lint runs ahead of the two gates that do not contain it, and not ahead of // the strict matrix, which declares it as its first cell (JUM-596). Test - // integrity runs ahead of all three, including the strict matrix, which has - // no cell for it (JUM-683). + // integrity, workspace boundaries, and build:dev run ahead of all three, + // including the strict matrix path used by release/main (JUM-683 / JUM-786). expect(stepIds(execute)).toStrictEqual([ - 'lint', 'test-integrity', 'task-changes', - 'lint', 'test-integrity', 'unit', - 'test-integrity', 'full-matrix', - 'lint', 'test-integrity', 'task-changes' + 'lint', 'test-integrity', 'workspace-boundaries', 'build-dev', 'task-changes', + 'lint', 'test-integrity', 'workspace-boundaries', 'build-dev', 'unit', + 'test-integrity', 'workspace-boundaries', 'build-dev', 'full-matrix', + 'lint', 'test-integrity', 'workspace-boundaries', 'build-dev', 'task-changes' ]); const lintPassed = [ { id: 'lint', script: 'lint', status: 0 }, - { id: 'test-integrity', script: 'test:integrity', status: 0 } + { id: 'test-integrity', script: 'test:integrity', status: 0 }, + { id: 'workspace-boundaries', script: 'arch:check-workspace-boundaries', status: 0 }, + { id: 'build-dev', script: 'build:dev', status: 0 } + ]; + const integrityOnlyPassed = [ + { id: 'test-integrity', script: 'test:integrity', status: 0 }, + { id: 'workspace-boundaries', script: 'arch:check-workspace-boundaries', status: 0 }, + { id: 'build-dev', script: 'build:dev', status: 0 } ]; expect(taskEvidence).toStrictEqual({ schemaVersion: 2, @@ -156,7 +163,7 @@ describe('run-branch-quality-gate', () => { selectedJobs: null, gate: 'full-matrix', script: 'ci:gate:strict', - preflight: [{ id: 'test-integrity', script: 'test:integrity', status: 0 }], + preflight: integrityOnlyPassed, outcome: 'passed', status: 0 }); @@ -199,7 +206,7 @@ describe('run-branch-quality-gate', () => { const evidence = runBranchQualityGate({ env: { CIRCLE_BRANCH: 'codex/feature/JUM-631-fast-ci', - CIRCLE_PULL_REQUEST: 'https://github.com/XpertMinds/Jumentix/pull/200', + CIRCLE_PULL_REQUEST: 'https://github.com/web2solutions/Jumentix/pull/200', CIRCLE_PR_BASE_BRANCH: 'dev' }, spawn: jest.fn().mockReturnValue({ status: 0, stdout: 'ci-cd/run-branch-quality-gate.js\n' }), @@ -216,7 +223,9 @@ describe('run-branch-quality-gate', () => { gate: 'task-changes', script: 'ci:gate:task' }); - expect(stepIds(execute)).toStrictEqual(['lint', 'test-integrity', 'task-changes']); + expect(stepIds(execute)).toStrictEqual([ + 'lint', 'test-integrity', 'workspace-boundaries', 'build-dev', 'task-changes' + ]); }); it('fails closed when CI pull request context is incomplete', () => { @@ -225,7 +234,7 @@ describe('run-branch-quality-gate', () => { const evidence = runBranchQualityGate({ env: { CIRCLE_BRANCH: 'codex/feature/JUM-631-fast-ci', - CIRCLE_PULL_REQUEST: 'https://github.com/XpertMinds/Jumentix/pull/200' + CIRCLE_PULL_REQUEST: 'https://github.com/web2solutions/Jumentix/pull/200' }, execute, logger: { log: jest.fn(), error: jest.fn() }, @@ -290,16 +299,36 @@ describe('run-branch-quality-gate', () => { * nowhere else — so a task branch and a direct push to `dev` were both * unlinted. That is how twenty lint errors reached `dev`. */ - it('runs lint ahead of the gates that do not already contain it', () => { + it('runs lint, integrity, workspace boundaries, and build:dev ahead of cheap gates (JUM-786)', () => { expect.hasAssertions(); const integrity = { id: 'test-integrity', script: 'test:integrity' }; - - expect(TASK_QUALITY_GATE.preflight).toStrictEqual([{ id: 'lint', script: 'lint' }, integrity]); - expect(UNIT_QUALITY_GATE.preflight).toStrictEqual([{ id: 'lint', script: 'lint' }, integrity]); + const workspaceBoundaries = { + id: 'workspace-boundaries', + script: 'arch:check-workspace-boundaries' + }; + const buildDev = { id: 'build-dev', script: 'build:dev' }; + + expect(TASK_QUALITY_GATE.preflight).toStrictEqual([ + { id: 'lint', script: 'lint' }, + integrity, + workspaceBoundaries, + buildDev + ]); + expect(UNIT_QUALITY_GATE.preflight).toStrictEqual([ + { id: 'lint', script: 'lint' }, + integrity, + workspaceBoundaries, + buildDev + ]); // The strict matrix declares lint as a cell; a second run costs minutes to - // learn the same thing. It declares no cell for test integrity, so that one - // runs here (JUM-683). - expect(FULL_MATRIX_QUALITY_GATE.preflight).toStrictEqual([integrity]); + // learn the same thing. It already cells architecture-workspaces + + // backend-build, but task/dev CI never selects that matrix — so those two + // also preflight here (JUM-786) alongside test integrity (JUM-683). + expect(FULL_MATRIX_QUALITY_GATE.preflight).toStrictEqual([ + integrity, + workspaceBoundaries, + buildDev + ]); }); it('does not run the suites when lint fails', () => { diff --git a/apps/backend-template/test/unit/ci-cd/run-full-test-matrix.test.ts b/apps/backend-template/test/unit/ci-cd/run-full-test-matrix.test.ts index 8384e8281..29e8ecef8 100644 --- a/apps/backend-template/test/unit/ci-cd/run-full-test-matrix.test.ts +++ b/apps/backend-template/test/unit/ci-cd/run-full-test-matrix.test.ts @@ -411,7 +411,8 @@ describe('run-full-test-matrix', () => { read('.github/workflows/ci.yml').includes('bun run website:storybook:smoke'), read('.github/workflows/ci.yml').includes('bun run website:test:cypress'), read('.github/workflows/ci.yml').includes('bun run coverage:patch'), - read('.github/workflows/ci.yml').includes('codecov --verbose upload-process'), + read('.github/workflows/ci.yml').includes('codecov/codecov-action@v5'), + read('.github/workflows/ci.yml').includes('codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac'), FULL_TEST_MATRIX.some((cell: FullMatrixTestCell) => cell.script === 'pr:governance:check'), FULL_TEST_MATRIX.some((cell: FullMatrixTestCell) => cell.script === 'requirements:check'), FULL_TEST_MATRIX.some((cell: FullMatrixTestCell) => cell.script === 'integrations:check'), @@ -422,7 +423,7 @@ describe('run-full-test-matrix', () => { !FULL_TEST_MATRIX.some( (cell: FullMatrixTestCell) => cell.script.startsWith('website:storybook') ) - ]).toStrictEqual(Array(37).fill(true)); + ]).toStrictEqual(Array(38).fill(true)); }); }); diff --git a/apps/backend-template/test/unit/ci-cd/run-service-management-integration.test.ts b/apps/backend-template/test/unit/ci-cd/run-service-management-integration.test.ts index c2e3d7da1..cb28e7e1e 100644 --- a/apps/backend-template/test/unit/ci-cd/run-service-management-integration.test.ts +++ b/apps/backend-template/test/unit/ci-cd/run-service-management-integration.test.ts @@ -34,22 +34,28 @@ describe('run-service-management-integration', () => { } ); expect(logger.log).toHaveBeenCalledWith( - `[ci] service-management integration target: ${CANDIDATE_TEST_DIRS[0]}` + `[ci] service-management integration targets: ${CANDIDATE_TEST_DIRS[0]}` ); }); - it('supports the legacy test location when the canonical location is absent', () => { + it('runs every existing service-management integration target', () => { expect.hasAssertions(); const spawn = jest.fn().mockReturnValue({ status: 0 }); expect(runServiceManagementIntegration({ root: '/workspace', - exists: (candidate: string) => candidate === `/workspace/${CANDIDATE_TEST_DIRS[1]}`, + exists: (candidate: string) => [ + `/workspace/${CANDIDATE_TEST_DIRS[0]}`, + `/workspace/${CANDIDATE_TEST_DIRS[1]}` + ].includes(candidate), discover: oneTestFile, spawn, logger: { log: jest.fn(), error: jest.fn() } })).toBe(0); - expect(spawn.mock.calls[0][1][0]).toBe(CANDIDATE_TEST_DIRS[1]); + expect(spawn.mock.calls[0][1].slice(0, 2)).toStrictEqual([ + CANDIDATE_TEST_DIRS[0], + CANDIDATE_TEST_DIRS[1] + ]); }); it('fails closed when no target exists or Jest does not return success', () => { @@ -96,7 +102,7 @@ describe('run-service-management-integration', () => { expect(status).toBe(1); expect(spawn).not.toHaveBeenCalled(); expect(logger.error).toHaveBeenCalledWith( - `[ci] service-management integration: no test files discovered in ${CANDIDATE_TEST_DIRS[0]}.` + `[ci] service-management integration: no test files discovered in ${CANDIDATE_TEST_DIRS[0]}, ${CANDIDATE_TEST_DIRS[1]}, ${CANDIDATE_TEST_DIRS[2]}.` ); }); diff --git a/apps/backend-template/test/unit/ci-cd/run-task-change-tests.test.ts b/apps/backend-template/test/unit/ci-cd/run-task-change-tests.test.ts index e9806099f..8b7dc9c0f 100644 --- a/apps/backend-template/test/unit/ci-cd/run-task-change-tests.test.ts +++ b/apps/backend-template/test/unit/ci-cd/run-task-change-tests.test.ts @@ -137,6 +137,7 @@ describe('run-task-change-tests', () => { expect(createTaskTestPlan([ '.github/dependabot.yml', '.github/workflows/ci.yml', + '.circleci/config.yml', '.husky/pre-push' ])).toStrictEqual({ type: 'mapped-unit-tests', diff --git a/apps/backend-template/test/unit/ci-cd/service-management-vendor-wiring.test.ts b/apps/backend-template/test/unit/ci-cd/service-management-vendor-wiring.test.ts new file mode 100644 index 000000000..ed149267b --- /dev/null +++ b/apps/backend-template/test/unit/ci-cd/service-management-vendor-wiring.test.ts @@ -0,0 +1,73 @@ +/* eslint-disable jest/prefer-expect-assertions */ +import fs from 'fs'; +import path from 'path'; + +/** + * JUM-734 — the entry points that start Service Management must vendor first. + * + * The SPA resolves `@jumentix/cana` and `@jumentix/designer-core/` through the + * import map in `index.html`, pointing at `apps/service-management/vendor/`. + * That directory is gitignored and generated. Before this wiring, a fresh clone + * ran `dev:service-management`, the server started, the shell rendered, and + * every panel stayed inert behind repeated `/vendor/...` 404s — a failure that + * looks like a broken designer rather than a missing build step. + * + * The test pins the wiring rather than the command text: any entry point that + * starts the Service Management process must run the vendor step first. + */ +describe('service management vendor wiring', () => { + const manifest = JSON.parse( + fs.readFileSync(path.resolve(process.cwd(), 'package.json'), 'utf-8') + ) as { scripts: Record }; + + const VENDOR_SCRIPT = 'service-management:vendor'; + + it('declares one script that generates both vendored bundles', () => { + expect.hasAssertions(); + + const vendor = manifest.scripts[VENDOR_SCRIPT]; + + expect(vendor).toBeDefined(); + expect(vendor).toContain('sync-service-management-cana-bundle.js'); + expect(vendor).toContain('sync-service-management-designer-core.js'); + expect(vendor).toContain('sync-service-management-d3.js'); + }); + + it('names generator scripts that exist on disk', () => { + expect.hasAssertions(); + + const referenced = manifest.scripts[VENDOR_SCRIPT] + .split('&&') + .map((part) => part.trim().replace(/^bun\s+/, '')) + .filter((part) => part.endsWith('.js')); + + expect(referenced).toHaveLength(3); + referenced.forEach((script) => { + expect(fs.existsSync(path.resolve(process.cwd(), script))).toBe(true); + }); + }); + + const startsDesigner = Object.entries(manifest.scripts).filter(([name, command]) => { + const isVendorScript = name === VENDOR_SCRIPT; + const launchesProcess = /pm2 start/.test(command); + const targetsDesigner = /jumentix-dev-service-management|apps\/service-management\/server\.js/ + .test(command); + return !isVendorScript && launchesProcess && targetsDesigner; + }); + + const withoutVendorStep = startsDesigner + .filter(([, command]) => !command.includes(`bun run ${VENDOR_SCRIPT}`)) + .map(([name]) => name); + + it('finds the entry points that start the designer', () => { + expect.hasAssertions(); + + expect(startsDesigner.length).toBeGreaterThan(0); + }); + + it('runs the vendor step from every one of them', () => { + expect.hasAssertions(); + + expect(withoutVendorStep).toStrictEqual([]); + }); +}); diff --git a/apps/backend-template/test/unit/infra/auth/AuthService.test.ts b/apps/backend-template/test/unit/infra/auth/AuthService.test.ts index ccb7ff4e1..726419915 100644 --- a/apps/backend-template/test/unit/infra/auth/AuthService.test.ts +++ b/apps/backend-template/test/unit/infra/auth/AuthService.test.ts @@ -336,8 +336,9 @@ describe('unit test suite for AuthService', () => { beforeAll(async () => { const deleteUserPayloads = [user1, user2, user3].map((user, index) => { const username = `delete-user-${index + 1}@xpertminds.dev`; + const { id: _seedId, ...rest } = user; return { - ...user, + ...rest, username, password: `delete_user_${index + 1}_password`, emails: [{ diff --git a/apps/backend-template/test/unit/infra/context/asyncContextMetrics.test.ts b/apps/backend-template/test/unit/infra/context/asyncContextMetrics.test.ts new file mode 100644 index 000000000..9cb6850ab --- /dev/null +++ b/apps/backend-template/test/unit/infra/context/asyncContextMetrics.test.ts @@ -0,0 +1,40 @@ +/* eslint-disable jest/prefer-expect-assertions, jest/max-expects, + jest/prefer-lowercase-title, jest/prefer-strict-equal */ +import { + Context, + redactSensitive, + resetAsyncContextMetricsForTests, + runWithContext, + snapshotAsyncContextMetrics +} from '@src/infra/context/Context'; + +describe('asyncLocalStorage context metrics + redact', () => { + beforeEach(() => { + resetAsyncContextMetricsForTests(); + }); + + it('redacts sensitive keys and preserves correlationId in recentStores', () => { + expect.hasAssertions(); + expect(redactSensitive({ authorization: 'Bearer secret', name: 'ok' })).toStrictEqual({ + authorization: '[REDACTED]', + name: 'ok' + }); + + runWithContext(new Map([ + ['correlationId', 'corr-abc'], + ['authorization', 'Bearer xyz'], + ['password', 'hunter2'] + ]), () => 'done'); + + const snapshot = snapshotAsyncContextMetrics(); + expect(snapshot.enteredTotal).toBe(1); + expect(snapshot.lastCorrelationIds).toContain('corr-abc'); + expect(snapshot.recentStores).toHaveLength(1); + expect(snapshot.recentStores[0].entries).toMatchObject({ + correlationId: 'corr-abc', + authorization: '[REDACTED]', + password: '[REDACTED]' + }); + expect(Context.getStore()).toBeUndefined(); + }); +}); diff --git a/apps/backend-template/test/unit/infra/context/contextInstrumentation.test.ts b/apps/backend-template/test/unit/infra/context/contextInstrumentation.test.ts new file mode 100644 index 000000000..9572e8e18 --- /dev/null +++ b/apps/backend-template/test/unit/infra/context/contextInstrumentation.test.ts @@ -0,0 +1,211 @@ +/* eslint-disable jest/prefer-expect-assertions, jest/max-expects, + jest/prefer-lowercase-title, jest/prefer-strict-equal */ +import { + Context, + redactSensitive, + resetAsyncContextMetricsForTests, + runWithContext, + snapshotAsyncContextMetrics +} from '@src/infra/context/Context'; + +/** + * The instrumentation half of the context store. + * + * The suite beside this one covers the redaction happy path. What it does not + * push are the bounds and the failure bookkeeping: the ring buffers that cap + * `recentStores`, `lastCorrelationIds` and the duration samples, the error + * counters for synchronous and asynchronous failures, and the serialization + * fallback for values JSON cannot represent. A metrics endpoint that grows + * without bound or that stops counting errors is worse than none — it reports + * a healthy service that is not. + */ +describe('asyncLocalStorage context instrumentation', () => { + beforeEach(() => { + resetAsyncContextMetricsForTests(); + }); + + it('serializes bigints, arrays, maps, plain objects and unserializable values', () => { + expect.hasAssertions(); + + const circular: Record = {}; + circular.self = circular; + + runWithContext(new Map([ + ['count', 1], + ['big', 10n], + ['list', ['a', 1]], + ['lookup', new Map([['k', 'v']])], + ['plain', { nested: true }], + ['nothing', null], + ['circular', circular], + ['fn', () => 'x'] + ]), () => 'done'); + + const { recentStores } = snapshotAsyncContextMetrics(); + expect(recentStores).toHaveLength(1); + expect(recentStores[0].entries).toStrictEqual({ + count: 1, + big: '10', + list: ['a', 1], + lookup: { k: 'v' }, + plain: { nested: true }, + nothing: null, + circular: '[unserializable]', + fn: expect.any(String) + }); + }); + + it('redacts arrays and nested objects by key hint at any depth', () => { + expect.hasAssertions(); + + expect(redactSensitive(['a', 'b'])).toStrictEqual(['a', 'b']); + expect(redactSensitive('value', 'secretKey')).toBe('[REDACTED]'); + expect(redactSensitive({ + token: 'x', + nested: { password: 'y', keep: 1 } + })).toStrictEqual({ + token: '[REDACTED]', + nested: { password: '[REDACTED]', keep: 1 } + }); + }); + + it('caps recent stores and correlation ids while totals keep counting', () => { + expect.hasAssertions(); + + for (let index = 0; index < 25; index += 1) { + runWithContext(new Map([['correlationId', `corr-${index}`]]), () => index); + } + + const snapshot = snapshotAsyncContextMetrics(); + expect(snapshot.enteredTotal).toBe(25); + expect(snapshot.exitedTotal).toBe(25); + expect(snapshot.recentStores).toHaveLength(20); + expect(snapshot.lastCorrelationIds).toStrictEqual( + Array.from({ length: 20 }, (_, offset) => `corr-${offset + 5}`) + ); + }); + + it('caps duration samples and still answers aggregate durations', () => { + expect.hasAssertions(); + + for (let index = 0; index < 260; index += 1) { + runWithContext(new Map(), () => index); + } + + const snapshot = snapshotAsyncContextMetrics(); + expect(snapshot.enteredTotal).toBe(260); + expect(snapshot.avgDurationMs).toBeGreaterThanOrEqual(0); + expect(snapshot.p95Ms).toBeGreaterThanOrEqual(0); + }); + + it('counts a rejected promise as an error and releases the active slot', async () => { + expect.hasAssertions(); + + await expect(runWithContext(new Map(), async () => { + throw new Error('async boom'); + })).rejects.toThrow('async boom'); + + const snapshot = snapshotAsyncContextMetrics(); + expect(snapshot.errorTotal).toBe(1); + expect(snapshot.enteredTotal).toBe(1); + expect(snapshot.exitedTotal).toBe(1); + expect(snapshot.active).toBe(0); + }); + + it('counts a synchronous throw as an error and releases the active slot', () => { + expect.hasAssertions(); + + expect(() => runWithContext(new Map(), () => { + throw new Error('sync boom'); + })).toThrow('sync boom'); + + const snapshot = snapshotAsyncContextMetrics(); + expect(snapshot.errorTotal).toBe(1); + expect(snapshot.exitedTotal).toBe(1); + expect(snapshot.active).toBe(0); + }); + + it('resolves asynchronous results and exposes the current store during a run', async () => { + expect.hasAssertions(); + + const result = await runWithContext(new Map([['correlationId', 'corr-async']]), async () => { + const during = snapshotAsyncContextMetrics(); + expect(during.active).toBe(1); + expect(during.currentStore).toMatchObject({ correlationId: 'corr-async' }); + return 'value'; + }); + + expect(result).toBe('value'); + const after = snapshotAsyncContextMetrics(); + expect(after.active).toBe(0); + expect(after.errorTotal).toBe(0); + expect(after.currentStore).toBeNull(); + }); + + it('ignores a non-finite duration sample instead of poisoning the average', () => { + expect.hasAssertions(); + + const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(NaN); + try { + runWithContext(new Map(), () => 'x'); + } finally { + nowSpy.mockRestore(); + } + + const snapshot = snapshotAsyncContextMetrics(); + expect(snapshot.exitedTotal).toBe(1); + expect(snapshot.avgDurationMs).toBe(0); + expect(snapshot.p95Ms).toBe(0); + }); + + it('skips correlation tracking for non-map stores and non-string ids', () => { + expect.hasAssertions(); + + runWithContext(new Map([['correlationId', 123]]), () => 'numeric'); + runWithContext(new Map([['correlationId', '']]), () => 'empty'); + runWithContext('not-a-map' as never, () => 'opaque'); + + const snapshot = snapshotAsyncContextMetrics(); + expect(snapshot.enteredTotal).toBe(3); + expect(snapshot.lastCorrelationIds).toStrictEqual([]); + expect(snapshot.recentStores[2].entries).toStrictEqual({}); + }); + + it('counts a double-settling thenable only once', () => { + expect.hasAssertions(); + + // A thenable that calls both callbacks settles twice: the first settle + // finishes the bookkeeping, and the rejection callback's rethrow escapes + // synchronously — but neither the exit nor an error is counted twice. + const doubleSettling = { + then: (onFulfilled: (value: unknown) => void, onRejected: (error: unknown) => void) => { + onFulfilled('value'); + onRejected(new Error('late rejection')); + } + }; + + expect(() => runWithContext(new Map(), () => doubleSettling as never)) + .toThrow('late rejection'); + + const snapshot = snapshotAsyncContextMetrics(); + expect(snapshot.enteredTotal).toBe(1); + expect(snapshot.exitedTotal).toBe(1); + expect(snapshot.errorTotal).toBe(0); + expect(snapshot.active).toBe(0); + }); + + // Last on purpose: `disable` turns the shared AsyncLocalStorage off for the + // rest of this file. + it('supports enterWith for synchronous entry and disable for teardown', () => { + expect.hasAssertions(); + + Context.enterWith(new Map([['correlationId', 'entered']])); + expect(Context.getStore()?.get('correlationId')).toBe('entered'); + expect(snapshotAsyncContextMetrics().currentStore) + .toMatchObject({ correlationId: 'entered' }); + + Context.disable(); + expect(Context.getStore()).toBeUndefined(); + expect(snapshotAsyncContextMetrics().currentStore).toBeNull(); + }); +}); diff --git a/apps/backend-template/test/unit/infra/persistence/InMemoryDatabase/InMemoryRelationalStore.indexes.test.ts b/apps/backend-template/test/unit/infra/persistence/InMemoryDatabase/InMemoryRelationalStore.indexes.test.ts index 35e3eed2b..6023725dc 100644 --- a/apps/backend-template/test/unit/infra/persistence/InMemoryDatabase/InMemoryRelationalStore.indexes.test.ts +++ b/apps/backend-template/test/unit/infra/persistence/InMemoryDatabase/InMemoryRelationalStore.indexes.test.ts @@ -54,6 +54,20 @@ describe('in-memory relational store indexes (JUM-681)', () => { .rejects.toThrow('username'); }); + it('holds unique values case-sensitively when the field has no case-insensitive index', async () => { + expect.hasAssertions(); + + // A plain unique index compares raw values: 'ALICE' and 'alice' are two + // keys, while an exact repeat is still a conflict. + const store = new InMemoryRelationalStore({ uniqueIndexes: ['username'] } as never); + await store.create('r1', { id: 'r1', username: 'alice', organization: 'org-1' }); + + await expect(store.create('r2', { id: 'r2', username: 'ALICE', organization: 'org-1' })) + .resolves.toBeDefined(); + await expect(store.create('r3', { id: 'r3', username: 'alice', organization: 'org-1' })) + .rejects.toThrow('username'); + }); + it('lets a record keep its own unique value across an unrelated edit', async () => { expect.hasAssertions(); @@ -109,6 +123,16 @@ describe('in-memory relational store indexes (JUM-681)', () => { await expect(store.getByRelation('username' as keyof Row, 'alice')).resolves.toStrictEqual([]); }); + it('treats a missing relation value as no parent, not as the empty key', async () => { + expect.hasAssertions(); + + const store = makeStore(); + await store.create('r1', { id: 'r1', username: 'alice', organization: undefined as never }); + + await expect(store.getByRelation('organization', '')).resolves.toStrictEqual([]); + expect((await store.getAll({}, { page: 1, size: 10 })).result).toHaveLength(1); + }); + it('indexes a record whose relation value is empty without claiming a key', async () => { expect.hasAssertions(); diff --git a/apps/backend-template/test/unit/infra/persistence/InMemoryDatabase/InMemoryRelationalStore.test.ts b/apps/backend-template/test/unit/infra/persistence/InMemoryDatabase/InMemoryRelationalStore.test.ts index ced7ad0cf..0345b39c4 100644 --- a/apps/backend-template/test/unit/infra/persistence/InMemoryDatabase/InMemoryRelationalStore.test.ts +++ b/apps/backend-template/test/unit/infra/persistence/InMemoryDatabase/InMemoryRelationalStore.test.ts @@ -52,7 +52,7 @@ describe('in memory relational store', () => { await store.create('1', { id: '1', username: 'john', organization: 'org-1' }); await store.create('2', { id: '2', username: 'mary', organization: 'org-2' }); - await expect(store.create('1', { id: '1', username: 'again' })).rejects.toThrow('Duplicated id'); + await expect(store.create('1', { id: '1', username: 'again' })).rejects.toThrow('The field "id" already exists.'); await store.update('2', { id: '2', username: 'mary', organization: 'org-3' }); await expect(store.getByRelation('organization', 'org-2')).resolves.toHaveLength(0); @@ -82,4 +82,115 @@ describe('in memory relational store', () => { await expect(store.delete('1')).resolves.toBe(true); }); + + it('soft-deletes, hides tombstones, and frees unique indexes', async () => { + expect.hasAssertions(); + const store = new InMemoryRelationalStore({ + uniqueIndexes: ['username'], + caseInsensitiveUniqueIndexes: ['username'], + relationIndexes: ['organization'], + softDelete: true + }); + + await store.create('1', { id: '1', username: 'john', organization: 'org-1' }); + await expect(store.delete('1')).resolves.toBe(true); + await expect(store.getOneById('1')).rejects.toThrow('Record not found'); + const tombstone = await store.getOneById('1', { includeDeleted: true }); + expect(tombstone.deletedAt).toBeTruthy(); + expect((await store.getAll({}, { page: 1, size: 10 })).total).toBe(0); + }); + + it('releases unique indexes after a tombstone and keeps the id reserved', async () => { + expect.hasAssertions(); + const store = new InMemoryRelationalStore({ + uniqueIndexes: ['username'], + caseInsensitiveUniqueIndexes: ['username'], + relationIndexes: ['organization'], + softDelete: true + }); + + await store.create('1', { id: '1', username: 'john', organization: 'org-1' }); + await store.delete('1'); + await expect(store.getByRelation('organization', 'org-1')).resolves.toHaveLength(0); + await expect(store.create('2', { id: '2', username: 'john', organization: 'org-1' })) + .resolves.toMatchObject({ username: 'john' }); + await expect(store.create('1', { id: '1', username: 'other' })) + .rejects.toThrow('The field "id" already exists.'); + }); + + it('hard-deletes a tombstone and reserves the id on the ledger', async () => { + expect.hasAssertions(); + const { InMemoryIdReservationLedger } = require('@jumentix/persistence-contracts'); + const ledger = new InMemoryIdReservationLedger(); + const store = new InMemoryRelationalStore({ + uniqueIndexes: ['username'], + softDelete: true, + entity: 'User', + ledger + }); + await store.create('gone', { id: 'gone', username: 'tmp' }); + await store.delete('gone'); + await expect(store.hardDelete('gone')).resolves.toBe(true); + ledger.reserve({ entity: 'User', id: 'gone', purgedAt: '2026-06-01T00:00:00.000Z' }); + await expect(store.create('gone', { id: 'gone', username: 'tmp2' })) + .rejects.toThrow('The field "id" already exists.'); + }); + + it('hard-delete reports a miss and skips empty relation refs', async () => { + expect.hasAssertions(); + const store = new InMemoryRelationalStore({ + relationIndexes: ['organization'] + }); + + await expect(store.hardDelete('missing')).resolves.toBe(false); + + await store.create('1', { id: '1', username: 'john' }); + await expect(store.hardDelete('1')).resolves.toBe(true); + }); + + it('hard-delete keeps a shared relation ref and drops an emptied one', async () => { + expect.hasAssertions(); + const store = new InMemoryRelationalStore({ + relationIndexes: ['organization'] + }); + + await store.create('2', { id: '2', username: 'mary', organization: 'org-1' }); + await store.create('3', { id: '3', username: 'anna', organization: 'org-1' }); + await store.create('4', { id: '4', username: 'solo', organization: 'org-2' }); + + await expect(store.hardDelete('2')).resolves.toBe(true); + await expect(store.getByRelation('organization', 'org-1')).resolves.toHaveLength(1); + + await expect(store.hardDelete('4')).resolves.toBe(true); + expect((store as any).relationIndexes.organization.has('org-2')).toBe(false); + }); + + it('hard-delete tolerates stale relation index entries', async () => { + expect.hasAssertions(); + const store = new InMemoryRelationalStore({ + relationIndexes: ['organization'] + }); + + await store.create('3', { id: '3', username: 'anna', organization: 'org-1' }); + (store as any).relationIndexes.organization.delete('org-1'); + + await expect(store.hardDelete('3')).resolves.toBe(true); + await expect(store.getByRelation('organization', 'org-1')).resolves.toHaveLength(0); + }); + + it('drops stale and tombstoned entries from relation lookups', async () => { + expect.hasAssertions(); + const store = new InMemoryRelationalStore({ + relationIndexes: ['organization'], + softDelete: true + }); + + await store.create('1', { id: '1', username: 'john', organization: 'org-1' }); + await store.delete('1'); + + const relationIndex = (store as any).relationIndexes.organization as Map>; + relationIndex.set('org-1', new Set(['1', 'ghost'])); + + await expect(store.getByRelation('organization', 'org-1')).resolves.toHaveLength(0); + }); }); diff --git a/apps/backend-template/test/unit/infra/persistence/purgeStores.test.ts b/apps/backend-template/test/unit/infra/persistence/purgeStores.test.ts new file mode 100644 index 000000000..291d1f225 --- /dev/null +++ b/apps/backend-template/test/unit/infra/persistence/purgeStores.test.ts @@ -0,0 +1,138 @@ +import { + InMemoryIdReservationLedger, + TOMBSTONE_PURGE_TTL_DAYS, + type IStore +} from '@jumentix/persistence-contracts'; +import { + adaptPurgeStore, + purgeUserAndOrganizationTombstones +} from '@src/infra/persistence/purgeStores'; +import { InMemoryRelationalStore } from '@src/infra/persistence/InMemoryDatabase/Stores/InMemoryRelationalStore'; + +type TRow = Record & { id: string; name: string; deletedAt?: unknown }; + +const farFuture = new Date('2100-01-01T00:00:00.000Z'); + +describe('adaptPurgeStore', () => { + it('lists only tombstoned rows and hard-deletes through the driver', async () => { + expect.hasAssertions(); + const store = new InMemoryRelationalStore({ softDelete: true }); + await store.create('live', { id: 'live', name: 'Live' }); + await store.create('gone', { id: 'gone', name: 'Gone' }); + await store.delete('gone'); + + const adapter = adaptPurgeStore('User', store); + const tombstones = await adapter.listTombstones(); + expect(tombstones.map((row) => row.id)).toStrictEqual(['gone']); + + await expect(adapter.hardDelete('gone')).resolves.toBe(true); + await expect(adapter.listTombstones()).resolves.toStrictEqual([]); + }); + + it('treats a page without result as empty and refuses stores without hardDelete', async () => { + expect.hasAssertions(); + const store: IStore = { + async delete() { + return true; + }, + async getOneById() { + throw new Error('Record not found'); + }, + async create(key, value) { + return value; + }, + async update(key, value) { + return value; + }, + async getAll() { + return { total: 0, result: null } as never; + } + }; + const adapter = adaptPurgeStore('User', store); + await expect(adapter.listTombstones()).resolves.toStrictEqual([]); + await expect(adapter.hardDelete('x')) + .rejects.toThrow('Store User does not implement hardDelete'); + }); +}); + +describe('purgeUserAndOrganizationTombstones', () => { + const committedPurge = async () => { + const ledger = new InMemoryIdReservationLedger(); + const userStore = new InMemoryRelationalStore({ softDelete: true }); + const organizationStore = new InMemoryRelationalStore({ softDelete: true }); + await userStore.create('u-old', { id: 'u-old', name: 'Old User' }); + await userStore.create('u-seed', { id: 'u-seed', name: 'Seed User' }); + await userStore.create('u-live', { id: 'u-live', name: 'Live User' }); + await organizationStore.create('o-old', { id: 'o-old', name: 'Old Org' }); + await userStore.delete('u-old'); + await userStore.delete('u-seed'); + await organizationStore.delete('o-old'); + + const report = await purgeUserAndOrganizationTombstones({ + userStore, + organizationStore, + ledger, + now: farFuture, + olderThanDays: 0, + commit: true, + excludeIds: ['u-seed'] + }); + return { + ledger, userStore, organizationStore, report + }; + }; + + it('reports the purged rows across both stores and the protected skip', async () => { + expect.hasAssertions(); + const { report } = await committedPurge(); + + expect(report.dryRun).toBe(false); + expect(report.events.map((event) => `${event.entity}:${event.id}`).sort()) + .toStrictEqual(['Organization:o-old', 'User:u-old']); + expect(report.skippedProtected).toBe(1); + }); + + it('reserves the purged ids on the ledger, never the protected one', async () => { + expect.hasAssertions(); + const { ledger } = await committedPurge(); + + expect(ledger.has('User', 'u-old')).toBe(true); + expect(ledger.has('Organization', 'o-old')).toBe(true); + expect(ledger.has('User', 'u-seed')).toBe(false); + }); + + it('drops the purged rows and keeps the protected and live ones', async () => { + expect.hasAssertions(); + const { userStore } = await committedPurge(); + + await expect(userStore.getOneById('u-old', { includeDeleted: true })) + .rejects.toThrow('Record not found'); + await expect(userStore.getOneById('u-seed', { includeDeleted: true })) + .resolves.toMatchObject({ id: 'u-seed' }); + await expect(userStore.getOneById('u-live')).resolves.toMatchObject({ id: 'u-live' }); + }); + + it('dry-run reports without touching rows, the ledger, or the default TTL', async () => { + expect.hasAssertions(); + const ledger = new InMemoryIdReservationLedger(); + const userStore = new InMemoryRelationalStore({ softDelete: true }); + const organizationStore = new InMemoryRelationalStore({ softDelete: true }); + await userStore.create('u-old', { id: 'u-old', name: 'Old User' }); + await userStore.delete('u-old'); + + const report = await purgeUserAndOrganizationTombstones({ + userStore, + organizationStore, + ledger, + now: farFuture, + commit: false + }); + + expect(report.dryRun).toBe(true); + expect(report.olderThanDays).toBe(TOMBSTONE_PURGE_TTL_DAYS); + expect(report.events).toHaveLength(1); + expect(ledger.list()).toHaveLength(0); + await expect(userStore.getOneById('u-old', { includeDeleted: true })) + .resolves.toMatchObject({ id: 'u-old' }); + }); +}); diff --git a/apps/backend-template/test/unit/interface/Async/RealtimeAPIBase.catalogs.test.ts b/apps/backend-template/test/unit/interface/Async/RealtimeAPIBase.catalogs.test.ts deleted file mode 100644 index 2f3e15311..000000000 --- a/apps/backend-template/test/unit/interface/Async/RealtimeAPIBase.catalogs.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ - -import fs from 'fs'; -import os from 'os'; -import path from 'path'; -import type { - IRealtimeAPIFactory -} from '@src/interface/Async/RealtimeAPIBase'; -import { - RealtimeAPIBase -} from '@src/interface/Async/RealtimeAPIBase'; -import { InMemoryDbClient } from '@src/infra/persistence/InMemoryDatabase/InMemoryDbClient'; -// eslint-disable-next-line import/no-unresolved -import { InMemoryMessageMediatorAdapter } from '@jumentix/message-mediator'; - -/** - * Unit suite for the Catalogs wiring inside RealtimeAPIBase (JUM-491), - * mirroring the existing `RealtimeAPIBase` suite's patterns exactly: the - * protected compose method is exercised for build + memoization, and the - * OAS-driven operation registration is driven from a temp spec dir with the - * controller-module spy, asserting the catalogs branch passes the composed - * use cases to the controller factory. - */ - -class TestRealtimeAPI extends RealtimeAPIBase { - public constructor(config: IRealtimeAPIFactory, autoBuild = false) { - super(config, autoBuild); - } -} - -const databaseClient = InMemoryDbClient; - -describe('realtimeAPIBase catalogs composition wiring', () => { - it('composes catalogs module once and reuses the memoized composition', () => { - expect.hasAssertions(); - const api = new TestRealtimeAPI({ - databaseClient, - messageMediator: new InMemoryMessageMediatorAdapter() - }); - - const composedOne = (api as any).composeCatalogsModule(); - const composedTwo = (api as any).composeCatalogsModule(); - expect(composedTwo).toBe(composedOne); - expect(composedOne.catalogUseCases).toBeDefined(); - expect(composedOne.catalogService).toBeDefined(); - expect(composedOne.dataRepository).toBeDefined(); - }); - - it('composes catalogs module without a mediator (event bus fallback)', () => { - expect.hasAssertions(); - const api = new TestRealtimeAPI({ databaseClient }); - const composed = (api as any).composeCatalogsModule(); - expect(composed.catalogUseCases).toBeDefined(); - }); - - it('registers catalogs operations from the OAS spec with the composed use cases', () => { - expect.hasAssertions(); - const specDir = fs.mkdtempSync(path.join(os.tmpdir(), 'realtime-catalogs-oas-')); - const filePath = path.join(specDir, '1.0.0.yml'); - fs.writeFileSync(filePath, ` -openapi: 3.1.0 -info: - version: 1.0.0 - title: test -paths: - /catalogs: - get: - operationId: getAllCatalogs -`, 'utf8'); - - const mockControllerFactory = jest.fn().mockImplementation(() => ({ - getAll: jest.fn().mockResolvedValue({ result: [] }) - })); - const getControllerModuleSpy = jest - .spyOn(RealtimeAPIBase as any, 'getControllerModule') - .mockReturnValue(mockControllerFactory); - - const api = new TestRealtimeAPI( - { - databaseClient, - specDir - }, - true - ); - - expect((api as any).listOperationIds()).toContain('getAllCatalogs'); - expect(mockControllerFactory).toHaveBeenCalledWith(expect.anything()); - const factoryArg = mockControllerFactory.mock.calls[0][0]; - expect(factoryArg.catalogUseCases).toBeDefined(); - expect(typeof factoryArg.catalogUseCases.getAll).toBe('function'); - - const operations: any[] = [...(api as any).operations.values()]; - const operation = operations.find( - (entry) => entry.operationId === 'getAllCatalogs' - ); - expect(operation.controllerMethod).toBe('getAll'); - - getControllerModuleSpy.mockRestore(); - fs.rmSync(specDir, { recursive: true, force: true }); - }); -}); diff --git a/apps/backend-template/test/unit/interface/Async/RealtimeAPIBase.loadFailure.test.ts b/apps/backend-template/test/unit/interface/Async/RealtimeAPIBase.loadFailure.test.ts new file mode 100644 index 000000000..40f44bce9 --- /dev/null +++ b/apps/backend-template/test/unit/interface/Async/RealtimeAPIBase.loadFailure.test.ts @@ -0,0 +1,110 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { RealtimeAPIBase } from '@src/interface/Async/RealtimeAPIBase'; + +/** + * Module-resolution failures that are NOT "module not found" (JUM-698's other + * half). + * + * A missing handler or controller module is an expected condition — fall back + * or answer `undefined`. A module that exists and explodes while loading is a + * different failure (a syntax error, a bad import), and swallowing it as + * "missing" would hide a broken deploy behind a silent fallback. These mocks + * replace real modules this suite never invokes for their declared operation + * with doubles that fail at load time — once with an `Error`, once with a bare + * value, since a broken module throws whatever it throws. + */ + +jest.mock( + '@src/modules/Users/interface/websocketapi/frameworks/socket-io/handlers/logout', + () => { + throw new TypeError('handler module exploded'); + } +); + +jest.mock( + '@src/modules/Users/interface/websocketapi/frameworks/socket-io/handlers/updateUserPassword', + () => { + // eslint-disable-next-line no-throw-literal + throw 'handler module exploded without an Error'; + } +); + +// The concrete controllers are all re-exported by the Users barrel, so a +// factory that throws at load would break every import of the barrel in this +// file — and a getter that throws only on access is flattened away by bun's +// mock interop. The controllers barrel itself is imported by nothing, which +// makes it the one module reachable through the controller template whose +// evaluation can fail honestly on both runners. A thrown value carries no +// `message`; the resolver must still rethrow it rather than read it as +// "module not found". +jest.mock( + '@src/modules/Users/adapters/in/http/controllers/index', + () => { + // eslint-disable-next-line no-throw-literal + throw 'controller module exploded without an Error'; + } +); + +class ProbeAPI extends RealtimeAPIBase { + public constructor() { + super({ + databaseClient: { + connect: jest.fn(), + disconnect: jest.fn() + } as any, + interfaceType: 'websocketapi', + frameworkName: 'socket-io' + }, false); + } +} + +describe('realtime api base load-time module failures', () => { + it('rethrows a load-time Error from a runtime handler module', () => { + expect.hasAssertions(); + + const api = new ProbeAPI(); + + expect(() => (api as any).getRuntimeHandlerFactory({ + moduleName: 'Users', + operationId: 'logout', + controllerMethod: 'logout', + controller: {}, + endPointConfig: {} + })).toThrow('handler module exploded'); + }); + + it('rethrows a load-time non-Error from a runtime handler module', () => { + expect.hasAssertions(); + + const api = new ProbeAPI(); + + let caught: unknown; + try { + (api as any).getRuntimeHandlerFactory({ + moduleName: 'Users', + operationId: 'updateUserPassword', + controllerMethod: 'updatePassword', + controller: {}, + endPointConfig: {} + }); + } catch (error) { + caught = error; + } + + expect(caught).toBe('handler module exploded without an Error'); + }); + + it('rethrows a load-time failure from a controller module', () => { + expect.hasAssertions(); + + let caught: unknown; + try { + (RealtimeAPIBase as any).getControllerModule('Users', 'index'); + } catch (error) { + caught = error; + } + + expect(caught).toBe('controller module exploded without an Error'); + }); +}); diff --git a/apps/backend-template/test/unit/interface/Async/RealtimeAPIBase.moduleResolution.test.ts b/apps/backend-template/test/unit/interface/Async/RealtimeAPIBase.moduleResolution.test.ts new file mode 100644 index 000000000..255c427b9 --- /dev/null +++ b/apps/backend-template/test/unit/interface/Async/RealtimeAPIBase.moduleResolution.test.ts @@ -0,0 +1,119 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { RealtimeAPIBase } from '@src/interface/Async/RealtimeAPIBase'; +import { PasswordCryptoService } from '@src/infra/security/PasswordCryptoService'; +import { InMemoryKeyValueStorageClient } from '@src/infra/persistence/KeyValueStorage/InMemoryKeyValueStorageClient'; +import { MutexService } from '@src/infra/mutex/adapter/MutexService'; +import { JwtService } from '@src/infra/jwt/JwtService'; +import { composeUsersAuthServices } from '@src/modules/Users'; +import { InMemoryDbClient } from '@src/infra/persistence/InMemoryDatabase/InMemoryDbClient'; + +/** + * The resolver's answer when the module loads but is not a handler factory. + * + * Jest cannot virtual-mock a path that only exists through `moduleNameMapper`, + * so these substitute real handler modules this suite never invokes for their + * declared operation. What is asserted is the resolver's behaviour: a loaded + * module whose default is not a factory is "no handler", and a framework that + * forwards the bare request reaches the `invoke` fallback the websocket + * wrapper normally hides. + */ + +jest.mock( + '@src/modules/Users/interface/websocketapi/frameworks/socket-io/handlers/login', + // No `__esModule` marker: bun's require unwraps marked mocks to the default + // value itself, while jest returns the factory result verbatim. The bare + // `{ default }` shape is what both runners hand to the resolver. + () => ({ default: 42 }) +); + +jest.mock( + '@src/modules/Users/interface/websocketapi/frameworks/socket-io/handlers/register', + // The framework module receives the deps and hands back the raw invoke, so + // the test can drive it without the websocket response wrapper. + () => ({ default: ({ invoke }: any) => invoke }) +); + +class ProbeAPI extends RealtimeAPIBase {} + +const databaseClient = { + connect: jest.fn(), + disconnect: jest.fn() +} as any; + +describe('realtime api base module resolution edge cases', () => { + it('builds operations from the real spec when autoBuild keeps its default', () => { + expect.hasAssertions(); + + const passwordCryptoService = PasswordCryptoService.compile(); + const keyValueStorageClient = InMemoryKeyValueStorageClient.compile(); + const mutexService = MutexService.compile(keyValueStorageClient); + const jwtService = JwtService.compile(); + const { authService } = composeUsersAuthServices({ + databaseClient: InMemoryDbClient, + passwordCryptoService, + mutexService, + jwtService + }); + + const api = new ProbeAPI({ + databaseClient: InMemoryDbClient, + passwordCryptoService, + mutexService, + authService + }); + + expect((api as any).listOperationIds()).toStrictEqual(expect.arrayContaining(['login', 'getAll'])); + }); + + it('answers undefined when the runtime module default is not a factory', () => { + expect.hasAssertions(); + + const api = new ProbeAPI({ + databaseClient, + interfaceType: 'websocketapi', + frameworkName: 'socket-io' + }, false); + + const handler = (api as any).getRuntimeHandlerFactory({ + moduleName: 'Users', + operationId: 'login', + controllerMethod: 'login', + controller: {}, + endPointConfig: {} + }); + + expect(handler).toBeUndefined(); + }); + + it('answers empty metadata when a bare invoke request carries none', async () => { + expect.hasAssertions(); + + // The websocket wrapper always decorates metadata before invoking; a + // framework that hands the request straight through (as this double does) + // exercises the `|| {}` the wrapper hides. + const api = new ProbeAPI({ + databaseClient, + interfaceType: 'websocketapi', + frameworkName: 'socket-io' + }, false); + const register = jest.fn().mockResolvedValue({ result: 'pong' }); + + const invoke = (api as any).getRuntimeHandlerFactory({ + moduleName: 'Users', + operationId: 'register', + controllerMethod: 'register', + controller: { register }, + endPointConfig: { operationId: 'register' } + }); + + const response = await invoke({ operationId: 'register' }); + + expect(response).toStrictEqual({ + ok: true, + operationId: 'register', + metadata: {}, + result: 'pong' + }); + }); +}); diff --git a/apps/backend-template/test/unit/interface/CLI/entityModelManager.flows.test.ts b/apps/backend-template/test/unit/interface/CLI/entityModelManager.flows.test.ts index ad0a79f92..02c898806 100644 --- a/apps/backend-template/test/unit/interface/CLI/entityModelManager.flows.test.ts +++ b/apps/backend-template/test/unit/interface/CLI/entityModelManager.flows.test.ts @@ -200,6 +200,38 @@ describe('entity manager flows (JUM-681)', () => { expect(updated.entities[0].behaviors).toStrictEqual(['settle']); }); + it('keeps the stored domain when the typed custom domain is empty', async () => { + expect.hasAssertions(); + + // update → entity 0 → kind entity → "Type custom domain" → blank answer → + // back. The custom-domain prompt falls back to the entity's own domain. + const run = scriptedContext( + catalogWithEntity(), + [3, 0, 0, 1, BACK], + ['', '', '', ''] + ); + + await entityModelManagerSubApplication.run(run.context as never); + + expect(run.saved[0].entities[0].domain).toBe('Billing'); + }); + + it('keeps an empty stored domain empty when the typed custom domain is also empty', async () => { + expect.hasAssertions(); + + const catalog = catalogWithEntity(); + catalog.entities[0].domain = ''; + const run = scriptedContext( + catalog, + [3, 0, 0, 1, BACK], + ['', '', '', ''] + ); + + await entityModelManagerSubApplication.run(run.context as never); + + expect(run.saved[0].entities[0].domain).toBe(''); + }); + it('deletes only on the typed confirmation', async () => { expect.hasAssertions(); diff --git a/apps/backend-template/test/unit/interface/HTTP/RestAPI.catalogs.test.ts b/apps/backend-template/test/unit/interface/HTTP/RestAPI.catalogs.test.ts deleted file mode 100644 index 973f8a1aa..000000000 --- a/apps/backend-template/test/unit/interface/HTTP/RestAPI.catalogs.test.ts +++ /dev/null @@ -1,460 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any, jest/max-expects */ -import { Express } from 'express'; -import { ExpressServer } from '@src/interface/HTTP/adapters/express/ExpressServer'; -import { infraHandlers } from '@src/interface/HTTP/adapters/express/handlers/infraHandlers'; -import { RestAPI } from '@src/interface/HTTP/RestAPI'; -import { InMemoryDbClient } from '@src/infra/persistence/InMemoryDatabase/InMemoryDbClient'; -import { EHTTPFrameworks } from '@src/interface/HTTP/ports'; -import { PasswordCryptoService } from '@src/infra/security/PasswordCryptoService'; -import { InMemoryKeyValueStorageClient } from '@src/infra/persistence/KeyValueStorage/InMemoryKeyValueStorageClient'; -import { MutexService } from '@src/infra/mutex/adapter/MutexService'; -import { JwtService } from '@src/infra/jwt/JwtService'; -import { composeUsersAuthServices } from '@src/modules/Users'; -import { _DOCS_PREFIX_ } from '@src/config/constants'; -import { composeCatalogsServices } from '@src/modules/Catalogs'; -// eslint-disable-next-line import/no-unresolved -import { InMemoryMessageMediatorAdapter } from '@jumentix/message-mediator'; - -type ModuleLoader = (request: string, parent: unknown, isMain: boolean) => unknown; - -const loadModule = ( - moduleLoads: Map, - originalLoad: ModuleLoader, - request: string, - parent: unknown, - isMain: boolean -): unknown => { - const mockedLoad = moduleLoads.get(request); - if (mockedLoad) return mockedLoad(request, parent, isMain); - return originalLoad(request, parent, isMain); -}; - -/** - * Unit suite for the Catalogs wiring inside RestAPI (JUM-491): the REAL - * RestAPI is constructed against the REAL OAS spec, the REAL Express server - * adapter and the REAL in-memory database client — the same boot the - * production adapters run — so the catalogs composition path - * (`composeCatalogsModule`, the `/catalogs` route/controller registration and - * the handler factories) executes exactly as deployed, not as a description - * of it. The pattern mirrors the existing `RealtimeAPIBase` unit suite - * (real infra services, memoization asserted through the protected method). - */ - -const databaseClient = InMemoryDbClient; -const passwordCryptoService = PasswordCryptoService.compile(); -const keyValueStorageClient = InMemoryKeyValueStorageClient.compile(); -const mutexService = MutexService.compile(keyValueStorageClient); -const jwtService = JwtService.compile(); -const { authService } = composeUsersAuthServices({ - databaseClient, - passwordCryptoService, - mutexService, - jwtService -}); - -const previousHttpPort = process.env.JUMENTIX_HTTP_PORT; - -const restoreHttpPort = () => { - if (previousHttpPort === undefined) { - delete process.env.JUMENTIX_HTTP_PORT; - return; - } - process.env.JUMENTIX_HTTP_PORT = previousHttpPort; -}; - -describe('restAPI catalogs composition wiring', () => { - // Stub the framework-handler factory at the prototype: route REGISTRATION - // (spec parsing, controller composition, endpoint paths) stays real, but - // loading every module's framework handlers is the integration suites' - // proof (test/integration/Express/*), not this unit file's — importing - // them here would drag their handler bodies into the unit coverage set. - let handlerFactorySpy: jest.SpyInstance | undefined; - - beforeEach(() => { - handlerFactorySpy = jest - .spyOn(RestAPI.prototype as any, 'getHandlerFactory') - .mockImplementation(({ endPointConfig }: any) => ({ - path: '/stubbed', - method: 'get', - handler: async () => undefined, - operationId: endPointConfig?.operationId - })); - }); - - afterEach(() => { - handlerFactorySpy?.mockRestore(); - }); - - it('registers the catalogs routes from the OAS spec with a composed controller', () => { - expect.hasAssertions(); - const webServer = ExpressServer.compile(); - - // Construction registers every spec route, including /catalogs — a - // missing catalogs composition fails closed right here. - const api = new RestAPI({ - databaseClient, - webServer, - infraHandlers, - serverType: EHTTPFrameworks.express, - authService, - passwordCryptoService, - keyValueStorageClient, - mutexService, - messageMediator: new InMemoryMessageMediatorAdapter() - }); - - expect(api).toBeDefined(); - const registeredOperations = (handlerFactorySpy as jest.SpyInstance).mock.calls.map( - (call) => (call[0] as any).operationId - ); - expect(registeredOperations).toStrictEqual(expect.arrayContaining([ - 'getAllCatalogs', - 'createCatalog', - 'getCatalogById', - 'updateCatalog', - 'deleteCatalog', - 'restoreCatalog' - ])); - }); - - it('composes catalogs module once and reuses the memoized composition', () => { - expect.hasAssertions(); - const webServer = ExpressServer.compile(); - const api = new RestAPI({ - databaseClient, - webServer, - infraHandlers, - serverType: EHTTPFrameworks.express, - authService, - passwordCryptoService, - keyValueStorageClient, - mutexService - }); - - const composedOne = (api as any).composeCatalogsModule(); - const composedTwo = (api as any).composeCatalogsModule(); - expect(composedTwo).toBe(composedOne); - expect(composedOne.catalogUseCases).toBeDefined(); - expect(composedOne.catalogService).toBeDefined(); - expect(composedOne.dataRepository).toBeDefined(); - }); - - it('propagates the module resolution error for a missing handler module', () => { - expect.hasAssertions(); - const webServer = ExpressServer.compile(); - const api = new RestAPI({ - databaseClient, - webServer, - infraHandlers, - serverType: EHTTPFrameworks.express, - authService, - passwordCryptoService, - keyValueStorageClient, - mutexService - }); - // Restore AFTER construction: the real factory runs only for these calls. - handlerFactorySpy?.mockRestore(); - expect(() => (api as any).getHandlerFactory({ - moduleName: 'Missing', - operationId: 'notThere' - })).toThrow(/Handler not found for module Missing|Could not locate module/); - - const catalogsHandler = (api as any).getHandlerFactory({ - moduleName: 'Catalogs', - operationId: 'getAllCatalogs', - endPointConfig: { operationId: 'getAllCatalogs' }, - controller: {} - }); - expect(catalogsHandler.path).toBe('/catalogs'); - expect(catalogsHandler.method).toBe('get'); - }); - - it('propagates the module resolution error for a missing controller module', () => { - expect.hasAssertions(); - expect(() => (RestAPI as any).getControllerModule('Users', 'MissingController')) - .toThrow(/Could not locate module|Controller MissingController not found/); - }); - - it('honours a plain event bus when no mediator is configured', () => { - expect.hasAssertions(); - const webServer = ExpressServer.compile(); - const eventBus = { publish: async () => undefined, subscribe: () => undefined }; - const api = new RestAPI({ - databaseClient, - webServer, - infraHandlers, - serverType: EHTTPFrameworks.express, - authService, - passwordCryptoService, - keyValueStorageClient, - mutexService, - eventBus - } as any); - expect((api as any).eventBus).toBe(eventBus); - }); - - it('defaults to Express and serves API versions without an auth service', () => { - expect.hasAssertions(); - const webServer = ExpressServer.compile(); - const registered: any[] = []; - const registerSpy = jest - .spyOn(webServer, 'endPointRegister') - .mockImplementation((endPoint: any) => { - registered.push(endPoint); - return endPoint; - }); - const buildEndPointsSpy = jest - .spyOn(RestAPI.prototype as any, 'buildEndPoints') - .mockImplementation(() => undefined); - - try { - const api = new RestAPI({ - databaseClient, - webServer, - infraHandlers - } as any); - - expect((api as any).serverType).toBe(EHTTPFrameworks.express); - const versionsEndpoint = registered.find((endPoint) => endPoint.path === '/versions'); - expect(versionsEndpoint).toBeDefined(); - } finally { - registerSpy.mockRestore(); - buildEndPointsSpy.mockRestore(); - } - }); - - it('treats undefined path blocks in a spec as empty endpoint maps', () => { - expect.hasAssertions(); - const api = Object.create(RestAPI.prototype); - const registerOperationEndpoint = jest.fn(); - api.registerOperationEndpoint = registerOperationEndpoint; - - (api as any).registerSpecVersionEndpoints('1.0.0', { - openapi: '3.1.0', - paths: { '/ghost': undefined } - }); - - expect(registerOperationEndpoint).not.toHaveBeenCalled(); - }); - - it('declares the users composition dependencies it requires', () => { - expect.hasAssertions(); - const baseConfig = { - databaseClient, - webServer: ExpressServer.compile(), - infraHandlers, - serverType: EHTTPFrameworks.express, - authService - }; - expect(() => new RestAPI(baseConfig as any)) - .toThrow('PasswordCryptoService is required to compose Users module.'); - expect(() => new RestAPI({ - ...baseConfig, - webServer: ExpressServer.compile(), - passwordCryptoService - } as any)).toThrow('MutexService is required to compose Users module.'); - expect(() => new RestAPI({ - ...baseConfig, - webServer: ExpressServer.compile(), - passwordCryptoService, - mutexService, - authService: { authenticate: () => Promise.resolve({}) } - } as any)).toThrow('AuthService with JwtService is required to compose Users module.'); - }); - - it('rejects seed operations when the use cases fail', async () => { - expect.hasAssertions(); - const webServer = ExpressServer.compile(); - const api = new RestAPI({ - databaseClient, - webServer, - infraHandlers, - serverType: EHTTPFrameworks.express, - authService, - passwordCryptoService, - keyValueStorageClient, - mutexService - }); - (api as any).usersComposition = { - userUseCases: { - create: async () => ({ error: new Error('db down') }), - delete: async () => ({ error: new Error('db down') }), - getAll: async () => ({ result: [{ id: 'u-1' }] }) - }, - organizationUseCases: { - getOneById: async () => ({ result: { id: 'org-1' } }), - create: async () => ({ result: { id: 'org-1' } }) - } - }; - await expect(api.seedUsers()).rejects.toThrow('db down'); - await expect(api.deleteUsers()).rejects.toThrow('db down'); - - (api as any).usersComposition = { - organizationUseCases: { - getOneById: async () => ({ result: null }), - create: async () => ({ error: new Error('org db down') }) - } - }; - await expect(api.seedOrganizations()).rejects.toThrow('org db down'); - - (api as any).usersComposition = { - organizationUseCases: { - getOneById: async () => ({ result: null }), - create: async () => ({ result: undefined }) - } - }; - await expect(api.seedOrganizations()).rejects.toThrow('Organization seed failed'); - - (api as any).usersComposition = { - userUseCases: { - create: async () => ({ result: undefined }) - }, - organizationUseCases: { - getOneById: async () => ({ result: { id: 'org-1' } }) - } - }; - await expect(api.seedUsers()).rejects.toThrow('User seed failed'); - - (api as any).usersComposition = { - userUseCases: { - delete: async () => ({ result: undefined }), - getAll: async () => ({ result: [{ id: 'u-1' }] }) - }, - organizationUseCases: { - getOneById: async () => ({ result: { id: 'org-1' } }) - } - }; - await expect(api.deleteUsers()).rejects.toThrow('User delete failed'); - - (api as any).usersComposition = { - userUseCases: { - getAll: async () => ({ result: undefined }) - }, - organizationUseCases: { - getOneById: async () => ({ result: { id: 'org-1' } }) - } - }; - await expect(api.deleteUsers()).resolves.toStrictEqual([]); - }); - - it('falls through handler modules without a default export before failing closed', () => { - expect.hasAssertions(); - handlerFactorySpy?.mockRestore(); - const nodeModule = require('module'); - const originalLoad = nodeModule._load; - const moduleLoads = new Map([ - [ - '@src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/virtualMissing', - () => ({}) - ], - [ - '@src/modules/Catalogs/interface/restapi/frameworks/express/handlers/virtualMissing', - () => ({}) - ] - ]); - const loadSpy = jest.spyOn(nodeModule, '_load').mockImplementation((...args: unknown[]) => { - const [request, parent, isMain] = args as [string, unknown, boolean]; - return loadModule(moduleLoads, originalLoad, request, parent, isMain); - }); - const api = Object.create(RestAPI.prototype); - api.serverType = EHTTPFrameworks.fastify; - - expect(() => (api as any).getHandlerFactory({ - moduleName: 'Catalogs', - operationId: 'virtualMissing' - })).toThrow('Handler not found for module Catalogs'); - loadSpy.mockRestore(); - }); - - it('serves the asyncapi versions document through the infra handler', () => { - expect.hasAssertions(); - const webServer = ExpressServer.compile(); - const endpoints: any[] = []; - const registerSpy = jest - .spyOn(webServer, 'endPointRegister') - .mockImplementation((endPoint: any) => { - endpoints.push(endPoint); - return endPoint; - }); - // eslint-disable-next-line no-new - new RestAPI({ - databaseClient, - webServer, - infraHandlers, - serverType: EHTTPFrameworks.express, - authService, - passwordCryptoService, - keyValueStorageClient, - mutexService - }); - const asyncVersions = endpoints.find( - (endPoint) => endPoint.path === `${_DOCS_PREFIX_}/asyncapi/versions` - ); - const res = { - status: jest.fn().mockReturnThis(), - json: jest.fn() - }; - asyncVersions.handler({}, res); - expect(res.status).toHaveBeenCalledWith(200); - expect(res.json.mock.calls[0][0].versions['1.0.0']).toContain('asyncapi'); - registerSpy.mockRestore(); - }); - - it('starts, seeds, deletes and stops over the in-memory driver', async () => { - expect.hasAssertions(); - const webServer = ExpressServer.compile(); - const api = new RestAPI({ - databaseClient, - webServer, - infraHandlers, - serverType: EHTTPFrameworks.express, - authService, - passwordCryptoService, - keyValueStorageClient, - mutexService - }); - process.env.JUMENTIX_HTTP_PORT = '0'; - try { - await api.start(); - await api.start(); - await api.seedData(); - const deleted = await api.deleteUsers(); - expect(deleted.length).toBeGreaterThan(0); - await api.stop(); - } finally { - restoreHttpPort(); - } - }); - - it('passes the composed use cases to the catalogs controller factory', () => { - expect.hasAssertions(); - const webServer = ExpressServer.compile(); - const composition = composeCatalogsServices({ databaseClient }); - const factorySpy = jest.fn().mockImplementation(() => ({})); - const getControllerModuleSpy = jest - .spyOn(RestAPI as any, 'getControllerModule') - .mockReturnValue(factorySpy); - - // eslint-disable-next-line no-new - new RestAPI({ - databaseClient, - webServer, - infraHandlers, - serverType: EHTTPFrameworks.express, - authService, - passwordCryptoService, - keyValueStorageClient, - mutexService - }); - - expect(factorySpy).toHaveBeenCalledWith(expect.anything()); - const catalogsCall = factorySpy.mock.calls - .map((call) => call[0]) - .find((factoryArg) => factoryArg.catalogUseCases); - expect(catalogsCall.catalogUseCases).toBeDefined(); - expect(typeof catalogsCall.catalogUseCases.getAll).toBe('function'); - expect(typeof catalogsCall.catalogUseCases.create).toBe('function'); - expect(catalogsCall.catalogUseCases).not.toBe(composition.catalogUseCases); - getControllerModuleSpy.mockRestore(); - }); -}); diff --git a/apps/backend-template/test/unit/interface/HTTP/RestAPI.composition.test.ts b/apps/backend-template/test/unit/interface/HTTP/RestAPI.composition.test.ts new file mode 100644 index 000000000..005ae8cd9 --- /dev/null +++ b/apps/backend-template/test/unit/interface/HTTP/RestAPI.composition.test.ts @@ -0,0 +1,290 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, jest/max-expects */ + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { RestAPI } from '@src/interface/HTTP/RestAPI'; +import { EHTTPFrameworks } from '@src/interface/HTTP/ports'; +import { infraHandlers } from '@src/interface/HTTP/adapters/express/handlers/infraHandlers'; +import { PasswordCryptoService } from '@src/infra/security/PasswordCryptoService'; +import { InMemoryKeyValueStorageClient } from '@src/infra/persistence/KeyValueStorage/InMemoryKeyValueStorageClient'; +import { MutexService } from '@src/infra/mutex/adapter/MutexService'; +import { JwtService } from '@src/infra/jwt/JwtService'; +import { composeUsersAuthServices } from '@src/modules/Users'; +import { InMemoryDbClient } from '@src/infra/persistence/InMemoryDatabase/InMemoryDbClient'; + +/** + * Constructor wiring that only exists when a spec directory does not. + * + * `RestAPI.buildWithOAS` reads `./spec` relative to the process cwd, so these + * suites chdir into a fixture directory per test. That is what lets the specs + * be wrong on purpose — a spec without an `openapi` key, an AsyncAPI file + * without an `asyncapi` key, a path outside the Users module — each a file a + * real deployment can ship. + */ + +const serverDouble = () => { + const registered: any[] = []; + const server = { + endPointRegister: jest.fn((endpoint: any) => { registered.push(endpoint); }), + start: jest.fn().mockResolvedValue(undefined), + stop: jest.fn().mockResolvedValue(undefined) + }; + return { server, registered }; +}; + +const bareConfig = (server: any, overrides: Record = {}) => ({ + databaseClient: InMemoryDbClient, + webServer: server, + infraHandlers, + ...overrides +}); + +describe('restAPI composition with fixture spec directories', () => { + let cwd: string; + let tmp: string; + + afterEach(() => { + process.chdir(cwd); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + const chdirTo = (files: Record) => { + cwd = process.cwd(); + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'restapi-spec-')); + Object.entries(files).forEach(([name, content]) => { + const target = path.join(tmp, name); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content, 'utf8'); + }); + process.chdir(tmp); + }; + + it('skips non-spec files and specs without an openapi declaration', () => { + expect.hasAssertions(); + + chdirTo({ + 'spec/notes.txt': 'not a spec', + 'spec/broken.yaml': 'foo: bar\n', + 'spec/empty.yml': 'openapi: 3.1.0\ninfo:\n version: 9.9.9\npaths:\n /null-path:\n', + 'spec/asyncapi/bad.yml': 'foo: bar\n', + 'spec/asyncapi/v2.yaml': 'asyncapi: 3.0.0\ninfo:\n version: 2.0.0\n title: fixture\n' + }); + + const { server, registered } = serverDouble(); + // eslint-disable-next-line no-new + new RestAPI(bareConfig(server)); + + const paths = registered.map((endpoint) => endpoint.path); + // The spec with a path that declares no operations still gets its docs + // route, and the path itself registers nothing; the broken one gets + // nothing at all. + expect(paths).toContain('/docs/9.9.9'); + expect(paths).toContain('/docs/asyncapi/2.0.0'); + expect(paths.some((entry) => entry.includes('broken'))).toBe(false); + expect(paths.some((entry) => entry.includes('bad'))).toBe(false); + expect(paths.some((entry) => entry.includes('null-path'))).toBe(false); + }); + + it('registers endpoints for a module outside Users through the same pipeline', () => { + expect.hasAssertions(); + + chdirTo({ + 'spec/1.0.0.yml': [ + 'openapi: 3.1.0', + 'info:', + ' version: 1.0.0', + ' title: fixture', + 'paths:', + ' /tasks:', + ' post:', + ' operationId: createTask' + ].join('\n') + }); + + // Tasks is not a module this template ships; the controller and handler + // lookups are doubled so the wiring itself is what runs. + const controllerFactory = jest.fn().mockImplementation(() => ({})); + const controllerSpy = jest + .spyOn(RestAPI as any, 'getControllerModule') + .mockReturnValue(controllerFactory); + const handlerSpy = jest + .spyOn(RestAPI.prototype as any, 'getHandlerFactory') + .mockReturnValue({ method: 'post', path: '/tasks', handler: jest.fn() }); + + const { server, registered } = serverDouble(); + // eslint-disable-next-line no-new + new RestAPI(bareConfig(server)); + + expect(registered.map((endpoint) => endpoint.path)).toContain('/api/1.0.0/tasks'); + expect(controllerFactory).toHaveBeenCalledWith(expect.objectContaining({ + userService: undefined, + authUseCases: undefined + })); + + handlerSpy.mockRestore(); + controllerSpy.mockRestore(); + }); + + it('treats a missing asyncapi directory and an asyncapi file as no async specs', () => { + expect.hasAssertions(); + + chdirTo({ + 'spec/empty.yml': 'openapi: 3.1.0\ninfo:\n version: 9.9.9\npaths: {}\n' + }); + const withoutDir = serverDouble(); + // eslint-disable-next-line no-new + new RestAPI(bareConfig(withoutDir.server)); + expect(withoutDir.registered.map((endpoint) => endpoint.path)) + .toContain('/docs/asyncapi/versions'); + + process.chdir(cwd); + fs.rmSync(tmp, { recursive: true, force: true }); + + // `spec/asyncapi` present but a regular file, not a directory. + cwd = process.cwd(); + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'restapi-spec-')); + fs.mkdirSync(path.join(tmp, 'spec')); + fs.writeFileSync( + path.join(tmp, 'spec', 'empty.yml'), + 'openapi: 3.1.0\ninfo:\n version: 9.9.9\npaths: {}\n', + 'utf8' + ); + fs.writeFileSync(path.join(tmp, 'spec', 'asyncapi'), 'not a directory', 'utf8'); + process.chdir(tmp); + + const withFile = serverDouble(); + // eslint-disable-next-line no-new + new RestAPI(bareConfig(withFile.server)); + expect(withFile.registered.map((endpoint) => endpoint.path)) + .toContain('/docs/asyncapi/versions'); + }); + + it('uses the message mediator as the event bus when both are configured', () => { + expect.hasAssertions(); + + chdirTo({ 'spec/empty.yml': 'openapi: 3.1.0\ninfo:\n version: 9.9.9\npaths: {}\n' }); + + const eventBus = { publish: jest.fn() }; + const messageMediator = { publish: jest.fn(), subscribe: jest.fn() }; + + const both = serverDouble(); + const apiWithBoth = new RestAPI(bareConfig(both.server, { eventBus, messageMediator })); + expect((apiWithBoth as any).messageMediator).toBe(messageMediator); + expect((apiWithBoth as any).eventBus).toBe(messageMediator); + + const onlyBus = serverDouble(); + const apiWithOnlyBus = new RestAPI(bareConfig(onlyBus.server, { eventBus })); + expect((apiWithOnlyBus as any).eventBus).toBe(eventBus); + + const neither = serverDouble(); + const apiWithNeither = new RestAPI(bareConfig(neither.server)); + expect((apiWithNeither as any).eventBus).toBeUndefined(); + expect((apiWithNeither as any).serverType).toBe(EHTTPFrameworks.express); + }); + + it('fails composition with the name of each missing service, in order', () => { + expect.hasAssertions(); + + chdirTo({ 'spec/empty.yml': 'openapi: 3.1.0\ninfo:\n version: 9.9.9\npaths: {}\n' }); + const { server } = serverDouble(); + const api = new RestAPI(bareConfig(server)) as any; + + expect(() => api.composeUsersModule()) + .toThrow('PasswordCryptoService is required to compose Users module.'); + + api.passwordCryptoService = PasswordCryptoService.compile(); + expect(() => api.composeUsersModule()) + .toThrow('MutexService is required to compose Users module.'); + + api.mutexClient = MutexService.compile(InMemoryKeyValueStorageClient.compile()); + expect(() => api.composeUsersModule()) + .toThrow('AuthService with JwtService is required to compose Users module.'); + + // An auth service without a JWT service fails the same guard. + api.authService = {}; + expect(() => api.composeUsersModule()) + .toThrow('AuthService with JwtService is required to compose Users module.'); + + const { authService } = composeUsersAuthServices({ + databaseClient: InMemoryDbClient, + passwordCryptoService: api.passwordCryptoService, + mutexService: api.mutexClient, + jwtService: JwtService.compile() + }); + api.authService = authService; + + const composition = api.composeUsersModule(); + expect(composition.userUseCases).toBeDefined(); + expect(api.composeUsersModule()).toBe(composition); + }); + + it('starts and stops cleanly without a key-value client or replay worker', async () => { + expect.hasAssertions(); + + chdirTo({ 'spec/empty.yml': 'openapi: 3.1.0\ninfo:\n version: 9.9.9\npaths: {}\n' }); + + const passwordCryptoService = PasswordCryptoService.compile(); + const mutexService = MutexService.compile(InMemoryKeyValueStorageClient.compile()); + const { authService } = composeUsersAuthServices({ + databaseClient: InMemoryDbClient, + passwordCryptoService, + mutexService, + jwtService: JwtService.compile() + }); + + const { server } = serverDouble(); + const api = new RestAPI(bareConfig(server, { + passwordCryptoService, + mutexService, + authService + })); + + await api.start(); + expect((api as any).started).toBe(true); + // Without a shared store there is no queue and no worker to start. + expect((api as any).usersComposition.deadLetterWorker).toBeUndefined(); + expect(server.start).toHaveBeenCalledTimes(1); + + await api.stop(); + expect(server.stop).toHaveBeenCalledTimes(1); + }); + + it('registers process hooks that stop the server on exit and fail on unhandled rejection', () => { + expect.hasAssertions(); + + chdirTo({ 'spec/empty.yml': 'openapi: 3.1.0\ninfo:\n version: 9.9.9\npaths: {}\n' }); + + const onSpy = jest.spyOn(process, 'on'); + const callsBefore = onSpy.mock.calls.length; + const { server } = serverDouble(); + const api = new RestAPI(bareConfig(server)); + const added = onSpy.mock.calls.slice(callsBefore); + onSpy.mockRestore(); + + const exitHandler = added.find(([event]) => event === 'exit')?.[1] as () => void; + const rejectionHandler = added.find(([event]) => event === 'unhandledRejection')?.[1] as + (error: unknown) => void; + expect(exitHandler).toBeDefined(); + expect(rejectionHandler).toBeDefined(); + + // Exiting the process stops the API instead of dropping connections. + const stopSpy = jest.spyOn(api, 'stop').mockResolvedValue(undefined); + exitHandler(); + expect(stopSpy).toHaveBeenCalledTimes(1); + stopSpy.mockRestore(); + + // An unhandled rejection is reported and terminates the process loudly. + const errorLog = jest.spyOn(console, 'error').mockImplementation(); + const exitSpy = jest + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as any); + const failure = new Error('unhandled kaboom'); + rejectionHandler(failure); + expect(errorLog).toHaveBeenCalledWith(failure); + expect(exitSpy).toHaveBeenCalledWith(1); + errorLog.mockRestore(); + exitSpy.mockRestore(); + }); +}); diff --git a/apps/backend-template/test/unit/interface/HTTP/RestAPI.loadFailure.test.ts b/apps/backend-template/test/unit/interface/HTTP/RestAPI.loadFailure.test.ts new file mode 100644 index 000000000..df3e7fd40 --- /dev/null +++ b/apps/backend-template/test/unit/interface/HTTP/RestAPI.loadFailure.test.ts @@ -0,0 +1,160 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { RestAPI } from '@src/interface/HTTP/RestAPI'; +import { infraHandlers } from '@src/interface/HTTP/adapters/express/handlers/infraHandlers'; +import { InMemoryDbClient } from '@src/infra/persistence/InMemoryDatabase/InMemoryDbClient'; + +/** + * Load-time module failures in the resolver (JUM-698's other half, REST side). + * + * A missing handler module is expected — the resolver falls back to express. + * A module that exists and explodes while loading must propagate: swallowing + * it as "missing" hides a broken deploy behind a fallback that cannot work + * either. + * + * Runner portability, learned the hard way: bun's require unwraps mocked + * modules marked `__esModule` to the default itself, and flattens away + * property getters that throw — so the handler doubles throw from a `default` + * getter (nothing pre-loads those modules, and the first require runs the + * factory on both runners). For the controller side, the concrete controllers + * are all re-exported by the Users barrel, which loads them before any getter + * can fire; the controllers barrel itself is imported by nothing, so its + * factory can throw at evaluation time — exactly what `require` surfaces for + * a broken module, on both runners. It throws a bare value on purpose: bun + * treats an `Error` thrown by the factory body as a failed mock and falls + * back to the real module, while a non-Error propagates. + */ + +jest.mock( + '@src/modules/Users/interface/restapi/frameworks/express/handlers/login', + () => ({ + __esModule: true, + get default(): unknown { + throw new TypeError('handler module exploded'); + } + }) +); + +jest.mock( + '@src/modules/Users/interface/restapi/frameworks/express/handlers/logout', + () => ({ + __esModule: true + // No default export: the module loads, but holds no handler factory, so + // the resolver moves on to the next framework candidate. + }) +); + +jest.mock( + '@src/modules/Users/interface/restapi/frameworks/express/handlers/register', + () => ({ + __esModule: true, + get default(): unknown { + // eslint-disable-next-line no-throw-literal + throw 'register handler exploded without an Error'; + } + }) +); + +jest.mock( + '@src/modules/Users/adapters/in/http/controllers/index', + () => { + // eslint-disable-next-line no-throw-literal + throw 'controller module exploded'; + } +); + +describe('restAPI resolver load-time failures', () => { + let cwd: string; + let tmp: string; + + // A bare fixture spec keeps construction from resolving real Users modules, + // which the doubles above would break. + beforeEach(() => { + cwd = process.cwd(); + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'restapi-loadfail-')); + fs.mkdirSync(path.join(tmp, 'spec')); + fs.writeFileSync( + path.join(tmp, 'spec', 'empty.yml'), + 'openapi: 3.1.0\ninfo:\n version: 9.9.9\npaths: {}\n', + 'utf8' + ); + process.chdir(tmp); + }); + + afterEach(() => { + process.chdir(cwd); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + const bareApi = () => new RestAPI({ + databaseClient: InMemoryDbClient, + webServer: { + endPointRegister: jest.fn(), + start: jest.fn(), + stop: jest.fn() + } as any, + infraHandlers + }) as any; + + it('rethrows a load-time failure from a handler module instead of falling back', () => { + expect.hasAssertions(); + + const api = bareApi(); + + expect(() => api.getHandlerFactory({ + moduleName: 'Users', + operationId: 'login', + endPointConfig: { operationId: 'login' } + })).toThrow('handler module exploded'); + }); + + it('moves past a handler module with no default export and fails when none remains', () => { + expect.hasAssertions(); + + const api = bareApi(); + + expect(() => api.getHandlerFactory({ + moduleName: 'Users', + operationId: 'logout', + endPointConfig: { operationId: 'logout' } + })).toThrow('Handler not found for module Users, operation logout, framework express.'); + }); + + it('rethrows a load-time failure from a controller module', () => { + expect.hasAssertions(); + + let caught: unknown; + try { + (RestAPI as any).getControllerModule('Users', 'index'); + } catch (error) { + caught = error; + } + + expect(caught).toBe('controller module exploded'); + }); + + it('rethrows a load-time non-Error from a handler module instead of falling back', () => { + expect.hasAssertions(); + + const api = bareApi(); + + // A thrown value carries no `message`; the missing-module check must not + // mistake that for an absent module and fall back over a broken deploy. + let caught: unknown; + try { + api.getHandlerFactory({ + moduleName: 'Users', + operationId: 'register', + endPointConfig: { operationId: 'register' } + }); + } catch (error) { + caught = error; + } + + expect(caught).toBe('register handler exploded without an Error'); + }); +}); diff --git a/apps/backend-template/test/unit/interface/HTTP/RestAPI.test.ts b/apps/backend-template/test/unit/interface/HTTP/RestAPI.test.ts new file mode 100644 index 000000000..797107e6d --- /dev/null +++ b/apps/backend-template/test/unit/interface/HTTP/RestAPI.test.ts @@ -0,0 +1,580 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, jest/max-expects */ + +import { DataBaseNotFoundError } from '@src/infra/exceptions'; +import { RestAPI } from '@src/interface/HTTP/RestAPI'; +import { EHTTPFrameworks } from '@src/interface/HTTP/ports'; +import { infraHandlers } from '@src/interface/HTTP/adapters/express/handlers/infraHandlers'; +import { PasswordCryptoService } from '@src/infra/security/PasswordCryptoService'; +import { InMemoryKeyValueStorageClient } from '@src/infra/persistence/KeyValueStorage/InMemoryKeyValueStorageClient'; +import { MutexService } from '@src/infra/mutex/adapter/MutexService'; +import { JwtService } from '@src/infra/jwt/JwtService'; +import { composeUsersAuthServices } from '@src/modules/Users'; +import { InMemoryDbClient } from '@src/infra/persistence/InMemoryDatabase/InMemoryDbClient'; + +import seedOrganizations_ from '@seed/organizations'; +import seedUsers_, { seedUserIds } from '@seed/users'; +import { entityIdLedger } from '@src/infra/persistence/InMemoryDatabase/idReservationLedger'; +import { InMemoryRelationalStore } from '@src/infra/persistence/InMemoryDatabase/Stores/InMemoryRelationalStore'; +import { TOMBSTONE_PURGE_TTL_DAYS } from '@jumentix/persistence-contracts'; + +/** + * The REST runtime wired against the real OAS and AsyncAPI specs. + * + * The suites beside this one cover the dead-letter lifecycle and the + * module-resolution helper in isolation. This one builds the API the way the + * composition root does — real spec directory, real Users composition, real + * infra handler factories — and asserts what the runtime publishes: versioned + * endpoints, docs routes, seed behaviour, and the guard errors that tell an + * operator which service the composition is missing. + */ + +type FakeServer = { + endPointRegister: jest.Mock; + start: jest.Mock; + stop: jest.Mock; +}; + +const buildServices = () => { + const passwordCryptoService = PasswordCryptoService.compile(); + const keyValueStorageClient = InMemoryKeyValueStorageClient.compile(); + const mutexService = MutexService.compile(keyValueStorageClient); + const jwtService = JwtService.compile(); + const { authService } = composeUsersAuthServices({ + databaseClient: InMemoryDbClient, + passwordCryptoService, + mutexService, + jwtService + }); + return { + passwordCryptoService, + keyValueStorageClient, + mutexService, + authService + }; +}; + +const buildApi = (overrides: Record = {}) => { + const registered: any[] = []; + const server: FakeServer = { + endPointRegister: jest.fn((endpoint: any) => { registered.push(endpoint); }), + start: jest.fn().mockResolvedValue(undefined), + stop: jest.fn().mockResolvedValue(undefined) + }; + const api = new RestAPI({ + databaseClient: InMemoryDbClient, + webServer: server as any, + serverType: EHTTPFrameworks.express, + infraHandlers, + ...buildServices(), + ...overrides + }); + return { api, server, registered }; +}; + +describe('restAPI endpoint wiring against the real specs', () => { + it('registers versioned module, infra and documentation endpoints', () => { + expect.hasAssertions(); + + const { registered } = buildApi(); + + expect(registered.length).toBeGreaterThan(0); + const paths = registered.map((endpoint) => endpoint.path); + + // Module endpoints from the OAS, with path params converted for the + // framework and the version prefix applied. + expect(paths).toContain('/api/1.0.0/auth/login'); + expect(paths).toContain('/api/1.0.0/users/:id'); + expect(paths).toContain('/api/1.0.0/organizations/:id'); + + // Infra and docs endpoints. + expect(paths).toContain('/'); + expect(paths).toContain('/async-context-metrics'); + expect(paths).toContain('/docs/1.0.0'); + expect(paths).toContain('/docs/asyncapi/versions'); + expect(paths).toContain('/docs/asyncapi/1.0.0'); + }); + + it('serves async context metrics and the asyncapi version index', () => { + expect.hasAssertions(); + + const { registered } = buildApi(); + const responseFor = () => { + const res = { status: jest.fn().mockReturnThis(), json: jest.fn() }; + return res; + }; + + const metrics = registered.find((endpoint) => endpoint.path === '/async-context-metrics'); + const metricsRes = responseFor(); + metrics.handler({}, metricsRes); + expect(metricsRes.status).toHaveBeenCalledWith(200); + expect(metricsRes.json).toHaveBeenCalledWith(expect.objectContaining({ + enteredTotal: expect.any(Number), + active: expect.any(Number) + })); + + const versions = registered.find((endpoint) => endpoint.path === '/docs/asyncapi/versions'); + const versionsRes = responseFor(); + versions.handler({}, versionsRes); + expect(versionsRes.status).toHaveBeenCalledWith(200); + expect(versionsRes.json).toHaveBeenCalledWith({ + versions: { '1.0.0': '/docs/asyncapi/1.0.0' } + }); + }); + + it('falls back to the express handler when the configured framework lacks one', () => { + expect.hasAssertions(); + + // Every operation in the spec has an express handler; none has a + // derby-js one, so each registration walks the candidate list. + const { registered } = buildApi({ serverType: EHTTPFrameworks.derby_js }); + + expect(registered.length).toBeGreaterThan(0); + expect(registered.map((endpoint) => endpoint.path)).toContain('/api/1.0.0/auth/login'); + }); + + it('resolves handlers directly and fails loudly when none exists', () => { + expect.hasAssertions(); + + const { api } = buildApi(); + + const handler = (api as any).getHandlerFactory({ + moduleName: 'Users', + operationId: 'login', + endPointConfig: { operationId: 'login' } + }); + expect(handler.method).toBe('post'); + expect(handler.path).toBe('/auth/login'); + + expect(() => (api as any).getHandlerFactory({ + moduleName: 'Users', + operationId: 'no-such-operation', + endPointConfig: { operationId: 'no-such-operation' } + })).toThrow( + 'Handler not found for module Users, operation no-such-operation, framework express.' + ); + }); + + it('fails loudly when a controller module loads without the named export', () => { + expect.hasAssertions(); + + // The controllers barrel loads fine but holds no controller under its own + // name — a refactor that renames the export reads exactly like this. + expect(() => (RestAPI as any).getControllerModule('Users', 'index')) + .toThrow('Controller index not found for module Users.'); + }); +}); + +describe('restAPI lifecycle with the real composition', () => { + it('starts the dead-letter worker with the server and stops it on stop', async () => { + expect.hasAssertions(); + + const { api, server } = buildApi(); + + await api.start(); + expect((api as any).started).toBe(true); + + const worker = (api as any).usersComposition.deadLetterWorker; + expect(worker.running).toBe(true); + + // A second start must not double-connect or start a second timer. + await api.start(); + expect(server.start).toHaveBeenCalledTimes(1); + + await api.stop(); + expect(worker.running).toBe(false); + }); + + it('seeds organizations and users idempotently, then deletes every user', async () => { + expect.hasAssertions(); + + const { api } = buildApi(); + + const organizations = await api.seedOrganizations(); + expect(organizations.length).toBeGreaterThan(0); + + // A second pass finds each organization already present and returns the + // stored records instead of creating duplicates. + const again = await api.seedOrganizations(); + expect(again.map((org: any) => org.id).sort()) + .toStrictEqual(organizations.map((org: any) => org.id).sort()); + + await api.seedData(); + const reseeded = await api.seedUsers(); + expect(reseeded.length).toBeGreaterThan(0); + + const deleted = await api.deleteUsers(); + expect(deleted).toHaveLength(reseeded.length); + expect(deleted.every(Boolean)).toBe(true); + }); +}); + +const missingRecordDb = () => ({ + stores: { + Organization: { + getOneById: jest.fn().mockRejectedValue(new DataBaseNotFoundError('Record not found')) + }, + User: { + getOneById: jest.fn().mockRejectedValue(new DataBaseNotFoundError('Record not found')) + } + }, + connect: jest.fn(), + disconnect: jest.fn() +}) as any; + +const noTombstoneDb = () => ({ + stores: { + Organization: { getOneById: jest.fn().mockResolvedValue(undefined) }, + User: { getOneById: jest.fn().mockResolvedValue(undefined) } + }, + connect: jest.fn(), + disconnect: jest.fn() +}) as any; + +/** + * A store double with soft-delete semantics, wired to a use-case double that + * reads the same record — the tombstone contract without a shared singleton. + */ +const softDeleteDb = ( + storeName: 'Organization' | 'User', + records: Array> +) => { + const state = { records: new Map(records.map((record) => [record.id, record])) }; + const store = { + getOneById: jest.fn(async (id: string, options?: { includeDeleted?: boolean }) => { + const record = state.records.get(id); + if (!record || (record.deletedAt && !options?.includeDeleted)) { + throw new DataBaseNotFoundError('Record not found'); + } + return record; + }), + update: jest.fn(async (id: string, value: Record) => { + const merged = { ...state.records.get(id), ...value }; + state.records.set(id, merged); + return merged; + }) + }; + const useCases = { + getOneById: jest.fn(async (id: string) => { + try { + return { result: await store.getOneById(id) }; + } catch { + return { error: new DataBaseNotFoundError('Record not found') }; + } + }), + create: jest.fn() + }; + const databaseClient: any = { + stores: { [storeName]: store }, + connect: jest.fn(), + disconnect: jest.fn() + }; + return { + databaseClient, store, useCases, state + }; +}; + +describe('restAPI seed tombstone restore (JUM-787)', () => { + it('restores a soft-deleted organization instead of failing on the reserved id', async () => { + expect.hasAssertions(); + + const tombstoned = seedOrganizations_.map((org: any) => ({ + ...org, + deletedAt: '2026-01-01T00:00:00.000Z' + })); + const { + databaseClient, store, useCases, state + } = softDeleteDb('Organization', tombstoned); + const { api } = buildApi({ databaseClient }); + (api as any).usersComposition = { organizationUseCases: useCases }; + + // Soft-deleted: the use case cannot see them, but a fresh create would + // clash on the reserved ids. The seed restores the tombstones in place. + const seeded = await api.seedOrganizations(); + + expect(seeded.map((org: any) => org.id).sort()) + .toStrictEqual(seedOrganizations_.map((org: any) => org.id).sort()); + for (const record of state.records.values()) { + expect(record.deletedAt).toBeNull(); + } + expect(store.update).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ deletedAt: null }) + ); + expect(useCases.create).not.toHaveBeenCalled(); + }); + + it('restores a soft-deleted user instead of failing on the reserved id', async () => { + expect.hasAssertions(); + + const tombstoned = seedUsers_.map((user: any) => ({ + ...user, + deletedAt: '2026-01-01T00:00:00.000Z' + })); + const { + databaseClient, store, useCases, state + } = softDeleteDb('User', tombstoned); + const { api } = buildApi({ databaseClient }); + (api as any).usersComposition = { + organizationUseCases: { + getOneById: async () => ({ result: { id: 'org-1' } }), + create: async () => ({ result: { id: 'org-1' } }) + }, + userUseCases: useCases + }; + + const seeded = await api.seedUsers(); + + expect(seeded.map((user: any) => user.id).sort()) + .toStrictEqual(seedUsers_.map((user: any) => user.id).sort()); + for (const record of state.records.values()) { + expect(record.deletedAt).toBeNull(); + } + expect(store.update).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ deletedAt: null }) + ); + expect(useCases.create).not.toHaveBeenCalled(); + }); +}); + +describe('restAPI seed failure propagation', () => { + it('propagates organization create failures when the record is truly missing', async () => { + expect.hasAssertions(); + + // The seed path asks the store about a tombstone before creating; a store + // that answers "not found" (throwing, the InMemory contract) is what lets + // the create run at all. + const { api } = buildApi({ databaseClient: missingRecordDb() }); + (api as any).usersComposition = { + organizationUseCases: { + getOneById: async () => ({ result: null }), + create: async () => ({ error: new Error('organization create failed') }) + } + }; + await expect(api.seedOrganizations()).rejects.toThrow('organization create failed'); + + // A create that answers neither result nor error is a failed seed, not a + // silent skip. + (api as any).usersComposition = { + organizationUseCases: { + getOneById: async () => ({ result: null }), + create: async () => ({}) + } + }; + await expect(api.seedOrganizations()).rejects.toThrow('Organization seed failed'); + }); + + it('keeps the seed id reserved when the store holds a record the use case cannot see', async () => { + expect.hasAssertions(); + + // JUM-787: with soft delete, an id the use case cannot see may still sit + // in the store as a tombstone. The seed restores it (deletedAt: null) + // instead of creating a duplicate — and if it stays invisible it is + // skipped, silently, rather than double-created. + const organizationStore = { + getOneById: jest.fn().mockResolvedValue({ id: 'org-1', deletedAt: '2026-01-01' }), + update: jest.fn().mockResolvedValue({ id: 'org-1', deletedAt: null }) + }; + const databaseClient: any = { + stores: { Organization: organizationStore }, + connect: jest.fn(), + disconnect: jest.fn() + }; + const create = jest.fn().mockResolvedValue({ result: { id: 'org-1' } }); + const { api } = buildApi({ databaseClient }); + (api as any).usersComposition = { + organizationUseCases: { + getOneById: async () => ({ result: null }), + create + } + }; + + const seeded = await api.seedOrganizations(); + + expect(seeded).toStrictEqual([]); + expect(create).not.toHaveBeenCalled(); + expect(organizationStore.update).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ deletedAt: null }) + ); + }); + + it('propagates user create failures when the record is truly missing', async () => { + expect.hasAssertions(); + + // A store that answers "no tombstone" without throwing (other drivers do) + // takes the same create path as one that throws not-found. + const { api } = buildApi({ databaseClient: noTombstoneDb() }); + (api as any).usersComposition = { + organizationUseCases: { + getOneById: async () => ({ result: { id: 'org-1' } }), + create: async () => ({ result: { id: 'org-1' } }) + }, + userUseCases: { + getOneById: async () => ({ result: null }), + create: async () => ({ error: new Error('user create failed') }) + } + }; + await expect(api.seedUsers()).rejects.toThrow('user create failed'); + + (api as any).usersComposition = { + organizationUseCases: { + getOneById: async () => ({ result: { id: 'org-1' } }), + create: async () => ({ result: { id: 'org-1' } }) + }, + userUseCases: { + getOneById: async () => ({ result: null }), + create: async () => ({}) + } + }; + await expect(api.seedUsers()).rejects.toThrow('User seed failed'); + }); + + it('answers an empty batch when no users exist to delete', async () => { + expect.hasAssertions(); + + // A list response without a payload is "nothing to delete", not a crash. + const { api } = buildApi(); + (api as any).usersComposition = { + userUseCases: { + getAll: async () => ({}) + } + }; + + await expect(api.deleteUsers()).resolves.toStrictEqual([]); + }); + + it('rejects the batch when any user delete reports a failure', async () => { + expect.hasAssertions(); + + const { api } = buildApi(); + (api as any).usersComposition = { + userUseCases: { + getAll: async () => ({ result: [{ id: 'user-1' }, { id: 'user-2' }] }), + delete: async () => ({ error: new Error('delete refused') }) + } + }; + await expect(api.deleteUsers()).rejects.toThrow('delete refused'); + + (api as any).usersComposition = { + userUseCases: { + getAll: async () => ({ result: [{ id: 'user-1' }] }), + delete: async () => ({}) + } + }; + await expect(api.deleteUsers()).rejects.toThrow('User delete failed'); + }); +}); + +/** + * JUM-804: the loopback-only purge endpoint and the purgeTombstones facade. + * Private store instances keep the singleton InMemoryDbClient — and the seed + * ledger — untouched by what these tests purge. + */ +const purgeDb = () => { + const userStore = new InMemoryRelationalStore({ softDelete: true }); + const organizationStore = new InMemoryRelationalStore({ softDelete: true }); + const databaseClient: any = { + stores: { User: userStore, Organization: organizationStore }, + connect: jest.fn(), + disconnect: jest.fn() + }; + return { databaseClient, userStore, organizationStore }; +}; + +describe('restAPI tombstone purge endpoint (JUM-804)', () => { + const responseFor = () => ({ status: jest.fn().mockReturnThis(), json: jest.fn() }); + + it('rejects non-loopback callers and dry-runs for loopback ones', async () => { + expect.hasAssertions(); + + const { databaseClient, userStore } = purgeDb(); + await userStore.create('purge-u1', { id: 'purge-u1', username: 'purge-u1' }); + await userStore.delete('purge-u1'); + const { registered } = buildApi({ databaseClient }); + const endpoint = registered.find((e: any) => e.path === '/internal/tombstones/purge'); + + const remote = responseFor(); + await endpoint.handler({ ip: '10.0.0.1', body: { commit: true } }, remote); + expect(remote.status).toHaveBeenCalledWith(403); + expect(remote.json).toHaveBeenCalledWith({ error: 'Purge is loopback-only.' }); + + const noAddress = responseFor(); + await endpoint.handler({}, noAddress); + expect(noAddress.status).toHaveBeenCalledWith(403); + + // A fresh tombstone is younger than the default TTL: reported, not purged. + const viaSocket = responseFor(); + await endpoint.handler({ socket: { remoteAddress: '::1' } }, viaSocket); + expect(viaSocket.status).toHaveBeenCalledWith(200); + expect(viaSocket.json).toHaveBeenCalledWith(expect.objectContaining({ + dryRun: true, + events: [], + skippedTooYoung: 1 + })); + + const ipv4 = responseFor(); + await endpoint.handler({ ip: '127.0.0.1', body: {} }, ipv4); + expect(ipv4.status).toHaveBeenCalledWith(200); + await expect(userStore.getOneById('purge-u1', { includeDeleted: true })) + .resolves.toMatchObject({ id: 'purge-u1' }); + }); + + it('commits the purge for a loopback caller and drops the rows', async () => { + expect.hasAssertions(); + + const { databaseClient, userStore, organizationStore } = purgeDb(); + await userStore.create('purge-u2', { id: 'purge-u2', username: 'purge-u2' }); + await organizationStore.create('purge-o2', { id: 'purge-o2', name: 'purge-o2' }); + await userStore.delete('purge-u2'); + await organizationStore.delete('purge-o2'); + const { registered } = buildApi({ databaseClient }); + const endpoint = registered.find((e: any) => e.path === '/internal/tombstones/purge'); + + const res = responseFor(); + await endpoint.handler( + { ip: '::ffff:127.0.0.1', body: { commit: true, olderThanDays: -1, protectSeed: false } }, + res + ); + + expect(res.status).toHaveBeenCalledWith(200); + const report = res.json.mock.calls[0][0]; + expect(report.dryRun).toBe(false); + expect(report.events.map((event: any) => `${event.entity}:${event.id}`).sort()) + .toStrictEqual(['Organization:purge-o2', 'User:purge-u2']); + await expect(userStore.getOneById('purge-u2', { includeDeleted: true })) + .rejects.toThrow('Record not found'); + expect(entityIdLedger.has('User', 'purge-u2')).toBe(true); + expect(entityIdLedger.has('Organization', 'purge-o2')).toBe(true); + }); + + it('defaults options, protects seed ids, and honors protectSeed false', async () => { + expect.hasAssertions(); + + const { databaseClient, userStore } = purgeDb(); + const { api } = buildApi({ databaseClient }); + await userStore.create('purge-u3', { id: 'purge-u3', username: 'purge-u3' }); + await userStore.delete('purge-u3'); + + const dryDefault = await api.purgeTombstones(); + expect(dryDefault.dryRun).toBe(true); + expect(dryDefault.olderThanDays).toBe(TOMBSTONE_PURGE_TTL_DAYS); + expect(dryDefault.events).toStrictEqual([]); + expect(dryDefault.skippedTooYoung).toBe(1); + + const committed = await api.purgeTombstones({ olderThanDays: 0, commit: true }); + expect(committed.events.map((event) => event.id)).toStrictEqual(['purge-u3']); + expect(entityIdLedger.has('User', 'purge-u3')).toBe(true); + + await userStore.create(seedUserIds[0], { id: seedUserIds[0], username: 'purge-seed-user' }); + await userStore.delete(seedUserIds[0]); + + const protectedReport = await api.purgeTombstones({ olderThanDays: -1 }); + expect(protectedReport.skippedProtected).toBe(1); + expect(protectedReport.events).toStrictEqual([]); + + const unprotected = await api.purgeTombstones({ olderThanDays: -1, protectSeed: false }); + expect(unprotected.skippedProtected).toBe(0); + expect(unprotected.events.map((event) => event.id)).toStrictEqual([seedUserIds[0]]); + }); +}); diff --git a/apps/backend-template/test/unit/interface/HTTP/adapters/express/usersMetrics.handler.test.ts b/apps/backend-template/test/unit/interface/HTTP/adapters/express/usersMetrics.handler.test.ts new file mode 100644 index 000000000..935d98513 --- /dev/null +++ b/apps/backend-template/test/unit/interface/HTTP/adapters/express/usersMetrics.handler.test.ts @@ -0,0 +1,130 @@ +import getUsersMetrics from '@src/modules/Users/interface/restapi/frameworks/express/handlers/getUsersMetrics'; +import getOrganizationsMetrics from '@src/modules/Users/interface/restapi/frameworks/express/handlers/getOrganizationsMetrics'; +import { ValidationError } from '@src/infra/exceptions'; + +const makeRes = () => ({ + status: jest.fn().mockReturnThis(), + json: jest.fn() +}); + +describe('express entity metrics handlers', () => { + it('returns 200 for count and 400 when metric is not accepted', async () => { + expect.hasAssertions(); + const controller = { + getUsersMetrics: jest.fn() + .mockResolvedValueOnce({ result: { metric: 'count', buckets: [{ key: 'total', count: 2 }] } }) + .mockRejectedValueOnce(new ValidationError( + 'The parameter metric is not accepted. Accepted: count, groupBy, series.' + )) + }; + const endpoint = getUsersMetrics({ + endPointConfig: { 'x-metrics-capabilities': { groupable: [], series: [] } }, + controller + } as any); + expect(endpoint.path).toBe('/users/metrics'); + + const ok = makeRes(); + await endpoint.handler({ + query: { metric: 'count' }, + headers: { authorization: 'Bearer token' } + } as any, ok as any); + expect(ok.status).toHaveBeenCalledWith(200); + expect(ok.json).toHaveBeenCalledWith({ metric: 'count', buckets: [{ key: 'total', count: 2 }] }); + + const bad = makeRes(); + await endpoint.handler({ + query: { metric: 'avg' }, + headers: { authorization: 'Bearer token' } + } as any, bad as any); + expect(bad.status).toHaveBeenCalledWith(400); + expect(bad.json).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('Accepted: count, groupBy, series') + })); + }); + + it('registers organizations metrics on GET /organizations/metrics', async () => { + expect.hasAssertions(); + const controller = { + getOrganizationsMetrics: jest.fn().mockResolvedValue({ + result: { metric: 'count', buckets: [{ key: 'total', count: 1 }] } + }) + }; + const endpoint = getOrganizationsMetrics({ + endPointConfig: {}, + controller + } as any); + expect(endpoint.path).toBe('/organizations/metrics'); + const res = makeRes(); + await endpoint.handler({ + query: { metric: 'count' }, + headers: { authorization: 'Bearer token' } + } as any, res as any); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('defaults a missing query string and authorization header on users metrics', async () => { + expect.hasAssertions(); + const controller = { + getUsersMetrics: jest.fn().mockResolvedValue({ + result: { metric: 'count', buckets: [{ key: 'total', count: 0 }] } + }) + }; + const endpoint = getUsersMetrics({ endPointConfig: {}, controller } as any); + const res = makeRes(); + await endpoint.handler({ headers: {} } as any, res as any); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('can not be empty') + })); + }); + + it('defaults a missing query string and authorization header on organizations metrics', async () => { + expect.hasAssertions(); + const controller = { + getOrganizationsMetrics: jest.fn().mockResolvedValue({ + result: { metric: 'count', buckets: [{ key: 'total', count: 0 }] } + }) + }; + const endpoint = getOrganizationsMetrics({ endPointConfig: {}, controller } as any); + const res = makeRes(); + await endpoint.handler({ headers: {} } as any, res as any); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('can not be empty') + })); + }); + + it('rethrows a resolved service error through the error response path', async () => { + expect.hasAssertions(); + const usersController = { + getUsersMetrics: jest.fn().mockResolvedValue({ + error: new ValidationError('The parameter metric is not accepted. Accepted: count.') + }) + }; + const usersEndpoint = getUsersMetrics({ + endPointConfig: {}, controller: usersController + } as any); + const usersRes = makeRes(); + await usersEndpoint.handler({ + query: { metric: 'avg' }, + headers: { authorization: 'Bearer token' } + } as any, usersRes as any); + expect(usersRes.status).toHaveBeenCalledWith(400); + + const organizationsController = { + getOrganizationsMetrics: jest.fn().mockResolvedValue({ + error: new ValidationError('The parameter metric is not accepted. Accepted: count.') + }) + }; + const organizationsEndpoint = getOrganizationsMetrics({ + endPointConfig: {}, + controller: organizationsController + } as any); + const organizationsRes = makeRes(); + await organizationsEndpoint.handler({ + query: { metric: 'avg' }, + headers: { authorization: 'Bearer token' } + } as any, organizationsRes as any); + expect(organizationsRes.status).toHaveBeenCalledWith(400); + }); +}); diff --git a/apps/backend-template/test/unit/interface/HTTP/adapters/fastify/FastifyServer.edgeCases.test.ts b/apps/backend-template/test/unit/interface/HTTP/adapters/fastify/FastifyServer.edgeCases.test.ts new file mode 100644 index 000000000..5a3982d61 --- /dev/null +++ b/apps/backend-template/test/unit/interface/HTTP/adapters/fastify/FastifyServer.edgeCases.test.ts @@ -0,0 +1,72 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { FastifyServer } from '@src/interface/HTTP/adapters/fastify/FastifyServer'; + +const CORS_ORIGINS_ENV = 'JUMENTIX_CORS_ALLOWED_ORIGINS'; + +/** + * The two paths the lifecycle suite beside this one cannot take: an origin the + * allow list refuses, and a `listen` that fails. + * + * The CORS allow list is empty in every test environment, which makes every + * origin acceptable and turns the rejection branch into dead code — until a + * deployment sets `JUMENTIX_CORS_ALLOWED_ORIGINS` and the branch becomes the + * only thing standing between the API and a cross-origin caller. + */ +describe('fastify server origin rejection and startup failure', () => { + afterEach(() => { + delete process.env[CORS_ORIGINS_ENV]; + }); + + it('rejects a request whose origin is outside the allow list', async () => { + expect.hasAssertions(); + + process.env[CORS_ORIGINS_ENV] = 'https://allowed.example'; + + const server = FastifyServer.compile() as any; + server.endPointRegister({ + method: 'get', + path: '/fastify-cors-origin-probe', + handler: () => Promise.resolve({ ok: true }) + }); + await server.application.ready(); + + const denied = await server.application.inject({ + method: 'GET', + url: '/fastify-cors-origin-probe', + headers: { origin: 'https://denied.example' } + }); + expect(denied.statusCode).toBe(500); + expect(denied.json()).toMatchObject({ message: 'Not allowed by CORS' }); + + const allowed = await server.application.inject({ + method: 'GET', + url: '/fastify-cors-origin-probe', + headers: { origin: 'https://allowed.example' } + }); + expect(allowed.statusCode).toBe(200); + expect(allowed.json()).toStrictEqual({ ok: true }); + }); + + it('logs the failure, stops and rethrows when listening fails', async () => { + expect.hasAssertions(); + + const server = FastifyServer.compile() as any; + const failure = new Error('port already in use'); + const listen = jest.spyOn(server.application, 'listen').mockRejectedValue(failure); + const errorLog = jest.spyOn(console, 'error').mockImplementation(); + // Keeping `stop` a double protects the shared application instance the + // suites in this process still need; the rejection and the log line are + // the observable contract. + const stop = jest.spyOn(server, 'stop').mockResolvedValue(undefined); + + await expect(server.start()).rejects.toThrow('port already in use'); + + expect(errorLog).toHaveBeenCalledWith(expect.stringContaining('An error occurred')); + expect(stop).toHaveBeenCalledTimes(1); + + listen.mockRestore(); + errorLog.mockRestore(); + stop.mockRestore(); + }); +}); diff --git a/apps/backend-template/test/unit/interface/HTTP/adapters/fastify/metricsHandlers.test.ts b/apps/backend-template/test/unit/interface/HTTP/adapters/fastify/metricsHandlers.test.ts new file mode 100644 index 000000000..4a9ae93d4 --- /dev/null +++ b/apps/backend-template/test/unit/interface/HTTP/adapters/fastify/metricsHandlers.test.ts @@ -0,0 +1,118 @@ +import getUsersMetrics from '@src/modules/Users/interface/restapi/frameworks/fastify/handlers/getUsersMetrics'; +import getOrganizationsMetrics from '@src/modules/Users/interface/restapi/frameworks/fastify/handlers/getOrganizationsMetrics'; +import { ValidationError } from '@src/infra/exceptions'; + +const makeRes = () => ({ + code: jest.fn().mockReturnThis(), + send: jest.fn() +}); + +describe('fastify entity metrics handlers', () => { + it('returns 200 and the buckets for an accepted metric', async () => { + expect.hasAssertions(); + const controller = { + getUsersMetrics: jest.fn().mockResolvedValue({ + result: { metric: 'count', buckets: [{ key: 'total', count: 2 }] } + }) + }; + const endpoint = getUsersMetrics({ + endPointConfig: { 'x-metrics-capabilities': { groupable: [], series: [] } }, + controller + } as any); + expect(endpoint.path).toBe('/users/metrics'); + expect(endpoint.method).toBe('get'); + + const res = makeRes(); + const payload = await endpoint.handler({ + query: { metric: 'count' }, + headers: { authorization: 'Bearer token' } + } as any, res as any); + expect(res.code).toHaveBeenCalledWith(200); + expect(payload).toStrictEqual({ metric: 'count', buckets: [{ key: 'total', count: 2 }] }); + }); + + it('registers organizations metrics on GET /organizations/metrics', async () => { + expect.hasAssertions(); + const controller = { + getOrganizationsMetrics: jest.fn().mockResolvedValue({ + result: { metric: 'count', buckets: [{ key: 'total', count: 1 }] } + }) + }; + const endpoint = getOrganizationsMetrics({ + endPointConfig: {}, + controller + } as any); + expect(endpoint.path).toBe('/organizations/metrics'); + const res = makeRes(); + await endpoint.handler({ + query: { metric: 'count' }, + headers: { authorization: 'Bearer token' } + } as any, res as any); + expect(res.code).toHaveBeenCalledWith(200); + }); + + it('defaults a missing query string and authorization header on users metrics', async () => { + expect.hasAssertions(); + const controller = { + getUsersMetrics: jest.fn().mockResolvedValue({ + result: { metric: 'count', buckets: [{ key: 'total', count: 0 }] } + }) + }; + const endpoint = getUsersMetrics({ endPointConfig: {}, controller } as any); + const res = makeRes(); + await endpoint.handler({ headers: {} } as any, res as any); + expect(res.code).toHaveBeenCalledWith(400); + expect(res.send).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('can not be empty') + })); + }); + + it('defaults a missing query string and authorization header on organizations metrics', async () => { + expect.hasAssertions(); + const controller = { + getOrganizationsMetrics: jest.fn().mockResolvedValue({ + result: { metric: 'count', buckets: [{ key: 'total', count: 0 }] } + }) + }; + const endpoint = getOrganizationsMetrics({ endPointConfig: {}, controller } as any); + const res = makeRes(); + await endpoint.handler({ headers: {} } as any, res as any); + expect(res.code).toHaveBeenCalledWith(400); + expect(res.send).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('can not be empty') + })); + }); + + it('rethrows a resolved service error through the error response path', async () => { + expect.hasAssertions(); + const usersEndpoint = getUsersMetrics({ + endPointConfig: {}, + controller: { + getUsersMetrics: jest.fn().mockResolvedValue({ + error: new ValidationError('The parameter metric is not accepted. Accepted: count.') + }) + } + } as any); + const usersRes = makeRes(); + await usersEndpoint.handler({ + query: { metric: 'avg' }, + headers: { authorization: 'Bearer token' } + } as any, usersRes as any); + expect(usersRes.code).toHaveBeenCalledWith(400); + + const organizationsEndpoint = getOrganizationsMetrics({ + endPointConfig: {}, + controller: { + getOrganizationsMetrics: jest.fn().mockResolvedValue({ + error: new ValidationError('The parameter metric is not accepted. Accepted: count.') + }) + } + } as any); + const organizationsRes = makeRes(); + await organizationsEndpoint.handler({ + query: { metric: 'avg' }, + headers: { authorization: 'Bearer token' } + } as any, organizationsRes as any); + expect(organizationsRes.code).toHaveBeenCalledWith(400); + }); +}); diff --git a/apps/backend-template/test/unit/interface/HTTP/adapters/restify/metricsHandlers.test.ts b/apps/backend-template/test/unit/interface/HTTP/adapters/restify/metricsHandlers.test.ts new file mode 100644 index 000000000..ba4d13556 --- /dev/null +++ b/apps/backend-template/test/unit/interface/HTTP/adapters/restify/metricsHandlers.test.ts @@ -0,0 +1,118 @@ +import getUsersMetrics from '@src/modules/Users/interface/restapi/frameworks/restify/handlers/getUsersMetrics'; +import getOrganizationsMetrics from '@src/modules/Users/interface/restapi/frameworks/restify/handlers/getOrganizationsMetrics'; +import { ValidationError } from '@src/infra/exceptions'; + +const makeRes = () => ({ + status: jest.fn().mockReturnThis(), + json: jest.fn() +}); + +describe('restify entity metrics handlers', () => { + it('returns 200 and the buckets for an accepted metric', async () => { + expect.hasAssertions(); + const controller = { + getUsersMetrics: jest.fn().mockResolvedValue({ + result: { metric: 'count', buckets: [{ key: 'total', count: 2 }] } + }) + }; + const endpoint = getUsersMetrics({ + endPointConfig: { 'x-metrics-capabilities': { groupable: [], series: [] } }, + controller + } as any); + expect(endpoint.path).toBe('/users/metrics'); + expect(endpoint.method).toBe('get'); + + const res = makeRes(); + await endpoint.handler({ + query: { metric: 'count' }, + headers: { authorization: 'Bearer token' } + } as any, res as any); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ metric: 'count', buckets: [{ key: 'total', count: 2 }] }); + }); + + it('registers organizations metrics on GET /organizations/metrics', async () => { + expect.hasAssertions(); + const controller = { + getOrganizationsMetrics: jest.fn().mockResolvedValue({ + result: { metric: 'count', buckets: [{ key: 'total', count: 1 }] } + }) + }; + const endpoint = getOrganizationsMetrics({ + endPointConfig: {}, + controller + } as any); + expect(endpoint.path).toBe('/organizations/metrics'); + const res = makeRes(); + await endpoint.handler({ + query: { metric: 'count' }, + headers: { authorization: 'Bearer token' } + } as any, res as any); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('defaults a missing query string and authorization header on users metrics', async () => { + expect.hasAssertions(); + const controller = { + getUsersMetrics: jest.fn().mockResolvedValue({ + result: { metric: 'count', buckets: [{ key: 'total', count: 0 }] } + }) + }; + const endpoint = getUsersMetrics({ endPointConfig: {}, controller } as any); + const res = makeRes(); + await endpoint.handler({ headers: {} } as any, res as any); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('can not be empty') + })); + }); + + it('defaults a missing query string and authorization header on organizations metrics', async () => { + expect.hasAssertions(); + const controller = { + getOrganizationsMetrics: jest.fn().mockResolvedValue({ + result: { metric: 'count', buckets: [{ key: 'total', count: 0 }] } + }) + }; + const endpoint = getOrganizationsMetrics({ endPointConfig: {}, controller } as any); + const res = makeRes(); + await endpoint.handler({ headers: {} } as any, res as any); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('can not be empty') + })); + }); + + it('rethrows a resolved service error through the error response path', async () => { + expect.hasAssertions(); + const usersEndpoint = getUsersMetrics({ + endPointConfig: {}, + controller: { + getUsersMetrics: jest.fn().mockResolvedValue({ + error: new ValidationError('The parameter metric is not accepted. Accepted: count.') + }) + } + } as any); + const usersRes = makeRes(); + await usersEndpoint.handler({ + query: { metric: 'avg' }, + headers: { authorization: 'Bearer token' } + } as any, usersRes as any); + expect(usersRes.status).toHaveBeenCalledWith(400); + + const organizationsEndpoint = getOrganizationsMetrics({ + endPointConfig: {}, + controller: { + getOrganizationsMetrics: jest.fn().mockResolvedValue({ + error: new ValidationError('The parameter metric is not accepted. Accepted: count.') + }) + } + } as any); + const organizationsRes = makeRes(); + await organizationsEndpoint.handler({ + query: { metric: 'avg' }, + headers: { authorization: 'Bearer token' } + } as any, organizationsRes as any); + expect(organizationsRes.status).toHaveBeenCalledWith(400); + }); +}); diff --git a/apps/backend-template/test/unit/interface/HTTP/validators/index.test.ts b/apps/backend-template/test/unit/interface/HTTP/validators/index.test.ts index 95b100572..4a99f7a10 100644 --- a/apps/backend-template/test/unit/interface/HTTP/validators/index.test.ts +++ b/apps/backend-template/test/unit/interface/HTTP/validators/index.test.ts @@ -270,7 +270,8 @@ describe('http validators', () => { expect(throwIfOASInputValidationFails(spec, endPointConfig, { firstName: 'John', createdAt: '2026-07-25T00:00:00.000Z', - updatedAt: '2026-07-25T00:00:00.000Z' + updatedAt: '2026-07-25T00:00:00.000Z', + deletedAt: null })).toBe(true); expect(() => throwIfOASInputValidationFails(spec, endPointConfig, { firstName: 'John', @@ -379,6 +380,30 @@ describe('http validators', () => { * into a mock, and spreading touches every re-export getter. Removing that dead * mock (JUM-583) revealed the barrel had no coverage of its own. */ +describe('validateRequestParams query coercion (JUM-777)', () => { + const endPoint = { + parameters: [ + { name: 'page', in: 'query', schema: { type: 'integer', minimum: 1 } }, + { name: 'size', in: 'query', schema: { type: 'integer', minimum: 1, maximum: 100 } }, + { name: 'active', in: 'query', schema: { type: 'boolean' } } + ] + }; + + it('coerces numeric and boolean query strings before validating their schema', () => { + expect.hasAssertions(); + expect(validateRequestParams(endPoint, {}, { page: '2', size: '30', active: 'true' })).toBe(true); + expect(validateRequestParams(endPoint, {}, { page: 1 })).toBe(true); + }); + + it('still rejects values that do not parse or violate the bounds', () => { + expect.hasAssertions(); + expect(() => validateRequestParams(endPoint, {}, { page: 'two' })).toThrow(ValidationError); + expect(() => validateRequestParams(endPoint, {}, { page: '0' })).toThrow(ValidationError); + expect(() => validateRequestParams(endPoint, {}, { size: '101' })).toThrow(ValidationError); + expect(() => validateRequestParams(endPoint, {}, { active: 'yes' })).toThrow(ValidationError); + }); +}); + describe('http validators barrel', () => { it.each([ 'throwIfOASInputValidationFails', diff --git a/apps/backend-template/test/unit/interface/HTTP/validators/oasInputMessages.test.ts b/apps/backend-template/test/unit/interface/HTTP/validators/oasInputMessages.test.ts index 7ab564eca..994606964 100644 --- a/apps/backend-template/test/unit/interface/HTTP/validators/oasInputMessages.test.ts +++ b/apps/backend-template/test/unit/interface/HTTP/validators/oasInputMessages.test.ts @@ -149,13 +149,14 @@ describe('openAPI input validation messages (JUM-681)', () => { it('ignores the server-managed properties a client echoed back', () => { expect.hasAssertions(); - // `createdAt` and `updatedAt` are not in the contract's input, and a client - // that round-trips a record it read must not be refused for sending them. + // `createdAt`, `updatedAt` and `deletedAt` are not in the contract's input, + // and a client that round-trips a record it read must not be refused. expect(throwIfOASInputValidationFails(spec, endPoint('createUser'), { username: 'alice', password: 'longenough', createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:00.000Z' + updatedAt: '2026-01-01T00:00:00.000Z', + deletedAt: null })).toBe(true); }); }); diff --git a/apps/backend-template/test/unit/interface/HTTP/validators/validateRequestParams.edgeCases.test.ts b/apps/backend-template/test/unit/interface/HTTP/validators/validateRequestParams.edgeCases.test.ts new file mode 100644 index 000000000..0184070ea --- /dev/null +++ b/apps/backend-template/test/unit/interface/HTTP/validators/validateRequestParams.edgeCases.test.ts @@ -0,0 +1,111 @@ +import { ValidationError } from '@src/infra/exceptions'; +import validateRequestParams from '@src/interface/HTTP/validators/validateRequestParams'; + +/** + * Parameter-validation edges the happy-path suite beside this one does not + * reach: optional parameters that arrive empty, schemas declared as type + * unions, values that vanish under trimming, and parameters that rely on the + * `in`/`schema` defaults. Each one is a way a real request is rejected (or + * accepted) for the wrong reason. + */ +describe('validateRequestParams edge cases', () => { + it('rejects an optional parameter that arrives as an empty string', () => { + expect.hasAssertions(); + + // Not a required-field failure: the parameter is optional, so the request + // only fails because an empty string is not a value at all. + const endPointConfig = { + parameters: [{ + name: 'filter', required: false, in: 'query', schema: { type: 'string' } + }] + }; + + expect(() => validateRequestParams(endPointConfig, {}, { filter: '' })) + .toThrow(new ValidationError('The parameter filter can not be empty.')); + expect(validateRequestParams(endPointConfig, {}, {})).toBe(true); + }); + + it('defaults the parameter location to path and the schema to an open one', () => { + expect.hasAssertions(); + + const endPointConfig = { + parameters: [{ name: 'id', required: true }] + }; + + expect(validateRequestParams(endPointConfig, { id: 'any-value' })).toBe(true); + expect(() => validateRequestParams(endPointConfig, {})) + .toThrow(new ValidationError('The parameter id is required in path.')); + }); + + it('coerces query strings against union-typed schemas', () => { + expect.hasAssertions(); + + const endPointConfig = { + parameters: [{ + name: 'page', + required: false, + in: 'query', + schema: { type: ['integer', 'null'], minimum: 1 } + }] + }; + + expect(validateRequestParams(endPointConfig, {}, { page: '3' })).toBe(true); + expect(() => validateRequestParams(endPointConfig, {}, { page: '0' })) + .toThrow(ValidationError); + }); + + it('keeps a whitespace-only query value a string and lets the schema reject it', () => { + expect.hasAssertions(); + + // Trimming to '' and returning the original string is what makes the + // failure message point at the type rule instead of the required rule. + const endPointConfig = { + parameters: [{ + name: 'page', + required: false, + in: 'query', + schema: { type: 'integer' } + }] + }; + + expect(() => validateRequestParams(endPointConfig, {}, { page: ' ' })) + .toThrow(ValidationError); + }); + + it('coerces the boolean false literal without rejecting it as empty', () => { + expect.hasAssertions(); + + const endPointConfig = { + parameters: [{ + name: 'active', + required: true, + in: 'query', + schema: { type: 'boolean' } + }] + }; + + // `active=false` is a present, valid value — not a missing parameter. + expect(validateRequestParams(endPointConfig, {}, { active: 'false' })).toBe(true); + }); + + it('validates a required header parameter against its declared schema', () => { + expect.hasAssertions(); + + const endPointConfig = { + parameters: [{ + name: 'x-request-count', + required: true, + in: 'header', + schema: { type: 'integer' } + }] + }; + + // Headers arrive typed already (no query-string coercion), so a string + // fails the integer rule. + expect(validateRequestParams(endPointConfig, {}, {}, { 'x-request-count': 7 })).toBe(true); + expect(() => validateRequestParams(endPointConfig, {}, {}, { 'x-request-count': 'seven' })) + .toThrow(ValidationError); + expect(() => validateRequestParams(endPointConfig, {}, {})) + .toThrow(new ValidationError('The parameter x-request-count is required in header.')); + }); +}); diff --git a/apps/backend-template/test/unit/modules/Users/adapters/in/controller/controllers.test.ts b/apps/backend-template/test/unit/modules/Users/adapters/in/controller/controllers.test.ts index 572bbc2de..cea58f3e1 100644 --- a/apps/backend-template/test/unit/modules/Users/adapters/in/controller/controllers.test.ts +++ b/apps/backend-template/test/unit/modules/Users/adapters/in/controller/controllers.test.ts @@ -39,7 +39,8 @@ const makeFactory = (overrides: Record = {}) => { deletePhone: jest.fn().mockResolvedValue({ result: { id: 'u1' } }), createEmail: jest.fn().mockResolvedValue({ result: { id: 'u1' } }), updateEmail: jest.fn().mockResolvedValue({ result: { id: 'u1' } }), - deleteEmail: jest.fn().mockResolvedValue({ result: { id: 'u1' } }) + deleteEmail: jest.fn().mockResolvedValue({ result: { id: 'u1' } }), + metrics: jest.fn().mockResolvedValue({ result: { metric: 'count', buckets: [] } }) }, organizationUseCases: { create: jest.fn().mockResolvedValue({ result: { id: 'o1', name: 'Org 1' } }), @@ -57,7 +58,8 @@ const makeFactory = (overrides: Record = {}) => { deletePhone: jest.fn().mockResolvedValue({ result: { id: 'o1', name: 'Org 1' } }), createEmail: jest.fn().mockResolvedValue({ result: { id: 'o1', name: 'Org 1' } }), updateEmail: jest.fn().mockResolvedValue({ result: { id: 'o1', name: 'Org 1' } }), - deleteEmail: jest.fn().mockResolvedValue({ result: { id: 'o1', name: 'Org 1' } }) + deleteEmail: jest.fn().mockResolvedValue({ result: { id: 'o1', name: 'Org 1' } }), + metrics: jest.fn().mockResolvedValue({ result: { metric: 'count', buckets: [] } }) }, authUseCases: { login: jest.fn().mockResolvedValue({ result: { token: 't' } }), @@ -125,6 +127,9 @@ describe('users controllers', () => { await controller.createEmail(event); await controller.updateEmail(event); await controller.deleteEmail(event); + await controller.getUsersMetrics(makeEvent({ + queryString: { metric: 'count' } + })); expect(factory.userUseCases.create).toHaveBeenCalled(); expect(factory.userUseCases.update).toHaveBeenCalledWith('u1', event.input); @@ -145,6 +150,9 @@ describe('users controllers', () => { await controller.delete(event); await controller.getOneById(event); await controller.getAll(event); + await controller.getOrganizationsMetrics(makeEvent({ + queryString: { metric: 'count' } + })); await controller.createAddress(event); await controller.updateAddress(event); await controller.deleteAddress(event); diff --git a/apps/backend-template/test/unit/modules/Users/application/OrganizationUseCases.test.ts b/apps/backend-template/test/unit/modules/Users/application/OrganizationUseCases.test.ts index 2b6d218ce..62c4d6427 100644 --- a/apps/backend-template/test/unit/modules/Users/application/OrganizationUseCases.test.ts +++ b/apps/backend-template/test/unit/modules/Users/application/OrganizationUseCases.test.ts @@ -19,7 +19,8 @@ describe('organization use cases', () => { deletePhone: jest.fn().mockResolvedValue({ result: { id: 'o1' } }), createEmail: jest.fn().mockResolvedValue({ result: { id: 'o1' } }), updateEmail: jest.fn().mockResolvedValue({ result: { id: 'o1' } }), - deleteEmail: jest.fn().mockResolvedValue({ result: { id: 'o1' } }) + deleteEmail: jest.fn().mockResolvedValue({ result: { id: 'o1' } }), + metrics: jest.fn().mockResolvedValue({ result: { metric: 'count', buckets: [] } }) }; const useCases = OrganizationUseCases.compile(organizationService as any); @@ -37,5 +38,7 @@ describe('organization use cases', () => { expect((await useCases.createEmail('o1', { email: 'contact@org.dev', type: EEmailType.work })).result?.id).toBe('o1'); expect((await useCases.updateEmail('o1', 'e1', { id: 'e1', email: 'new@org.dev' })).result?.id).toBe('o1'); expect((await useCases.deleteEmail('o1', 'e1')).result?.id).toBe('o1'); + expect((await useCases.metrics({}, { metric: 'count' }, { groupable: [], series: [] })).result) + .toStrictEqual({ metric: 'count', buckets: [] }); }); }); diff --git a/apps/backend-template/test/unit/modules/Users/application/service/AuthService.branches.test.ts b/apps/backend-template/test/unit/modules/Users/application/service/AuthService.branches.test.ts index 5b81ed8cf..52570f86e 100644 --- a/apps/backend-template/test/unit/modules/Users/application/service/AuthService.branches.test.ts +++ b/apps/backend-template/test/unit/modules/Users/application/service/AuthService.branches.test.ts @@ -390,4 +390,65 @@ describe('auth service extra branches', () => { expect(response.result).toBe(true); expect(response.error).toBeUndefined(); }); + + it('locks the account on the default lockout window when the env omits it', async () => { + expect.hasAssertions(); + process.env.JUMENTIX_AUTH_MAX_LOGIN_ATTEMPTS = '1'; + delete process.env.JUMENTIX_AUTH_LOCKOUT_SECONDS; + const store = new Map(); + const keyValueStorageClient = { + get: jest.fn().mockImplementation(async (key: string) => ({ result: store.get(key) })), + set: jest.fn().mockImplementation(async (key: string, value: any) => { + store.set(key, value); + return { result: true }; + }), + del: jest.fn().mockResolvedValue({ result: true }) + }; + const { service, passwordCryptoService } = setup({ keyValueStorageClient }); + + passwordCryptoService.compare.mockResolvedValueOnce(false); + const failed = await service.authenticate('john', 'invalid', EAuthSchemaType.Bearer); + expect(failed.error?.message).toBe('password does not matches'); + expect(keyValueStorageClient.set).toHaveBeenCalledWith( + 'auth:locked:john', + expect.objectContaining({ count: 1, expiresAt: expect.any(Number) }) + ); + + // The lock lives on the default 900-second window: the next attempt is + // refused before any password comparison runs. + passwordCryptoService.compare.mockClear(); + const locked = await service.authenticate('john', 'whatever', EAuthSchemaType.Bearer); + expect(locked.error?.message).toBe('authentication temporarily locked'); + expect(passwordCryptoService.compare).not.toHaveBeenCalled(); + }); + + it('covers logout when the token carries no jti', async () => { + expect.hasAssertions(); + const keyValueStorageClient = { + get: jest.fn().mockResolvedValue({ result: null }), + set: jest.fn().mockResolvedValue({ result: true }), + del: jest.fn().mockResolvedValue({ result: true }) + }; + const { service, jwtService } = setup({ keyValueStorageClient }); + jwtService.decodeToken.mockReturnValueOnce({ + id: 'u1', + username: 'john', + exp: Math.floor(Date.now() / 1000) + 120 + }); + + const response = await service.logout('Bearer token'); + expect(response.result).toBe(true); + expect(keyValueStorageClient.set).not.toHaveBeenCalled(); + }); + + it('treats an empty security entry as no required permission', () => { + expect.hasAssertions(); + const { service } = setup(); + + const allowed = service.throwIfUserHasNoAccessToResource( + { id: 'u1', username: 'john', roles: [EUserRole.user] } as any, + { security: [{}] } as any + ); + expect(allowed).toBe(true); + }); }); diff --git a/apps/backend-template/test/unit/modules/Users/application/service/OrganizationService.test.ts b/apps/backend-template/test/unit/modules/Users/application/service/OrganizationService.test.ts index 3bb663ae6..bd7716aae 100644 --- a/apps/backend-template/test/unit/modules/Users/application/service/OrganizationService.test.ts +++ b/apps/backend-template/test/unit/modules/Users/application/service/OrganizationService.test.ts @@ -1,6 +1,7 @@ /* eslint-disable jest/max-expects */ import { OrganizationService } from '@src/modules/Users/service/OrganizationService'; import { Organization } from '@src/modules/Users/domain/Model/Organization'; +import { ValidationError } from '@src/infra/exceptions'; const domainOrganization = (name = 'Org') => new Organization({ id: '4fae5b16-261f-4de7-9cff-1429d5614e44', @@ -221,3 +222,71 @@ describe('organization service', () => { expect((await service.deleteEmail('o1', 'e1')).error?.message).toBe('delete-email-fail'); }); }); + +describe('organization service metrics', () => { + const capabilities = { groupable: ['name'], series: ['createdAt'] }; + + it('serializes Date and string timestamps before running the query', async () => { + expect.hasAssertions(); + const { service, dataRepository } = setup(); + dataRepository.getAll.mockResolvedValueOnce({ + page: 1, + size: 2, + total: 2, + result: [ + { + id: 'o1', + name: 'Org', + createdAt: new Date('2026-01-05T00:00:00.000Z'), + updatedAt: new Date('2026-01-06T00:00:00.000Z') + }, + { + id: 'o2', + name: 'Org Two', + createdAt: '2026-01-07T00:00:00.000Z', + updatedAt: '2026-01-08T00:00:00.000Z' + } + ] + }); + + const response = await service.metrics({}, { metric: 'count' }, capabilities); + + expect(response.error).toBeUndefined(); + expect(response.result).toStrictEqual({ + metric: 'count', + buckets: [{ key: 'total', count: 2 }] + }); + }); + + it('groups by a groupable field over serialized rows', async () => { + expect.hasAssertions(); + const { service } = setup(); + + const response = await service.metrics({}, { metric: 'groupBy', field: 'name' }, capabilities); + + expect(response.error).toBeUndefined(); + expect(response.result).toStrictEqual({ + metric: 'groupBy', + field: 'name', + buckets: [{ key: 'Org', count: 1 }] + }); + }); + + it('wraps metric acceptance failures in ValidationError and passes other errors through', async () => { + expect.hasAssertions(); + const { service, dataRepository } = setup(); + + const notGroupable = await service.metrics({}, { metric: 'groupBy', field: 'address' }, capabilities); + expect(notGroupable.error).toBeInstanceOf(ValidationError); + expect(String(notGroupable.error?.message)).toContain('Accepted: name.'); + + const missingField = await service.metrics({}, { metric: 'groupBy' }, capabilities); + expect(missingField.error).toBeInstanceOf(Error); + expect(missingField.error).not.toBeInstanceOf(ValidationError); + expect(String(missingField.error?.message)).toContain('field is required'); + + dataRepository.getAll.mockRejectedValueOnce('repository exploded'); + const nonError = await service.metrics({}, { metric: 'count' }, capabilities); + expect(nonError.error).toBe('repository exploded'); + }); +}); diff --git a/apps/backend-template/test/unit/modules/Users/application/service/UserService.test.ts b/apps/backend-template/test/unit/modules/Users/application/service/UserService.test.ts index a381ffaf1..13d58de72 100644 --- a/apps/backend-template/test/unit/modules/Users/application/service/UserService.test.ts +++ b/apps/backend-template/test/unit/modules/Users/application/service/UserService.test.ts @@ -3,6 +3,7 @@ import { UserService } from '@src/modules/Users/service/UserService'; import { EDocumentType } from '@src/modules/ddd/valueObjects/EDocumentType'; import { EEmailType } from '@src/modules/ddd/valueObjects/EEmailType'; import { UserIntegrationEventName } from '@src/modules/Users/events/contracts/UserIntegrationEventName'; +import { ValidationError } from '@src/infra/exceptions'; import { createUser } from '@src/modules/Users/features/createUser'; import { updateUser } from '@src/modules/Users/features/updateUser'; @@ -498,3 +499,73 @@ describe('user service', () => { expect(organizationDataRepository.update).not.toHaveBeenCalled(); }); }); + +describe('user service metrics', () => { + const capabilities = { groupable: ['roles'], series: ['createdAt'] }; + + it('serializes Date and string timestamps before running the query', async () => { + expect.hasAssertions(); + const { service } = setup(); + mockedGetAllUsers.mockResolvedValueOnce({ + page: 1, + size: 2, + total: 2, + result: [ + { + ...baseUser, + createdAt: new Date('2026-01-05T00:00:00.000Z'), + updatedAt: new Date('2026-01-06T00:00:00.000Z') + }, + { + ...baseUser, + id: '00000000-0000-4000-8000-000000000002', + createdAt: '2026-01-07T00:00:00.000Z', + updatedAt: '2026-01-08T00:00:00.000Z' + } + ] + } as any); + + const response = await service.metrics({}, { metric: 'count' }, capabilities); + + expect(response.error).toBeUndefined(); + expect(response.result).toStrictEqual({ + metric: 'count', + buckets: [{ key: 'total', count: 2 }] + }); + }); + + it('groups by a groupable field over serialized rows', async () => { + expect.hasAssertions(); + const { service } = setup(); + mockedGetAllUsers.mockResolvedValueOnce({ + page: 1, size: 2, total: 2, result: [baseUser, { ...baseUser, roles: ['admin'] }] + } as any); + + const response = await service.metrics({}, { metric: 'groupBy', field: 'roles' }, capabilities); + + expect(response.error).toBeUndefined(); + expect(response.result).toStrictEqual({ + metric: 'groupBy', + field: 'roles', + buckets: [{ key: 'user', count: 1 }, { key: 'admin', count: 1 }] + }); + }); + + it('wraps metric acceptance failures in ValidationError and passes other errors through', async () => { + expect.hasAssertions(); + const { service } = setup(); + + const notGroupable = await service.metrics({}, { metric: 'groupBy', field: 'username' }, capabilities); + expect(notGroupable.error).toBeInstanceOf(ValidationError); + expect(String(notGroupable.error?.message)).toContain('Accepted: roles.'); + + const missingField = await service.metrics({}, { metric: 'groupBy' }, capabilities); + expect(missingField.error).toBeInstanceOf(Error); + expect(missingField.error).not.toBeInstanceOf(ValidationError); + expect(String(missingField.error?.message)).toContain('field is required'); + + mockedGetAllUsers.mockRejectedValueOnce('repository exploded' as never); + const nonError = await service.metrics({}, { metric: 'count' }, capabilities); + expect(nonError.error).toBe('repository exploded'); + }); +}); diff --git a/apps/backend-template/test/unit/modules/Users/application/useCases.test.ts b/apps/backend-template/test/unit/modules/Users/application/useCases.test.ts index 5e35b25b8..469d89968 100644 --- a/apps/backend-template/test/unit/modules/Users/application/useCases.test.ts +++ b/apps/backend-template/test/unit/modules/Users/application/useCases.test.ts @@ -48,10 +48,15 @@ describe('users application use cases', () => { const useCases = new AuthUseCases(authService, mutexService); const ok = await useCases.updatePassword('Bearer token', { password: '12345678' }); - expect(ok.result).toBe(true); expect(mutexService.lock).toHaveBeenCalledWith('user', 'u1'); expect(mutexService.unlock).toHaveBeenCalledWith('user', 'u1'); + // A lock answer without a payload means "not previously locked", not a + // failure — the update still goes through. + mutexService.lock.mockResolvedValueOnce(undefined); + const unlocked = await useCases.updatePassword('Bearer token', { password: '12345678' }); + expect([ok.result, unlocked.result]).toStrictEqual([true, true]); + mutexService.lock.mockResolvedValueOnce({ result: { previouslyLocked: true } }); const locked = await useCases.updatePassword('Bearer token', { password: '12345678' }); expect(locked.error).toBeDefined(); @@ -77,6 +82,31 @@ describe('users application use cases', () => { expect(invalid.error).toBeDefined(); }); + it('surfaces auth service failures from updatePassword and logout', async () => { + expect.hasAssertions(); + const updateFailure = new Error('password update rejected'); + const authService: any = { + decodeToken: jest.fn().mockResolvedValue({ id: 'u1', username: 'john' }), + updatePassword: jest.fn().mockResolvedValue({ error: updateFailure }), + logout: jest.fn().mockResolvedValue({ error: new Error('logout rejected') }) + }; + const mutexService: any = { + lock: jest.fn().mockResolvedValue({ result: { previouslyLocked: false } }), + unlock: jest.fn().mockResolvedValue({ result: true }) + }; + const useCases = new AuthUseCases(authService, mutexService); + + // The service error must reach the caller unchanged, and the user lock + // must be released even though the update failed. + const updated = await useCases.updatePassword('Bearer token', { password: '12345678' }); + expect(updated).toMatchObject({ result: false, error: updateFailure }); + expect(mutexService.unlock).toHaveBeenCalledWith('user', 'u1'); + + const loggedOut = await useCases.logout('Bearer token', { username: 'john' }); + expect(loggedOut).toMatchObject({ result: false }); + expect(loggedOut.error?.message).toBe('logout rejected'); + }); + it('delegates all user use case methods to user service', async () => { expect.hasAssertions(); const userService: any = { @@ -94,7 +124,8 @@ describe('users application use cases', () => { deletePhone: jest.fn().mockResolvedValue({ result: { id: 'u1' } }), createEmail: jest.fn().mockResolvedValue({ result: { id: 'u1' } }), updateEmail: jest.fn().mockResolvedValue({ result: { id: 'u1' } }), - deleteEmail: jest.fn().mockResolvedValue({ result: { id: 'u1' } }) + deleteEmail: jest.fn().mockResolvedValue({ result: { id: 'u1' } }), + metrics: jest.fn().mockResolvedValue({ result: { metric: 'count', buckets: [] } }) }; const useCases = new UserUseCases(userService); @@ -113,6 +144,7 @@ describe('users application use cases', () => { await useCases.createEmail('u1', { email: 'john@mail.com' } as any); await useCases.updateEmail('u1', 'e1', { email: 'john@mail.com' } as any); await useCases.deleteEmail('u1', 'e1'); + await useCases.metrics({}, { metric: 'count' }, { groupable: [], series: [] }); expect(userService.create).toHaveBeenCalled(); expect(userService.update).toHaveBeenCalledWith('u1', { firstName: 'John' }); diff --git a/apps/backend-template/test/unit/modules/Users/domain/Model/Organization.test.ts b/apps/backend-template/test/unit/modules/Users/domain/Model/Organization.test.ts index 4480f1966..55d005166 100644 --- a/apps/backend-template/test/unit/modules/Users/domain/Model/Organization.test.ts +++ b/apps/backend-template/test/unit/modules/Users/domain/Model/Organization.test.ts @@ -29,7 +29,7 @@ describe('organization domain model', () => { expect(Organization.dataEntitySchema.name).toBe('Organization'); const relations = getModelRelations(Organization as any); expect(relations).toStrictEqual(expect.arrayContaining([ - expect.objectContaining({ property: 'userEntities', kind: 'hasMany' }) + expect.objectContaining({ property: 'users', kind: 'hasMany', target: 'User' }) ])); }); diff --git a/apps/backend-template/test/unit/modules/Users/domain/Model/User.test.ts b/apps/backend-template/test/unit/modules/Users/domain/Model/User.test.ts index 1340dbd24..133779625 100644 --- a/apps/backend-template/test/unit/modules/Users/domain/Model/User.test.ts +++ b/apps/backend-template/test/unit/modules/Users/domain/Model/User.test.ts @@ -72,6 +72,28 @@ describe('user domain model', () => { expect(user.deleteEmail('missing')).toBe(false); }); + it('returns false when updating a value object id the aggregate does not hold', () => { + expect.hasAssertions(); + + // The update has to answer "not found" without touching the aggregate — + // a false return, and the existing value objects stay exactly as built. + const user = new User(basePayload()); + const { phones, documents, emails } = user; + + expect(user.updatePhone({ + id: 'missing', countryCode: '1', localCode: '212', number: '1111111' + })).toBe(false); + expect(user.updateDocument({ + id: 'missing', type: EDocumentType.RG, countryIssue: 'BR', data: '222' + })).toBe(false); + expect(user.updateEmail({ + id: 'missing', email: 'jane@example.com', type: EEmailType.work + })).toBe(false); + expect(user.phones).toStrictEqual(phones); + expect(user.documents).toStrictEqual(documents); + expect(user.emails).toStrictEqual(emails); + }); + it('enforces read only fields', () => { expect.hasAssertions(); const user = new User({ ...basePayload(), readOnly: true }); diff --git a/apps/backend-template/test/unit/modules/Users/interface/restapi/frameworks/derby-js/handlers.test.ts b/apps/backend-template/test/unit/modules/Users/interface/restapi/frameworks/derby-js/handlers.test.ts new file mode 100644 index 000000000..7461a2cdb --- /dev/null +++ b/apps/backend-template/test/unit/modules/Users/interface/restapi/frameworks/derby-js/handlers.test.ts @@ -0,0 +1,787 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, jest/max-expects, + jest/no-conditional-in-test */ + +import { ValidationError } from '@src/infra/exceptions'; +import type { EndPointFactory } from '@src/interface/HTTP/ports'; + +import login from '@src/modules/Users/interface/restapi/frameworks/derby-js/handlers/login'; +import logout from '@src/modules/Users/interface/restapi/frameworks/derby-js/handlers/logout'; +import register from '@src/modules/Users/interface/restapi/frameworks/derby-js/handlers/register'; +import updateUserPassword from '@src/modules/Users/interface/restapi/frameworks/derby-js/handlers/updateUserPassword'; +import getAll from '@src/modules/Users/interface/restapi/frameworks/derby-js/handlers/getAll'; +import create from '@src/modules/Users/interface/restapi/frameworks/derby-js/handlers/create'; +import update from '@src/modules/Users/interface/restapi/frameworks/derby-js/handlers/update'; +import deleteOne from '@src/modules/Users/interface/restapi/frameworks/derby-js/handlers/deleteOne'; +import getOneById from '@src/modules/Users/interface/restapi/frameworks/derby-js/handlers/getOneById'; +import updatePassword from '@src/modules/Users/interface/restapi/frameworks/derby-js/handlers/updatePassword'; +import createEmail from '@src/modules/Users/interface/restapi/frameworks/derby-js/handlers/createEmail'; +import updateEmail from '@src/modules/Users/interface/restapi/frameworks/derby-js/handlers/updateEmail'; +import deleteEmail from '@src/modules/Users/interface/restapi/frameworks/derby-js/handlers/deleteEmail'; +import createPhone from '@src/modules/Users/interface/restapi/frameworks/derby-js/handlers/createPhone'; +import updatePhone from '@src/modules/Users/interface/restapi/frameworks/derby-js/handlers/updatePhone'; +import deletePhone from '@src/modules/Users/interface/restapi/frameworks/derby-js/handlers/deletePhone'; +import createOrganization from '@src/modules/Users/interface/restapi/frameworks/derby-js/handlers/createOrganization'; +import getAllOrganizations from '@src/modules/Users/interface/restapi/frameworks/derby-js/handlers/getAllOrganizations'; +import getOrganizationById from '@src/modules/Users/interface/restapi/frameworks/derby-js/handlers/getOrganizationById'; +import updateOrganization from '@src/modules/Users/interface/restapi/frameworks/derby-js/handlers/updateOrganization'; +import deleteOrganization from '@src/modules/Users/interface/restapi/frameworks/derby-js/handlers/deleteOrganization'; +import createDocument from '@src/modules/Users/interface/restapi/frameworks/derby-js/handlers/createDocument'; +import deleteDocument from '@src/modules/Users/interface/restapi/frameworks/derby-js/handlers/deleteDocument'; +import updateDocument from '@src/modules/Users/interface/restapi/frameworks/derby-js/handlers/updateDocument'; +import updateOrganizationAddress from '@src/modules/Users/interface/restapi/frameworks/derby-js/handlers/updateOrganizationAddress'; +import createOrganizationEmail from '@src/modules/Users/interface/restapi/frameworks/derby-js/handlers/createOrganizationEmail'; +import updateOrganizationEmail from '@src/modules/Users/interface/restapi/frameworks/derby-js/handlers/updateOrganizationEmail'; +import deleteOrganizationEmail from '@src/modules/Users/interface/restapi/frameworks/derby-js/handlers/deleteOrganizationEmail'; +import createOrganizationPhone from '@src/modules/Users/interface/restapi/frameworks/derby-js/handlers/createOrganizationPhone'; +import updateOrganizationPhone from '@src/modules/Users/interface/restapi/frameworks/derby-js/handlers/updateOrganizationPhone'; +import deleteOrganizationPhone from '@src/modules/Users/interface/restapi/frameworks/derby-js/handlers/deleteOrganizationPhone'; +import createOrganizationAddress from '@src/modules/Users/interface/restapi/frameworks/derby-js/handlers/createOrganizationAddress'; +import deleteOrganizationAddress from '@src/modules/Users/interface/restapi/frameworks/derby-js/handlers/deleteOrganizationAddress'; + +/** + * The derby-js REST handlers, driven as the derby adapter drives them. + * + * Same adapter contract as the express variants: the domain event the + * controller receives (type, authorization, input, params, schema), the + * status and body the client receives, and the error mapping when the + * controller or the event validation reports a failure. The mutation + * factories (`_documentMutationHandlerFactory`, + * `_organizationMutationHandlerFactory`) tolerate requests without `params` + * or `headers` — the event validation then rejects them, which is asserted + * rather than assumed. + */ + +const SCHEMA_OAS = { operationId: 'probe' } as any; +const AUTH = 'Bearer derby-token'; + +const makeRes = () => { + const res: any = { status: jest.fn(), json: jest.fn() }; + res.status.mockReturnValue(res); + return res; +}; + +const makeReq = (overrides: Record = {}): any => ({ + headers: { authorization: AUTH }, + params: { id: 'user-1' }, + body: { name: 'payload' }, + query: {}, + ...overrides +}); + +type HandlerCase = { + name: string; + factory: EndPointFactory; + method: string; + path: string; + controllerMethod: string; + status: number; + eventType: string; + result: any; + req: () => any; + expectedEvent: Record; + expectedBody?: (result: any) => any; + readsAuth: boolean; + authEnforced?: boolean; +}; + +const withBody = { input: { name: 'payload' } }; +const withParams = { params: { id: 'user-1' } }; +const PAGING = { page: 1, size: 10, total: 1 }; + +const HANDLERS: HandlerCase[] = [ + { + name: 'login', + factory: login, + method: 'post', + path: '/auth/login', + controllerMethod: 'login', + status: 200, + eventType: 'LoginRequestEvent', + result: { token: 'jwt' }, + req: () => makeReq(), + expectedEvent: { ...withBody }, + readsAuth: false + }, + { + name: 'register', + factory: register, + method: 'post', + path: '/auth/register', + controllerMethod: 'register', + status: 201, + eventType: 'RegisterRequestEvent', + result: { id: 'user-1' }, + req: () => makeReq(), + expectedEvent: { ...withBody }, + readsAuth: false + }, + { + name: 'logout', + factory: logout, + method: 'post', + path: '/auth/logout', + controllerMethod: 'logout', + status: 200, + eventType: 'LogoutRequestEvent', + result: true, + req: () => makeReq(), + expectedEvent: { authorization: AUTH, ...withBody }, + readsAuth: true, + authEnforced: false + }, + { + name: 'updateUserPassword', + factory: updateUserPassword, + method: 'post', + path: '/auth/updateUserPassword', + controllerMethod: 'updatePassword', + status: 200, + eventType: 'UpdatePasswordRequestEvent', + result: true, + req: () => makeReq(), + expectedEvent: { authorization: AUTH, ...withBody }, + readsAuth: true, + authEnforced: false + }, + { + name: 'getAll', + factory: getAll, + method: 'get', + path: '/users', + controllerMethod: 'getAll', + status: 200, + eventType: 'UserGetAllRequestEvent', + result: [{ id: 'user-1' }], + req: () => makeReq({ query: { page: '3', size: '5' } }), + expectedEvent: { authorization: AUTH, queryString: { page: '3', size: '5' } }, + expectedBody: (result) => ({ result, error: undefined, ...PAGING }), + readsAuth: true + }, + { + name: 'create', + factory: create, + method: 'post', + path: '/users', + controllerMethod: 'create', + status: 201, + eventType: 'UserCreateRequestEvent', + result: { id: 'user-1' }, + req: () => makeReq(), + expectedEvent: { authorization: AUTH, ...withBody }, + readsAuth: true + }, + { + name: 'update', + factory: update, + method: 'put', + path: '/users/{id}', + controllerMethod: 'update', + status: 200, + eventType: 'UserUpdateRequestEvent', + result: { id: 'user-1' }, + req: () => makeReq(), + expectedEvent: { authorization: AUTH, ...withBody, ...withParams }, + readsAuth: true + }, + { + name: 'deleteOne', + factory: deleteOne, + method: 'delete', + path: '/users/{id}', + controllerMethod: 'delete', + status: 200, + eventType: 'UserDeleteRequestEvent', + result: true, + req: () => makeReq(), + expectedEvent: { authorization: AUTH, ...withParams }, + readsAuth: true + }, + { + name: 'getOneById', + factory: getOneById, + method: 'get', + path: '/users/{id}', + controllerMethod: 'getOneById', + status: 200, + eventType: 'UserGetOneRequestEvent', + result: { id: 'user-1' }, + req: () => makeReq(), + expectedEvent: { authorization: AUTH, ...withParams }, + readsAuth: true + }, + { + name: 'updatePassword', + factory: updatePassword, + method: 'put', + path: '/users/{id}/updatePassword', + controllerMethod: 'updatePassword', + status: 200, + eventType: 'UserPasswordUpdateRequestEvent', + result: { id: 'user-1' }, + req: () => makeReq(), + expectedEvent: { authorization: AUTH, ...withBody, ...withParams }, + readsAuth: true + }, + { + name: 'createEmail', + factory: createEmail, + method: 'post', + path: '/users/{id}/createEmail', + controllerMethod: 'createEmail', + status: 201, + eventType: 'UserEmailCreateRequestEvent', + result: { id: 'email-1' }, + req: () => makeReq(), + expectedEvent: { authorization: AUTH, ...withBody, ...withParams }, + readsAuth: true + }, + { + name: 'updateEmail', + factory: updateEmail, + method: 'put', + path: '/users/{id}/updateEmail/{emailId}', + controllerMethod: 'updateEmail', + status: 200, + eventType: 'UserEmailUpdateRequestEvent', + result: { id: 'email-1' }, + req: () => makeReq(), + expectedEvent: { authorization: AUTH, ...withBody, ...withParams }, + readsAuth: true + }, + { + name: 'deleteEmail', + factory: deleteEmail, + method: 'delete', + path: '/users/{id}/deleteEmail/{emailId}', + controllerMethod: 'deleteEmail', + status: 200, + eventType: 'UserEmailDeleteRequestEvent', + result: true, + req: () => makeReq(), + expectedEvent: { authorization: AUTH, ...withParams }, + readsAuth: true + }, + { + name: 'createPhone', + factory: createPhone, + method: 'post', + path: '/users/{id}/createPhone', + controllerMethod: 'createPhone', + status: 201, + eventType: 'UserPhoneCreateRequestEvent', + result: { id: 'phone-1' }, + req: () => makeReq(), + expectedEvent: { authorization: AUTH, ...withBody, ...withParams }, + readsAuth: true + }, + { + name: 'updatePhone', + factory: updatePhone, + method: 'put', + path: '/users/{id}/updatePhone/{phoneId}', + controllerMethod: 'updatePhone', + status: 200, + eventType: 'UserPhoneUpdateRequestEvent', + result: { id: 'phone-1' }, + req: () => makeReq(), + expectedEvent: { authorization: AUTH, ...withBody, ...withParams }, + readsAuth: true + }, + { + name: 'deletePhone', + factory: deletePhone, + method: 'delete', + path: '/users/{id}/deletePhone/{phoneId}', + controllerMethod: 'deletePhone', + status: 200, + eventType: 'UserPhoneDeleteRequestEvent', + result: true, + req: () => makeReq(), + expectedEvent: { authorization: AUTH, ...withParams }, + readsAuth: true + }, + { + name: 'createOrganization', + factory: createOrganization, + method: 'post', + path: '/organizations', + controllerMethod: 'createOrganization', + status: 201, + eventType: 'OrganizationCreateRequestEvent', + result: { id: 'org-1' }, + req: () => makeReq(), + expectedEvent: { authorization: AUTH, ...withBody }, + readsAuth: true + }, + { + name: 'getAllOrganizations', + factory: getAllOrganizations, + method: 'get', + path: '/organizations', + controllerMethod: 'getAllOrganizations', + status: 200, + eventType: 'OrganizationGetAllRequestEvent', + result: [{ id: 'org-1' }], + req: () => makeReq({ query: { page: '2', size: '10' } }), + expectedEvent: { authorization: AUTH, queryString: { page: '2', size: '10' } }, + expectedBody: (result) => ({ result, error: undefined, ...PAGING }), + readsAuth: true + }, + { + name: 'getOrganizationById', + factory: getOrganizationById, + method: 'get', + path: '/organizations/{id}', + controllerMethod: 'getOrganizationById', + status: 200, + eventType: 'OrganizationGetOneRequestEvent', + result: { id: 'org-1' }, + req: () => makeReq(), + expectedEvent: { authorization: AUTH, ...withParams }, + readsAuth: true + }, + { + name: 'updateOrganization', + factory: updateOrganization, + method: 'put', + path: '/organizations/{id}', + controllerMethod: 'updateOrganization', + status: 200, + eventType: 'OrganizationUpdateRequestEvent', + result: { id: 'org-1' }, + req: () => makeReq(), + expectedEvent: { authorization: AUTH, ...withBody, ...withParams }, + readsAuth: true + }, + { + name: 'deleteOrganization', + factory: deleteOrganization, + method: 'delete', + path: '/organizations/{id}', + controllerMethod: 'deleteOrganization', + status: 200, + eventType: 'OrganizationDeleteRequestEvent', + result: true, + req: () => makeReq(), + expectedEvent: { authorization: AUTH, ...withParams }, + readsAuth: true + } +]; + +describe('derby-js restapi handlers', () => { + describe.each(HANDLERS.map((entry) => [entry.name, entry] as [string, HandlerCase]))( + '%s', + (_name, entry) => { + it(`answers ${entry.method} ${entry.path} with ${entry.status} and the controller result`, async () => { + expect.hasAssertions(); + + const controller = { + [entry.controllerMethod]: jest.fn().mockResolvedValue({ + result: entry.result, ...PAGING + }) + }; + const endpoint = entry.factory({ endPointConfig: SCHEMA_OAS, controller } as any); + + expect(endpoint.method).toBe(entry.method); + expect(endpoint.path).toBe(entry.path); + + const res = makeRes(); + await endpoint.handler(entry.req(), res); + + const expected = entry.expectedBody + ? entry.expectedBody(entry.result) + : entry.result; + expect(res.status).toHaveBeenCalledWith(entry.status); + expect(res.json).toHaveBeenCalledWith(expected); + + const [event] = controller[entry.controllerMethod].mock.calls[0]; + expect(event.type).toBe(entry.eventType); + expect(event).toMatchObject({ ...entry.expectedEvent, schemaOAS: SCHEMA_OAS }); + }); + + if (entry.readsAuth && entry.authEnforced !== false) { + it('answers 400 without calling the controller when authorization is missing', async () => { + expect.hasAssertions(); + + // The domain event validates the message: an empty authorization is + // rejected before the use case runs, and the handler maps it. + const controller = { + [entry.controllerMethod]: jest.fn().mockResolvedValue({ + result: entry.result, ...PAGING + }) + }; + const endpoint = entry.factory({ endPointConfig: SCHEMA_OAS, controller } as any); + + const res = makeRes(); + await endpoint.handler(makeReq({ headers: {}, query: {} }), res); + + expect(controller[entry.controllerMethod]).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('authorization can not be empty') + })); + }); + } + + if (entry.readsAuth && entry.authEnforced === false) { + it('forwards an empty authorization to the controller when the event allows it', async () => { + expect.hasAssertions(); + + const controller = { + [entry.controllerMethod]: jest.fn().mockResolvedValue({ + result: entry.result, ...PAGING + }) + }; + const endpoint = entry.factory({ endPointConfig: SCHEMA_OAS, controller } as any); + + const res = makeRes(); + await endpoint.handler(makeReq({ headers: {}, query: {} }), res); + + const [event] = controller[entry.controllerMethod].mock.calls[0]; + expect(event.authorization).toBe(''); + expect(res.status).toHaveBeenCalledWith(entry.status); + }); + } + + it('maps a controller error to the error response', async () => { + expect.hasAssertions(); + + const controller = { + [entry.controllerMethod]: jest.fn().mockResolvedValue({ + error: new ValidationError('invalid payload') + }) + }; + const endpoint = entry.factory({ endPointConfig: SCHEMA_OAS, controller } as any); + + const res = makeRes(); + await endpoint.handler(entry.req(), res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('invalid payload') + })); + }); + } + ); + + describe.each([ + ['getAll', getAll, 'getAll', 'UserGetAllRequestEvent'], + ['getAllOrganizations', getAllOrganizations, 'getAllOrganizations', 'OrganizationGetAllRequestEvent'] + ] as const)('%s pagination', (_name, factory, controllerMethod, eventType) => { + it('defaults the page to 1 and keeps explicit paging and filters', async () => { + expect.hasAssertions(); + + const controller = { + [controllerMethod]: jest.fn().mockResolvedValue({ result: [], ...PAGING }) + }; + const endpoint = factory({ endPointConfig: SCHEMA_OAS, controller } as any); + + await endpoint.handler(makeReq({ query: { size: '5' } }), makeRes()); + const [defaulted] = controller[controllerMethod].mock.calls[0]; + expect(defaulted.type).toBe(eventType); + expect(defaulted.queryString).toStrictEqual({ size: '5', page: 1 }); + + // A request without a query string at all still paginates. + await endpoint.handler(makeReq({ query: undefined }), makeRes()); + const [noQuery] = controller[controllerMethod].mock.calls[1]; + expect(noQuery.queryString).toStrictEqual({ page: 1 }); + }); + }); + + describe('document mutation factory', () => { + it('builds the create-document endpoint reading the body by default', async () => { + expect.hasAssertions(); + + const controller = { + createDocument: jest.fn().mockResolvedValue({ result: { id: 'doc-1' } }) + }; + const endpoint = createDocument({ endPointConfig: SCHEMA_OAS, controller } as any); + + expect(endpoint.method).toBe('post'); + expect(endpoint.path).toBe('/users/{id}/createDocument'); + + const res = makeRes(); + await endpoint.handler(makeReq(), res); + + expect(res.status).toHaveBeenCalledWith(201); + expect(res.json).toHaveBeenCalledWith({ id: 'doc-1' }); + const [event] = controller.createDocument.mock.calls[0]; + expect(event.type).toBe('UserDocumentCreateRequestEvent'); + expect(event).toMatchObject({ + authorization: AUTH, + input: { name: 'payload' }, + params: { id: 'user-1' }, + schemaOAS: SCHEMA_OAS + }); + }); + + it('builds the delete-document endpoint without reading a body', async () => { + expect.hasAssertions(); + + const controller = { + deleteDocument: jest.fn().mockResolvedValue({ result: true }) + }; + const endpoint = deleteDocument({ endPointConfig: SCHEMA_OAS, controller } as any); + + expect(endpoint.method).toBe('delete'); + expect(endpoint.path).toBe('/users/{id}/deleteDocument/{documentId}'); + + const res = makeRes(); + // A body on a delete request must not leak into the domain event. + await endpoint.handler( + makeReq({ params: { id: 'user-1', documentId: 'doc-1' }, body: { rogue: true } }), + res + ); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(true); + const [event] = controller.deleteDocument.mock.calls[0]; + expect(event.type).toBe('UserDocumentDeleteRequestEvent'); + expect(event.input).toStrictEqual({}); + }); + + it('maps a factory-built endpoint error to the error response', async () => { + expect.hasAssertions(); + + const controller = { + createDocument: jest.fn().mockResolvedValue({ + error: new ValidationError('document refused') + }) + }; + const endpoint = createDocument({ endPointConfig: SCHEMA_OAS, controller } as any); + + const res = makeRes(); + await endpoint.handler(makeReq(), res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('document refused') + })); + }); + + it('maps an unexpected failure without a known code to HTTP 500', async () => { + expect.hasAssertions(); + + const controller = { + createDocument: jest.fn().mockResolvedValue({ error: new Error('unexpected boom') }) + }; + const endpoint = createDocument({ endPointConfig: SCHEMA_OAS, controller } as any); + + const res = makeRes(); + await endpoint.handler(makeReq(), res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.any(String) + })); + }); + + it('builds the update-document endpoint with its configured route', async () => { + expect.hasAssertions(); + + const controller = { + updateDocument: jest.fn().mockResolvedValue({ result: { id: 'doc-1' } }) + }; + const endpoint = updateDocument({ endPointConfig: SCHEMA_OAS, controller } as any); + + expect(endpoint.method).toBe('put'); + expect(endpoint.path).toBe('/users/{id}/updateDocument/{documentId}'); + + const res = makeRes(); + await endpoint.handler( + makeReq({ params: { id: 'user-1', documentId: 'doc-1' } }), + res + ); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ id: 'doc-1' }); + const [event] = controller.updateDocument.mock.calls[0]; + expect(event.type).toBe('UserDocumentUpdateRequestEvent'); + expect(event).toMatchObject({ + authorization: AUTH, + input: { name: 'payload' }, + params: { id: 'user-1', documentId: 'doc-1' } + }); + }); + + it('rejects requests without params or headers through event validation', async () => { + expect.hasAssertions(); + + // The factory tolerates the missing request parts (`req.params || {}`, + // `req.headers?.authorization`); the event validation is what turns + // them into a 400 rather than a crash. + const controller = { + createDocument: jest.fn().mockResolvedValue({ result: { id: 'doc-1' } }) + }; + const endpoint = createDocument({ endPointConfig: SCHEMA_OAS, controller } as any); + + const res = makeRes(); + await endpoint.handler({ body: { name: 'payload' }, query: {} } as any, res); + + expect(controller.createDocument).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('can not be empty') + })); + }); + }); + + describe('organization sub-resource mutation factory', () => { + it('builds the create-address endpoint reading the body by default', async () => { + expect.hasAssertions(); + + const controller = { + createOrganizationAddress: jest.fn().mockResolvedValue({ result: { id: 'addr-1' } }) + }; + const endpoint = createOrganizationAddress({ endPointConfig: SCHEMA_OAS, controller } as any); + + expect(endpoint.method).toBe('post'); + expect(endpoint.path).toBe('/organizations/{id}/createAddress'); + + const res = makeRes(); + await endpoint.handler(makeReq({ params: { id: 'org-1' } }), res); + + expect(res.status).toHaveBeenCalledWith(201); + expect(res.json).toHaveBeenCalledWith({ id: 'addr-1' }); + const [event] = controller.createOrganizationAddress.mock.calls[0]; + expect(event.type).toBe('OrganizationAddressCreateRequestEvent'); + expect(event).toMatchObject({ + authorization: AUTH, + input: { name: 'payload' }, + params: { id: 'org-1' }, + schemaOAS: SCHEMA_OAS + }); + }); + + it('builds the delete-address endpoint without reading a body', async () => { + expect.hasAssertions(); + + const controller = { + deleteOrganizationAddress: jest.fn().mockResolvedValue({ result: true }) + }; + const endpoint = deleteOrganizationAddress({ endPointConfig: SCHEMA_OAS, controller } as any); + + expect(endpoint.method).toBe('delete'); + expect(endpoint.path).toBe('/organizations/{id}/deleteAddress/{addressId}'); + + const res = makeRes(); + await endpoint.handler( + makeReq({ params: { id: 'org-1', addressId: 'addr-1' }, body: { rogue: true } }), + res + ); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(true); + const [event] = controller.deleteOrganizationAddress.mock.calls[0]; + expect(event.type).toBe('OrganizationAddressDeleteRequestEvent'); + expect(event.input).toStrictEqual({}); + }); + + it('maps a controller error from a factory-built endpoint to the error response', async () => { + expect.hasAssertions(); + + const controller = { + createOrganizationAddress: jest.fn().mockResolvedValue({ + error: new ValidationError('address refused') + }) + }; + const endpoint = createOrganizationAddress({ endPointConfig: SCHEMA_OAS, controller } as any); + + const res = makeRes(); + await endpoint.handler(makeReq({ params: { id: 'org-1' } }), res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('address refused') + })); + }); + + it('maps an unexpected failure without a known code to HTTP 500', async () => { + expect.hasAssertions(); + + const controller = { + createOrganizationAddress: jest.fn().mockResolvedValue({ + error: new Error('unexpected boom') + }) + }; + const endpoint = createOrganizationAddress({ endPointConfig: SCHEMA_OAS, controller } as any); + + const res = makeRes(); + await endpoint.handler(makeReq({ params: { id: 'org-1' } }), res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.any(String) + })); + }); + + it('rejects sub-resource requests without params or headers through event validation', async () => { + expect.hasAssertions(); + + const controller = { + deleteOrganizationAddress: jest.fn().mockResolvedValue({ result: true }) + }; + const endpoint = deleteOrganizationAddress({ endPointConfig: SCHEMA_OAS, controller } as any); + + const res = makeRes(); + await endpoint.handler({ query: {} } as any, res); + + expect(controller.deleteOrganizationAddress).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('can not be empty') + })); + }); + }); + + describe('organization email/phone sub-resource wrappers', () => { + const SUBRESOURCE_WRAPPERS = [ + ['updateOrganizationAddress', updateOrganizationAddress, 'put', + '/organizations/{id}/updateAddress/{addressId}', 200, 'OrganizationAddressUpdateRequestEvent', + { id: 'org-1', addressId: 'addr-1' }, { street: 'main' }], + ['createOrganizationEmail', createOrganizationEmail, 'post', + '/organizations/{id}/createEmail', 201, 'OrganizationEmailCreateRequestEvent', + { id: 'org-1' }, { email: 'ops@example.com' }], + ['updateOrganizationEmail', updateOrganizationEmail, 'put', + '/organizations/{id}/updateEmail/{emailId}', 200, 'OrganizationEmailUpdateRequestEvent', + { id: 'org-1', emailId: 'email-1' }, { email: 'ops@example.com' }], + ['deleteOrganizationEmail', deleteOrganizationEmail, 'delete', + '/organizations/{id}/deleteEmail/{emailId}', 200, 'OrganizationEmailDeleteRequestEvent', + { id: 'org-1', emailId: 'email-1' }, undefined], + ['createOrganizationPhone', createOrganizationPhone, 'post', + '/organizations/{id}/createPhone', 201, 'OrganizationPhoneCreateRequestEvent', + { id: 'org-1' }, { number: '999' }], + ['updateOrganizationPhone', updateOrganizationPhone, 'put', + '/organizations/{id}/updatePhone/{phoneId}', 200, 'OrganizationPhoneUpdateRequestEvent', + { id: 'org-1', phoneId: 'phone-1' }, { number: '999' }], + ['deleteOrganizationPhone', deleteOrganizationPhone, 'delete', + '/organizations/{id}/deletePhone/{phoneId}', 200, 'OrganizationPhoneDeleteRequestEvent', + { id: 'org-1', phoneId: 'phone-1' }, undefined] + ] as const; + + it.each(SUBRESOURCE_WRAPPERS)( + '%s answers with its configured route and status', + async (controllerMethod, factory, method, path, status, eventType, params, body) => { + expect.hasAssertions(); + + const controller = { + [controllerMethod]: jest.fn().mockResolvedValue({ result: { ok: true } }) + }; + const endpoint = factory({ endPointConfig: SCHEMA_OAS, controller } as any); + + expect(endpoint.method).toBe(method); + expect(endpoint.path).toBe(path); + + const res = makeRes(); + await endpoint.handler(makeReq({ params, body }), res); + + expect(res.status).toHaveBeenCalledWith(status); + expect(res.json).toHaveBeenCalledWith({ ok: true }); + const [event] = controller[controllerMethod].mock.calls[0]; + expect(event.type).toBe(eventType); + expect(event).toMatchObject({ authorization: AUTH, params, schemaOAS: SCHEMA_OAS }); + expect(event.input).toStrictEqual(body ?? {}); + } + ); + }); +}); diff --git a/apps/backend-template/test/unit/modules/Users/interface/restapi/frameworks/express/handlers.test.ts b/apps/backend-template/test/unit/modules/Users/interface/restapi/frameworks/express/handlers.test.ts new file mode 100644 index 000000000..4d0ad40ed --- /dev/null +++ b/apps/backend-template/test/unit/modules/Users/interface/restapi/frameworks/express/handlers.test.ts @@ -0,0 +1,669 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, jest/max-expects, + jest/no-conditional-in-test */ + +import { ValidationError } from '@src/infra/exceptions'; +import type { EndPointFactory } from '@src/interface/HTTP/ports'; + +import login from '@src/modules/Users/interface/restapi/frameworks/express/handlers/login'; +import logout from '@src/modules/Users/interface/restapi/frameworks/express/handlers/logout'; +import register from '@src/modules/Users/interface/restapi/frameworks/express/handlers/register'; +import updateUserPassword from '@src/modules/Users/interface/restapi/frameworks/express/handlers/updateUserPassword'; +import getAll from '@src/modules/Users/interface/restapi/frameworks/express/handlers/getAll'; +import create from '@src/modules/Users/interface/restapi/frameworks/express/handlers/create'; +import update from '@src/modules/Users/interface/restapi/frameworks/express/handlers/update'; +import deleteOne from '@src/modules/Users/interface/restapi/frameworks/express/handlers/deleteOne'; +import getOneById from '@src/modules/Users/interface/restapi/frameworks/express/handlers/getOneById'; +import updatePassword from '@src/modules/Users/interface/restapi/frameworks/express/handlers/updatePassword'; +import createEmail from '@src/modules/Users/interface/restapi/frameworks/express/handlers/createEmail'; +import updateEmail from '@src/modules/Users/interface/restapi/frameworks/express/handlers/updateEmail'; +import deleteEmail from '@src/modules/Users/interface/restapi/frameworks/express/handlers/deleteEmail'; +import createDocument from '@src/modules/Users/interface/restapi/frameworks/express/handlers/createDocument'; +import updateDocument from '@src/modules/Users/interface/restapi/frameworks/express/handlers/updateDocument'; +import deleteDocument from '@src/modules/Users/interface/restapi/frameworks/express/handlers/deleteDocument'; +import createPhone from '@src/modules/Users/interface/restapi/frameworks/express/handlers/createPhone'; +import updatePhone from '@src/modules/Users/interface/restapi/frameworks/express/handlers/updatePhone'; +import deletePhone from '@src/modules/Users/interface/restapi/frameworks/express/handlers/deletePhone'; +import createOrganization from '@src/modules/Users/interface/restapi/frameworks/express/handlers/createOrganization'; +import getAllOrganizations from '@src/modules/Users/interface/restapi/frameworks/express/handlers/getAllOrganizations'; +import getOrganizationById from '@src/modules/Users/interface/restapi/frameworks/express/handlers/getOrganizationById'; +import updateOrganization from '@src/modules/Users/interface/restapi/frameworks/express/handlers/updateOrganization'; +import deleteOrganization from '@src/modules/Users/interface/restapi/frameworks/express/handlers/deleteOrganization'; +import updateOrganizationAddress from '@src/modules/Users/interface/restapi/frameworks/express/handlers/updateOrganizationAddress'; +import createOrganizationEmail from '@src/modules/Users/interface/restapi/frameworks/express/handlers/createOrganizationEmail'; +import updateOrganizationEmail from '@src/modules/Users/interface/restapi/frameworks/express/handlers/updateOrganizationEmail'; +import deleteOrganizationEmail from '@src/modules/Users/interface/restapi/frameworks/express/handlers/deleteOrganizationEmail'; +import createOrganizationPhone from '@src/modules/Users/interface/restapi/frameworks/express/handlers/createOrganizationPhone'; +import updateOrganizationPhone from '@src/modules/Users/interface/restapi/frameworks/express/handlers/updateOrganizationPhone'; +import deleteOrganizationPhone from '@src/modules/Users/interface/restapi/frameworks/express/handlers/deleteOrganizationPhone'; +import createOrganizationAddress from '@src/modules/Users/interface/restapi/frameworks/express/handlers/createOrganizationAddress'; +import deleteOrganizationAddress from '@src/modules/Users/interface/restapi/frameworks/express/handlers/deleteOrganizationAddress'; + +/** + * The express REST handlers, driven as express drives them. + * + * Each handler is a thin adapter: build the domain event from the request, + * call the controller, answer with a status. What is asserted is the adapter + * contract — the event the controller receives (type, authorization, input, + * params, schema), the status and body the client receives, and the error + * mapping when the controller reports a failure. Pagination defaults and the + * organization sub-resource factory get their own cases because they carry + * branches the plain CRUD shape does not. + */ + +const SCHEMA_OAS = { operationId: 'probe' } as any; +const AUTH = 'Bearer express-token'; + +const makeRes = () => { + const res: any = { status: jest.fn(), json: jest.fn() }; + res.status.mockReturnValue(res); + return res; +}; + +const makeReq = (overrides: Record = {}): any => ({ + headers: { authorization: AUTH }, + params: { id: 'user-1' }, + body: { name: 'payload' }, + query: {}, + ...overrides +}); + +type HandlerCase = { + name: string; + factory: EndPointFactory; + method: string; + path: string; + controllerMethod: string; + status: number; + eventType: string; + result: any; + req: () => any; + expectedEvent: Record; + expectedBody?: (result: any) => any; + readsAuth: boolean; + authEnforced?: boolean; +}; + +const withBody = { input: { name: 'payload' } }; +const withParams = { params: { id: 'user-1' } }; +const PAGING = { page: 1, size: 10, total: 1 }; + +const HANDLERS: HandlerCase[] = [ + { + name: 'login', + factory: login, + method: 'post', + path: '/auth/login', + controllerMethod: 'login', + status: 200, + eventType: 'LoginRequestEvent', + result: { token: 'jwt' }, + req: () => makeReq(), + expectedEvent: { ...withBody }, + readsAuth: false + }, + { + name: 'register', + factory: register, + method: 'post', + path: '/auth/register', + controllerMethod: 'register', + status: 201, + eventType: 'RegisterRequestEvent', + result: { id: 'user-1' }, + req: () => makeReq(), + expectedEvent: { ...withBody }, + readsAuth: false + }, + { + name: 'logout', + factory: logout, + method: 'post', + path: '/auth/logout', + controllerMethod: 'logout', + status: 200, + eventType: 'LogoutRequestEvent', + result: true, + req: () => makeReq(), + expectedEvent: { authorization: AUTH, ...withBody }, + readsAuth: true, + authEnforced: false + }, + { + name: 'updateUserPassword', + factory: updateUserPassword, + method: 'post', + path: '/auth/updateUserPassword', + controllerMethod: 'updatePassword', + status: 200, + eventType: 'UpdatePasswordRequestEvent', + result: true, + req: () => makeReq(), + expectedEvent: { authorization: AUTH, ...withBody }, + readsAuth: true, + authEnforced: false + }, + { + name: 'getAll', + factory: getAll, + method: 'get', + path: '/users', + controllerMethod: 'getAll', + status: 200, + eventType: 'UserGetAllRequestEvent', + result: [{ id: 'user-1' }], + req: () => makeReq({ query: { page: '3', size: '5' } }), + expectedEvent: { authorization: AUTH, queryString: { page: '3', size: '5' } }, + expectedBody: (result) => ({ result, error: undefined, ...PAGING }), + readsAuth: true + }, + { + name: 'create', + factory: create, + method: 'post', + path: '/users', + controllerMethod: 'create', + status: 201, + eventType: 'UserCreateRequestEvent', + result: { id: 'user-1' }, + req: () => makeReq(), + expectedEvent: { authorization: AUTH, ...withBody }, + readsAuth: true + }, + { + name: 'update', + factory: update, + method: 'put', + path: '/users/{id}', + controllerMethod: 'update', + status: 200, + eventType: 'UserUpdateRequestEvent', + result: { id: 'user-1' }, + req: () => makeReq(), + expectedEvent: { authorization: AUTH, ...withBody, ...withParams }, + readsAuth: true + }, + { + name: 'deleteOne', + factory: deleteOne, + method: 'delete', + path: '/users/{id}', + controllerMethod: 'delete', + status: 200, + eventType: 'UserDeleteRequestEvent', + result: true, + req: () => makeReq(), + expectedEvent: { authorization: AUTH, ...withParams }, + readsAuth: true + }, + { + name: 'getOneById', + factory: getOneById, + method: 'get', + path: '/users/{id}', + controllerMethod: 'getOneById', + status: 200, + eventType: 'UserGetOneRequestEvent', + result: { id: 'user-1' }, + req: () => makeReq(), + expectedEvent: { authorization: AUTH, ...withParams }, + readsAuth: true + }, + { + name: 'updatePassword', + factory: updatePassword, + method: 'put', + path: '/users/{id}/updatePassword', + controllerMethod: 'updatePassword', + status: 200, + eventType: 'UserPasswordUpdateRequestEvent', + result: { id: 'user-1' }, + req: () => makeReq(), + expectedEvent: { authorization: AUTH, ...withBody, ...withParams }, + readsAuth: true + }, + { + name: 'createEmail', + factory: createEmail, + method: 'post', + path: '/users/{id}/createEmail', + controllerMethod: 'createEmail', + status: 201, + eventType: 'UserEmailCreateRequestEvent', + result: { id: 'email-1' }, + req: () => makeReq(), + expectedEvent: { authorization: AUTH, ...withBody, ...withParams }, + readsAuth: true + }, + { + name: 'updateEmail', + factory: updateEmail, + method: 'put', + path: '/users/{id}/updateEmail/{emailId}', + controllerMethod: 'updateEmail', + status: 200, + eventType: 'UserEmailUpdateRequestEvent', + result: { id: 'email-1' }, + req: () => makeReq(), + expectedEvent: { authorization: AUTH, ...withBody, ...withParams }, + readsAuth: true + }, + { + name: 'deleteEmail', + factory: deleteEmail, + method: 'delete', + path: '/users/{id}/deleteEmail/{emailId}', + controllerMethod: 'deleteEmail', + status: 200, + eventType: 'UserEmailDeleteRequestEvent', + result: true, + req: () => makeReq(), + expectedEvent: { authorization: AUTH, ...withParams }, + readsAuth: true + }, + { + name: 'createDocument', + factory: createDocument, + method: 'post', + path: '/users/{id}/createDocument', + controllerMethod: 'createDocument', + status: 201, + eventType: 'UserDocumentCreateRequestEvent', + result: { id: 'doc-1' }, + req: () => makeReq(), + expectedEvent: { authorization: AUTH, ...withBody, ...withParams }, + readsAuth: true + }, + { + name: 'updateDocument', + factory: updateDocument, + method: 'put', + path: '/users/{id}/updateDocument/{documentId}', + controllerMethod: 'updateDocument', + status: 200, + eventType: 'UserDocumentUpdateRequestEvent', + result: { id: 'doc-1' }, + req: () => makeReq(), + expectedEvent: { authorization: AUTH, ...withBody, ...withParams }, + readsAuth: true + }, + { + name: 'deleteDocument', + factory: deleteDocument, + method: 'delete', + path: '/users/{id}/deleteDocument/{documentId}', + controllerMethod: 'deleteDocument', + status: 200, + eventType: 'UserDocumentDeleteRequestEvent', + result: true, + req: () => makeReq(), + expectedEvent: { authorization: AUTH, ...withParams }, + readsAuth: true + }, + { + name: 'createPhone', + factory: createPhone, + method: 'post', + path: '/users/{id}/createPhone', + controllerMethod: 'createPhone', + status: 201, + eventType: 'UserPhoneCreateRequestEvent', + result: { id: 'phone-1' }, + req: () => makeReq(), + expectedEvent: { authorization: AUTH, ...withBody, ...withParams }, + readsAuth: true + }, + { + name: 'updatePhone', + factory: updatePhone, + method: 'put', + path: '/users/{id}/updatePhone/{phoneId}', + controllerMethod: 'updatePhone', + status: 200, + eventType: 'UserPhoneUpdateRequestEvent', + result: { id: 'phone-1' }, + req: () => makeReq(), + expectedEvent: { authorization: AUTH, ...withBody, ...withParams }, + readsAuth: true + }, + { + name: 'deletePhone', + factory: deletePhone, + method: 'delete', + path: '/users/{id}/deletePhone/{phoneId}', + controllerMethod: 'deletePhone', + status: 200, + eventType: 'UserPhoneDeleteRequestEvent', + result: true, + req: () => makeReq(), + expectedEvent: { authorization: AUTH, ...withParams }, + readsAuth: true + }, + { + name: 'createOrganization', + factory: createOrganization, + method: 'post', + path: '/organizations', + controllerMethod: 'createOrganization', + status: 201, + eventType: 'OrganizationCreateRequestEvent', + result: { id: 'org-1' }, + req: () => makeReq(), + expectedEvent: { authorization: AUTH, ...withBody }, + readsAuth: true + }, + { + name: 'getAllOrganizations', + factory: getAllOrganizations, + method: 'get', + path: '/organizations', + controllerMethod: 'getAllOrganizations', + status: 200, + eventType: 'OrganizationGetAllRequestEvent', + result: [{ id: 'org-1' }], + req: () => makeReq({ query: { page: '2', size: '10' } }), + expectedEvent: { authorization: AUTH, queryString: { page: '2', size: '10' } }, + expectedBody: (result) => ({ result, error: undefined, ...PAGING }), + readsAuth: true + }, + { + name: 'getOrganizationById', + factory: getOrganizationById, + method: 'get', + path: '/organizations/{id}', + controllerMethod: 'getOrganizationById', + status: 200, + eventType: 'OrganizationGetOneRequestEvent', + result: { id: 'org-1' }, + req: () => makeReq(), + expectedEvent: { authorization: AUTH, ...withParams }, + readsAuth: true + }, + { + name: 'updateOrganization', + factory: updateOrganization, + method: 'put', + path: '/organizations/{id}', + controllerMethod: 'updateOrganization', + status: 200, + eventType: 'OrganizationUpdateRequestEvent', + result: { id: 'org-1' }, + req: () => makeReq(), + expectedEvent: { authorization: AUTH, ...withBody, ...withParams }, + readsAuth: true + }, + { + name: 'deleteOrganization', + factory: deleteOrganization, + method: 'delete', + path: '/organizations/{id}', + controllerMethod: 'deleteOrganization', + status: 200, + eventType: 'OrganizationDeleteRequestEvent', + result: true, + req: () => makeReq(), + expectedEvent: { authorization: AUTH, ...withParams }, + readsAuth: true + } +]; + +describe('express restapi handlers', () => { + describe.each(HANDLERS.map((entry) => [entry.name, entry] as [string, HandlerCase]))( + '%s', + (_name, entry) => { + it(`answers ${entry.method} ${entry.path} with ${entry.status} and the controller result`, async () => { + expect.hasAssertions(); + + const controller = { + [entry.controllerMethod]: jest.fn().mockResolvedValue({ + result: entry.result, ...PAGING + }) + }; + const endpoint = entry.factory({ endPointConfig: SCHEMA_OAS, controller } as any); + + expect(endpoint.method).toBe(entry.method); + expect(endpoint.path).toBe(entry.path); + + const res = makeRes(); + await endpoint.handler(entry.req(), res); + + const expected = entry.expectedBody + ? entry.expectedBody(entry.result) + : entry.result; + expect(res.status).toHaveBeenCalledWith(entry.status); + expect(res.json).toHaveBeenCalledWith(expected); + + const [event] = controller[entry.controllerMethod].mock.calls[0]; + expect(event.type).toBe(entry.eventType); + expect(event).toMatchObject({ ...entry.expectedEvent, schemaOAS: SCHEMA_OAS }); + }); + + if (entry.readsAuth && entry.authEnforced !== false) { + it('answers 400 without calling the controller when authorization is missing', async () => { + expect.hasAssertions(); + + // The domain event validates the message: an empty authorization is + // rejected before the use case runs, and the handler maps it. + const controller = { + [entry.controllerMethod]: jest.fn().mockResolvedValue({ + result: entry.result, ...PAGING + }) + }; + const endpoint = entry.factory({ endPointConfig: SCHEMA_OAS, controller } as any); + + const res = makeRes(); + await endpoint.handler(makeReq({ headers: {}, query: {} }), res); + + expect(controller[entry.controllerMethod]).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('authorization can not be empty') + })); + }); + } + + if (entry.readsAuth && entry.authEnforced === false) { + it('forwards an empty authorization to the controller when the event allows it', async () => { + expect.hasAssertions(); + + const controller = { + [entry.controllerMethod]: jest.fn().mockResolvedValue({ + result: entry.result, ...PAGING + }) + }; + const endpoint = entry.factory({ endPointConfig: SCHEMA_OAS, controller } as any); + + const res = makeRes(); + await endpoint.handler(makeReq({ headers: {}, query: {} }), res); + + const [event] = controller[entry.controllerMethod].mock.calls[0]; + expect(event.authorization).toBe(''); + expect(res.status).toHaveBeenCalledWith(entry.status); + }); + } + + it('maps a controller error to the error response', async () => { + expect.hasAssertions(); + + const controller = { + [entry.controllerMethod]: jest.fn().mockResolvedValue({ + error: new ValidationError('invalid payload') + }) + }; + const endpoint = entry.factory({ endPointConfig: SCHEMA_OAS, controller } as any); + + const res = makeRes(); + await endpoint.handler(entry.req(), res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('invalid payload') + })); + }); + } + ); + + describe.each([ + ['getAll', getAll, 'getAll', 'UserGetAllRequestEvent'], + ['getAllOrganizations', getAllOrganizations, 'getAllOrganizations', 'OrganizationGetAllRequestEvent'] + ] as const)('%s pagination', (_name, factory, controllerMethod, eventType) => { + it('defaults the page to 1 and keeps explicit paging and filters', async () => { + expect.hasAssertions(); + + const controller = { + [controllerMethod]: jest.fn().mockResolvedValue({ result: [], ...PAGING }) + }; + const endpoint = factory({ endPointConfig: SCHEMA_OAS, controller } as any); + + await endpoint.handler(makeReq({ query: { size: '5' } }), makeRes()); + const [defaulted] = controller[controllerMethod].mock.calls[0]; + expect(defaulted.type).toBe(eventType); + expect(defaulted.queryString).toStrictEqual({ size: '5', page: 1 }); + + // A request without a query string at all still paginates. + await endpoint.handler(makeReq({ query: undefined }), makeRes()); + const [noQuery] = controller[controllerMethod].mock.calls[1]; + expect(noQuery.queryString).toStrictEqual({ page: 1 }); + }); + }); + + describe('organization sub-resource mutation factory', () => { + it('builds the create-address endpoint reading the body by default', async () => { + expect.hasAssertions(); + + const controller = { + createOrganizationAddress: jest.fn().mockResolvedValue({ result: { id: 'addr-1' } }) + }; + const endpoint = createOrganizationAddress({ endPointConfig: SCHEMA_OAS, controller } as any); + + expect(endpoint.method).toBe('post'); + expect(endpoint.path).toBe('/organizations/{id}/createAddress'); + + const res = makeRes(); + await endpoint.handler(makeReq(), res); + + expect(res.status).toHaveBeenCalledWith(201); + expect(res.json).toHaveBeenCalledWith({ id: 'addr-1' }); + const [event] = controller.createOrganizationAddress.mock.calls[0]; + expect(event.type).toBe('OrganizationAddressCreateRequestEvent'); + expect(event).toMatchObject({ + authorization: AUTH, + input: { name: 'payload' }, + params: { id: 'user-1' }, + schemaOAS: SCHEMA_OAS + }); + }); + + it('builds the delete-address endpoint without reading a body', async () => { + expect.hasAssertions(); + + const controller = { + deleteOrganizationAddress: jest.fn().mockResolvedValue({ result: true }) + }; + const endpoint = deleteOrganizationAddress({ endPointConfig: SCHEMA_OAS, controller } as any); + + expect(endpoint.method).toBe('delete'); + expect(endpoint.path).toBe('/organizations/{id}/deleteAddress/{addressId}'); + + const res = makeRes(); + // A body on a delete request must not leak into the domain event. + await endpoint.handler( + makeReq({ params: { id: 'org-1', addressId: 'addr-1' }, body: { rogue: true } }), + res + ); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(true); + const [event] = controller.deleteOrganizationAddress.mock.calls[0]; + expect(event.type).toBe('OrganizationAddressDeleteRequestEvent'); + expect(event.input).toStrictEqual({}); + }); + + it('maps a factory-built endpoint error to the error response', async () => { + expect.hasAssertions(); + + const controller = { + createOrganizationAddress: jest.fn().mockResolvedValue({ + error: new ValidationError('address refused') + }) + }; + const endpoint = createOrganizationAddress({ endPointConfig: SCHEMA_OAS, controller } as any); + + const res = makeRes(); + await endpoint.handler(makeReq(), res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('address refused') + })); + }); + + it('answers 400 when a sub-resource request arrives without authorization', async () => { + expect.hasAssertions(); + + const controller = { + createOrganizationAddress: jest.fn().mockResolvedValue({ result: { id: 'addr-1' } }) + }; + const endpoint = createOrganizationAddress({ endPointConfig: SCHEMA_OAS, controller } as any); + + const res = makeRes(); + await endpoint.handler(makeReq({ headers: {} }), res); + + expect(controller.createOrganizationAddress).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + message: expect.stringContaining('authorization can not be empty') + })); + }); + }); + + describe('organization email/phone sub-resource wrappers', () => { + const SUBRESOURCE_WRAPPERS = [ + ['updateOrganizationAddress', updateOrganizationAddress, 'put', + '/organizations/{id}/updateAddress/{addressId}', 200, 'OrganizationAddressUpdateRequestEvent', + { id: 'org-1', addressId: 'addr-1' }, { street: 'main' }], + ['createOrganizationEmail', createOrganizationEmail, 'post', + '/organizations/{id}/createEmail', 201, 'OrganizationEmailCreateRequestEvent', + { id: 'org-1' }, { email: 'ops@example.com' }], + ['updateOrganizationEmail', updateOrganizationEmail, 'put', + '/organizations/{id}/updateEmail/{emailId}', 200, 'OrganizationEmailUpdateRequestEvent', + { id: 'org-1', emailId: 'email-1' }, { email: 'ops@example.com' }], + ['deleteOrganizationEmail', deleteOrganizationEmail, 'delete', + '/organizations/{id}/deleteEmail/{emailId}', 200, 'OrganizationEmailDeleteRequestEvent', + { id: 'org-1', emailId: 'email-1' }, undefined], + ['createOrganizationPhone', createOrganizationPhone, 'post', + '/organizations/{id}/createPhone', 201, 'OrganizationPhoneCreateRequestEvent', + { id: 'org-1' }, { number: '999' }], + ['updateOrganizationPhone', updateOrganizationPhone, 'put', + '/organizations/{id}/updatePhone/{phoneId}', 200, 'OrganizationPhoneUpdateRequestEvent', + { id: 'org-1', phoneId: 'phone-1' }, { number: '999' }], + ['deleteOrganizationPhone', deleteOrganizationPhone, 'delete', + '/organizations/{id}/deletePhone/{phoneId}', 200, 'OrganizationPhoneDeleteRequestEvent', + { id: 'org-1', phoneId: 'phone-1' }, undefined] + ] as const; + + it.each(SUBRESOURCE_WRAPPERS)( + '%s answers with its configured route and status', + async (controllerMethod, factory, method, path, status, eventType, params, body) => { + expect.hasAssertions(); + + const controller = { + [controllerMethod]: jest.fn().mockResolvedValue({ result: { ok: true } }) + }; + const endpoint = factory({ endPointConfig: SCHEMA_OAS, controller } as any); + + expect(endpoint.method).toBe(method); + expect(endpoint.path).toBe(path); + + const res = makeRes(); + await endpoint.handler(makeReq({ params, body }), res); + + expect(res.status).toHaveBeenCalledWith(status); + expect(res.json).toHaveBeenCalledWith({ ok: true }); + const [event] = controller[controllerMethod].mock.calls[0]; + expect(event.type).toBe(eventType); + expect(event).toMatchObject({ authorization: AUTH, params, schemaOAS: SCHEMA_OAS }); + expect(event.input).toStrictEqual(body ?? {}); + } + ); + }); +}); diff --git a/apps/backend-template/test/unit/modules/Users/lambdaRuntime.test.ts b/apps/backend-template/test/unit/modules/Users/lambdaRuntime.test.ts index 1a668a4c4..ffb91a8d3 100644 --- a/apps/backend-template/test/unit/modules/Users/lambdaRuntime.test.ts +++ b/apps/backend-template/test/unit/modules/Users/lambdaRuntime.test.ts @@ -1,7 +1,10 @@ import type { APIGatewayProxyEvent } from 'aws-lambda'; import { Context } from '@src/infra/context/Context'; -import { withLambdaContext } from '@src/modules/Users/interface/restapi/frameworks/aws/lambda/handlers/runtime'; +import { + getSchemaOAS, + withLambdaContext +} from '@src/modules/Users/interface/restapi/frameworks/aws/lambda/handlers/runtime'; const lambdaEvent = (headers: Record = {}): APIGatewayProxyEvent => ({ body: '', @@ -45,4 +48,46 @@ describe('users Lambda runtime context', () => { throw new Error('lambda runtime failed'); })).rejects.toThrow('lambda runtime failed'); }); + + it('prefers the lowercase authorization header when both casings arrive', async () => { + expect.hasAssertions(); + + const authorization = await withLambdaContext( + lambdaEvent({ authorization: 'Bearer lowercase', Authorization: 'Bearer upper' }), + async () => (Context.getStore() as Map).get('authorization') + ); + + expect(authorization).toBe('Bearer lowercase'); + }); + + it('falls back to a bare Bearer prefix when the event carries no headers', async () => { + expect.hasAssertions(); + + const event = lambdaEvent(); + delete (event as { headers?: Record }).headers; + + const authorization = await withLambdaContext(event, async () => ( + (Context.getStore() as Map).get('authorization') + )); + + expect(authorization).toBe('Bearer'); + }); +}); + +describe('users Lambda OAS lookup', () => { + it('resolves the operation config declared for the event path and method', () => { + expect.hasAssertions(); + + const config = getSchemaOAS(lambdaEvent()); + + expect(config.operationId).toBe('getAll'); + }); + + it('answers an empty config for an undeclared path or method', () => { + expect.hasAssertions(); + + // A miss is "no validation schema", not a crash — the handler still runs. + expect(getSchemaOAS({ ...lambdaEvent(), path: '/no-such-path' })).toStrictEqual({}); + expect(getSchemaOAS({ ...lambdaEvent(), httpMethod: 'PATCH' })).toStrictEqual({}); + }); }); diff --git a/apps/backend-template/test/unit/modules/port/core.test.ts b/apps/backend-template/test/unit/modules/port/core.test.ts index 8c5bd2b7c..9cd29bbc1 100644 --- a/apps/backend-template/test/unit/modules/port/core.test.ts +++ b/apps/backend-template/test/unit/modules/port/core.test.ts @@ -32,6 +32,8 @@ class TestModelWithOwnSerialize extends BaseModel { } } +class TestModelWithMeta extends BaseModel {} + class TestRepo extends BaseRepo { public async create(data: any): Promise { return data; @@ -99,6 +101,36 @@ describe('port core helpers', () => { expect(model.updatedAt.toISOString()).toBe('2026-01-01T00:00:00.000Z'); }); + it('normalizes deletedAt through the constructor', () => { + expect.hasAssertions(); + + const fromDate = new TestModelWithMeta({ deletedAt: new Date('2026-02-01T00:00:00.000Z') }); + expect(fromDate.deletedAt).toBe('2026-02-01T00:00:00.000Z'); + + const fromString = new TestModelWithMeta({ deletedAt: '2026-02-02T00:00:00.000Z' }); + expect(fromString.deletedAt).toBe('2026-02-02T00:00:00.000Z'); + }); + + it('normalizes deletedAt through the setter', () => { + expect.hasAssertions(); + + const model = new TestModelWithMeta({ deletedAt: '2026-02-03T00:00:00.000Z' }); + model.deletedAt = undefined as any; + expect(model.deletedAt).toBeNull(); + + model.deletedAt = null; + expect(model.deletedAt).toBeNull(); + + model.deletedAt = ''; + expect(model.deletedAt).toBe(''); + + model.deletedAt = '2026-02-04T00:00:00.000Z'; + expect(model.deletedAt).toBe('2026-02-04T00:00:00.000Z'); + + model.deletedAt = new Date('2026-02-05T00:00:00.000Z'); + expect(model.deletedAt).toBe('2026-02-05T00:00:00.000Z'); + }); + it('builds event metadata and parses string input', () => { expect.hasAssertions(); const event = new TestEvent({ diff --git a/apps/backend-template/test/unit/modules/port/relations.test.ts b/apps/backend-template/test/unit/modules/port/relations.test.ts index da43eb607..dff4c8a1f 100644 --- a/apps/backend-template/test/unit/modules/port/relations.test.ts +++ b/apps/backend-template/test/unit/modules/port/relations.test.ts @@ -6,10 +6,10 @@ class ParentEntity {} class ChildEntity {} class RelModel { - @belongsTo(() => ParentEntity) + @belongsTo('ParentEntity') public parent!: ParentEntity | null; - @hasMany(() => ChildEntity) + @hasMany('ChildEntity') public children!: ChildEntity[]; } diff --git a/apps/backend-template/test/unit/modules/port/setListQuery.test.ts b/apps/backend-template/test/unit/modules/port/setListQuery.test.ts new file mode 100644 index 000000000..3f4fe4451 --- /dev/null +++ b/apps/backend-template/test/unit/modules/port/setListQuery.test.ts @@ -0,0 +1,117 @@ +import { BaseDomainEvent } from '@src/modules/port/BaseDomainEvent'; +import { readListCapabilities, setListQuery } from '@src/modules/port/setListQuery'; +import { ValidationError } from '@src/infra/exceptions'; +import { _DEFAULT_PAGE_SIZE_ } from '@src/config/constants'; + +class TestEvent extends BaseDomainEvent {} + +const capabilities = { + sortable: ['firstName', 'createdAt'], + filterable: { firstName: 'text', roles: 'enum', createdAt: 'date' }, + searchable: ['firstName', 'lastName'], + defaultSize: 20, + maxSize: 50 +}; + +const b64 = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString('base64'); + +const event = ( + queryString: Record, + schemaOAS: Record | undefined = { 'x-list-capabilities': capabilities } +) => new TestEvent({ queryString, schemaOAS }); + +/** + * JUM-777 — `x-list-capabilities` is the single source of what a client may + * sort, filter and search by. These tests prove the server rejects anything + * outside it with the accepted list in the message, and that operations + * without the extension keep the legacy paging behaviour. + */ +describe('setListQuery', () => { + it('reads the capabilities declared by the operation', () => { + expect.hasAssertions(); + expect(readListCapabilities({ 'x-list-capabilities': capabilities })).toStrictEqual(capabilities); + expect(readListCapabilities({})).toBeUndefined(); + expect(readListCapabilities({ 'x-list-capabilities': { sortable: ['a'] } })).toStrictEqual({ + sortable: ['a'], filterable: {}, searchable: [], defaultSize: 30, maxSize: 100 + }); + }); + + it('parses page, size, filter, sort and q into filters + paging', () => { + expect.hasAssertions(); + const { filters, paging } = setListQuery(event({ + page: '2', + size: '10', + filter: b64({ firstName: { operator: 'contains', value: 'an' }, roles: 'admin' }), + sort: 'createdAt:desc,firstName', + q: ' Ana ' + })); + expect(filters).toStrictEqual({ firstName: { operator: 'contains', value: 'an' }, roles: 'admin' }); + expect(paging).toStrictEqual({ + page: 2, + size: 10, + sort: [{ field: 'createdAt', direction: 'desc' }, { field: 'firstName', direction: 'asc' }], + q: 'Ana', + searchFields: ['firstName', 'lastName'] + }); + }); + + it('uses the declared defaultSize and falls back to the app default without capabilities', () => { + expect.hasAssertions(); + expect(setListQuery(event({})).paging).toStrictEqual({ page: 1, size: 20 }); + expect(setListQuery(event({}, {})).paging) + .toStrictEqual({ page: 1, size: _DEFAULT_PAGE_SIZE_ }); + }); + + it('rejects sizes above maxSize naming the bound', () => { + expect.hasAssertions(); + expect(() => setListQuery(event({ size: '51' }))).toThrow(ValidationError); + expect(() => setListQuery(event({ size: '51' }))).toThrow('between 1 and 50; received 51'); + }); + + it('rejects sort fields, filter fields and operators outside the capabilities, listing what is accepted', () => { + expect.hasAssertions(); + expect(() => setListQuery(event({ sort: 'username:asc' }))) + .toThrow('The sort field "username" is not sortable. Accepted: firstName, createdAt.'); + expect(() => setListQuery(event({ filter: b64({ username: 'x' }) }))) + .toThrow('The filter field "username" is not filterable. Accepted: firstName, roles, createdAt.'); + expect(() => setListQuery(event({ filter: b64({ firstName: { operator: 'regex', value: '.*' } }) }))) + .toThrow('The filter operator "regex" on "firstName" is not accepted.'); + }); + + it('rejects q when nothing is searchable, and sort/q entirely for legacy operations', () => { + expect.hasAssertions(); + const unsearchable = { 'x-list-capabilities': { ...capabilities, searchable: [] } }; + expect(() => setListQuery(event({ q: 'x' }, unsearchable))).toThrow('declares no searchable fields'); + expect(() => setListQuery(event({ q: 'x' }, {}))).toThrow('not supported by this operation'); + expect(() => setListQuery(event({ sort: 'a' }, {}))).toThrow('not supported by this operation'); + expect(setListQuery(event({ filter: b64({ anything: 1 }) }, {})).filters) + .toStrictEqual({ anything: 1 }); + }); + + it('parses includeDeleted flags and tolerates a missing queryString', () => { + expect.hasAssertions(); + expect(setListQuery(event({ includeDeleted: 'true' })).paging.includeDeleted).toBe(true); + expect(setListQuery(event({ includeDeleted: '1' })).paging.includeDeleted).toBe(true); + expect(setListQuery(event({ includeDeleted: '0' })).paging.includeDeleted).toBe(false); + const noQuery = new TestEvent({ schemaOAS: { 'x-list-capabilities': capabilities } }); + (noQuery as any).queryString = undefined; + expect(setListQuery(noQuery).paging).toStrictEqual({ page: 1, size: 20 }); + }); + + it('treats a non-array sortable declaration as empty and names "(none)" as accepted', () => { + expect.hasAssertions(); + expect(readListCapabilities({ 'x-list-capabilities': { sortable: 'firstName' } })) + .toStrictEqual({ + sortable: [], filterable: {}, searchable: [], defaultSize: 30, maxSize: 100 + }); + const unsortable = { 'x-list-capabilities': { ...capabilities, sortable: [] } }; + expect(() => setListQuery(event({ sort: 'username:asc' }, unsortable))) + .toThrow('The sort field "username" is not sortable. Accepted: (none).'); + }); + + it('rejects filter objects that omit the operator', () => { + expect.hasAssertions(); + expect(() => setListQuery(event({ filter: b64({ firstName: { value: 'an' } }) }))) + .toThrow('The filter operator "" on "firstName" is not accepted.'); + }); +}); diff --git a/apps/backend-template/test/unit/modules/port/setMetricsQuery.test.ts b/apps/backend-template/test/unit/modules/port/setMetricsQuery.test.ts new file mode 100644 index 000000000..5a5e006a8 --- /dev/null +++ b/apps/backend-template/test/unit/modules/port/setMetricsQuery.test.ts @@ -0,0 +1,40 @@ +import { BaseDomainEvent } from '@src/modules/port/BaseDomainEvent'; +import { setMetricsQuery } from '@src/modules/port/setMetricsQuery'; +import { ValidationError } from '@src/infra/exceptions'; + +class TestEvent extends BaseDomainEvent {} + +const event = (queryString: Record) => new TestEvent({ + queryString, + schemaOAS: { 'x-metrics-capabilities': { groupable: ['roles'], series: ['createdAt'] } } +}); + +describe('setMetricsQuery', () => { + it('parses a count query', () => { + expect.hasAssertions(); + const parsed = setMetricsQuery(event({ metric: 'count' })); + expect(parsed.query.metric).toBe('count'); + expect(parsed.capabilities).toStrictEqual({ groupable: ['roles'], series: ['createdAt'] }); + }); + + it('names accepted metrics and intervals on 400-style ValidationError', () => { + expect.hasAssertions(); + expect(() => setMetricsQuery(event({ metric: 'avg' }))).toThrow(ValidationError); + expect(() => setMetricsQuery(event({ metric: 'avg' }))) + .toThrow('Accepted: count, groupBy, series'); + expect(() => setMetricsQuery(event({ metric: 'series', interval: 'year' }))) + .toThrow('Accepted: day, week, month.'); + }); + + it('defaults a missing queryString and metric, and accepts a declared groupBy field', () => { + expect.hasAssertions(); + const noQuery = new TestEvent({ + schemaOAS: { 'x-metrics-capabilities': { groupable: ['roles'], series: ['createdAt'] } } + }); + (noQuery as any).queryString = undefined; + expect(() => setMetricsQuery(noQuery)).toThrow('Accepted: count, groupBy, series.'); + + const parsed = setMetricsQuery(event({ metric: 'groupBy', field: 'roles' })); + expect(parsed.query).toMatchObject({ metric: 'groupBy', field: 'roles' }); + }); +}); diff --git a/apps/backend-template/test/unit/ownership/backendTemplateCatalogOwnership.test.ts b/apps/backend-template/test/unit/ownership/backendTemplateCatalogOwnership.test.ts new file mode 100644 index 000000000..c9735396e --- /dev/null +++ b/apps/backend-template/test/unit/ownership/backendTemplateCatalogOwnership.test.ts @@ -0,0 +1,37 @@ +import fs from 'fs'; +import path from 'path'; +import YAML from 'yaml'; + +const repoRoot = path.resolve(__dirname, '../../../../..'); + +const forbiddenRuntimePaths = [ + 'apps/backend-template/src/modules/Catalogs', + 'apps/backend-template/src/infra/persistence/InMemoryDatabase/Stores/CatalogStoreAPI.ts', + 'apps/backend-template/test/unit/modules/Catalogs', + 'apps/backend-template/test/integration/Express/Catalogs' +]; + +describe('backend-template catalog ownership boundary', () => { + it('does not ship Service Management catalog runtime or tests', () => { + expect.hasAssertions(); + for (const relativePath of forbiddenRuntimePaths) { + expect(fs.existsSync(path.join(repoRoot, relativePath))).toBe(false); + } + }); + + it('does not expose platform-owned /catalogs routes in the generated-service OAS', () => { + expect.hasAssertions(); + const spec = YAML.parse(fs.readFileSync(path.join(repoRoot, 'spec/1.0.0.yml'), 'utf8')); + const pathNames = Object.keys(spec.paths); + expect(pathNames).not.toContain('/catalogs'); + expect(pathNames).not.toContain('/catalogs/{id}'); + expect(pathNames).not.toContain('/catalogs/{id}/restore'); + }); + + it('does not grant catalog-specific scopes from template roles', async () => { + expect.hasAssertions(); + const { ROLE_SCOPE_MATRIX } = await import('@src/modules/Users/domain/security/Rbac'); + const scopes = Object.values(ROLE_SCOPE_MATRIX).flat(); + expect(scopes.filter((scope) => String(scope).includes('catalog'))).toStrictEqual([]); + }); +}); diff --git a/apps/backend-template/test/unit/service-management/pm2EcosystemUi.contract.test.ts b/apps/backend-template/test/unit/service-management/pm2EcosystemUi.contract.test.ts deleted file mode 100644 index 231a8dcf8..000000000 --- a/apps/backend-template/test/unit/service-management/pm2EcosystemUi.contract.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* eslint-disable jest/prefer-expect-assertions, jest/max-expects */ -/* - * JUM-480 — Designer-side contract for the PM2 ecosystem preview and the - * multi-environment editing surface. - * - * The acceptance criterion "no package-manager string hardcoded in the - * designer" is enforced here structurally: the designer sources must not - * contain a PM2 invocation bound to a package manager (`pnpm run`, `bun run`, - * `npm run`) nor the `pm2:start:*` script names — the preview derives every - * command from the real ecosystem files through GET /api/runtime/pm2-ecosystem. - */ -import fs from 'fs'; -import path from 'path'; - -const readDesignerSource = (relative: string): string => fs.readFileSync(path.resolve(process.cwd(), 'apps/service-management', relative), 'utf-8'); - -describe('service management PM2 preview UI contract (JUM-480)', () => { - const designerSources = ['script.js', 'index.html', 'src/ui/inspectors.js']; - - it('hardcodes no package-manager PM2 invocation anywhere in the designer', () => { - expect.hasAssertions(); - designerSources.forEach((relative) => { - const source = readDesignerSource(relative); - expect(source).not.toContain('pnpm run'); - expect(source).not.toContain('bun run pm2'); - expect(source).not.toContain('npm run pm2'); - expect(source).not.toContain('pm2:start:'); - }); - }); - - it('fetches the real ecosystem through the runtime API instead of a literal map', () => { - expect.hasAssertions(); - const script = readDesignerSource('script.js'); - expect(script).toContain('/api/runtime/pm2-ecosystem'); - const inspectors = readDesignerSource('src/ui/inspectors.js'); - // The old hardcoded profile map is gone; the preview derives commands. - expect(inspectors).not.toContain('runtimeProfiles'); - expect(inspectors).toContain('getPm2EcosystemPreview'); - expect(inspectors).toContain('--only'); - }); - - it('offers a preview environment per ecosystem the repository defines', () => { - expect.hasAssertions(); - const html = readDesignerSource('index.html'); - const selectMatch = html.match(/ + + ))} + + + + + + +
+

{labels.flow}

+
    + {FLOW[locale].map((step) => ( +
  1. {step}
  2. + ))} +
+
+ +

{labels.source}

+ + ); +} diff --git a/apps/jumentix-website/components/code/MDXMonacoPre.test.tsx b/apps/jumentix-website/components/code/MDXMonacoPre.test.tsx index 6754bc2fb..45dc16e24 100644 --- a/apps/jumentix-website/components/code/MDXMonacoPre.test.tsx +++ b/apps/jumentix-website/components/code/MDXMonacoPre.test.tsx @@ -1,6 +1,7 @@ import { render, screen, waitFor } from '@/test-utils'; import { monacoTestState, resetMonacoTestState } from '../../test/mocks/monaco-editor'; import { MDXCodeSourceProvider, parseMDXCodeBlocks } from './MDXCodeSourceProvider'; +import type { ReactNode } from 'react'; import { MDXMonacoPre } from './MDXMonacoPre'; describe('MDXMonacoPre', () => { @@ -40,6 +41,81 @@ describe('MDXMonacoPre', () => { expect(screen.getByText(/categories/)).toBeInTheDocument(); }); + it.each(['bash', 'sh', 'shell', 'zsh', 'console'])( + 'renders a %s fence through the theme pre instead of mounting an editor', + async (language) => { + expect.hasAssertions(); + + const { container } = render( + + {'bun install\n'} + + ); + + const pre = container.querySelector('pre'); + expect(pre).toBeInTheDocument(); + expect(pre).toHaveAttribute('data-copy'); + expect(pre?.textContent).toContain('bun install'); + expect(monacoTestState.models).toHaveLength(0); + } + ); + + it('still mounts an editor for a language that is read rather than copied', async () => { + expect.hasAssertions(); + + render( + + {'const port = 3200;\n'} + + ); + + await waitFor(() => expect(monacoTestState.models).toHaveLength(1)); + expect(monacoTestState.models[0].getValue()).toBe('const port = 3200;'); + }); + + it('renders a shell fence through the pre component it is given', () => { + expect.hasAssertions(); + + function ThemePre(props: { children?: ReactNode; [key: string]: unknown }) { + return
;
+    }
+
+    const { container } = render(
+      
+        {'bun run dev\n'}
+      
+    );
+
+    expect(container.querySelector('pre')).toHaveAttribute('data-theme-pre');
+    expect(monacoTestState.models).toHaveLength(0);
+  });
+
+  it('reads the fence language from the data-language attribute Nextra sets', () => {
+    expect.hasAssertions();
+
+    const { container } = render(
+      
+        {'bun run dev\n'}
+      
+    );
+
+    expect(container.querySelector('pre')).toBeInTheDocument();
+    expect(monacoTestState.models).toHaveLength(0);
+  });
+
+  it('passes the data-language fence language to the editor for read languages', async () => {
+    expect.hasAssertions();
+
+    render(
+      
+        {'{ "port": 3200 }\n'}
+      
+    );
+
+    await waitFor(() => expect(monacoTestState.models).toHaveLength(1));
+    expect(monacoTestState.models[0].language).toBe('json');
+  });
+
   it('keeps empty pre blocks as plain pre elements', () => {
     expect.hasAssertions();
 
diff --git a/apps/jumentix-website/components/code/MDXMonacoPre.tsx b/apps/jumentix-website/components/code/MDXMonacoPre.tsx
index 8244e82a1..8ef2fe219 100644
--- a/apps/jumentix-website/components/code/MDXMonacoPre.tsx
+++ b/apps/jumentix-website/components/code/MDXMonacoPre.tsx
@@ -1,6 +1,7 @@
 'use client';
 
-import type { ReactNode } from 'react';
+import { useMDXComponents as getDocsMDXComponents } from 'nextra-theme-docs';
+import type { ElementType, ReactNode } from 'react';
 import { isValidElement } from 'react';
 import type { MDXCodeBlockSource } from './MDXCodeSourceProvider';
 import { useMDXCodeSourceBlocks } from './MDXCodeSourceProvider';
@@ -73,16 +74,71 @@ function findSourceBlock(
   return sourceBlocks.find((block) => canonicalCodePrefix(block.value).startsWith(renderedPrefix))?.value;
 }
 
-export function MDXMonacoPre(props: {
+/**
+ * Languages a reader copies rather than reads (JUM-728).
+ *
+ * A shell fence is almost always one command someone is about to paste into a
+ * terminal. Mounting an editor for it costs a scrollable viewport per block —
+ * the guide page carried 19 of them, each with its own inner scrollbar, and
+ * none with a copy button. These fences render through the theme's own `pre`
+ * instead, which brings syntax highlighting, the copy button and a height that
+ * matches the content.
+ */
+const SHELL_LANGUAGES = new Set([
+  'bash',
+  'sh',
+  'shell',
+  'shellscript',
+  'shell-session',
+  'zsh',
+  'console',
+  'powershell',
+  'ps1'
+]);
+
+export function isShellLanguage(language: string | undefined): boolean {
+  const normalized = normalizeLanguageName(language);
+  return normalized !== undefined && SHELL_LANGUAGES.has(normalized);
+}
+
+type MDXPreProps = {
   children?: ReactNode;
   className?: string;
+  /** Override the theme `pre` — tests inject their own; production resolves it below. */
+  DefaultPre?: ElementType;
   [key: string]: unknown;
-}) {
+};
+
+/**
+ * The theme's `pre`, resolved inside this client module.
+ *
+ * `mdx-components.ts` is evaluated on the server, so it cannot call a function
+ * exported from a `'use client'` module — it can only render one. Resolving the
+ * theme component here keeps the whole decision on the client side of the
+ * boundary.
+ */
+const THEME_PRE: ElementType = (getDocsMDXComponents() as { pre?: ElementType }).pre ?? 'pre';
+
+export function MDXMonacoPre({ DefaultPre = THEME_PRE, ...props }: MDXPreProps) {
   const sourceBlocks = useMDXCodeSourceBlocks();
   const renderedCode = trimTrailingBlankCodeLines(textFromNode(props.children));
-  const language = languageFromNode(props.children) ?? languageFromClassName(props.className);
+  // Nextra's syntax highlighter reports the fence language on the `pre` as
+  // `data-language`; the `language-*` class only appears when the fence was not
+  // highlighted. Reading the attribute first is what makes shell detection work
+  // at all — without it every fence arrived with no language and Monaco fell
+  // back to TypeScript, which is why `bash` blocks were highlighted as TS.
+  const language =
+    (typeof props['data-language'] === 'string' ? (props['data-language'] as string) : undefined)
+    ?? languageFromNode(props.children)
+    ?? languageFromClassName(props.className);
   const code = findSourceBlock(sourceBlocks, language, renderedCode) ?? renderedCode;
 
+  if (isShellLanguage(language)) {
+    // The theme's `pre` keeps the highlighted children and the copy button that
+    // `defaultShowCopyCode` turns on in next.config.mjs.
+    return ;
+  }
+
   if (code.trim().length > 0) {
     return (
       
+    
       {props.children}
-    
+ ); } + diff --git a/apps/jumentix-website/components/commercial/CommercialPages.link-quality.test.tsx b/apps/jumentix-website/components/commercial/CommercialPages.link-quality.test.tsx index 9c6b0b872..85f90592f 100644 --- a/apps/jumentix-website/components/commercial/CommercialPages.link-quality.test.tsx +++ b/apps/jumentix-website/components/commercial/CommercialPages.link-quality.test.tsx @@ -19,6 +19,21 @@ const INVALID_LINK_PATTERNS = [ const EXTERNAL_DOMAINS = ['github.com', 'vercel.com', 'mantine.dev', 'tabler.io', 'bun.sh', 'pm2.keymetrics.io']; +/** + * True when `href` is an absolute URL whose host is one of the known external + * domains (or a subdomain of it). A substring check would also match + * `github.com.evil.example`, which is not a GitHub link. + */ +function isKnownExternalUrl(href: string): boolean { + let hostname: string; + try { + hostname = new URL(href).hostname; + } catch { + return false; + } + return EXTERNAL_DOMAINS.some((domain) => hostname === domain || hostname.endsWith(`.${domain}`)); +} + function extractInternalLinks(html: string): string[] { const links: string[] = []; const hrefRegex = /href="([^"]+)"/g; @@ -27,7 +42,7 @@ function extractInternalLinks(html: string): string[] { const href = match[1]; if (!href) continue; if (href.startsWith('http://') || href.startsWith('https://')) { - const isExternal = EXTERNAL_DOMAINS.some((domain) => href.includes(domain)); + const isExternal = isKnownExternalUrl(href); if (!isExternal) { links.push(href); } @@ -106,10 +121,7 @@ describe('Link quality', () => { // and known duplicate internal links (e.g., home page has duplicate /docs/jumentix) const filteredLinks = internalLinks.filter( (link) => - !link.includes('github.com') && - !link.includes('vercel.com') && - !link.includes('mantine.dev') && - !link.includes('tabler.io') && + !isKnownExternalUrl(link) && link !== '/docs/jumentix' && link !== '/product' && link !== '/pt-BR/docs/jumentix' && @@ -173,9 +185,9 @@ describe('Link quality', () => { it('ActionLink external has valid href', () => { expect.hasAssertions(); - render(GitHub); + render(GitHub); const link = screen.getByRole('link'); - expect(link).toHaveAttribute('href', 'https://github.com/XpertMinds/Jumentix'); + expect(link).toHaveAttribute('href', 'https://github.com/web2solutions/Jumentix'); }); it('ActionLink quiet variant has valid href', () => { diff --git a/apps/jumentix-website/components/commercial/CommercialPages.test.tsx b/apps/jumentix-website/components/commercial/CommercialPages.test.tsx index 215e6f3d9..1766427d4 100644 --- a/apps/jumentix-website/components/commercial/CommercialPages.test.tsx +++ b/apps/jumentix-website/components/commercial/CommercialPages.test.tsx @@ -139,6 +139,8 @@ describe('Commercial pages', () => { expect.hasAssertions(); renderPage('architecture', 'en'); expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Domain ownership at the center, technology at the edges'); + expect(screen.getByTestId('hexagonal-architecture-map')).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 2, name: 'Backend-template hexagonal map' })).toBeInTheDocument(); expect(screen.getByText('External request')).toBeInTheDocument(); expect(screen.getByText('Input adapter')).toBeInTheDocument(); expect(screen.getByText('Application core')).toBeInTheDocument(); diff --git a/apps/jumentix-website/components/commercial/CommercialPages.tsx b/apps/jumentix-website/components/commercial/CommercialPages.tsx index 37421cb80..50552bf9e 100644 --- a/apps/jumentix-website/components/commercial/CommercialPages.tsx +++ b/apps/jumentix-website/components/commercial/CommercialPages.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react'; import type { CodeSample } from '../design-system'; +import { HexagonalArchitectureMap } from '../architecture/HexagonalArchitectureMap'; import { IconApi, IconArrowRight, @@ -66,7 +67,7 @@ const localize = (href: string, locale: CommercialLocale) => const t = (locale: CommercialLocale, en: T, pt: T) => (locale === 'pt-BR' ? pt : en); -const repositoryUrl = 'https://github.com/XpertMinds/Jumentix'; +const repositoryUrl = 'https://github.com/web2solutions/Jumentix'; const playgroundRuntimeOrder: readonly DocsRuntimeId[] = [ 'jumentix-browser-lab', 'cana', @@ -3426,18 +3427,23 @@ function Architecture({ locale }: { locale: CommercialLocale }) { <> +
+ +
+
+
{ it('ActionLink external has valid href', () => { expect.hasAssertions(); - render(GitHub); + render(GitHub); const link = screen.getByRole('link'); - expect(link).toHaveAttribute('href', 'https://github.com/XpertMinds/Jumentix'); + expect(link).toHaveAttribute('href', 'https://github.com/web2solutions/Jumentix'); }); it('ActionLink quiet variant has valid href', () => { diff --git a/apps/jumentix-website/components/design-system/DesignSystem.stories.tsx b/apps/jumentix-website/components/design-system/DesignSystem.stories.tsx index c10e5c0a3..99a8efb2c 100644 --- a/apps/jumentix-website/components/design-system/DesignSystem.stories.tsx +++ b/apps/jumentix-website/components/design-system/DesignSystem.stories.tsx @@ -48,7 +48,7 @@ export const ActionLinks: Story = { Explore the platform - + View on GitHub
diff --git a/apps/jumentix-website/components/design-system/index.tsx b/apps/jumentix-website/components/design-system/index.tsx index 1866160ef..325ecfd5b 100644 --- a/apps/jumentix-website/components/design-system/index.tsx +++ b/apps/jumentix-website/components/design-system/index.tsx @@ -488,7 +488,7 @@ export function SiteHeader({
- + GitHub {/* @@ -552,7 +552,7 @@ export function SiteFooter({ locale = 'en' }: { locale?: 'en' | 'pt-BR' }) {
{isPortuguese ? 'Comunidade' : 'Community'} - GitHub + GitHub Roadmap {isPortuguese ? 'Contribua' : 'Contribute'} {isPortuguese ? 'Segurança' : 'Security'} @@ -567,7 +567,7 @@ export function DocsToolbar() {
- + diff --git a/apps/jumentix-website/components/service-management-designer/ServiceManagementDesigner.stories.tsx b/apps/jumentix-website/components/service-management-designer/ServiceManagementDesigner.stories.tsx index f4de26f79..3b76dd496 100644 --- a/apps/jumentix-website/components/service-management-designer/ServiceManagementDesigner.stories.tsx +++ b/apps/jumentix-website/components/service-management-designer/ServiceManagementDesigner.stories.tsx @@ -221,7 +221,7 @@ export const StatusSurfaces: Story = {

- Loaded ecosystem.dev.cjs — 2 processes. + Loaded ecosystem.dev.config.cjs — 2 processes.

Port 70000 is outside the accepted range (1-65535). diff --git a/apps/jumentix-website/config/content-sources.json b/apps/jumentix-website/config/content-sources.json index 0456eb60a..8d665e883 100644 --- a/apps/jumentix-website/config/content-sources.json +++ b/apps/jumentix-website/config/content-sources.json @@ -4,9 +4,9 @@ "section": "concepts", "slug": "overview", "title": "Jumentix Overview", - "titlePtBr": "Vis\u00e3o geral do Jumentix", + "titlePtBr": "Visão geral do Jumentix", "description": "Product, audience, and adoption overview.", - "descriptionPtBr": "Vis\u00e3o geral do produto, p\u00fablico e ado\u00e7\u00e3o.", + "descriptionPtBr": "Visão geral do produto, público e adoção.", "source": "../../documentation/consumers/PRODUCT-OVERVIEW.md", "sourcePtBr": "../../documentation/consumers/PRODUCT-OVERVIEW.pt-BR.md" }, @@ -14,9 +14,9 @@ "section": "concepts", "slug": "getting-started", "title": "Getting started", - "titlePtBr": "Come\u00e7ando", + "titlePtBr": "Começando", "description": "Junior on-ramp: install, mental model, and first adoption journey.", - "descriptionPtBr": "Rampa j\u00fanior: instala\u00e7\u00e3o, modelo mental e primeira jornada de ado\u00e7\u00e3o.", + "descriptionPtBr": "Rampa júnior: instalação, modelo mental e primeira jornada de adoção.", "source": "../../documentation/consumers/GETTING-STARTED.md", "sourcePtBr": "../../documentation/consumers/GETTING-STARTED.pt-BR.md" }, @@ -66,7 +66,7 @@ "title": "Create a SPA or Offline PWA", "titlePtBr": "Crie uma SPA ou PWA offline", "description": "Build frontend products with shared SDKs and offline behavior.", - "descriptionPtBr": "Construa produtos frontend com SDKs compartilhados e opera\u00e7\u00e3o offline.", + "descriptionPtBr": "Construa produtos frontend com SDKs compartilhados e operação offline.", "source": "../../apps/service-management/documentation/guides/CREATING-SPA-PWA-WITH-JUMENTIX.md", "sourcePtBr": "../../apps/service-management/documentation/guides/CREATING-SPA-PWA-WITH-JUMENTIX.pt-BR.md" }, @@ -76,7 +76,7 @@ "title": "Create a Modular SaaS", "titlePtBr": "Crie um SaaS modular", "description": "Launch a modular monolith with service-ready boundaries.", - "descriptionPtBr": "Lance um mon\u00f3lito modular com limites prontos para servi\u00e7os.", + "descriptionPtBr": "Lance um monólito modular com limites prontos para serviços.", "source": "../../documentation/md/guides/CREATING-SAAS-MONOLITH-WITH-JUMENTIX.md", "sourcePtBr": "../../documentation/md/guides/CREATING-SAAS-MONOLITH-WITH-JUMENTIX.pt-BR.md" }, @@ -84,9 +84,9 @@ "section": "guides", "slug": "saas-microservices", "title": "Create a Microservices SaaS", - "titlePtBr": "Crie um SaaS com microsservi\u00e7os", + "titlePtBr": "Crie um SaaS com microsserviços", "description": "Build distributed services with contract-based communication.", - "descriptionPtBr": "Construa servi\u00e7os distribu\u00eddos com comunica\u00e7\u00e3o baseada em contratos.", + "descriptionPtBr": "Construa serviços distribuídos com comunicação baseada em contratos.", "source": "../../documentation/md/guides/CREATING-SAAS-MICROSERVICES-WITH-JUMENTIX.md", "sourcePtBr": "../../documentation/md/guides/CREATING-SAAS-MICROSERVICES-WITH-JUMENTIX.pt-BR.md" }, @@ -96,7 +96,7 @@ "title": "Runtime Environment Contracts", "titlePtBr": "Contratos do ambiente de runtime", "description": "Environment variables and runtime composition behavior.", - "descriptionPtBr": "Vari\u00e1veis de ambiente e comportamento da composi\u00e7\u00e3o de runtime.", + "descriptionPtBr": "Variáveis de ambiente e comportamento da composição de runtime.", "source": "../../documentation/consumers/RUNTIME-CAPABILITIES.md", "sourcePtBr": "../../documentation/consumers/RUNTIME-CAPABILITIES.pt-BR.md" }, @@ -104,9 +104,9 @@ "section": "reference", "slug": "package-scripts", "title": "Package Scripts Reference", - "titlePtBr": "Refer\u00eancia de scripts dos pacotes", + "titlePtBr": "Referência de scripts dos pacotes", "description": "Every supported monorepo command and its usage.", - "descriptionPtBr": "Todos os comandos suportados pelo monorepo e como utiliz\u00e1-los.", + "descriptionPtBr": "Todos os comandos suportados pelo monorepo e como utilizá-los.", "source": "../../documentation/consumers/PACKAGE-SCRIPTS-REFERENCE.md", "sourcePtBr": "../../documentation/consumers/PACKAGE-SCRIPTS-REFERENCE.pt-BR.md" }, @@ -114,9 +114,9 @@ "section": "reference", "slug": "security-compliance", "title": "Security and PCI Hardening", - "titlePtBr": "Seguran\u00e7a e hardening PCI", + "titlePtBr": "Segurança e hardening PCI", "description": "Security controls and compliance evidence.", - "descriptionPtBr": "Controles de seguran\u00e7a e evid\u00eancias de compliance.", + "descriptionPtBr": "Controles de segurança e evidências de compliance.", "source": "../../documentation/consumers/SECURITY-AND-COMPLIANCE.md", "sourcePtBr": "../../documentation/consumers/SECURITY-AND-COMPLIANCE.pt-BR.md" }, @@ -126,7 +126,7 @@ "title": "Events and Messages Map", "titlePtBr": "Mapa de eventos e mensagens", "description": "Application event, request, response, and worker contracts.", - "descriptionPtBr": "Contratos de eventos, requests, responses e workers da aplica\u00e7\u00e3o.", + "descriptionPtBr": "Contratos de eventos, requests, responses e workers da aplicação.", "source": "../../documentation/md/EVENTS-AND-MESSAGES-MAP.md", "sourcePtBr": "../../documentation/md/EVENTS-AND-MESSAGES-MAP.pt-BR.md" }, @@ -146,7 +146,7 @@ "title": "Jumentix packages", "titlePtBr": "Pacotes Jumentix", "description": "Consumer package map for adopters (public site; private tooling excluded).", - "descriptionPtBr": "Mapa de pacotes para adotantes (site p\u00fablico; tooling privado exclu\u00eddo).", + "descriptionPtBr": "Mapa de pacotes para adotantes (site público; tooling privado excluído).", "source": "../../documentation/consumers/PACKAGES-HUB.md", "sourcePtBr": "../../documentation/consumers/PACKAGES-HUB.pt-BR.md" }, @@ -156,7 +156,7 @@ "title": "@jumentix/cana", "titlePtBr": "@jumentix/cana", "description": "IndexedDB offline database adapter for Jumentix applications.", - "descriptionPtBr": "Adaptador de banco de dados offline sobre IndexedDB para aplica\u00e7\u00f5es Jumentix.", + "descriptionPtBr": "Adaptador de banco de dados offline sobre IndexedDB para aplicações Jumentix.", "source": "../../packages/cana/README.md", "sourcePtBr": "../../packages/cana/README.pt-BR.md" }, @@ -166,7 +166,7 @@ "title": "Cana usage guide", "titlePtBr": "Guia de uso do Cana", "description": "Consumer API guide hub for @jumentix/cana with focused subpages.", - "descriptionPtBr": "Hub do guia de API do consumidor para @jumentix/cana com subp\u00e1ginas focadas.", + "descriptionPtBr": "Hub do guia de API do consumidor para @jumentix/cana com subpáginas focadas.", "source": "../../documentation/md/CANA-USAGE-GUIDE.md", "sourcePtBr": "../../documentation/md/CANA-USAGE-GUIDE.pt-BR.md" }, @@ -266,7 +266,7 @@ "title": "Cana with React Context API", "titlePtBr": "Cana com React Context API", "description": "From zero to final implementation: categorized tasks with Cana and React Context.", - "descriptionPtBr": "Do zero \u00e0 implementa\u00e7\u00e3o final: tarefas categorizadas com Cana e React Context.", + "descriptionPtBr": "Do zero à implementação final: tarefas categorizadas com Cana e React Context.", "source": "../../documentation/md/CANA-REACT-CONTEXT-TUTORIAL.md", "sourcePtBr": "../../documentation/md/CANA-REACT-CONTEXT-TUTORIAL.pt-BR.md" }, @@ -276,7 +276,7 @@ "title": "Cana with React Redux", "titlePtBr": "Cana com React Redux", "description": "From zero to final implementation: categorized tasks with Cana and Redux.", - "descriptionPtBr": "Do zero \u00e0 implementa\u00e7\u00e3o final: tarefas categorizadas com Cana e Redux.", + "descriptionPtBr": "Do zero à implementação final: tarefas categorizadas com Cana e Redux.", "source": "../../documentation/md/CANA-REACT-REDUX-TUTORIAL.md", "sourcePtBr": "../../documentation/md/CANA-REACT-REDUX-TUTORIAL.pt-BR.md" }, @@ -286,7 +286,7 @@ "title": "Cana with Vue 3 and Pinia", "titlePtBr": "Cana com Vue 3 e Pinia", "description": "From zero to final implementation: categorized tasks with Cana, Vue 3 and Pinia.", - "descriptionPtBr": "Do zero \u00e0 implementa\u00e7\u00e3o final: tarefas categorizadas com Cana, Vue 3 e Pinia.", + "descriptionPtBr": "Do zero à implementação final: tarefas categorizadas com Cana, Vue 3 e Pinia.", "source": "../../documentation/md/CANA-VUE-PINIA-TUTORIAL.md", "sourcePtBr": "../../documentation/md/CANA-VUE-PINIA-TUTORIAL.pt-BR.md" }, @@ -306,7 +306,7 @@ "title": "designer-core usage", "titlePtBr": "Uso do designer-core", "description": "Junior guide and playground for @jumentix/designer-core.", - "descriptionPtBr": "Guia j\u00fanior e playground para @jumentix/designer-core.", + "descriptionPtBr": "Guia júnior e playground para @jumentix/designer-core.", "source": "../../documentation/md/DESIGNER-CORE-USAGE-GUIDE.md", "sourcePtBr": "../../documentation/md/DESIGNER-CORE-USAGE-GUIDE.pt-BR.md" }, @@ -316,7 +316,7 @@ "title": "@jumentix/key-value-storage", "titlePtBr": "@jumentix/key-value-storage", "description": "Key/value storage clients with an in-memory adapter for tests and demos.", - "descriptionPtBr": "Clientes chave/valor com adaptador em mem\u00f3ria para testes e demos.", + "descriptionPtBr": "Clientes chave/valor com adaptador em memória para testes e demos.", "source": "../../packages/key-value-storage/README.md", "sourcePtBr": "../../packages/key-value-storage/README.pt-BR.md" }, @@ -326,7 +326,7 @@ "title": "key-value-storage usage", "titlePtBr": "Uso do key-value-storage", "description": "Junior guide and in-memory playground for key/value storage.", - "descriptionPtBr": "Guia j\u00fanior e playground em mem\u00f3ria para chave/valor.", + "descriptionPtBr": "Guia júnior e playground em memória para chave/valor.", "source": "../../documentation/md/KEY-VALUE-STORAGE-USAGE-GUIDE.md", "sourcePtBr": "../../documentation/md/KEY-VALUE-STORAGE-USAGE-GUIDE.pt-BR.md" }, @@ -346,7 +346,7 @@ "title": "mutex-service usage", "titlePtBr": "Uso do mutex-service", "description": "Junior guide and playground for mutex locks.", - "descriptionPtBr": "Guia j\u00fanior e playground para locks mutex.", + "descriptionPtBr": "Guia júnior e playground para locks mutex.", "source": "../../documentation/md/MUTEX-SERVICE-USAGE-GUIDE.md", "sourcePtBr": "../../documentation/md/MUTEX-SERVICE-USAGE-GUIDE.pt-BR.md" }, @@ -356,7 +356,7 @@ "title": "@jumentix/message-mediator", "titlePtBr": "@jumentix/message-mediator", "description": "In-process and broker-backed message mediation.", - "descriptionPtBr": "Media\u00e7\u00e3o de mensagens em processo e via brokers.", + "descriptionPtBr": "Mediação de mensagens em processo e via brokers.", "source": "../../packages/message-mediator/README.md", "sourcePtBr": "../../packages/message-mediator/README.pt-BR.md" }, @@ -366,7 +366,7 @@ "title": "message-mediator usage", "titlePtBr": "Uso do message-mediator", "description": "Junior guide and in-memory mediator playground.", - "descriptionPtBr": "Guia j\u00fanior e playground do mediator em mem\u00f3ria.", + "descriptionPtBr": "Guia júnior e playground do mediator em memória.", "source": "../../documentation/md/MESSAGE-MEDIATOR-USAGE-GUIDE.md", "sourcePtBr": "../../documentation/md/MESSAGE-MEDIATOR-USAGE-GUIDE.pt-BR.md" }, @@ -376,7 +376,7 @@ "title": "@jumentix/external-db-repositories", "titlePtBr": "@jumentix/external-db-repositories", "description": "Reusable external DB repository adapters.", - "descriptionPtBr": "Adaptadores de reposit\u00f3rio para bancos externos.", + "descriptionPtBr": "Adaptadores de repositório para bancos externos.", "source": "../../documentation/consumers/packages/external-db-repositories.md", "sourcePtBr": "../../documentation/consumers/packages/external-db-repositories.pt-BR.md" }, @@ -386,7 +386,7 @@ "title": "@jumentix/external-persistence-core", "titlePtBr": "@jumentix/external-persistence-core", "description": "Base class and options for external persistence adapters.", - "descriptionPtBr": "Classe base e op\u00e7\u00f5es para adaptadores de persist\u00eancia externa.", + "descriptionPtBr": "Classe base e opções para adaptadores de persistência externa.", "source": "../../documentation/consumers/packages/external-persistence-core.md", "sourcePtBr": "../../documentation/consumers/packages/external-persistence-core.pt-BR.md" }, @@ -406,7 +406,7 @@ "title": "@jumentix/persistence-contracts", "titlePtBr": "@jumentix/persistence-contracts", "description": "Shared persistence ports (IStore, IDatabaseClient).", - "descriptionPtBr": "Ports de persist\u00eancia compartilhados (IStore, IDatabaseClient).", + "descriptionPtBr": "Ports de persistência compartilhados (IStore, IDatabaseClient).", "source": "../../documentation/consumers/packages/persistence-contracts.md", "sourcePtBr": "../../documentation/consumers/packages/persistence-contracts.pt-BR.md" }, @@ -426,7 +426,7 @@ "title": "@jumentix/shared-contracts", "titlePtBr": "@jumentix/shared-contracts", "description": "Canonical OpenAPI/AsyncAPI spec loading for SDKs.", - "descriptionPtBr": "Carga can\u00f4nica de OpenAPI/AsyncAPI para SDKs.", + "descriptionPtBr": "Carga canônica de OpenAPI/AsyncAPI para SDKs.", "source": "../../documentation/consumers/packages/shared-contracts.md", "sourcePtBr": "../../documentation/consumers/packages/shared-contracts.pt-BR.md" }, @@ -486,7 +486,7 @@ "title": "backend-template", "titlePtBr": "backend-template", "description": "Reference backend composition for juniors.", - "descriptionPtBr": "Backend de refer\u00eancia para juniores.", + "descriptionPtBr": "Backend de referência para juniores.", "source": "../../documentation/consumers/apps/backend-template.md", "sourcePtBr": "../../documentation/consumers/apps/backend-template.pt-BR.md" }, @@ -506,7 +506,7 @@ "title": "jumentix-website", "titlePtBr": "jumentix-website", "description": "Public docs and marketing site hub.", - "descriptionPtBr": "Hub do site p\u00fablico de docs e marketing.", + "descriptionPtBr": "Hub do site público de docs e marketing.", "source": "../../documentation/consumers/apps/jumentix-website.md", "sourcePtBr": "../../documentation/consumers/apps/jumentix-website.pt-BR.md" }, @@ -589,6 +589,16 @@ "descriptionPtBr": "O catálogo compartilhado, o versionamento de pacotes de domínio e a fronteira de empacotamento do designer-core.", "source": "../../documentation/md/SERVICE-MANAGEMENT-COLLABORATION-PACKAGING.md", "sourcePtBr": "../../documentation/md/SERVICE-MANAGEMENT-COLLABORATION-PACKAGING.pt-BR.md" + }, + { + "section": "reference", + "slug": "frontend-offline-data-layer", + "title": "Frontend offline data layer", + "titlePtBr": "Camada de dados offline do frontend", + "description": "Cana boot, local repository, outbox, delta sync and PWA shell for apps/frontend.", + "descriptionPtBr": "Boot Cana, repositório local, outbox, delta sync e shell PWA do apps/frontend.", + "source": "../../documentation/md/FRONTEND-OFFLINE-DATA-LAYER.md", + "sourcePtBr": "../../documentation/md/FRONTEND-OFFLINE-DATA-LAYER.pt-BR.md" } ], "collections": [ diff --git a/apps/jumentix-website/config/index.ts b/apps/jumentix-website/config/index.ts index 365e94e95..e1febbfe7 100644 --- a/apps/jumentix-website/config/index.ts +++ b/apps/jumentix-website/config/index.ts @@ -65,7 +65,7 @@ export default { */ nextraLayout: { docsRepositoryBase: - 'https://github.com/XpertMinds/Jumentix/tree/dev/apps/jumentix-website', + 'https://github.com/web2solutions/Jumentix/tree/dev/apps/jumentix-website', sidebar: { defaultMenuCollapseLevel: 1 } @@ -89,10 +89,10 @@ export default { * This information is used to fetch the releases from the GitHub API. */ gitHub: { - repo: 'XpertMinds/Jumentix', + repo: 'web2solutions/Jumentix', apiUrl: 'https://api.github.com', - releasesUrl: 'https://api.github.com/repos/XpertMinds/Jumentix/releases', - commitsUrl: 'https://api.github.com/repos/XpertMinds/Jumentix/commits', + releasesUrl: 'https://api.github.com/repos/web2solutions/Jumentix/releases', + commitsUrl: 'https://api.github.com/repos/web2solutions/Jumentix/commits', defaultBranch: 'dev' }, @@ -101,7 +101,7 @@ export default { * This is used to link the release notes in the app. */ releaseNotes: { - url: 'https://github.com/XpertMinds/Jumentix/releases', + url: 'https://github.com/web2solutions/Jumentix/releases', maxReleases: 10 }, diff --git a/apps/jumentix-website/content/jumentix/concepts/architecture.mdx b/apps/jumentix-website/content/jumentix/concepts/architecture.mdx index c8e7170f8..e78c8a415 100644 --- a/apps/jumentix-website/content/jumentix/concepts/architecture.mdx +++ b/apps/jumentix-website/content/jumentix/concepts/architecture.mdx @@ -23,6 +23,9 @@ Jumentix delivers a practical architecture baseline for production software: - HTTP/REST adapters for multiple Node.js frameworks - Realtime adapters for WebSocket and gRPC - Function-oriented runtime paths for cloud providers +- GUI inbound slot at `apps/backend-template/src/interface/GUI/` for future web (SPA/PWA/React/Vue/…) and desktop (Electron/GTK/…) clients — structure only today; GUIs drive the core and must not own domain rules + +See the interactive map on the commercial site: [/architecture](/architecture). ## Data and Integration Options diff --git a/apps/jumentix-website/content/jumentix/guides/service-management.mdx b/apps/jumentix-website/content/jumentix/guides/service-management.mdx index 77c43f87b..e5315bb52 100644 --- a/apps/jumentix-website/content/jumentix/guides/service-management.mdx +++ b/apps/jumentix-website/content/jumentix/guides/service-management.mdx @@ -82,30 +82,33 @@ Install the workspace dependencies once: rtk proxy bun install ``` -## 3. Vendored browser bundles — do this before the first run +## 3. Vendored browser bundles The application is a zero-build SPA that resolves two bare specifiers through -the import map in `index.html`. Both targets are gitignored and must be -generated locally before the first boot: +the import map in `index.html`: `@jumentix/cana` and `@jumentix/designer-core/`. +Both targets live under `apps/service-management/vendor/`, are gitignored, and +are generated locally. -```bash -rtk proxy bun ci-cd/sync-service-management-cana-bundle.js -``` +The dev entry points generate them for you — `dev:service-management` and the +`pm2:start:dev:*` scripts run the vendor step before starting the process, so a +fresh clone boots a working designer with no extra command. + +Generate them by hand when you start the server directly, without PM2: ```bash -rtk proxy bun ci-cd/sync-service-management-designer-core.js +rtk proxy bun run service-management:vendor ``` -The first vendors the Cana browser bundle into -`apps/service-management/vendor/cana/index.js`; the second mirrors +That single script writes the Cana browser bundle to +`apps/service-management/vendor/cana/index.js` and mirrors `packages/designer-core/src` into `apps/service-management/vendor/designer-core/`. -Skipping this step is the single most common first-run failure: the page loads, -the shell renders, and every panel stays inert because the module graph never -resolves. The browser integration suites run both scripts before booting the -server, so a stale bundle can never read as a designer outage in CI — but a -manual run has to do it explicitly. +Without the bundles the page loads, the shell renders, and every panel stays +inert because the module graph never resolves, with repeated `/vendor/...` 404s +in the browser console. The browser integration suites generate them before +booting the server, so a stale bundle can never read as a designer outage in +CI. ## 4. Running the application @@ -130,7 +133,7 @@ rtk proxy bun run dev `dev` maps to `pm2:start:dev:restapi`, which starts `jumentix-dev-service-management` and `jumentix-dev-restapi` from -`pm2/ecosystem.dev.cjs`. Use the realtime variants when you also need a +`pm2/ecosystem.dev.config.cjs`. Use the realtime variants when you also need a WebSocket or gRPC process: ```bash @@ -143,7 +146,7 @@ rtk proxy bun run dev:grpc ```mermaid flowchart LR - CMD["bun run dev"] --> PM2["PM2 · pm2/ecosystem.dev.cjs"] + CMD["bun run dev"] --> PM2["PM2 · pm2/ecosystem.dev.config.cjs"] WS["bun run dev:websocket"] --> PM2 GRPC["bun run dev:grpc"] --> PM2 PM2 --> SM["jumentix-dev-service-management
:3200"] @@ -160,15 +163,18 @@ NODE_ENV=dev bun apps/service-management/server.js ### 4.4 Environment variables +Every variable below is prefixed `JUMENTIX_SERVICE_MANAGEMENT_`, except +`NODE_ENV`. + | Variable | Default | Purpose | | --- | --- | --- | -| `JUMENTIX_SERVICE_MANAGEMENT_PORT` | `3200` | HTTP port | -| `JUMENTIX_SERVICE_MANAGEMENT_HOST` | `127.0.0.1` | Bind address | -| `JUMENTIX_SERVICE_MANAGEMENT_CONFIG_DIR` | `apps/backend-template/src/config` | Directory the runtime env API reads and writes | -| `JUMENTIX_SERVICE_MANAGEMENT_PM2_DIR` | `pm2/` | Directory the PM2 ecosystem preview reads | -| `JUMENTIX_SERVICE_MANAGEMENT_AUTH_TOKEN` | unset | When set, `POST /api/runtime/env` requires `Authorization: Bearer ` | -| `JUMENTIX_SERVICE_MANAGEMENT_STATIC_MANIFEST_REFRESH` | derived from `NODE_ENV` | `on-miss` re-scans the static manifest on a miss; `boot-only` never re-scans | -| `NODE_ENV` | `dev` | Fallback environment when a request does not name one | +| `…_PORT` | `3200` | HTTP port | +| `…_HOST` | `127.0.0.1` | Bind address | +| `…_CONFIG_DIR` | `apps/backend-template/src/config` | Where the runtime env API reads and writes | +| `…_PM2_DIR` | `pm2/` | Where the PM2 ecosystem preview reads | +| `…_AUTH_TOKEN` | unset | Requires `Authorization: Bearer ` on the env `POST` | +| `…_STATIC_MANIFEST_REFRESH` | from `NODE_ENV` | `on-miss` re-scans the static manifest; `boot-only` never does | +| `NODE_ENV` | `dev` | Fallback environment when a request names none | The server **fails closed at boot** when the configured directory does not exist: it prints `Service Management config directory not found: ` on @@ -179,9 +185,9 @@ Ports per PM2 profile: | Ecosystem | Process | Port | | --- | --- | --- | -| `pm2/ecosystem.dev.cjs` | `jumentix-dev-service-management` | `3200` | -| `pm2/ecosystem.staging.cjs` | `jumentix-staging-service-management` | `4200` | -| `pm2/ecosystem.production.cjs` | `jumentix-prod-service-management` | `5200` | +| `pm2/ecosystem.dev.config.cjs` | `jumentix-dev-service-management` | `3200` | +| `pm2/ecosystem.staging.config.cjs` | `jumentix-staging-service-management` | `4200` | +| `pm2/ecosystem.production.config.cjs` | `jumentix-prod-service-management` | `5200` | ### 4.5 Process control @@ -281,7 +287,7 @@ With an entity selected, the Entity Inspector offers `Save Name`, flag and the invariants (one rule per line). Aggregate roots show an `AR` marker on their card. -![Entity Inspector: aggregate root, invariants, the RBAC matrix, a message contract, OpenAPI composition, fields and the generated CRUD API preview](/docs-assets/documentation/images/service-manager/03-entity-inspector.png "Entity Inspector") +![Entity Inspector: the entity name, aggregate-root flag, invariants and the RBAC matrix](/docs-assets/documentation/images/service-manager/03a-entity-inspector-rules-and-rbac.png "Entity Inspector — rules and RBAC") Fields carry OpenAPI-aligned metadata: @@ -331,8 +337,14 @@ a relationship between the same pair cannot be duplicated. ### 6.5 RBAC, message contracts and OpenAPI composition For each entity and action (`list`, `getById`, `create`, `update`, `delete`), -toggle `superadmin`, `admin` and `user`, plus the tenant-scope flag, then click -`Save RBAC Rule`. +toggle `superadmin`, `admin` and `user`, then click `Save RBAC Rule`. Tenant +scope is **derived from the roles**, not set by hand: `admin` and `user` scope to +their organization, `superadmin` is global, which is what the runtime enforces. +Legacy direct scopes still run but are not editable here. + +![Entity Inspector: message contracts and the OpenAPI composition controls](/docs-assets/documentation/images/service-manager/03b-entity-inspector-contracts-and-composition.png "Entity Inspector — contracts and composition") + +![Entity Inspector: the field editor and the generated OpenAPI CRUD preview](/docs-assets/documentation/images/service-manager/03c-entity-inspector-fields-and-api-preview.png "Entity Inspector — fields and API preview") Declare `event`, `command`, `request` and `response` contracts per entity with a name, channel or topic, version, and a JSON payload schema. `Add Contract` @@ -382,7 +394,9 @@ case, controller and handler. `Generate Examples` renders request and response payload examples. Select an entity to scope the output, or leave nothing selected for the whole canvas. -![Export panel with the code skeleton preview and the generated request and response examples](/docs-assets/documentation/images/service-manager/05-export-panel.png "Export panel") +![Export panel: the export, import and generation buttons](/docs-assets/documentation/images/service-manager/05a-export-and-import-targets.png "Export and import targets") + +![The generated code skeletons and the request and response examples](/docs-assets/documentation/images/service-manager/05b-code-preview-and-examples.png "Code preview and generated examples") | Button | Downloaded file | Use | | --- | --- | --- | @@ -563,7 +577,7 @@ the boot surfaces, never a silent fallback. flowchart TB EDIT["You edit the model"] --> STORE[("Cana · IndexedDB")] STORE --> TABS["Other tabs in this browser
designerSync"] - STORE --> CAT["Shared catalog
other users, via the backend Catalogs module"] + STORE --> CAT["Shared catalog
other users, via the Service Management Catalog API"] STORE --> EXPORT["Export JSON
the portable backup"] CAT -->|409 on a stale write| CONFLICT["Conflict raised
take-server or take-local
the local edit is never discarded"] ``` @@ -616,8 +630,9 @@ flowchart LR ### The page loads but every panel is inert -The vendored bundles are missing. Run both sync scripts from section 3, then -reload. The browser console shows repeated 404s for `/vendor/...` in this +The vendored bundles are missing. Run `bun run service-management:vendor`, then +reload. The dev entry points do this for you; starting `server.js` directly does +not. The browser console shows repeated 404s for `/vendor/...` in this state. ### The server exits immediately diff --git a/apps/jumentix-website/content/jumentix/guides/spa-pwa.mdx b/apps/jumentix-website/content/jumentix/guides/spa-pwa.mdx index 081e1678f..4bba0197d 100644 --- a/apps/jumentix-website/content/jumentix/guides/spa-pwa.mdx +++ b/apps/jumentix-website/content/jumentix/guides/spa-pwa.mdx @@ -74,6 +74,13 @@ Use **Communication Interface Designer** to choose how the SPA talks to backend: Contracts become the source for SDK integration — do not hand-write fetch URLs that are not in OpenAPI. +The hybrid seed (`apps/frontend`) plugs a generated domain in as a **module** +(`src/modules/manifest.ts`): navigation lists modules, each open module is a +taskbar button, entity screens are tabs plus a Dashboard tab. See +[Frontend Seed and the X-CRUD Kit](/docs/jumentix/FRONTEND-SEED-AND-XCRUD). +Offline-first Cana boot, sync, outbox and PWA: +[Frontend offline data layer](/docs/jumentix/reference/frontend-offline-data-layer). + **Success check:** OpenAPI/AsyncAPI files match designer export; operationIds stable. ### Step 3 — Configure service runtime (< 10 minutes) diff --git a/apps/jumentix-website/content/jumentix/packages/cana/index.mdx b/apps/jumentix-website/content/jumentix/packages/cana/index.mdx index c3cafe968..1cb21e355 100644 --- a/apps/jumentix-website/content/jumentix/packages/cana/index.mdx +++ b/apps/jumentix-website/content/jumentix/packages/cana/index.mdx @@ -7,6 +7,9 @@ description: "IndexedDB offline database adapter for Jumentix applications." IndexedDB offline database adapter for Jumentix applications. +The frontend seed (`apps/frontend`) opens Cana before login and syncs through +the OAS: [Frontend offline data layer](/docs/jumentix/reference/frontend-offline-data-layer). +

| `pm2:logs` | Manage PM2 runtime processes. | `bun run pm2:logs` | `pm2 logs` | | `pm2:stop:all` | Manage PM2 runtime processes. | `bun run pm2:stop:all` | `pm2 stop all` | | `pm2:delete:all` | Manage PM2 runtime processes. | `bun run pm2:delete:all` | `pm2 delete all` | -| `pm2:start:dev:restapi` | Manage PM2 runtime processes. | `bun run pm2:start:dev:restapi` | `pm2 start ./pm2/ecosystem.dev.cjs --only jumentix-dev-service-management,jumentix-dev-restapi --update-env` | -| `pm2:start:dev:websocket-rest` | Manage PM2 runtime processes. | `bun run pm2:start:dev:websocket-rest` | `pm2 start ./pm2/ecosystem.dev.cjs --only jumentix-dev-service-management,jumentix-dev-restapi,jumentix-dev-websocketapi --update-env` | -| `pm2:start:dev:grpc-rest` | Manage PM2 runtime processes. | `bun run pm2:start:dev:grpc-rest` | `pm2 start ./pm2/ecosystem.dev.cjs --only jumentix-dev-service-management,jumentix-dev-restapi,jumentix-dev-grpcapi --update-env` | -| `pm2:start:staging:restapi` | Manage PM2 runtime processes. | `bun run pm2:start:staging:restapi` | `pm2 start ./pm2/ecosystem.staging.cjs --only jumentix-staging-service-management,jumentix-staging-restapi --update-env` | -| `pm2:start:staging:websocket-rest` | Manage PM2 runtime processes. | `bun run pm2:start:staging:websocket-rest` | `pm2 start ./pm2/ecosystem.staging.cjs --only jumentix-staging-service-management,jumentix-staging-restapi,jumentix-staging-websocketapi --update-env` | -| `pm2:start:staging:grpc-rest` | Manage PM2 runtime processes. | `bun run pm2:start:staging:grpc-rest` | `pm2 start ./pm2/ecosystem.staging.cjs --only jumentix-staging-service-management,jumentix-staging-restapi,jumentix-staging-grpcapi --update-env` | -| `pm2:start:prod:restapi` | Manage PM2 runtime processes. | `bun run pm2:start:prod:restapi` | `pm2 start ./pm2/ecosystem.production.cjs --only jumentix-prod-service-management,jumentix-prod-restapi --update-env` | -| `pm2:start:prod:websocket-rest` | Manage PM2 runtime processes. | `bun run pm2:start:prod:websocket-rest` | `pm2 start ./pm2/ecosystem.production.cjs --only jumentix-prod-service-management,jumentix-prod-restapi,jumentix-prod-websocketapi --update-env` | -| `pm2:start:prod:grpc-rest` | Manage PM2 runtime processes. | `bun run pm2:start:prod:grpc-rest` | `pm2 start ./pm2/ecosystem.production.cjs --only jumentix-prod-service-management,jumentix-prod-restapi,jumentix-prod-grpcapi --update-env` | +| `pm2:start:dev:restapi` | Manage PM2 runtime processes. | `bun run pm2:start:dev:restapi` | `pm2 start ./pm2/ecosystem.dev.config.cjs --only jumentix-dev-service-management,jumentix-dev-restapi --update-env` | +| `pm2:start:dev:websocket-rest` | Manage PM2 runtime processes. | `bun run pm2:start:dev:websocket-rest` | `pm2 start ./pm2/ecosystem.dev.config.cjs --only jumentix-dev-service-management,jumentix-dev-restapi,jumentix-dev-websocketapi --update-env` | +| `pm2:start:dev:grpc-rest` | Manage PM2 runtime processes. | `bun run pm2:start:dev:grpc-rest` | `pm2 start ./pm2/ecosystem.dev.config.cjs --only jumentix-dev-service-management,jumentix-dev-restapi,jumentix-dev-grpcapi --update-env` | +| `pm2:start:staging:restapi` | Manage PM2 runtime processes. | `bun run pm2:start:staging:restapi` | `pm2 start ./pm2/ecosystem.staging.config.cjs --only jumentix-staging-service-management,jumentix-staging-restapi --update-env` | +| `pm2:start:staging:websocket-rest` | Manage PM2 runtime processes. | `bun run pm2:start:staging:websocket-rest` | `pm2 start ./pm2/ecosystem.staging.config.cjs --only jumentix-staging-service-management,jumentix-staging-restapi,jumentix-staging-websocketapi --update-env` | +| `pm2:start:staging:grpc-rest` | Manage PM2 runtime processes. | `bun run pm2:start:staging:grpc-rest` | `pm2 start ./pm2/ecosystem.staging.config.cjs --only jumentix-staging-service-management,jumentix-staging-restapi,jumentix-staging-grpcapi --update-env` | +| `pm2:start:prod:restapi` | Manage PM2 runtime processes. | `bun run pm2:start:prod:restapi` | `pm2 start ./pm2/ecosystem.production.config.cjs --only jumentix-prod-service-management,jumentix-prod-restapi --update-env` | +| `pm2:start:prod:websocket-rest` | Manage PM2 runtime processes. | `bun run pm2:start:prod:websocket-rest` | `pm2 start ./pm2/ecosystem.production.config.cjs --only jumentix-prod-service-management,jumentix-prod-restapi,jumentix-prod-websocketapi --update-env` | +| `pm2:start:prod:grpc-rest` | Manage PM2 runtime processes. | `bun run pm2:start:prod:grpc-rest` | `pm2 start ./pm2/ecosystem.production.config.cjs --only jumentix-prod-service-management,jumentix-prod-restapi,jumentix-prod-grpcapi --update-env` | | `commit` | Use when you need this specific workspace operation. | `bun run commit` | `bun run lint && bun run test && bun ci-cd/bumpPackage.ts && git add . && git-cz` | | `lint` | Run lint checks before commit/PR. | `bun run lint` | `eslint . --ext .ts` | | `lint:fix` | Use when you need this specific workspace operation. | `bun run lint:fix` | `eslint . --ext .ts --fix` | diff --git a/apps/jumentix-website/content/jumentix/reference/service-management-cana-adoption.mdx b/apps/jumentix-website/content/jumentix/reference/service-management-cana-adoption.mdx index c8d811500..2c143b8cc 100644 --- a/apps/jumentix-website/content/jumentix/reference/service-management-cana-adoption.mdx +++ b/apps/jumentix-website/content/jumentix/reference/service-management-cana-adoption.mdx @@ -344,13 +344,13 @@ must be obvious, not merely available. - **How to export:** the Domain Designer toolbar's **Export JSON** button downloads `domain-designer.json` — the full-suite document (JUM-547: - `{ kind: "service-management-suite", version: "2.0.0", domains, relationships, interfaces, serviceConfiguration, runtimeEnvironment, deployments, view }`). **Import JSON** on the same toolbar restores it. The + `{ kind: "service-management-suite", version: "2.0.0", domains, relationships, interfaces, serviceConfiguration, runtimeEnvironment, codeWorkspace, deployments, view }`). **Import JSON** on the same toolbar restores it. The other export buttons (Markdown, JSON Schema, OAS 3.1, AsyncAPI, gRPC proto, boilerplate bundle, domain package) are design artifacts for downstream tooling, not backups. -- **The export's scope, honestly:** the JSON export carries all four tabs of +- **The export's scope, honestly:** the JSON export carries all five tabs of the suite state — the domain model, the interface adapters, the service - configuration and the deploy targets — with one recorded boundary: the + configuration, the generated-code workspace and the deploy targets — with one recorded boundary: the runtime environment crosses as the environment *selection* only (`environment`, `fileName`), never its values, so no machine configuration (and no secret) leaves in a bundle; import restores the selection and keeps diff --git a/apps/jumentix-website/content/jumentix/reference/service-management-collaboration-packaging.mdx b/apps/jumentix-website/content/jumentix/reference/service-management-collaboration-packaging.mdx index 76d854f1d..b27005e1f 100644 --- a/apps/jumentix-website/content/jumentix/reference/service-management-collaboration-packaging.mdx +++ b/apps/jumentix-website/content/jumentix/reference/service-management-collaboration-packaging.mdx @@ -61,7 +61,7 @@ explanation. ### What shipped - **A contract-first `Catalogs` backend module** - (`apps/backend-template/src/modules/Catalogs/domain/Model/Catalog.ts`), + (`apps/service-management-api/src/modules/Catalogs/domain/Model/Catalog.ts`), hexagonal like the Users reference module: domain aggregate (version bump, tombstone, restore), pure `CatalogAuthorizationPolicy`, @@ -333,7 +333,7 @@ how a designer user's work is stored. ### The full-suite export (JUM-547), the portable bundle The JSON export is the versioned full-suite document -(`{ kind: "service-management-suite", version: "2.0.0", domains, relationships, interfaces, serviceConfiguration, runtimeEnvironment, deployments, view }`) carrying **all four tabs** in a re-importable shape +(`{ kind: "service-management-suite", version: "2.0.0", domains, relationships, interfaces, serviceConfiguration, runtimeEnvironment, codeWorkspace, deployments, view }`) carrying **all five tabs** in a re-importable shape ([JUM-547](https://linear.app/jumentix/issue/JUM-547/feature-full-suite-exportimport-carry-interfaces-service-configuration)). One recorded security decision matters for packaging: the bundle carries the runtime environment **selection only** (`{ environment, fileName }`) — @@ -430,7 +430,7 @@ when the gate closes. ## References - Collaboration (JUM-491): - `apps/backend-template/src/modules/Catalogs/domain/Model/Catalog.ts`, + `apps/service-management-api/src/modules/Catalogs/domain/Model/Catalog.ts`, `CatalogAuthorizationPolicy`, `CatalogUseCases.ts`, `CatalogService.ts`, diff --git a/apps/jumentix-website/content/jumentix/reference/service-management-contract-parity.mdx b/apps/jumentix-website/content/jumentix/reference/service-management-contract-parity.mdx index e108c020e..406052f09 100644 --- a/apps/jumentix-website/content/jumentix/reference/service-management-contract-parity.mdx +++ b/apps/jumentix-website/content/jumentix/reference/service-management-contract-parity.mdx @@ -330,12 +330,12 @@ the importer rebuilds it against the same contract. ## Full-suite export and the `runtimeEnvironment` decision (JUM-547, landed) -Export and import now carry **all four tabs**, not just the domain model. The +Export and import now carry **all five tabs**, not just the domain model. The JSON export (`domain-designer.json`) is the versioned full-suite document: -`{ kind: "service-management-suite", version: "2.0.0", domains, relationships, interfaces, serviceConfiguration, runtimeEnvironment, deployments, view }` — +`{ kind: "service-management-suite", version: "2.0.0", domains, relationships, interfaces, serviceConfiguration, runtimeEnvironment, codeWorkspace, deployments, view }` — the same sections the pinned `service-management.v1` document persists in Cana (Requirement 126, Contract 2), minus the session selections and -`idCounter`. A model designed across all four tabs exports and re-imports +`idCounter`. A model designed across all five tabs exports and re-imports with every tab intact; a bundle exported before this change (the domain-only shape, no `kind`/`version`) imports cleanly with the missing sections defaulted, and a bundle with an unknown section or a newer major version diff --git a/apps/jumentix-website/content/jumentix/reference/service-management-design-system-pwa.mdx b/apps/jumentix-website/content/jumentix/reference/service-management-design-system-pwa.mdx index 025ad6897..db4f449ec 100644 --- a/apps/jumentix-website/content/jumentix/reference/service-management-design-system-pwa.mdx +++ b/apps/jumentix-website/content/jumentix/reference/service-management-design-system-pwa.mdx @@ -159,7 +159,7 @@ itself is exercised end-to-end by the browser suites below. ### The tab bar is a real tablist -The four tabs are a WAI-ARIA tablist (`role="tablist"`, `role="tab"`, +The six visible tabs are a WAI-ARIA tablist (`role="tablist"`, `role="tab"`, `aria-selected`, `aria-controls` in `index.html`; behaviour in `src/ui/tabs.js`): diff --git a/apps/jumentix-website/content/jumentix/reference/service-management-module-architecture.mdx b/apps/jumentix-website/content/jumentix/reference/service-management-module-architecture.mdx index d4fa53c0d..275b5f774 100644 --- a/apps/jumentix-website/content/jumentix/reference/service-management-module-architecture.mdx +++ b/apps/jumentix-website/content/jumentix/reference/service-management-module-architecture.mdx @@ -127,20 +127,20 @@ below), then a single `await loadState()` in `script.js`. ### The state core (`src/state/designerState.js`) -- **State object.** One object holding the twelve persisted sections of the +- **State object.** One object holding the fourteen persisted sections of the `service-management.v1` document (schema pinned by Requirement 126, Contract 2 — link, not copy). - **`normalizeStatePayload(parsed)`** — normalises a decoded payload into the - model slice restored on load. Only `domains`, `relationships`, the three - selections, `idCounter` and `view` come back; the other pinned sections are + model slice restored on load. `domains`, `relationships`, the three + selections, `idCounter`, `codeWorkspace` and `view` come back; the other pinned sections are intentionally not restored at load time. Normalisation drops relationships pointing at unknown entities and clamps the view (zoom to 0.5–2, edge style and severity to their enums). - **`snapshotState()`/`applySnapshot()`** — deep-copy the persisted sections out of `state` and restore them back, recomputing `idCounter` from the highest numeric id suffix. -- **`saveState()`** — builds the twelve-section payload and calls +- **`saveState()`** — builds the fourteen-section payload and calls `store.save(payload)` without awaiting (fire-and-forget, preserving pre-extraction behaviour; see the adapter section for why this is safe today and why callers must not depend on it). @@ -152,13 +152,13 @@ below), then a single `await loadState()` in `script.js`. ### The `service-management.v1` storage schema -The entire suite state (all four tabs) persists as ONE JSON payload under the +The entire suite state (all five tabs) persists as ONE JSON payload under the single pinned key `service-management.v1`; the schema-diff baseline lives under `service-management.schema-baseline.v1`. Since JUM-484's landed migration, both documents live in Cana — one IndexedDB object store (`designerDocuments`, database `service-management`, schema version 1) — as byte copies of the same JSON documents the localStorage adapter used to write. The localStorage era is -historical; the pinned wire format did NOT change. The schema — the twelve +historical; the pinned wire format now includes `codeWorkspace`. The schema — the fourteen top-level sections, their enums, and the baseline shape — is pinned by Requirement 126, Contract 2 and is **not duplicated here** so the two cannot drift. Any structural change diff --git a/apps/jumentix-website/content/jumentix/reference/service-management-operations-console.mdx b/apps/jumentix-website/content/jumentix/reference/service-management-operations-console.mdx index c6bb7ebc0..f24ceb7c1 100644 --- a/apps/jumentix-website/content/jumentix/reference/service-management-operations-console.mdx +++ b/apps/jumentix-website/content/jumentix/reference/service-management-operations-console.mdx @@ -221,6 +221,22 @@ from**: the persisted `service-management.v1` payload (Requirement 126, Contract 2) — a server-derived snapshot is not design state. +## The PM2 monitoring dashboard (JUM-736) — by PM2, not by ecosystem + +The **Monitoring** tab is runtime telemetry, not another static preview. Its +endpoint, `GET /api/runtime/pm2-metrics` (Requirement 126, Contract 1c), connects +to PM2 through the PM2 Node API and reads `pm2.list`. The dashboard reports live +process status, CPU, memory, restarts, uptime, namespace, watch mode and PM2 +custom metrics. It also compares those live process names with the selected +`pm2/ecosystem.*.cjs` file so an expected app that is not running is visible in +the UI without asking the operator to inspect a terminal. + +The Monitoring snapshot is intentionally transient: it is refreshed manually or +every five seconds while the tab is active, and it is never written into +`service-management.v1` or exported in the suite JSON. If PM2 cannot be loaded, +connected or listed, the endpoint fails with the explicit PM2 metrics envelope +rather than falling back to ecosystem-only data. + **Proven by:** `pm2Ecosystem.integration.test.ts` (real ecosystem reads, edit-reflected-without-restart, explicit missing-file @@ -392,7 +408,7 @@ same model: - Shared matrix reader: `packages/designer-core/src/model/deployCapabilityMatrix.js`; matrix documents: JUMENTIX-DEPLOY-TARGET-AND-PACKAGING-MATRIX, JUMENTIX-SERVICE-FACTORY-CAPABILITIES-MATRIX - Validators: `serviceConfigurationValidation.js`, `deployTargetValidation.js`, `deployTargetLifecycleValidation.js` - Server endpoints: `server.js`; UI glue: `script.js`, `inspectors.js`, state/migration: `designerState.js` -- Ecosystem sources: `pm2/ecosystem.dev.cjs`, `pm2/ecosystem.staging.cjs`, `pm2/ecosystem.production.cjs` +- Ecosystem sources: `pm2/ecosystem.dev.config.cjs`, `pm2/ecosystem.staging.config.cjs`, `pm2/ecosystem.production.config.cjs` - Suites: `serviceConfigurationValidation.test.ts`, `deployTargetValidation.test.ts`, `deployTargetLifecycle.test.ts`, `designerState.test.ts`, `pm2EcosystemUi.contract.test.ts`, `runtimeEnvUi.contract.test.ts`, `pm2Ecosystem.integration.test.ts`, `runtimeEnv.integration.test.ts`, `runtimeEnvContract.integration.test.ts`, `deployTargetLifecycle.browser.integration.test.ts` - Requirements: Requirement 126, Contracts 1 and 1b (Contracts 1, 1b and 2), 059 (the deploy and factory matrices), 076 (EN/PT parity) - Sibling E-chain documents: [Runtime Environment Contracts](/docs/jumentix/reference/service-management-runtime-environment) (E1), [Service Management Module Architecture](/docs/jumentix/reference/service-management-module-architecture) (E3), [Service Management Contract Parity Guarantees](/docs/jumentix/reference/service-management-contract-parity) (E4), Service Management Application, [Domain Designer Features and Usage](/docs/jumentix/reference/domain-designer-features) diff --git a/apps/jumentix-website/content/pt-BR/jumentix/concepts/architecture.mdx b/apps/jumentix-website/content/pt-BR/jumentix/concepts/architecture.mdx index 52aa5d6a2..ba7051aa7 100644 --- a/apps/jumentix-website/content/pt-BR/jumentix/concepts/architecture.mdx +++ b/apps/jumentix-website/content/pt-BR/jumentix/concepts/architecture.mdx @@ -23,6 +23,9 @@ Jumentix entrega uma base arquitetural prática para software em produção: - Adaptadores HTTP/REST para múltiplos frameworks Node.js - Adaptadores realtime para WebSocket e gRPC - Caminhos de runtime orientados a funções para provedores de nuvem +- Slot GUI inbound em `apps/backend-template/src/interface/GUI/` para futuros clientes web (SPA/PWA/React/Vue/…) e desktop (Electron/GTK/…) — só estrutura hoje; GUIs dirigem o núcleo e não devem possuir regras de domínio + +Veja o mapa interativo no site comercial: [/pt-BR/architecture](/pt-BR/architecture). ## Opções de dados e integração diff --git a/apps/jumentix-website/content/pt-BR/jumentix/guides/service-management.mdx b/apps/jumentix-website/content/pt-BR/jumentix/guides/service-management.mdx index 00bda42b3..2dce6749f 100644 --- a/apps/jumentix-website/content/pt-BR/jumentix/guides/service-management.mdx +++ b/apps/jumentix-website/content/pt-BR/jumentix/guides/service-management.mdx @@ -83,30 +83,33 @@ Instale as dependências do workspace uma vez: rtk proxy bun install ``` -## 3. Bundles vendorizados — faça isso antes do primeiro boot +## 3. Bundles vendorizados -A aplicação é uma SPA sem build que resolve dois specifiers bare pelo import -map do `index.html`. Os dois alvos são gitignorados e precisam ser gerados -localmente antes do primeiro boot: +A aplicação é uma SPA sem build que resolve dois specifiers bare pelo import map +do `index.html`: `@jumentix/cana` e `@jumentix/designer-core/`. Os dois alvos +ficam em `apps/service-management/vendor/`, são gitignorados e são gerados +localmente. -```bash -rtk proxy bun ci-cd/sync-service-management-cana-bundle.js -``` +Os pontos de entrada de desenvolvimento geram para você — `dev:service-management` +e os scripts `pm2:start:dev:*` rodam a etapa de vendor antes de subir o +processo, então um clone novo sobe um designer funcional sem comando extra. + +Gere à mão quando subir o servidor diretamente, sem PM2: ```bash -rtk proxy bun ci-cd/sync-service-management-designer-core.js +rtk proxy bun run service-management:vendor ``` -O primeiro vendoriza o bundle de navegador do Cana em -`apps/service-management/vendor/cana/index.js`; o segundo espelha +Esse único script escreve o bundle de navegador do Cana em +`apps/service-management/vendor/cana/index.js` e espelha `packages/designer-core/src` em `apps/service-management/vendor/designer-core/`. -Pular esta etapa é a falha de primeiro boot mais comum: a página carrega, o -shell aparece e todos os painéis ficam inertes porque o grafo de módulos nunca -resolve. As suítes de integração de navegador rodam os dois scripts antes de -subir o servidor, então um bundle desatualizado nunca se disfarça de falha do -designer no CI — mas uma execução manual precisa fazer isso explicitamente. +Sem os bundles, a página carrega, o shell aparece e todos os painéis ficam +inertes porque o grafo de módulos nunca resolve, com 404 repetidos de +`/vendor/...` no console do navegador. As suítes de integração de navegador +geram os bundles antes de subir o servidor, então um bundle desatualizado nunca +se disfarça de falha do designer no CI. ## 4. Executando a aplicação @@ -131,7 +134,7 @@ rtk proxy bun run dev `dev` aponta para `pm2:start:dev:restapi`, que inicia `jumentix-dev-service-management` e `jumentix-dev-restapi` a partir de -`pm2/ecosystem.dev.cjs`. Use as variantes realtime quando também precisar de um +`pm2/ecosystem.dev.config.cjs`. Use as variantes realtime quando também precisar de um processo WebSocket ou gRPC: ```bash @@ -144,7 +147,7 @@ rtk proxy bun run dev:grpc ```mermaid flowchart LR - CMD["bun run dev"] --> PM2["PM2 · pm2/ecosystem.dev.cjs"] + CMD["bun run dev"] --> PM2["PM2 · pm2/ecosystem.dev.config.cjs"] WS["bun run dev:websocket"] --> PM2 GRPC["bun run dev:grpc"] --> PM2 PM2 --> SM["jumentix-dev-service-management
:3200"] @@ -161,14 +164,17 @@ NODE_ENV=dev bun apps/service-management/server.js ### 4.4 Variáveis de ambiente +Todas as variáveis abaixo têm o prefixo `JUMENTIX_SERVICE_MANAGEMENT_`, exceto +`NODE_ENV`. + | Variável | Padrão | Finalidade | | --- | --- | --- | -| `JUMENTIX_SERVICE_MANAGEMENT_PORT` | `3200` | Porta HTTP | -| `JUMENTIX_SERVICE_MANAGEMENT_HOST` | `127.0.0.1` | Endereço de bind | -| `JUMENTIX_SERVICE_MANAGEMENT_CONFIG_DIR` | `apps/backend-template/src/config` | Diretório que a API de runtime env lê e grava | -| `JUMENTIX_SERVICE_MANAGEMENT_PM2_DIR` | `pm2/` | Diretório que o preview de ecossistema PM2 lê | -| `JUMENTIX_SERVICE_MANAGEMENT_AUTH_TOKEN` | não definida | Quando definida, `POST /api/runtime/env` exige `Authorization: Bearer ` | -| `JUMENTIX_SERVICE_MANAGEMENT_STATIC_MANIFEST_REFRESH` | derivada de `NODE_ENV` | `on-miss` refaz o manifesto estático em uma falha de busca; `boot-only` nunca refaz | +| `…_PORT` | `3200` | Porta HTTP | +| `…_HOST` | `127.0.0.1` | Endereço de bind | +| `…_CONFIG_DIR` | `apps/backend-template/src/config` | Onde a API de runtime env lê e grava | +| `…_PM2_DIR` | `pm2/` | Onde o preview de ecossistema PM2 lê | +| `…_AUTH_TOKEN` | não definida | Exige `Authorization: Bearer ` no `POST` de ambiente | +| `…_STATIC_MANIFEST_REFRESH` | de `NODE_ENV` | `on-miss` refaz o manifesto estático; `boot-only` nunca refaz | | `NODE_ENV` | `dev` | Ambiente de fallback quando a requisição não informa um | O servidor **falha fechado no boot** quando o diretório configurado não existe: @@ -180,9 +186,9 @@ Portas por perfil PM2: | Ecossistema | Processo | Porta | | --- | --- | --- | -| `pm2/ecosystem.dev.cjs` | `jumentix-dev-service-management` | `3200` | -| `pm2/ecosystem.staging.cjs` | `jumentix-staging-service-management` | `4200` | -| `pm2/ecosystem.production.cjs` | `jumentix-prod-service-management` | `5200` | +| `pm2/ecosystem.dev.config.cjs` | `jumentix-dev-service-management` | `3200` | +| `pm2/ecosystem.staging.config.cjs` | `jumentix-staging-service-management` | `4200` | +| `pm2/ecosystem.production.config.cjs` | `jumentix-prod-service-management` | `5200` | ### 4.5 Controle de processos @@ -283,7 +289,7 @@ Com uma entidade selecionada, o Entity Inspector oferece `Save Name`, agregado e as invariantes (uma regra por linha). Raízes de agregado exibem o marcador `AR` no card. -![Entity Inspector: raiz de agregado, invariantes, matriz RBAC, contrato de mensagem, composição OpenAPI, campos e o preview de API CRUD gerado](/docs-assets/documentation/images/service-manager/03-entity-inspector.png "Entity Inspector") +![Entity Inspector: o nome da entidade, a flag de raiz de agregado, as invariantes e a matriz RBAC](/docs-assets/documentation/images/service-manager/03a-entity-inspector-rules-and-rbac.png "Entity Inspector — regras e RBAC") Os campos carregam metadados alinhados ao OpenAPI: @@ -335,8 +341,15 @@ diferentes, e não é possível duplicar um relacionamento entre o mesmo par. ### 6.5 RBAC, contratos de mensagem e composição OpenAPI Para cada entidade e ação (`list`, `getById`, `create`, `update`, `delete`), -marque `superadmin`, `admin` e `user`, além da flag de escopo por tenant, e -clique em `Save RBAC Rule`. +marque `superadmin`, `admin` e `user` e clique em `Save RBAC Rule`. O escopo por +tenant é **derivado dos papéis**, não definido à mão: `admin` e `user` ficam no +escopo da própria organização e `superadmin` é global, que é o que o runtime +aplica. Escopos diretos legados continuam funcionando, mas não são editáveis +aqui. + +![Entity Inspector: contratos de mensagem e os controles de composição OpenAPI](/docs-assets/documentation/images/service-manager/03b-entity-inspector-contracts-and-composition.png "Entity Inspector — contratos e composição") + +![Entity Inspector: o editor de campos e o preview de CRUD OpenAPI gerado](/docs-assets/documentation/images/service-manager/03c-entity-inspector-fields-and-api-preview.png "Entity Inspector — campos e preview de API") Declare contratos `event`, `command`, `request` e `response` por entidade com nome, canal ou tópico, versão e um schema JSON de payload. `Add Contract` @@ -385,7 +398,9 @@ caso de uso, controller e handler. `Generate Examples` renderiza exemplos de payload de request e response. Selecione uma entidade para limitar a saída, ou deixe nada selecionado para gerar a partir de todo o canvas. -![Painel Export com o preview de esqueleto de código e os exemplos de request e response gerados](/docs-assets/documentation/images/service-manager/05-export-panel.png "Painel Export") +![Painel Export: os botões de exportação, importação e geração](/docs-assets/documentation/images/service-manager/05a-export-and-import-targets.png "Alvos de exportação e importação") + +![Os esqueletos de código gerados e os exemplos de request e response](/docs-assets/documentation/images/service-manager/05b-code-preview-and-examples.png "Preview de código e exemplos gerados") | Botão | Arquivo baixado | Uso | | --- | --- | --- | @@ -571,7 +586,7 @@ fallback silencioso. flowchart TB EDIT["Você edita o modelo"] --> STORE[("Cana · IndexedDB")] STORE --> TABS["Outras abas deste navegador
designerSync"] - STORE --> CAT["Catálogo compartilhado
outros usuários, via módulo Catalogs do backend"] + STORE --> CAT["Catálogo compartilhado
outros usuários, via API de Catálogo do Service Management"] STORE --> EXPORT["Export JSON
o backup portátil"] CAT -->|409 em escrita obsoleta| CONFLICT["Conflito levantado
take-server ou take-local
a edição local nunca é descartada"] ``` @@ -626,8 +641,9 @@ flowchart LR ### A página carrega mas todos os painéis ficam inertes -Faltam os bundles vendorizados. Rode os dois scripts de sync da seção 3 e -recarregue. Nesse estado o console do navegador mostra 404 repetidos para +Faltam os bundles vendorizados. Rode `bun run service-management:vendor` e +recarregue. Os pontos de entrada de desenvolvimento fazem isso por você; subir o +`server.js` diretamente, não. Nesse estado o console do navegador mostra 404 repetidos para `/vendor/...`. ### O servidor sai imediatamente diff --git a/apps/jumentix-website/content/pt-BR/jumentix/guides/spa-pwa.mdx b/apps/jumentix-website/content/pt-BR/jumentix/guides/spa-pwa.mdx index 5948fd12d..1b61aa9dc 100644 --- a/apps/jumentix-website/content/pt-BR/jumentix/guides/spa-pwa.mdx +++ b/apps/jumentix-website/content/pt-BR/jumentix/guides/spa-pwa.mdx @@ -65,6 +65,13 @@ Para arquitetura PWA/offline-first: - portas CI de back-end - construção de front-end e verificações de fumaça offline +O seed híbrido (`apps/frontend`) encaixa um domínio gerado como **módulo** +(`src/modules/manifest.ts`): o menu lista módulos, cada módulo aberto vira botão +na taskbar, as entidades são abas mais a aba Dashboard. Ver +[Seed de Frontend e o Kit X-CRUD](/docs/pt-BR/jumentix/FRONTEND-SEED-AND-XCRUD). +Offline-first Cana, sync, outbox e PWA: +[Camada de dados offline do frontend](/docs/pt-BR/jumentix/reference/frontend-offline-data-layer). + ## 6. Offline com Cana + designs diff --git a/apps/jumentix-website/content/pt-BR/jumentix/packages/cana/index.mdx b/apps/jumentix-website/content/pt-BR/jumentix/packages/cana/index.mdx index 126535178..2a70f8702 100644 --- a/apps/jumentix-website/content/pt-BR/jumentix/packages/cana/index.mdx +++ b/apps/jumentix-website/content/pt-BR/jumentix/packages/cana/index.mdx @@ -7,6 +7,9 @@ description: "Adaptador de banco de dados offline sobre IndexedDB para aplicaç Adaptador de banco de dados offline sobre IndexedDB para aplicações Jumentix. +O seed de frontend (`apps/frontend`) abre o Cana antes do login e sincroniza +pela OAS: [Camada de dados offline do frontend](/docs/pt-BR/jumentix/reference/frontend-offline-data-layer). +
| `pm2:logs` | Gerencia processos de runtime com PM2. | `bun run pm2:logs` | `pm2 logs` | | `pm2:stop:all` | Gerencia processos de runtime com PM2. | `bun run pm2:stop:all` | `pm2 stop all` | | `pm2:delete:all` | Gerencia processos de runtime com PM2. | `bun run pm2:delete:all` | `pm2 delete all` | -| `pm2:start:dev:restapi` | Gerencia processos de runtime com PM2. | `bun run pm2:start:dev:restapi` | `pm2 start ./pm2/ecosystem.dev.cjs --only jumentix-dev-service-management,jumentix-dev-restapi --update-env` | -| `pm2:start:dev:websocket-rest` | Gerencia processos de runtime com PM2. | `bun run pm2:start:dev:websocket-rest` | `pm2 start ./pm2/ecosystem.dev.cjs --only jumentix-dev-service-management,jumentix-dev-restapi,jumentix-dev-websocketapi --update-env` | -| `pm2:start:dev:grpc-rest` | Gerencia processos de runtime com PM2. | `bun run pm2:start:dev:grpc-rest` | `pm2 start ./pm2/ecosystem.dev.cjs --only jumentix-dev-service-management,jumentix-dev-restapi,jumentix-dev-grpcapi --update-env` | -| `pm2:start:staging:restapi` | Gerencia processos de runtime com PM2. | `bun run pm2:start:staging:restapi` | `pm2 start ./pm2/ecosystem.staging.cjs --only jumentix-staging-service-management,jumentix-staging-restapi --update-env` | -| `pm2:start:staging:websocket-rest` | Gerencia processos de runtime com PM2. | `bun run pm2:start:staging:websocket-rest` | `pm2 start ./pm2/ecosystem.staging.cjs --only jumentix-staging-service-management,jumentix-staging-restapi,jumentix-staging-websocketapi --update-env` | -| `pm2:start:staging:grpc-rest` | Gerencia processos de runtime com PM2. | `bun run pm2:start:staging:grpc-rest` | `pm2 start ./pm2/ecosystem.staging.cjs --only jumentix-staging-service-management,jumentix-staging-restapi,jumentix-staging-grpcapi --update-env` | -| `pm2:start:prod:restapi` | Gerencia processos de runtime com PM2. | `bun run pm2:start:prod:restapi` | `pm2 start ./pm2/ecosystem.production.cjs --only jumentix-prod-service-management,jumentix-prod-restapi --update-env` | -| `pm2:start:prod:websocket-rest` | Gerencia processos de runtime com PM2. | `bun run pm2:start:prod:websocket-rest` | `pm2 start ./pm2/ecosystem.production.cjs --only jumentix-prod-service-management,jumentix-prod-restapi,jumentix-prod-websocketapi --update-env` | -| `pm2:start:prod:grpc-rest` | Gerencia processos de runtime com PM2. | `bun run pm2:start:prod:grpc-rest` | `pm2 start ./pm2/ecosystem.production.cjs --only jumentix-prod-service-management,jumentix-prod-restapi,jumentix-prod-grpcapi --update-env` | +| `pm2:start:dev:restapi` | Gerencia processos de runtime com PM2. | `bun run pm2:start:dev:restapi` | `pm2 start ./pm2/ecosystem.dev.config.cjs --only jumentix-dev-service-management,jumentix-dev-restapi --update-env` | +| `pm2:start:dev:websocket-rest` | Gerencia processos de runtime com PM2. | `bun run pm2:start:dev:websocket-rest` | `pm2 start ./pm2/ecosystem.dev.config.cjs --only jumentix-dev-service-management,jumentix-dev-restapi,jumentix-dev-websocketapi --update-env` | +| `pm2:start:dev:grpc-rest` | Gerencia processos de runtime com PM2. | `bun run pm2:start:dev:grpc-rest` | `pm2 start ./pm2/ecosystem.dev.config.cjs --only jumentix-dev-service-management,jumentix-dev-restapi,jumentix-dev-grpcapi --update-env` | +| `pm2:start:staging:restapi` | Gerencia processos de runtime com PM2. | `bun run pm2:start:staging:restapi` | `pm2 start ./pm2/ecosystem.staging.config.cjs --only jumentix-staging-service-management,jumentix-staging-restapi --update-env` | +| `pm2:start:staging:websocket-rest` | Gerencia processos de runtime com PM2. | `bun run pm2:start:staging:websocket-rest` | `pm2 start ./pm2/ecosystem.staging.config.cjs --only jumentix-staging-service-management,jumentix-staging-restapi,jumentix-staging-websocketapi --update-env` | +| `pm2:start:staging:grpc-rest` | Gerencia processos de runtime com PM2. | `bun run pm2:start:staging:grpc-rest` | `pm2 start ./pm2/ecosystem.staging.config.cjs --only jumentix-staging-service-management,jumentix-staging-restapi,jumentix-staging-grpcapi --update-env` | +| `pm2:start:prod:restapi` | Gerencia processos de runtime com PM2. | `bun run pm2:start:prod:restapi` | `pm2 start ./pm2/ecosystem.production.config.cjs --only jumentix-prod-service-management,jumentix-prod-restapi --update-env` | +| `pm2:start:prod:websocket-rest` | Gerencia processos de runtime com PM2. | `bun run pm2:start:prod:websocket-rest` | `pm2 start ./pm2/ecosystem.production.config.cjs --only jumentix-prod-service-management,jumentix-prod-restapi,jumentix-prod-websocketapi --update-env` | +| `pm2:start:prod:grpc-rest` | Gerencia processos de runtime com PM2. | `bun run pm2:start:prod:grpc-rest` | `pm2 start ./pm2/ecosystem.production.config.cjs --only jumentix-prod-service-management,jumentix-prod-restapi,jumentix-prod-grpcapi --update-env` | | `commit` | Use quando precisar desta operação específica do workspace. | `bun run commit` | `bun run lint && bun run test && bun ci-cd/bumpPackage.ts && git add . && git-cz` | | `lint` | Roda checks de lint antes de commit/PR. | `bun run lint` | `eslint . --ext .ts` | | `lint:fix` | Use quando precisar desta operação específica do workspace. | `bun run lint:fix` | `eslint . --ext .ts --fix` | diff --git a/apps/jumentix-website/content/pt-BR/jumentix/reference/service-management-collaboration-packaging.mdx b/apps/jumentix-website/content/pt-BR/jumentix/reference/service-management-collaboration-packaging.mdx index 18aaa46a8..24b5c201d 100644 --- a/apps/jumentix-website/content/pt-BR/jumentix/reference/service-management-collaboration-packaging.mdx +++ b/apps/jumentix-website/content/pt-BR/jumentix/reference/service-management-collaboration-packaging.mdx @@ -65,7 +65,7 @@ de manter uma segunda explicação divergente. ### O que foi entregue - **Um módulo de backend `Catalogs` contract-first** - (`apps/backend-template/src/modules/Catalogs/domain/Model/Catalog.ts`), + (`apps/service-management-api/src/modules/Catalogs/domain/Model/Catalog.ts`), hexagonal como o módulo de referência Users: agregado de domínio (incremento de versão, tombstone, restauração), `CatalogAuthorizationPolicy` @@ -457,7 +457,7 @@ quando o portão fecha. ## Referências - Colaboração (JUM-491): - `apps/backend-template/src/modules/Catalogs/domain/Model/Catalog.ts`, + `apps/service-management-api/src/modules/Catalogs/domain/Model/Catalog.ts`, `CatalogAuthorizationPolicy`, `CatalogUseCases.ts`, `CatalogService.ts`, diff --git a/apps/jumentix-website/content/pt-BR/jumentix/reference/service-management-operations-console.mdx b/apps/jumentix-website/content/pt-BR/jumentix/reference/service-management-operations-console.mdx index 2a111718d..8f807b0b6 100644 --- a/apps/jumentix-website/content/pt-BR/jumentix/reference/service-management-operations-console.mdx +++ b/apps/jumentix-website/content/pt-BR/jumentix/reference/service-management-operations-console.mdx @@ -408,7 +408,7 @@ console segue o mesmo modelo: - Leitor compartilhado da matriz: `packages/designer-core/src/model/deployCapabilityMatrix.js`; documentos de matriz: JUMENTIX-DEPLOY-TARGET-AND-PACKAGING-MATRIX, JUMENTIX-SERVICE-FACTORY-CAPABILITIES-MATRIX - Validadores: `serviceConfigurationValidation.js`, `deployTargetValidation.js`, `deployTargetLifecycleValidation.js` - Endpoints do servidor: `server.js`; cola da UI: `script.js`, `inspectors.js`, estado/migração: `designerState.js` -- Fontes de ecossistema: `pm2/ecosystem.dev.cjs`, `pm2/ecosystem.staging.cjs`, `pm2/ecosystem.production.cjs` +- Fontes de ecossistema: `pm2/ecosystem.dev.config.cjs`, `pm2/ecosystem.staging.config.cjs`, `pm2/ecosystem.production.config.cjs` - Suítes: `serviceConfigurationValidation.test.ts`, `deployTargetValidation.test.ts`, `deployTargetLifecycle.test.ts`, `designerState.test.ts`, `pm2EcosystemUi.contract.test.ts`, `runtimeEnvUi.contract.test.ts`, `pm2Ecosystem.integration.test.ts`, `runtimeEnv.integration.test.ts`, `runtimeEnvContract.integration.test.ts`, `deployTargetLifecycle.browser.integration.test.ts` - Requisitos: Requisito 126, Contratos 1 e 1b (Contratos 1, 1b e 2), 059 (as matrizes de deploy e da factory), 076 (paridade EN/PT) - Documentos irmãos da cadeia E: [Contratos de ambiente de runtime](/docs/pt-BR/jumentix/reference/service-management-runtime-environment) (E1), [Arquitetura de módulos do Service Management](/docs/pt-BR/jumentix/reference/service-management-module-architecture) (E3), [Garantias de paridade de contratos do Service Management](/docs/pt-BR/jumentix/reference/service-management-contract-parity) (E4), Aplicativo Service Management, [Funcionalidades e uso do Domain Designer](/docs/pt-BR/jumentix/reference/domain-designer-features) diff --git a/apps/jumentix-website/cypress/e2e/commercial-routes.cy.js b/apps/jumentix-website/cypress/e2e/commercial-routes.cy.js index 172dd9272..d726841c7 100644 --- a/apps/jumentix-website/cypress/e2e/commercial-routes.cy.js +++ b/apps/jumentix-website/cypress/e2e/commercial-routes.cy.js @@ -8,7 +8,7 @@ const englishRoutes = [ ['/use-cases/saas-microservices', ['Scale services', 'Request/response']], ['/use-cases/spa-pwa', ['keep working offline', 'IndexedDB']], ['/integrations', ['Choose infrastructure per service', 'MongoDB']], - ['/architecture', ['Domain ownership at the center', 'Application core']], + ['/architecture', ['Domain ownership at the center', 'Backend-template hexagonal map', 'Application core']], ['/security-compliance', ['Controls your audit can verify', 'RBAC']], ['/community', ['Build the factory with us', 'Every contribution']], ['/roadmap', ['A public path', 'Monorepo consolidation']], @@ -27,7 +27,7 @@ const portugueseRoutes = [ ['/pt-BR/use-cases/saas-microservices', ['Escale serviços', 'request/response']], ['/pt-BR/use-cases/spa-pwa', ['continuam funcionando offline', 'IndexedDB']], ['/pt-BR/integrations', ['Escolha a infraestrutura', 'MongoDB']], - ['/pt-BR/architecture', ['Domínio no centro', 'Núcleo da aplicação']], + ['/pt-BR/architecture', ['Domínio no centro', 'Mapa hexagonal do backend-template', 'Núcleo da aplicação']], ['/pt-BR/security-compliance', ['Controles que sua auditoria', 'RBAC']], ['/pt-BR/community', ['Construa a fábrica conosco', 'Toda contribuição']], ['/pt-BR/roadmap', ['Um caminho público', 'Consolidação do monorepo']], diff --git a/apps/jumentix-website/documentation/CHANGELOG-PAGE.md b/apps/jumentix-website/documentation/CHANGELOG-PAGE.md index 7247bbd05..044c116ec 100644 --- a/apps/jumentix-website/documentation/CHANGELOG-PAGE.md +++ b/apps/jumentix-website/documentation/CHANGELOG-PAGE.md @@ -7,7 +7,7 @@ The website exposes `/changelog` as a verifiable change history page for Jumenti - Build-time snapshot: `content/changelog.json`, generated by `scripts/sync-changelog.mjs` - Primary input: `git log` of the checkout being built (sha, ISO date, author, subject) - Fallback input: the generated `CHANGELOG.md` at the monorepo root (used when git history is unavailable, e.g. shallow clones) -- The page imports the JSON directly, so Next.js bundles the data into the deployment. There is no runtime dependency on the GitHub API and no `GITHUB_TOKEN` requirement — this is what keeps `/changelog` working in production, where the private repository would otherwise answer unauthenticated API calls with 404. +- The page imports the JSON directly, so Next.js bundles the data into the deployment. There is no runtime dependency on the GitHub API and no `GITHUB_TOKEN` requirement — this is what keeps `/changelog` working in production, where the previous private-repository setup would otherwise have answered unauthenticated API calls with 404. ## Pagination Contract diff --git a/apps/jumentix-website/documentation/CHANGELOG-PAGE.pt-BR.md b/apps/jumentix-website/documentation/CHANGELOG-PAGE.pt-BR.md index 855659ae3..8d9790563 100644 --- a/apps/jumentix-website/documentation/CHANGELOG-PAGE.pt-BR.md +++ b/apps/jumentix-website/documentation/CHANGELOG-PAGE.pt-BR.md @@ -11,7 +11,7 @@ O site expõe `/changelog` como uma página de histórico de alterações verifi - Snapshot em tempo de build: `content/changelog.json`, gerado por `scripts/sync-changelog.mjs` - Entrada primária: `git log` do checkout em build (sha, data ISO, autor, assunto) - Entrada de fallback: o `CHANGELOG.md` gerado na raiz do monorepo (usado quando o histórico git não está disponível, por exemplo em clones rasos) -- A página importa o JSON diretamente, então o Next.js empacota os dados no deploy. Não há dependência em runtime da API do GitHub nem exigência de `GITHUB_TOKEN` — é isso que mantém `/changelog` funcionando em produção, onde o repositório privado responderia 404 a chamadas de API não autenticadas. +- A página importa o JSON diretamente, então o Next.js empacota os dados no deploy. Não há dependência em runtime da API do GitHub nem exigência de `GITHUB_TOKEN` — é isso que mantém `/changelog` funcionando em produção, onde o repositório canônico anteriormente privado responderia 404 a chamadas de API não autenticadas. ## Contrato de paginação diff --git a/apps/jumentix-website/documentation/COMMERCIAL-EXPERIENCE.md b/apps/jumentix-website/documentation/COMMERCIAL-EXPERIENCE.md index 487de4d04..f9f2ac5ce 100644 --- a/apps/jumentix-website/documentation/COMMERCIAL-EXPERIENCE.md +++ b/apps/jumentix-website/documentation/COMMERCIAL-EXPERIENCE.md @@ -2,8 +2,8 @@ Issue tracking: -- Epic: [#167](https://github.com/XpertMinds/Jumentix/issues/167) -- Implementation: [#171](https://github.com/XpertMinds/Jumentix/issues/171) +- Epic: [#167](https://github.com/web2solutions/Jumentix/issues/167) +- Implementation: [#171](https://github.com/web2solutions/Jumentix/issues/171) ## Purpose @@ -22,7 +22,7 @@ open-source framework websites while preserving Jumentix language, product asset | `/product` | Complete platform capability and delivery lifecycle | | `/use-cases` and children | REST, realtime, modular SaaS, microservices, and offline PWA blueprints | | `/integrations` | HTTP, realtime, persistence, messaging, and deployment inventory | -| `/architecture` | DDD, Hexagonal, Event-Driven, SOLID, and contract boundaries | +| `/architecture` | DDD, Hexagonal, Event-Driven, SOLID, and contract boundaries, plus an interactive backend-template hexagonal map (including `interface/GUI`) | | `/security-compliance` | RBAC, PCI-oriented controls, secret safety, and evidence | | `/pricing-or-engagement` | Open-source, pilot, and platform adoption paths | | `/community` | Contribution workflow and governance | diff --git a/apps/jumentix-website/documentation/COMMERCIAL-EXPERIENCE.pt-BR.md b/apps/jumentix-website/documentation/COMMERCIAL-EXPERIENCE.pt-BR.md index c7403e62d..4352df800 100644 --- a/apps/jumentix-website/documentation/COMMERCIAL-EXPERIENCE.pt-BR.md +++ b/apps/jumentix-website/documentation/COMMERCIAL-EXPERIENCE.pt-BR.md @@ -2,8 +2,8 @@ Rastreamento: -- Épico: [#167](https://github.com/XpertMinds/Jumentix/issues/167) -- Implementação: [#171](https://github.com/XpertMinds/Jumentix/issues/171) +- Épico: [#167](https://github.com/web2solutions/Jumentix/issues/167) +- Implementação: [#171](https://github.com/web2solutions/Jumentix/issues/171) ## Propósito @@ -22,7 +22,7 @@ open source estabelecidos, preservando a linguagem, os recursos e a identidade d | `/product` | Capacidades completas e ciclo de entrega | | `/use-cases` e filhas | Blueprints REST, realtime, SaaS modular, microsserviços e PWA offline | | `/integrations` | Inventário HTTP, realtime, persistência, mensageria e deploy | -| `/architecture` | DDD, Hexagonal, Event-Driven, SOLID e limites contratuais | +| `/architecture` | DDD, Hexagonal, Event-Driven, SOLID e limites contratuais, mais o mapa hexagonal interativo do backend-template (incluindo `interface/GUI`) | | `/security-compliance` | RBAC, controles PCI, segredos e evidências | | `/pricing-or-engagement` | Caminhos open source, piloto e adoção como plataforma | | `/community` | Fluxo de contribuição e governança | diff --git a/apps/jumentix-website/documentation/CONTENT-PIPELINE.md b/apps/jumentix-website/documentation/CONTENT-PIPELINE.md index dbffe136f..a77a32367 100644 --- a/apps/jumentix-website/documentation/CONTENT-PIPELINE.md +++ b/apps/jumentix-website/documentation/CONTENT-PIPELINE.md @@ -2,8 +2,8 @@ Issue tracking: -- Epic: [#167](https://github.com/XpertMinds/Jumentix/issues/167) -- Task: [#172](https://github.com/XpertMinds/Jumentix/issues/172) +- Epic: [#167](https://github.com/web2solutions/Jumentix/issues/167) +- Task: [#172](https://github.com/web2solutions/Jumentix/issues/172) ## Purpose diff --git a/apps/jumentix-website/documentation/CONTENT-PIPELINE.pt-BR.md b/apps/jumentix-website/documentation/CONTENT-PIPELINE.pt-BR.md index 91468673d..a5c6aebc5 100644 --- a/apps/jumentix-website/documentation/CONTENT-PIPELINE.pt-BR.md +++ b/apps/jumentix-website/documentation/CONTENT-PIPELINE.pt-BR.md @@ -2,8 +2,8 @@ Rastreamento: -- Épico: [#167](https://github.com/XpertMinds/Jumentix/issues/167) -- Tarefa: [#172](https://github.com/XpertMinds/Jumentix/issues/172) +- Épico: [#167](https://github.com/web2solutions/Jumentix/issues/167) +- Tarefa: [#172](https://github.com/web2solutions/Jumentix/issues/172) ## Propósito diff --git a/apps/jumentix-website/documentation/DESIGN-SYSTEM-AND-STORYBOOK.md b/apps/jumentix-website/documentation/DESIGN-SYSTEM-AND-STORYBOOK.md index 5c43a1dc0..6b0c2f084 100644 --- a/apps/jumentix-website/documentation/DESIGN-SYSTEM-AND-STORYBOOK.md +++ b/apps/jumentix-website/documentation/DESIGN-SYSTEM-AND-STORYBOOK.md @@ -2,8 +2,8 @@ Issue tracking: -- Epic: [#167](https://github.com/XpertMinds/Jumentix/issues/167) -- Task: [#170](https://github.com/XpertMinds/Jumentix/issues/170) +- Epic: [#167](https://github.com/web2solutions/Jumentix/issues/167) +- Task: [#170](https://github.com/web2solutions/Jumentix/issues/170) ## Purpose diff --git a/apps/jumentix-website/documentation/DESIGN-SYSTEM-AND-STORYBOOK.pt-BR.md b/apps/jumentix-website/documentation/DESIGN-SYSTEM-AND-STORYBOOK.pt-BR.md index eab126ebf..0924dd932 100644 --- a/apps/jumentix-website/documentation/DESIGN-SYSTEM-AND-STORYBOOK.pt-BR.md +++ b/apps/jumentix-website/documentation/DESIGN-SYSTEM-AND-STORYBOOK.pt-BR.md @@ -2,8 +2,8 @@ Rastreamento: -- Épico: [#167](https://github.com/XpertMinds/Jumentix/issues/167) -- Tarefa: [#170](https://github.com/XpertMinds/Jumentix/issues/170) +- Épico: [#167](https://github.com/web2solutions/Jumentix/issues/167) +- Tarefa: [#170](https://github.com/web2solutions/Jumentix/issues/170) ## Propósito diff --git a/apps/jumentix-website/documentation/DOCUMENTATION-EXPERIENCE.md b/apps/jumentix-website/documentation/DOCUMENTATION-EXPERIENCE.md index b8b24be7b..43e94e2a7 100644 --- a/apps/jumentix-website/documentation/DOCUMENTATION-EXPERIENCE.md +++ b/apps/jumentix-website/documentation/DOCUMENTATION-EXPERIENCE.md @@ -2,8 +2,8 @@ Issue tracking: -- Epic: [#167](https://github.com/XpertMinds/Jumentix/issues/167) -- Task: [#172](https://github.com/XpertMinds/Jumentix/issues/172) +- Epic: [#167](https://github.com/web2solutions/Jumentix/issues/167) +- Task: [#172](https://github.com/web2solutions/Jumentix/issues/172) ## Product Goal diff --git a/apps/jumentix-website/documentation/DOCUMENTATION-EXPERIENCE.pt-BR.md b/apps/jumentix-website/documentation/DOCUMENTATION-EXPERIENCE.pt-BR.md index 31f449926..47c424291 100644 --- a/apps/jumentix-website/documentation/DOCUMENTATION-EXPERIENCE.pt-BR.md +++ b/apps/jumentix-website/documentation/DOCUMENTATION-EXPERIENCE.pt-BR.md @@ -2,8 +2,8 @@ Rastreamento: -- Épico: [#167](https://github.com/XpertMinds/Jumentix/issues/167) -- Tarefa: [#172](https://github.com/XpertMinds/Jumentix/issues/172) +- Épico: [#167](https://github.com/web2solutions/Jumentix/issues/167) +- Tarefa: [#172](https://github.com/web2solutions/Jumentix/issues/172) ## Objetivo do Produto diff --git a/apps/jumentix-website/documentation/JUNIOR-DOCS-EXTENSION-PLAN.md b/apps/jumentix-website/documentation/JUNIOR-DOCS-EXTENSION-PLAN.md index 6f87b2e99..f75427b64 100644 --- a/apps/jumentix-website/documentation/JUNIOR-DOCS-EXTENSION-PLAN.md +++ b/apps/jumentix-website/documentation/JUNIOR-DOCS-EXTENSION-PLAN.md @@ -2,7 +2,7 @@ **Status:** Implemented on PR #184 (2026-08-11) — public allowlist + fail-closed private exclusion **Workspace:** `apps/jumentix-website` -**Related prior work:** [PR #184](https://github.com/XpertMinds/Jumentix/pull/184), Linear project [epicdocs-jumentix-website-deep-docs-playgrounds-seoai](https://linear.app/jumentix/project/epicdocs-jumentix-website-deep-docs-playgrounds-seoai-dae8af894bf6) +**Related prior work:** [PR #184](https://github.com/web2solutions/Jumentix/pull/184), Linear project [epicdocs-jumentix-website-deep-docs-playgrounds-seoai](https://linear.app/jumentix/project/epicdocs-jumentix-website-deep-docs-playgrounds-seoai-dae8af894bf6) **Language policy:** EN + pt-BR parity on every page (no orphan locale) **Hard rules:** zero GitHub content links in MDX bodies; fail-closed content smoke; no assumed jargon without prior definition or link diff --git a/apps/jumentix-website/documentation/SEO-AND-PERFORMANCE-BASELINE.md b/apps/jumentix-website/documentation/SEO-AND-PERFORMANCE-BASELINE.md index a227c3fa8..0c081a683 100644 --- a/apps/jumentix-website/documentation/SEO-AND-PERFORMANCE-BASELINE.md +++ b/apps/jumentix-website/documentation/SEO-AND-PERFORMANCE-BASELINE.md @@ -2,8 +2,8 @@ Issue tracking: -- Epic: [#124](https://github.com/XpertMinds/Jumentix/issues/124) -- Task: [#129](https://github.com/XpertMinds/Jumentix/issues/129) +- Epic: [#124](https://github.com/web2solutions/Jumentix/issues/124) +- Task: [#129](https://github.com/web2solutions/Jumentix/issues/129) ## SEO Baseline Implemented diff --git a/apps/jumentix-website/documentation/SEO-AND-PERFORMANCE-BASELINE.pt-BR.md b/apps/jumentix-website/documentation/SEO-AND-PERFORMANCE-BASELINE.pt-BR.md index 30f1f56ea..9ed796cc3 100644 --- a/apps/jumentix-website/documentation/SEO-AND-PERFORMANCE-BASELINE.pt-BR.md +++ b/apps/jumentix-website/documentation/SEO-AND-PERFORMANCE-BASELINE.pt-BR.md @@ -6,8 +6,8 @@ Idioma alvo: Português (Brasil) Rastreamento de problemas: -- Épico: [#124](https://github.com/XpertMinds/Jumentix/issues/124) -- Tarefa: [#129](https://github.com/XpertMinds/Jumentix/issues/129) +- Épico: [#124](https://github.com/web2solutions/Jumentix/issues/124) +- Tarefa: [#129](https://github.com/web2solutions/Jumentix/issues/129) ## Linha de base de SEO implementada diff --git a/apps/jumentix-website/documentation/VERCEL-DEPLOYMENT.md b/apps/jumentix-website/documentation/VERCEL-DEPLOYMENT.md index b105bf42f..d3f115ed4 100644 --- a/apps/jumentix-website/documentation/VERCEL-DEPLOYMENT.md +++ b/apps/jumentix-website/documentation/VERCEL-DEPLOYMENT.md @@ -64,7 +64,7 @@ Configured values: ## Required Vercel environment -Because `XpertMinds/Jumentix` is private, unauthenticated GitHub API calls return 404. +Because `web2solutions/Jumentix` is private, unauthenticated GitHub API calls return 404. Set these on the Vercel project (Production + Preview): | Name | Purpose | diff --git a/apps/jumentix-website/documentation/VERCEL-DEPLOYMENT.pt-BR.md b/apps/jumentix-website/documentation/VERCEL-DEPLOYMENT.pt-BR.md index d875885ec..fd37c9b6f 100644 --- a/apps/jumentix-website/documentation/VERCEL-DEPLOYMENT.pt-BR.md +++ b/apps/jumentix-website/documentation/VERCEL-DEPLOYMENT.pt-BR.md @@ -68,7 +68,7 @@ Valores configurados: ## Ambiente obrigatório na Vercel -Como `XpertMinds/Jumentix` é privado, chamadas não autenticadas à API do GitHub retornam 404. +Como `web2solutions/Jumentix` é privado, chamadas não autenticadas à API do GitHub retornam 404. Defina no projeto Vercel (Production + Preview): | Nome | Propósito | diff --git a/apps/jumentix-website/documentation/WEBSITE-IA-AND-CONVERSION-PLAN.md b/apps/jumentix-website/documentation/WEBSITE-IA-AND-CONVERSION-PLAN.md index 9ce5f5959..36cfd38a7 100644 --- a/apps/jumentix-website/documentation/WEBSITE-IA-AND-CONVERSION-PLAN.md +++ b/apps/jumentix-website/documentation/WEBSITE-IA-AND-CONVERSION-PLAN.md @@ -2,8 +2,8 @@ Issue tracking: -- Epic: [#124](https://github.com/XpertMinds/Jumentix/issues/124) -- Task: [#125](https://github.com/XpertMinds/Jumentix/issues/125) +- Epic: [#124](https://github.com/web2solutions/Jumentix/issues/124) +- Task: [#125](https://github.com/web2solutions/Jumentix/issues/125) ## 1. Objective diff --git a/apps/jumentix-website/documentation/WEBSITE-IA-AND-CONVERSION-PLAN.pt-BR.md b/apps/jumentix-website/documentation/WEBSITE-IA-AND-CONVERSION-PLAN.pt-BR.md index 2b3682979..f4f1a8602 100644 --- a/apps/jumentix-website/documentation/WEBSITE-IA-AND-CONVERSION-PLAN.pt-BR.md +++ b/apps/jumentix-website/documentation/WEBSITE-IA-AND-CONVERSION-PLAN.pt-BR.md @@ -6,8 +6,8 @@ Idioma alvo: Português (Brasil) Rastreamento de problemas: -- Épico: [#124](https://github.com/XpertMinds/Jumentix/issues/124) -- Tarefa: [#125](https://github.com/XpertMinds/Jumentix/issues/125) +- Épico: [#124](https://github.com/web2solutions/Jumentix/issues/124) +- Tarefa: [#125](https://github.com/web2solutions/Jumentix/issues/125) ## 1. Objetivo diff --git a/apps/jumentix-website/documentation/research/ADONISJS-UX-AUDIT.md b/apps/jumentix-website/documentation/research/ADONISJS-UX-AUDIT.md index ea58011a3..39410b266 100644 --- a/apps/jumentix-website/documentation/research/ADONISJS-UX-AUDIT.md +++ b/apps/jumentix-website/documentation/research/ADONISJS-UX-AUDIT.md @@ -10,10 +10,10 @@ Research date: `2026-07-26` Related governance: -- Epic: [#167](https://github.com/XpertMinds/Jumentix/issues/167) -- Research task: [#168](https://github.com/XpertMinds/Jumentix/issues/168) +- Epic: [#167](https://github.com/web2solutions/Jumentix/issues/167) +- Research task: [#168](https://github.com/web2solutions/Jumentix/issues/168) - Milestone: - [Jumentix OSS website rebuild - 2026-08-23](https://github.com/XpertMinds/Jumentix/milestone/3) + [Jumentix OSS website rebuild - 2026-08-23](https://github.com/web2solutions/Jumentix/milestone/3) ## Evidence diff --git a/apps/jumentix-website/documentation/research/ADONISJS-UX-AUDIT.pt-BR.md b/apps/jumentix-website/documentation/research/ADONISJS-UX-AUDIT.pt-BR.md index 66db8936e..da7d57d5f 100644 --- a/apps/jumentix-website/documentation/research/ADONISJS-UX-AUDIT.pt-BR.md +++ b/apps/jumentix-website/documentation/research/ADONISJS-UX-AUDIT.pt-BR.md @@ -10,10 +10,10 @@ Data da pesquisa: `2026-07-26` Governança relacionada: -- Épico: [#167](https://github.com/XpertMinds/Jumentix/issues/167) -- Tarefa: [#168](https://github.com/XpertMinds/Jumentix/issues/168) +- Épico: [#167](https://github.com/web2solutions/Jumentix/issues/167) +- Tarefa: [#168](https://github.com/web2solutions/Jumentix/issues/168) - Milestone: - [Jumentix OSS website rebuild - 2026-08-23](https://github.com/XpertMinds/Jumentix/milestone/3) + [Jumentix OSS website rebuild - 2026-08-23](https://github.com/web2solutions/Jumentix/milestone/3) ## Evidências diff --git a/apps/jumentix-website/jest.config.cjs b/apps/jumentix-website/jest.config.cjs index 7558b2fdb..ae7509dd1 100644 --- a/apps/jumentix-website/jest.config.cjs +++ b/apps/jumentix-website/jest.config.cjs @@ -23,6 +23,10 @@ const customJestConfig = { // component that had switched itself off. The double lets the mount path // run; the real editor is covered by the Cypress suites, in a real browser. '^monaco-editor$': '/test/mocks/monaco-editor.ts', + // JUM-728: the theme package is ESM-only and does not resolve under jsdom. + // `MDXMonacoPre` imports it for one thing — the `pre` it delegates shell + // fences to — and the double provides exactly that. + '^nextra-theme-docs$': '/test/mocks/nextra-theme-docs.tsx', }, testEnvironment: 'jest-environment-jsdom', // JUM-158: the route-discovery suite is ESM (.mjs) because the script it diff --git a/apps/jumentix-website/next.config.mjs b/apps/jumentix-website/next.config.mjs index 9f7b09cb8..45d713380 100644 --- a/apps/jumentix-website/next.config.mjs +++ b/apps/jumentix-website/next.config.mjs @@ -12,6 +12,9 @@ const withBundleAnalyzer = bundleAnalyzer({ const withNextra = nextra({ latex: true, + // Shell fences render through the theme's own `pre` (JUM-728); this is what + // gives every fenced block its copy button. + defaultShowCopyCode: true, search: { codeblocks: false }, diff --git a/apps/jumentix-website/public/docs-index.json b/apps/jumentix-website/public/docs-index.json index ec44e29fa..86d62f5f7 100644 --- a/apps/jumentix-website/public/docs-index.json +++ b/apps/jumentix-website/public/docs-index.json @@ -634,7 +634,7 @@ "Using the Service Manager and the Domain Designer", "1. What the Service Manager is", "2. Prerequisites", - "3. Vendored browser bundles — do this before the first run", + "3. Vendored browser bundles", "4. Running the application", "4.1 Start it alone", "4.2 Start it with the backend", @@ -1590,6 +1590,7 @@ "Service Configuration: validate before state (JUM-544)", "Multi-environment editing (JUM-480) — by cross-reference", "The PM2 ecosystem preview (JUM-480) — by source, not by command string", + "The PM2 monitoring dashboard — WebSocket live stream (Contract 1e) + HTTP one-shot (1c)", "Deploy Management: the Requirement 059 metadata contract (JUM-481)", "Deploy target lifecycle: edit, duplicate and field validation (JUM-546)", "Lifecycle rules — what exists today, and what is open", @@ -1641,6 +1642,23 @@ "References" ] }, + { + "title": "Frontend offline data layer", + "url": "https://jumentix-website.vercel.app/docs/jumentix/reference/frontend-offline-data-layer", + "description": "Cana boot, local repository, outbox, delta sync and PWA shell for apps/frontend.", + "section": "reference", + "locale": "en", + "headings": [ + "Frontend offline data layer", + "Boot (JUM-802)", + "Local repository (JUM-803)", + "X-CRUD local mode (JUM-804)", + "Sync (JUM-805)", + "Outbox (JUM-806 / JUM-807)", + "PWA (JUM-808)", + "Tests (JUM-809)" + ] + }, { "title": "Events and Messages Map", "url": "https://jumentix-website.vercel.app/docs/jumentix/reference/events-messages", @@ -2439,7 +2457,7 @@ "Usando o Service Manager e o Domain Designer", "1. O que é o Service Manager", "2. Pré-requisitos", - "3. Bundles vendorizados — faça isso antes do primeiro boot", + "3. Bundles vendorizados", "4. Executando a aplicação", "4.1 Subir apenas ela", "4.2 Subir junto com o backend", @@ -3395,6 +3413,7 @@ "Service Configuration: validar antes do estado (JUM-544)", "Edição multi-ambiente (JUM-480) — por referência cruzada", "A prévia do ecossistema PM2 (JUM-480) — pela fonte, não pela string de comando", + "O dashboard de monitoramento PM2 — stream WebSocket (Contrato 1e) + HTTP one-shot (1c)", "Deploy Management: o contrato de metadados do Requisito 059 (JUM-481)", "Ciclo de vida dos deploy targets: edição, duplicação e validação de campos (JUM-546)", "Regras de ciclo de vida — o que existe hoje e o que está aberto", @@ -3445,6 +3464,23 @@ "Referências" ] }, + { + "title": "Camada de dados offline do frontend", + "url": "https://jumentix-website.vercel.app/docs/pt-BR/jumentix/reference/frontend-offline-data-layer", + "description": "Boot Cana, repositório local, outbox, delta sync e shell PWA do apps/frontend.", + "section": "reference", + "locale": "pt-BR", + "headings": [ + "Camada de dados offline do frontend", + "Boot (JUM-802)", + "Repositório local (JUM-803)", + "Modo local do X-CRUD (JUM-804)", + "Sync (JUM-805)", + "Outbox (JUM-806 / JUM-807)", + "PWA (JUM-808)", + "Testes (JUM-809)" + ] + }, { "title": "Mapa de eventos e mensagens", "url": "https://jumentix-website.vercel.app/docs/pt-BR/jumentix/reference/events-messages", diff --git a/apps/jumentix-website/public/llms-full.txt b/apps/jumentix-website/public/llms-full.txt index 77e4a1e4b..208152a23 100644 --- a/apps/jumentix-website/public/llms-full.txt +++ b/apps/jumentix-website/public/llms-full.txt @@ -669,7 +669,7 @@ Headings: - Using the Service Manager and the Domain Designer - 1. What the Service Manager is - 2. Prerequisites -- 3. Vendored browser bundles — do this before the first run +- 3. Vendored browser bundles - 4. Running the application - 4.1 Start it alone - 4.2 Start it with the backend @@ -1674,6 +1674,7 @@ Headings: - Service Configuration: validate before state (JUM-544) - Multi-environment editing (JUM-480) — by cross-reference - The PM2 ecosystem preview (JUM-480) — by source, not by command string +- The PM2 monitoring dashboard — WebSocket live stream (Contract 1e) + HTTP one-shot (1c) - Deploy Management: the Requirement 059 metadata contract (JUM-481) - Deploy target lifecycle: edit, duplicate and field validation (JUM-546) - Lifecycle rules — what exists today, and what is open @@ -1728,6 +1729,24 @@ Headings: --- +# Frontend offline data layer + +URL: https://jumentix-website.vercel.app/docs/jumentix/reference/frontend-offline-data-layer + +Cana boot, local repository, outbox, delta sync and PWA shell for apps/frontend. + +Headings: +- Frontend offline data layer +- Boot (JUM-802) +- Local repository (JUM-803) +- X-CRUD local mode (JUM-804) +- Sync (JUM-805) +- Outbox (JUM-806 / JUM-807) +- PWA (JUM-808) +- Tests (JUM-809) + +--- + # Events and Messages Map URL: https://jumentix-website.vercel.app/docs/jumentix/reference/events-messages diff --git a/apps/jumentix-website/public/llms.txt b/apps/jumentix-website/public/llms.txt index 8dc652adb..5dbd4f3fe 100644 --- a/apps/jumentix-website/public/llms.txt +++ b/apps/jumentix-website/public/llms.txt @@ -96,6 +96,7 @@ Docs index JSON: https://jumentix-website.vercel.app/docs-index.json - Service Management Operations Console: https://jumentix-website.vercel.app/docs/jumentix/reference/service-management-operations-console - Runtime Environment Contracts: https://jumentix-website.vercel.app/docs/jumentix/reference/runtime-contracts - Service Management Cana Adoption: https://jumentix-website.vercel.app/docs/jumentix/reference/service-management-cana-adoption +- Frontend offline data layer: https://jumentix-website.vercel.app/docs/jumentix/reference/frontend-offline-data-layer - Events and Messages Map: https://jumentix-website.vercel.app/docs/jumentix/reference/events-messages - Service Management Module Architecture: https://jumentix-website.vercel.app/docs/jumentix/reference/service-management-module-architecture - Security and PCI Hardening: https://jumentix-website.vercel.app/docs/jumentix/reference/security-compliance diff --git a/apps/jumentix-website/scripts/sync-markdown-content.mjs b/apps/jumentix-website/scripts/sync-markdown-content.mjs index 98e28ce3c..e9673c407 100644 --- a/apps/jumentix-website/scripts/sync-markdown-content.mjs +++ b/apps/jumentix-website/scripts/sync-markdown-content.mjs @@ -141,10 +141,18 @@ const inferTitle = (markdown, fallback) => { }; const sanitizeDocBody = (markdown) => { - const normalized = normalizeLineEndings(markdown) - .replace(//g, '') - .trim(); - return normalized || 'No content available.'; + // Repeat until stable: removing one comment can expose a marker the first + // pass hid (`` leaves `-->`), and a single pass would re-emit + // a live comment opener into the MDX output. + const commentPattern = //g; + let normalized = normalizeLineEndings(markdown); + let previous; + do { + previous = normalized; + normalized = previous.replace(commentPattern, ''); + } while (normalized !== previous); + const trimmed = normalized.trim(); + return trimmed || 'No content available.'; }; async function readJsonFile(filePath) { diff --git a/apps/jumentix-website/scripts/sync-releases.mjs b/apps/jumentix-website/scripts/sync-releases.mjs index ae2d7910f..fd95b3191 100644 --- a/apps/jumentix-website/scripts/sync-releases.mjs +++ b/apps/jumentix-website/scripts/sync-releases.mjs @@ -2,7 +2,7 @@ * Build-time release notes data source for /api/github-releases. * * Same failure mode as the changelog page (JUM-718): the route proxied the - * GitHub releases API at request time, and this private repository answers + * GitHub releases API at request time, and the public canonical repository answers * unauthenticated calls with 404, so production rendered a permanent error. * Releases are part of the deployed artifact: bake them at build time. * @@ -23,7 +23,7 @@ import { fileURLToPath } from 'node:url'; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const appRoot = path.resolve(scriptDir, '..'); const outputPath = path.join(appRoot, 'content', 'releases.json'); -const REPO = 'XpertMinds/Jumentix'; +const REPO = 'web2solutions/Jumentix'; const MAX_RELEASES = 20; const RELEASE_FIELDS = [ diff --git a/apps/jumentix-website/test/mocks/nextra-theme-docs.tsx b/apps/jumentix-website/test/mocks/nextra-theme-docs.tsx new file mode 100644 index 000000000..12ea7ea11 --- /dev/null +++ b/apps/jumentix-website/test/mocks/nextra-theme-docs.tsx @@ -0,0 +1,22 @@ +/** + * Double for `nextra-theme-docs` (JUM-728). + * + * The theme package is ESM-only and its transitive graph does not load under + * jsdom, so a suite that renders a component importing it fails at resolution + * rather than at anything the suite is testing. `MDXMonacoPre` needs exactly one + * thing from the theme — the `pre` it delegates shell fences to — so the double + * provides a `pre` that keeps the props (the copy attribute among them) and the + * highlighted children, which is the contract the component depends on. + * + * The real theme `pre`, including its copy button, is exercised by the Cypress + * suites against a real build. + */ +import type { HTMLAttributes } from 'react'; + +function ThemePre(props: HTMLAttributes) { + return
;
+}
+
+export function useMDXComponents(components?: Record) {
+  return { pre: ThemePre, ...components };
+}
diff --git a/apps/service-management-api/package.json b/apps/service-management-api/package.json
new file mode 100644
index 000000000..eb01d4bb0
--- /dev/null
+++ b/apps/service-management-api/package.json
@@ -0,0 +1,11 @@
+{
+  "name": "@jumentix/service-management-api",
+  "version": "0.0.2",
+  "private": true,
+  "type": "commonjs",
+  "scripts": {
+    "test": "bun test test/unit",
+    "test:unit": "bun test ../../apps/service-management-api/test/unit",
+    "test:integration": "NODE_ENV=dev bun x jest apps/service-management-api/test/integration --runInBand --forceExit"
+  }
+}
diff --git a/apps/service-management-api/spec/1.0.0.yml b/apps/service-management-api/spec/1.0.0.yml
new file mode 100644
index 000000000..818e83177
--- /dev/null
+++ b/apps/service-management-api/spec/1.0.0.yml
@@ -0,0 +1,367 @@
+openapi: 3.1.0
+info:
+  title: Jumentix Service Management Catalog API
+  version: 1.0.0
+  description: Platform-owned shared catalog API for Service Management and Cana
+    synchronization.
+servers:
+  - url: /api/1.0.0
+paths:
+  /catalogs:
+    get:
+      tags:
+        - catalog
+      summary: Get all shared catalog records
+      description: Returns the shared domain designs visible to the caller's tenant
+        scope; tombstoned records are excluded unless includeDeleted=true
+      operationId: getAllCatalogs
+      parameters:
+        - name: page
+          in: query
+          description: Page number to fetch
+          required: false
+          schema:
+            type: string
+          default: 1
+        - name: size
+          in: query
+          description: Page size to fetch
+          required: false
+          schema:
+            type: string
+        - name: includeDeleted
+          in: query
+          description: Include soft-deleted (tombstoned) records so deletions propagate to
+            sync clients
+          required: false
+          schema:
+            type: string
+            enum:
+              - "true"
+              - "false"
+          default: "false"
+      responses:
+        "200":
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/CatalogArrayOf"
+        "400":
+          description: Invalid request
+        "401":
+          description: Unauthorized
+        "403":
+          description: Forbidden
+      security:
+        - bearerAuth:
+            - access_allow
+    post:
+      tags:
+        - catalog
+      summary: Publish a domain design into the shared catalog
+      description: Creates a shared catalog record with version 1
+      operationId: createCatalog
+      requestBody:
+        description: Domain design to share
+        content:
+          application/json:
+            schema:
+              $ref: "#/components/schemas/RequestCreateCatalog"
+        required: true
+      responses:
+        "201":
+          description: Catalog record created successfully
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/Catalog"
+        "400":
+          description: Invalid request
+        "401":
+          description: Unauthorized
+        "403":
+          description: Forbidden
+        "409":
+          description: Conflict
+      security:
+        - bearerAuth:
+            - access_allow
+  /catalogs/{id}:
+    get:
+      tags:
+        - catalog
+      summary: Get a shared catalog record by ID
+      description: Returns a single shared catalog record
+      operationId: getCatalogById
+      parameters:
+        - name: id
+          in: path
+          description: ID of the catalog record to return
+          required: true
+          schema:
+            type: string
+      responses:
+        "200":
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/Catalog"
+        "400":
+          description: Invalid ID supplied
+        "401":
+          description: Unauthorized
+        "403":
+          description: Forbidden
+        "404":
+          description: Catalog record not found
+      security:
+        - bearerAuth:
+            - access_allow
+    put:
+      tags:
+        - catalog
+      summary: Update a shared catalog record
+      description: Updates a shared catalog record when the caller's expected version
+        is current; a stale version is rejected with 409 and the current server
+        version so the edit can be reconciled
+      operationId: updateCatalog
+      parameters:
+        - name: id
+          in: path
+          description: ID of the catalog record to update
+          required: true
+          schema:
+            type: string
+      requestBody:
+        description: New content plus the expected version (optimistic-concurrency token)
+        content:
+          application/json:
+            schema:
+              $ref: "#/components/schemas/RequestUpdateCatalog"
+        required: true
+      responses:
+        "200":
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/Catalog"
+        "400":
+          description: Invalid ID supplied
+        "401":
+          description: Unauthorized
+        "403":
+          description: Forbidden
+        "404":
+          description: Catalog record not found
+        "409":
+          description: Stale version conflict
+      security:
+        - bearerAuth:
+            - access_allow
+    delete:
+      tags:
+        - catalog
+      summary: Soft-delete a shared catalog record
+      description: Tombstones a shared catalog record so the deletion propagates to
+        every sync client; recoverable through the restore operation
+      operationId: deleteCatalog
+      parameters:
+        - name: id
+          in: path
+          description: ID of the catalog record to delete
+          required: true
+          schema:
+            type: string
+        - name: version
+          in: query
+          description: Expected current version (optimistic-concurrency token)
+          required: true
+          schema:
+            type: string
+      responses:
+        "200":
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/ResourceDeleteResponse"
+        "400":
+          description: Invalid ID supplied
+        "401":
+          description: Unauthorized
+        "403":
+          description: Forbidden
+        "404":
+          description: Catalog record not found
+        "409":
+          description: Stale version conflict
+      security:
+        - bearerAuth:
+            - access_allow
+  /catalogs/{id}/restore:
+    post:
+      tags:
+        - catalog
+      summary: Restore a soft-deleted shared catalog record
+      description: Recovers a tombstoned shared catalog record; a restore is a write,
+        so the version bumps and a restore event is published
+      operationId: restoreCatalog
+      parameters:
+        - name: id
+          in: path
+          description: ID of the catalog record to restore
+          required: true
+          schema:
+            type: string
+      requestBody:
+        description: Expected version of the tombstoned record
+        content:
+          application/json:
+            schema:
+              $ref: "#/components/schemas/RequestRestoreCatalog"
+        required: true
+      responses:
+        "200":
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                $ref: "#/components/schemas/Catalog"
+        "400":
+          description: Invalid ID supplied
+        "401":
+          description: Unauthorized
+        "403":
+          description: Forbidden
+        "404":
+          description: Catalog record not found
+        "409":
+          description: Stale version conflict
+      security:
+        - bearerAuth:
+            - access_allow
+components:
+  securitySchemes:
+    bearerAuth:
+      type: http
+      scheme: bearer
+  schemas:
+    Catalog:
+      description: Port output object for one shared catalog record — a shared domain
+        design with its optimistic-concurrency token (JUM-491).
+      required:
+        - id
+        - organization
+        - name
+        - version
+        - design
+      type: object
+      properties:
+        id:
+          type: string
+          format: uuid
+          description: Catalog record id
+        organization:
+          type: string
+          description: Tenant organization that owns and shares this record
+        name:
+          type: string
+          minLength: 1
+          description: Human name of the shared domain design
+        description:
+          type: string
+          description: Optional summary of the shared domain design
+        version:
+          type: integer
+          minimum: 1
+          description: Server-managed optimistic-concurrency token (etag); every write
+            bumps it
+        design:
+          type: object
+          description: The shared domain design document (designer domain serialization)
+        provenance:
+          type: object
+          description: Domain-package provenance stamp (package name/version, JUM-492)
+        createdBy:
+          type: string
+          description: Actor that published the record
+        updatedBy:
+          type: string
+          description: Actor of the last write
+        createdAt:
+          type: string
+          format: date-time
+        updatedAt:
+          type: string
+          format: date-time
+        deletedAt:
+          type: string
+          description: Tombstone — empty when active, ISO timestamp when soft-deleted
+    CatalogArrayOf:
+      description: Port output array of shared catalog records.
+      type: array
+      items:
+        $ref: "#/components/schemas/Catalog"
+    RequestCreateCatalog:
+      description: Port input object for publishing a domain design into the shared catalog.
+      required:
+        - name
+        - design
+      type: object
+      properties:
+        name:
+          type: string
+          minLength: 1
+          description: Human name of the shared domain design
+        description:
+          type: string
+          description: Optional summary of the shared domain design
+        organization:
+          type: string
+          nullable: true
+          description: Owning organization — superadmin only; tenant users are bound to
+            their own organization
+        design:
+          type: object
+          description: The domain design document to share (designer domain serialization)
+        provenance:
+          type: object
+          description: Domain-package provenance stamp (package name/version, JUM-492)
+    RequestUpdateCatalog:
+      description: Port input object for updating a shared catalog record with
+        optimistic concurrency.
+      required:
+        - version
+      type: object
+      properties:
+        name:
+          type: string
+          minLength: 1
+          description: Human name of the shared domain design
+        description:
+          type: string
+          description: Optional summary of the shared domain design
+        design:
+          type: object
+          description: The domain design document to share (designer domain serialization)
+        provenance:
+          type: object
+          description: Domain-package provenance stamp (package name/version, JUM-492)
+        version:
+          type: integer
+          minimum: 1
+          description: Expected current version — a stale value is rejected with 409
+    RequestRestoreCatalog:
+      description: Port input object for recovering a soft-deleted shared catalog record.
+      required:
+        - version
+      type: object
+      properties:
+        version:
+          type: integer
+          minimum: 1
+          description: Expected current version of the tombstoned record — a stale value
+            is rejected with 409
diff --git a/apps/service-management-api/src/ServiceManagementCatalogAPI.ts b/apps/service-management-api/src/ServiceManagementCatalogAPI.ts
new file mode 100644
index 000000000..ea6c779c1
--- /dev/null
+++ b/apps/service-management-api/src/ServiceManagementCatalogAPI.ts
@@ -0,0 +1,126 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import fs from 'fs';
+import path from 'path';
+import YAML from 'yaml';
+import { OpenAPIV3 } from 'openapi-types';
+
+import { _API_PREFIX_ } from '@src/config/constants';
+import { replaceVars } from '@src/shared/utils';
+import type { IDatabaseClient } from '@src/infra/persistence/port/IDatabaseClient';
+import type { IAuthService } from '@src/modules/Users';
+import type { IEventBus, IMessageMediator } from '@src/modules/port';
+import type { HTTPBaseServer, IbaseHandler } from '@src/interface/HTTP/ports';
+
+import { CatalogController } from '@service-management-api/modules/Catalogs/adapters/in/http/controllers/CatalogController';
+import { composeCatalogsServices } from '@service-management-api/modules/Catalogs';
+
+import expressCreate from '@service-management-api/modules/Catalogs/interface/restapi/frameworks/express/handlers/createCatalog';
+import expressDelete from '@service-management-api/modules/Catalogs/interface/restapi/frameworks/express/handlers/deleteCatalog';
+import expressGetAll from '@service-management-api/modules/Catalogs/interface/restapi/frameworks/express/handlers/getAllCatalogs';
+import expressGetOne from '@service-management-api/modules/Catalogs/interface/restapi/frameworks/express/handlers/getCatalogById';
+import expressRestore from '@service-management-api/modules/Catalogs/interface/restapi/frameworks/express/handlers/restoreCatalog';
+import expressUpdate from '@service-management-api/modules/Catalogs/interface/restapi/frameworks/express/handlers/updateCatalog';
+
+export interface IServiceManagementCatalogAPIFactory {
+  databaseClient: IDatabaseClient;
+  webServer: HTTPBaseServer;
+  authService: IAuthService;
+  eventBus?: IEventBus;
+  messageMediator?: IMessageMediator;
+  specDir?: string;
+}
+
+const handlerFactoryByOperationId: Record = {
+  getAllCatalogs: expressGetAll,
+  createCatalog: expressCreate,
+  getCatalogById: expressGetOne,
+  updateCatalog: expressUpdate,
+  deleteCatalog: expressDelete,
+  restoreCatalog: expressRestore
+};
+
+export class ServiceManagementCatalogAPI {
+  private readonly oas: Map = new Map();
+
+  public readonly server: HTTPBaseServer;
+
+  public readonly databaseClient: IDatabaseClient;
+
+  private readonly authService: IAuthService;
+
+  private readonly eventBus?: IEventBus;
+
+  private readonly messageMediator?: IMessageMediator;
+
+  private readonly specDir: string;
+
+  private started = false;
+
+  constructor(config: IServiceManagementCatalogAPIFactory) {
+    this.server = config.webServer;
+    this.databaseClient = config.databaseClient;
+    this.authService = config.authService;
+    this.eventBus = config.eventBus;
+    this.messageMediator = config.messageMediator;
+    this.specDir = config.specDir || path.resolve(__dirname, '..', 'spec');
+    this.buildWithOAS();
+  }
+
+  private buildWithOAS(): void {
+    const specs = fs.readdirSync(this.specDir)
+      .filter((specName) => specName.endsWith('.yml') || specName.endsWith('.yaml'));
+    for (const specFileName of specs) {
+      const file = fs.readFileSync(path.join(this.specDir, specFileName), 'utf8');
+      const parsedSpec = YAML.parse(file);
+      if (parsedSpec?.openapi && parsedSpec?.info?.version) {
+        this.oas.set(parsedSpec.info.version, parsedSpec as OpenAPIV3.Document);
+      }
+    }
+    this.buildEndPoints();
+  }
+
+  private buildEndPoints(): void {
+    const { catalogUseCases } = composeCatalogsServices({
+      databaseClient: this.databaseClient,
+      eventBus: this.eventBus,
+      messageMediator: this.messageMediator
+    });
+    for (const [version, spec] of this.oas) {
+      const controller = new CatalogController({
+        authService: this.authService,
+        openApiSpecification: spec,
+        databaseClient: this.databaseClient,
+        catalogUseCases,
+        messageMediator: this.messageMediator
+      } as any);
+      for (const routePath of Object.keys(spec.paths || {})) {
+        const endPointConfigs: Record = spec.paths[routePath] ?? {};
+        for (const method of Object.keys(endPointConfigs)) {
+          const endPointConfig = endPointConfigs[method];
+          const factory = handlerFactoryByOperationId[endPointConfig.operationId];
+          if (!factory) {
+            throw new Error(`Service Management catalog handler not found for ${endPointConfig.operationId}.`);
+          }
+          const handler = factory({ endPointConfig, controller }) as IbaseHandler;
+          this.server.endPointRegister({
+            ...handler,
+            path: `${_API_PREFIX_}/${version}${replaceVars(handler.path)}`
+          });
+        }
+      }
+    }
+  }
+
+  public async start(): Promise {
+    if (this.started) return;
+    await this.databaseClient.connect();
+    await this.server.start();
+    this.started = true;
+  }
+
+  public async stop(): Promise {
+    await this.server.stop();
+    await this.databaseClient.disconnect();
+    this.started = false;
+  }
+}
diff --git a/apps/service-management-api/src/index.ts b/apps/service-management-api/src/index.ts
new file mode 100644
index 000000000..a9beb9c01
--- /dev/null
+++ b/apps/service-management-api/src/index.ts
@@ -0,0 +1,6 @@
+export { ServiceManagementCatalogAPI } from './ServiceManagementCatalogAPI';
+export {
+  InMemoryCatalogDbClient,
+  createServiceManagementCatalogDbClient
+} from './infra/persistence/InMemoryDatabase/InMemoryCatalogDbClient';
+export * from './modules/Catalogs';
diff --git a/apps/service-management-api/src/infra/persistence/InMemoryDatabase/InMemoryCatalogDbClient.ts b/apps/service-management-api/src/infra/persistence/InMemoryDatabase/InMemoryCatalogDbClient.ts
new file mode 100644
index 000000000..31b75ac28
--- /dev/null
+++ b/apps/service-management-api/src/infra/persistence/InMemoryDatabase/InMemoryCatalogDbClient.ts
@@ -0,0 +1,22 @@
+import type { IDatabaseClient, IDbStores } from '@src/infra/persistence/port/IDatabaseClient';
+import { InMemoryDbClient } from '@src/infra/persistence/InMemoryDatabase/InMemoryDbClient';
+import { CatalogStoreAPI } from '@service-management-api/infra/persistence/InMemoryDatabase/Stores/CatalogStoreAPI';
+
+export const createServiceManagementCatalogDbClient = (
+  baseDatabaseClient?: IDatabaseClient
+): IDatabaseClient => {
+  const sourceDatabaseClient = baseDatabaseClient || InMemoryDbClient;
+  const stores: IDbStores = {
+    ...sourceDatabaseClient.stores,
+    Catalog: CatalogStoreAPI
+  };
+  const connect = async () => {
+    await sourceDatabaseClient.connect?.();
+  };
+  const disconnect = async () => {
+    await sourceDatabaseClient.disconnect?.();
+  };
+  return { stores, connect, disconnect } as IDatabaseClient;
+};
+
+export const InMemoryCatalogDbClient = createServiceManagementCatalogDbClient();
diff --git a/apps/backend-template/src/infra/persistence/InMemoryDatabase/Stores/CatalogStoreAPI.ts b/apps/service-management-api/src/infra/persistence/InMemoryDatabase/Stores/CatalogStoreAPI.ts
similarity index 76%
rename from apps/backend-template/src/infra/persistence/InMemoryDatabase/Stores/CatalogStoreAPI.ts
rename to apps/service-management-api/src/infra/persistence/InMemoryDatabase/Stores/CatalogStoreAPI.ts
index 37cf894d4..6b96ccd43 100644
--- a/apps/backend-template/src/infra/persistence/InMemoryDatabase/Stores/CatalogStoreAPI.ts
+++ b/apps/service-management-api/src/infra/persistence/InMemoryDatabase/Stores/CatalogStoreAPI.ts
@@ -1,5 +1,5 @@
 import type { IStore } from '@src/infra/ports/persistence/IStore';
-import type { ICatalog } from '@src/modules/Catalogs/domain/Entity/ICatalog';
+import type { ICatalog } from '@service-management-api/modules/Catalogs/domain/Entity/ICatalog';
 import { InMemoryRelationalStore } from '@src/infra/persistence/InMemoryDatabase/Stores/InMemoryRelationalStore';
 
 export const CatalogStoreAPI: IStore = new InMemoryRelationalStore({
diff --git a/apps/backend-template/src/modules/Catalogs/adapters/in/http/controllers/CatalogController.ts b/apps/service-management-api/src/modules/Catalogs/adapters/in/http/controllers/CatalogController.ts
similarity index 77%
rename from apps/backend-template/src/modules/Catalogs/adapters/in/http/controllers/CatalogController.ts
rename to apps/service-management-api/src/modules/Catalogs/adapters/in/http/controllers/CatalogController.ts
index 4237805ae..b7ad379db 100644
--- a/apps/backend-template/src/modules/Catalogs/adapters/in/http/controllers/CatalogController.ts
+++ b/apps/service-management-api/src/modules/Catalogs/adapters/in/http/controllers/CatalogController.ts
@@ -18,18 +18,27 @@ import {
 } from '@src/modules/port';
 import type {
   ICatalog
-} from '@src/modules/Catalogs/domain/Entity/ICatalog';
-import type { RequestCreateCatalog } from '@src/modules/Catalogs/interface/dto/RequestCreateCatalog';
-import type { RequestUpdateCatalog } from '@src/modules/Catalogs/interface/dto/RequestUpdateCatalog';
-import type { ICatalogUseCases } from '@src/modules/Catalogs/application/ports/ICatalogUseCases';
+} from '@service-management-api/modules/Catalogs/domain/Entity/ICatalog';
+import type { RequestCreateCatalog } from '@service-management-api/modules/Catalogs/interface/dto/RequestCreateCatalog';
+import type { RequestUpdateCatalog } from '@service-management-api/modules/Catalogs/interface/dto/RequestUpdateCatalog';
+import type { ICatalogUseCases } from '@service-management-api/modules/Catalogs/application/ports/ICatalogUseCases';
 import type {
   ITenantAuthorizationDecision
 } from '@src/modules/Users/domain/security/TenantAuthorizationPolicy';
+import {
+  EUserRole,
+  hasSuperadminRole,
+  normalizeRoles
+} from '@src/modules/Users/domain/security/Rbac';
 import {
   decideCatalogAccess,
   resolveCatalogCollectionScope,
   resolveCatalogCreationOrganization
-} from '@src/modules/Catalogs/domain/security/CatalogAuthorizationPolicy';
+} from '@service-management-api/modules/Catalogs/domain/security/CatalogAuthorizationPolicy';
+
+type CatalogControllerFactory = IControllerFactory & {
+  catalogUseCases?: ICatalogUseCases;
+};
 
 /**
  * CatalogController — the shared catalog's inbound HTTP adapter (JUM-491).
@@ -41,7 +50,7 @@ import {
 export class CatalogController extends BaseController implements IController {
   private readonly catalogUseCases: ICatalogUseCases;
 
-  constructor(factory: IControllerFactory) {
+  constructor(factory: CatalogControllerFactory) {
     super(factory);
     if (!factory.catalogUseCases) {
       const error = new Error('CatalogUseCases is not implemented');
@@ -69,6 +78,33 @@ export class CatalogController extends BaseController implements IController {
     }
   }
 
+  // eslint-disable-next-line class-methods-use-this
+  private throwIfCatalogScopeDenied(event: BaseDomainEvent, scope: string): void {
+    const authenticatedUser = this.getAuthenticatedUser(event);
+    const roles = normalizeRoles(authenticatedUser.roles || []);
+    if (hasSuperadminRole(roles)) return;
+    const scopesByRole: Record = {
+      [EUserRole.admin]: [
+        'read_catalog',
+        'create_catalog',
+        'update_catalog',
+        'delete_catalog'
+      ],
+      [EUserRole.user]: [
+        'read_catalog',
+        'create_catalog',
+        'update_catalog'
+      ]
+    };
+    const granted = new Set();
+    roles.forEach((role) => {
+      (scopesByRole[role] || []).forEach((grantedScope) => granted.add(grantedScope));
+    });
+    if (!granted.has(scope)) {
+      throw new ForbiddenError(`Insufficient permission - missing Service Management scope ${scope}`);
+    }
+  }
+
   // eslint-disable-next-line class-methods-use-this
   private parseExpectedVersion(rawVersion: any): number {
     const expectedVersion = Number(rawVersion);
@@ -98,6 +134,7 @@ export class CatalogController extends BaseController implements IController {
       event.schemaOAS,
       event
     );
+    this.throwIfCatalogScopeDenied(event, 'create_catalog');
     const requestCreateCatalog = event.input as RequestCreateCatalog;
     const authenticatedUser = this.getAuthenticatedUser(event);
     const tenantBinding = resolveCatalogCreationOrganization(
@@ -122,6 +159,7 @@ export class CatalogController extends BaseController implements IController {
       event.schemaOAS,
       event
     );
+    this.throwIfCatalogScopeDenied(event, 'update_catalog');
     const requestUpdateCatalog = event.input as RequestUpdateCatalog;
     const catalogId = Security.xss(event.params.id);
     await this.enforceCatalogReadScope(event, catalogId);
@@ -142,6 +180,7 @@ export class CatalogController extends BaseController implements IController {
       event.schemaOAS,
       event
     );
+    this.throwIfCatalogScopeDenied(event, 'delete_catalog');
     const catalogId = Security.xss(event.params.id);
     await this.enforceCatalogReadScope(event, catalogId);
     const expectedVersion = this.parseExpectedVersion(event.queryString?.version);
@@ -162,6 +201,7 @@ export class CatalogController extends BaseController implements IController {
       event.schemaOAS,
       event
     );
+    this.throwIfCatalogScopeDenied(event, 'update_catalog');
     const catalogId = Security.xss(event.params.id);
     await this.enforceCatalogReadScope(event, catalogId);
     const requestVersion = (event.input as RequestUpdateCatalog)?.version;
@@ -183,6 +223,7 @@ export class CatalogController extends BaseController implements IController {
       event.schemaOAS,
       event
     );
+    this.throwIfCatalogScopeDenied(event, 'read_catalog');
     const catalogId = Security.xss(event.params.id);
     await this.enforceCatalogReadScope(event, catalogId);
     const { result, error } = await this.catalogUseCases.getOneById(catalogId);
@@ -198,6 +239,7 @@ export class CatalogController extends BaseController implements IController {
       event.schemaOAS,
       event
     );
+    this.throwIfCatalogScopeDenied(event, 'read_catalog');
     const filters = setFilter(event);
     const authenticatedUser = this.getAuthenticatedUser(event);
     const tenantScope = resolveCatalogCollectionScope(authenticatedUser);
diff --git a/apps/backend-template/src/modules/Catalogs/adapters/out/persistence/CatalogDataRepository.ts b/apps/service-management-api/src/modules/Catalogs/adapters/out/persistence/CatalogDataRepository.ts
similarity index 69%
rename from apps/backend-template/src/modules/Catalogs/adapters/out/persistence/CatalogDataRepository.ts
rename to apps/service-management-api/src/modules/Catalogs/adapters/out/persistence/CatalogDataRepository.ts
index 1d6063933..ce245338d 100644
--- a/apps/backend-template/src/modules/Catalogs/adapters/out/persistence/CatalogDataRepository.ts
+++ b/apps/service-management-api/src/modules/Catalogs/adapters/out/persistence/CatalogDataRepository.ts
@@ -4,37 +4,26 @@ import {
   canNotBeEmpty
 } from '@src/shared/validators';
 import { ConflictError } from '@src/infra/exceptions';
-import type {
-  ICatalog
-} from '@src/modules/Catalogs/domain/Entity/ICatalog';
-import { Catalog } from '@src/modules/Catalogs/domain/Model/Catalog';
-import type { RequestCreateCatalog } from '@src/modules/Catalogs/interface/dto/RequestCreateCatalog';
-import type { RequestUpdateCatalog } from '@src/modules/Catalogs/interface/dto/RequestUpdateCatalog';
-import type { RequestCatalogListOptions } from '@src/modules/Catalogs/interface/dto/RequestCatalogListOptions';
-import type { ICatalogRepository } from '@src/modules/Catalogs/service/ports/ICatalogRepository';
+import type { ICatalog } from '@service-management-api/modules/Catalogs/domain/Entity/ICatalog';
+import { Catalog } from '@service-management-api/modules/Catalogs/domain/Model/Catalog';
+import type { RequestCreateCatalog } from '@service-management-api/modules/Catalogs/interface/dto/RequestCreateCatalog';
+import type { RequestUpdateCatalog } from '@service-management-api/modules/Catalogs/interface/dto/RequestUpdateCatalog';
+import type { RequestCatalogListOptions } from '@service-management-api/modules/Catalogs/interface/dto/RequestCatalogListOptions';
+import type { ICatalogRepository } from '@service-management-api/modules/Catalogs/service/ports/ICatalogRepository';
 import type {
   IPagingRequest,
   IPagingResponse,
   IRepoConfig
 } from '@src/modules/port';
-import {
-  BaseRepo
-} from '@src/modules/port';
-
+import { BaseRepo } from '@src/modules/port';
 import { _DEFAULT_PAGE_SIZE_ } from '@src/config/constants';
 
 /**
- * CatalogDataRepository — the outbound persistence adapter for the shared
- * catalog (JUM-491).
+ * Outbound persistence adapter for the Service Management shared catalog.
  *
- * This is where optimistic concurrency is enforced: every mutation reads the
- * current record and refuses the write when the caller's expected version is
- * stale, throwing a `ConflictError` whose metadata carries the current
- * version AND the current record — the reviewable rejection path the issue
- * demands, because the client can reconcile against real server state instead
- * of losing the user's edit. Drivers with native conditional writes should
- * push the same check down to `IStoreMutationOptions.expectedVersion`; the
- * read-check-write here is the reference behaviour every driver must match.
+ * Optimistic concurrency lives here as the portable reference behavior: native
+ * drivers may push the same expected-version check into conditional writes, but
+ * every driver must preserve this conflict payload shape for client recovery.
  */
 export class CatalogDataRepository
   extends BaseRepo
@@ -120,19 +109,27 @@ export class CatalogDataRepository
     if (!options.includeDeleted) {
       scopedFilters.deletedAt = '';
     }
+    // The repository owns the default page size: a bare request pages with
+    // `this.limit`, and the store is told so instead of inventing its own
+    // (JUM-777 moved paging into the shared list-query helpers, which default
+    // to 10 when asked to page without a size).
+    const effectivePaging: IPagingRequest = {
+      ...paging,
+      page: paging?.page ?? 1,
+      size: paging?.size ?? this.limit
+    };
     const {
       result, page, size, total
-    } = await this.store.getAll(scopedFilters, paging);
-    const currentPage = page ?? paging?.page ?? 1;
-    const currentSize = size ?? paging?.size ?? this.limit;
+    } = await this.store.getAll(scopedFilters, effectivePaging);
+    const currentPage = page ?? effectivePaging.page;
+    const currentSize = size ?? effectivePaging.size;
     const rows = result ?? [];
-    const pagedResponse: IPagingResponse = {
+    return {
       page: currentPage,
       size: currentSize,
       total,
       result: rows.map((rawDoc: ICatalog) => new Catalog(rawDoc as RequestCreateCatalog & ICatalog))
     };
-    return pagedResponse;
   }
 
   public static compile(config: IRepoConfig): CatalogDataRepository {
diff --git a/apps/backend-template/src/modules/Catalogs/application/ports/ICatalogUseCases.ts b/apps/service-management-api/src/modules/Catalogs/application/ports/ICatalogUseCases.ts
similarity index 64%
rename from apps/backend-template/src/modules/Catalogs/application/ports/ICatalogUseCases.ts
rename to apps/service-management-api/src/modules/Catalogs/application/ports/ICatalogUseCases.ts
index 27abd2a51..f9d88ed0d 100644
--- a/apps/backend-template/src/modules/Catalogs/application/ports/ICatalogUseCases.ts
+++ b/apps/service-management-api/src/modules/Catalogs/application/ports/ICatalogUseCases.ts
@@ -1,9 +1,9 @@
 import type { IPagingRequest } from '@src/modules/port/IPagingRequest';
 import type { IServiceResponse } from '@src/modules/port/IServiceResponse';
-import type { ICatalog } from '@src/modules/Catalogs/domain/Entity/ICatalog';
-import type { RequestCreateCatalog } from '@src/modules/Catalogs/interface/dto/RequestCreateCatalog';
-import type { RequestUpdateCatalog } from '@src/modules/Catalogs/interface/dto/RequestUpdateCatalog';
-import type { RequestCatalogListOptions } from '@src/modules/Catalogs/interface/dto/RequestCatalogListOptions';
+import type { ICatalog } from '@service-management-api/modules/Catalogs/domain/Entity/ICatalog';
+import type { RequestCreateCatalog } from '@service-management-api/modules/Catalogs/interface/dto/RequestCreateCatalog';
+import type { RequestUpdateCatalog } from '@service-management-api/modules/Catalogs/interface/dto/RequestUpdateCatalog';
+import type { RequestCatalogListOptions } from '@service-management-api/modules/Catalogs/interface/dto/RequestCatalogListOptions';
 
 export interface ICatalogUseCases {
   create(
diff --git a/apps/backend-template/src/modules/Catalogs/application/use-cases/CatalogUseCases.ts b/apps/service-management-api/src/modules/Catalogs/application/use-cases/CatalogUseCases.ts
similarity index 78%
rename from apps/backend-template/src/modules/Catalogs/application/use-cases/CatalogUseCases.ts
rename to apps/service-management-api/src/modules/Catalogs/application/use-cases/CatalogUseCases.ts
index 390408aeb..107b5dba1 100644
--- a/apps/backend-template/src/modules/Catalogs/application/use-cases/CatalogUseCases.ts
+++ b/apps/service-management-api/src/modules/Catalogs/application/use-cases/CatalogUseCases.ts
@@ -1,9 +1,9 @@
 import type { IPagingRequest } from '@src/modules/port/IPagingRequest';
 import type { IServiceResponse } from '@src/modules/port/IServiceResponse';
-import type { ICatalog } from '@src/modules/Catalogs/domain/Entity/ICatalog';
-import type { RequestCatalogListOptions } from '@src/modules/Catalogs/interface/dto/RequestCatalogListOptions';
-import type { ICatalogUseCases } from '@src/modules/Catalogs/application/ports/ICatalogUseCases';
-import type { CatalogService } from '@src/modules/Catalogs/service/CatalogService';
+import type { ICatalog } from '@service-management-api/modules/Catalogs/domain/Entity/ICatalog';
+import type { RequestCatalogListOptions } from '@service-management-api/modules/Catalogs/interface/dto/RequestCatalogListOptions';
+import type { ICatalogUseCases } from '@service-management-api/modules/Catalogs/application/ports/ICatalogUseCases';
+import type { CatalogService } from '@service-management-api/modules/Catalogs/service/CatalogService';
 
 export class CatalogUseCases implements ICatalogUseCases {
   private readonly catalogService: CatalogService;
diff --git a/apps/backend-template/src/modules/Catalogs/composition/composeCatalogsServices.ts b/apps/service-management-api/src/modules/Catalogs/composition/composeCatalogsServices.ts
similarity index 69%
rename from apps/backend-template/src/modules/Catalogs/composition/composeCatalogsServices.ts
rename to apps/service-management-api/src/modules/Catalogs/composition/composeCatalogsServices.ts
index eff8df94a..59d494b32 100644
--- a/apps/backend-template/src/modules/Catalogs/composition/composeCatalogsServices.ts
+++ b/apps/service-management-api/src/modules/Catalogs/composition/composeCatalogsServices.ts
@@ -1,10 +1,10 @@
 import type { IDatabaseClient } from '@src/infra/persistence/port/IDatabaseClient';
 import type { IEventBus, IMessageMediator } from '@src/modules/port';
 
-import { CatalogDataRepository } from '@src/modules/Catalogs/adapters/out/persistence/CatalogDataRepository';
-import { CatalogService } from '@src/modules/Catalogs/service/CatalogService';
-import { CatalogUseCases } from '@src/modules/Catalogs/application/use-cases/CatalogUseCases';
-import type { ICatalogUseCases } from '@src/modules/Catalogs/application/ports/ICatalogUseCases';
+import { CatalogDataRepository } from '@service-management-api/modules/Catalogs/adapters/out/persistence/CatalogDataRepository';
+import { CatalogService } from '@service-management-api/modules/Catalogs/service/CatalogService';
+import { CatalogUseCases } from '@service-management-api/modules/Catalogs/application/use-cases/CatalogUseCases';
+import type { ICatalogUseCases } from '@service-management-api/modules/Catalogs/application/ports/ICatalogUseCases';
 
 interface ICatalogsCompositionConfig {
   databaseClient: IDatabaseClient;
diff --git a/apps/backend-template/src/modules/Catalogs/domain/Entity/ICatalog.ts b/apps/service-management-api/src/modules/Catalogs/domain/Entity/ICatalog.ts
similarity index 100%
rename from apps/backend-template/src/modules/Catalogs/domain/Entity/ICatalog.ts
rename to apps/service-management-api/src/modules/Catalogs/domain/Entity/ICatalog.ts
diff --git a/apps/backend-template/src/modules/Catalogs/domain/Model/Catalog.ts b/apps/service-management-api/src/modules/Catalogs/domain/Model/Catalog.ts
similarity index 91%
rename from apps/backend-template/src/modules/Catalogs/domain/Model/Catalog.ts
rename to apps/service-management-api/src/modules/Catalogs/domain/Model/Catalog.ts
index c203c37c1..990d0c487 100644
--- a/apps/backend-template/src/modules/Catalogs/domain/Model/Catalog.ts
+++ b/apps/service-management-api/src/modules/Catalogs/domain/Model/Catalog.ts
@@ -7,10 +7,10 @@ import {
 } from '@src/shared/validators';
 import type {
   ICatalog
-} from '@src/modules/Catalogs/domain/Entity/ICatalog';
+} from '@service-management-api/modules/Catalogs/domain/Entity/ICatalog';
 import type {
   RequestCreateCatalog
-} from '@src/modules/Catalogs/interface/dto/RequestCreateCatalog';
+} from '@service-management-api/modules/Catalogs/interface/dto/RequestCreateCatalog';
 
 interface CatalogFactory extends RequestCreateCatalog {
   id?: string;
@@ -44,13 +44,12 @@ export class Catalog extends BaseModel implements ICatalog {
 
   private _updatedBy: string = '';
 
-  private _deletedAt: string = '';
-
   constructor(payload: CatalogFactory) {
     super({
       id: payload.id,
       createdAt: payload.createdAt,
-      updatedAt: payload.updatedAt
+      updatedAt: payload.updatedAt,
+      deletedAt: payload.deletedAt ?? ''
     });
     canNotBeEmpty('name', payload.name);
     canNotBeEmpty('organization', payload.organization);
@@ -62,7 +61,6 @@ export class Catalog extends BaseModel implements ICatalog {
     this._provenance = payload.provenance;
     this._createdBy = payload.createdBy ?? '';
     this._updatedBy = payload.updatedBy ?? '';
-    this._deletedAt = payload.deletedAt ?? '';
     this._excludeOnSerialize = [
       'deleted',
       'bumpVersion',
@@ -125,11 +123,11 @@ export class Catalog extends BaseModel implements ICatalog {
   }
 
   public get deletedAt(): string {
-    return this._deletedAt;
+    return this._deletedAt ?? '';
   }
 
   public get deleted(): boolean {
-    return this._deletedAt !== '';
+    return this._deletedAt !== '' && this._deletedAt != null;
   }
 
   /** Bump the concurrency token and stamp the mutation time/actor. */
diff --git a/apps/backend-template/src/modules/Catalogs/domain/security/CatalogAuthorizationPolicy.ts b/apps/service-management-api/src/modules/Catalogs/domain/security/CatalogAuthorizationPolicy.ts
similarity index 100%
rename from apps/backend-template/src/modules/Catalogs/domain/security/CatalogAuthorizationPolicy.ts
rename to apps/service-management-api/src/modules/Catalogs/domain/security/CatalogAuthorizationPolicy.ts
diff --git a/apps/backend-template/src/modules/Catalogs/events/CatalogCreateRequestEvent.ts b/apps/service-management-api/src/modules/Catalogs/events/CatalogCreateRequestEvent.ts
similarity index 100%
rename from apps/backend-template/src/modules/Catalogs/events/CatalogCreateRequestEvent.ts
rename to apps/service-management-api/src/modules/Catalogs/events/CatalogCreateRequestEvent.ts
diff --git a/apps/backend-template/src/modules/Catalogs/events/CatalogDeleteRequestEvent.ts b/apps/service-management-api/src/modules/Catalogs/events/CatalogDeleteRequestEvent.ts
similarity index 100%
rename from apps/backend-template/src/modules/Catalogs/events/CatalogDeleteRequestEvent.ts
rename to apps/service-management-api/src/modules/Catalogs/events/CatalogDeleteRequestEvent.ts
diff --git a/apps/backend-template/src/modules/Catalogs/events/CatalogGetAllRequestEvent.ts b/apps/service-management-api/src/modules/Catalogs/events/CatalogGetAllRequestEvent.ts
similarity index 100%
rename from apps/backend-template/src/modules/Catalogs/events/CatalogGetAllRequestEvent.ts
rename to apps/service-management-api/src/modules/Catalogs/events/CatalogGetAllRequestEvent.ts
diff --git a/apps/backend-template/src/modules/Catalogs/events/CatalogGetOneRequestEvent.ts b/apps/service-management-api/src/modules/Catalogs/events/CatalogGetOneRequestEvent.ts
similarity index 100%
rename from apps/backend-template/src/modules/Catalogs/events/CatalogGetOneRequestEvent.ts
rename to apps/service-management-api/src/modules/Catalogs/events/CatalogGetOneRequestEvent.ts
diff --git a/apps/backend-template/src/modules/Catalogs/events/CatalogRestoreRequestEvent.ts b/apps/service-management-api/src/modules/Catalogs/events/CatalogRestoreRequestEvent.ts
similarity index 100%
rename from apps/backend-template/src/modules/Catalogs/events/CatalogRestoreRequestEvent.ts
rename to apps/service-management-api/src/modules/Catalogs/events/CatalogRestoreRequestEvent.ts
diff --git a/apps/backend-template/src/modules/Catalogs/events/CatalogUpdateRequestEvent.ts b/apps/service-management-api/src/modules/Catalogs/events/CatalogUpdateRequestEvent.ts
similarity index 100%
rename from apps/backend-template/src/modules/Catalogs/events/CatalogUpdateRequestEvent.ts
rename to apps/service-management-api/src/modules/Catalogs/events/CatalogUpdateRequestEvent.ts
diff --git a/apps/backend-template/src/modules/Catalogs/events/contracts/CatalogIntegrationEventName.ts b/apps/service-management-api/src/modules/Catalogs/events/contracts/CatalogIntegrationEventName.ts
similarity index 100%
rename from apps/backend-template/src/modules/Catalogs/events/contracts/CatalogIntegrationEventName.ts
rename to apps/service-management-api/src/modules/Catalogs/events/contracts/CatalogIntegrationEventName.ts
diff --git a/apps/service-management-api/src/modules/Catalogs/features/createCatalog.ts b/apps/service-management-api/src/modules/Catalogs/features/createCatalog.ts
new file mode 100644
index 000000000..8157acb7c
--- /dev/null
+++ b/apps/service-management-api/src/modules/Catalogs/features/createCatalog.ts
@@ -0,0 +1,11 @@
+import type { ICatalog } from '@service-management-api/modules/Catalogs/domain/Entity/ICatalog';
+import type { RequestCreateCatalog } from '@service-management-api/modules/Catalogs/interface/dto/RequestCreateCatalog';
+import type { ICatalogRepository } from '@service-management-api/modules/Catalogs/service/ports/ICatalogRepository';
+
+export const createCatalog = async (
+  payload: RequestCreateCatalog & { createdBy?: string },
+  catalogRepository: ICatalogRepository
+): Promise => {
+  const model = await catalogRepository.create(payload);
+  return model.serialize();
+};
diff --git a/apps/backend-template/src/modules/Catalogs/features/deleteCatalogById.ts b/apps/service-management-api/src/modules/Catalogs/features/deleteCatalogById.ts
similarity index 66%
rename from apps/backend-template/src/modules/Catalogs/features/deleteCatalogById.ts
rename to apps/service-management-api/src/modules/Catalogs/features/deleteCatalogById.ts
index 2582ca12a..98aa13b63 100644
--- a/apps/backend-template/src/modules/Catalogs/features/deleteCatalogById.ts
+++ b/apps/service-management-api/src/modules/Catalogs/features/deleteCatalogById.ts
@@ -1,4 +1,4 @@
-import type { ICatalogRepository } from '@src/modules/Catalogs/service/ports/ICatalogRepository';
+import type { ICatalogRepository } from '@service-management-api/modules/Catalogs/service/ports/ICatalogRepository';
 
 export const deleteCatalogById = async (
   id: string,
diff --git a/apps/backend-template/src/modules/Catalogs/features/getAllCatalogs.ts b/apps/service-management-api/src/modules/Catalogs/features/getAllCatalogs.ts
similarity index 55%
rename from apps/backend-template/src/modules/Catalogs/features/getAllCatalogs.ts
rename to apps/service-management-api/src/modules/Catalogs/features/getAllCatalogs.ts
index da9e6dcb6..903e52aa7 100644
--- a/apps/backend-template/src/modules/Catalogs/features/getAllCatalogs.ts
+++ b/apps/service-management-api/src/modules/Catalogs/features/getAllCatalogs.ts
@@ -1,8 +1,8 @@
 import type { IPagingRequest, IPagingResponse } from '@src/modules/port';
-import type { ICatalog } from '@src/modules/Catalogs/domain/Entity/ICatalog';
-import type { Catalog } from '@src/modules/Catalogs/domain/Model/Catalog';
-import type { RequestCatalogListOptions } from '@src/modules/Catalogs/interface/dto/RequestCatalogListOptions';
-import type { ICatalogRepository } from '@src/modules/Catalogs/service/ports/ICatalogRepository';
+import type { ICatalog } from '@service-management-api/modules/Catalogs/domain/Entity/ICatalog';
+import type { Catalog } from '@service-management-api/modules/Catalogs/domain/Model/Catalog';
+import type { RequestCatalogListOptions } from '@service-management-api/modules/Catalogs/interface/dto/RequestCatalogListOptions';
+import type { ICatalogRepository } from '@service-management-api/modules/Catalogs/service/ports/ICatalogRepository';
 
 export const getAllCatalogs = async (
   filters: Record,
diff --git a/apps/service-management-api/src/modules/Catalogs/features/getCatalogById.ts b/apps/service-management-api/src/modules/Catalogs/features/getCatalogById.ts
new file mode 100644
index 000000000..39c6da6c8
--- /dev/null
+++ b/apps/service-management-api/src/modules/Catalogs/features/getCatalogById.ts
@@ -0,0 +1,10 @@
+import type { ICatalog } from '@service-management-api/modules/Catalogs/domain/Entity/ICatalog';
+import type { ICatalogRepository } from '@service-management-api/modules/Catalogs/service/ports/ICatalogRepository';
+
+export const getCatalogById = async (
+  id: string,
+  catalogRepository: ICatalogRepository
+): Promise => {
+  const model = await catalogRepository.getOneById(id);
+  return model.serialize();
+};
diff --git a/apps/backend-template/src/modules/Catalogs/features/restoreCatalog.ts b/apps/service-management-api/src/modules/Catalogs/features/restoreCatalog.ts
similarity index 56%
rename from apps/backend-template/src/modules/Catalogs/features/restoreCatalog.ts
rename to apps/service-management-api/src/modules/Catalogs/features/restoreCatalog.ts
index d7b7a8647..9e5b5b2e8 100644
--- a/apps/backend-template/src/modules/Catalogs/features/restoreCatalog.ts
+++ b/apps/service-management-api/src/modules/Catalogs/features/restoreCatalog.ts
@@ -1,5 +1,5 @@
-import type { ICatalog } from '@src/modules/Catalogs/domain/Entity/ICatalog';
-import type { ICatalogRepository } from '@src/modules/Catalogs/service/ports/ICatalogRepository';
+import type { ICatalog } from '@service-management-api/modules/Catalogs/domain/Entity/ICatalog';
+import type { ICatalogRepository } from '@service-management-api/modules/Catalogs/service/ports/ICatalogRepository';
 
 export const restoreCatalog = async (
   id: string,
diff --git a/apps/service-management-api/src/modules/Catalogs/features/updateCatalog.ts b/apps/service-management-api/src/modules/Catalogs/features/updateCatalog.ts
new file mode 100644
index 000000000..a60713d17
--- /dev/null
+++ b/apps/service-management-api/src/modules/Catalogs/features/updateCatalog.ts
@@ -0,0 +1,13 @@
+import type { ICatalog } from '@service-management-api/modules/Catalogs/domain/Entity/ICatalog';
+import type { RequestUpdateCatalog } from '@service-management-api/modules/Catalogs/interface/dto/RequestUpdateCatalog';
+import type { ICatalogRepository } from '@service-management-api/modules/Catalogs/service/ports/ICatalogRepository';
+
+export const updateCatalog = async (
+  id: string,
+  payload: RequestUpdateCatalog,
+  catalogRepository: ICatalogRepository,
+  actor: string = ''
+): Promise => {
+  const model = await catalogRepository.update(id, payload, actor);
+  return model.serialize();
+};
diff --git a/apps/service-management-api/src/modules/Catalogs/index.ts b/apps/service-management-api/src/modules/Catalogs/index.ts
new file mode 100644
index 000000000..6abb573d5
--- /dev/null
+++ b/apps/service-management-api/src/modules/Catalogs/index.ts
@@ -0,0 +1,38 @@
+export type { ICatalog } from '@service-management-api/modules/Catalogs/domain/Entity/ICatalog';
+export { Catalog } from '@service-management-api/modules/Catalogs/domain/Model/Catalog';
+export { CatalogDataRepository } from '@service-management-api/modules/Catalogs/adapters/out/persistence/CatalogDataRepository';
+export { CatalogService } from '@service-management-api/modules/Catalogs/service/CatalogService';
+export { composeCatalogsServices } from '@service-management-api/modules/Catalogs/composition/composeCatalogsServices';
+export { CatalogUseCases } from '@service-management-api/modules/Catalogs/application/use-cases/CatalogUseCases';
+
+export { CatalogController } from '@service-management-api/modules/Catalogs/adapters/in/http/controllers/CatalogController';
+export { CatalogController as CatalogHttpController } from '@service-management-api/modules/Catalogs/adapters/in/http/controllers/CatalogController';
+
+export { CatalogIntegrationEventName } from '@service-management-api/modules/Catalogs/events/contracts/CatalogIntegrationEventName';
+export { CatalogCreateRequestEvent } from '@service-management-api/modules/Catalogs/events/CatalogCreateRequestEvent';
+export { CatalogUpdateRequestEvent } from '@service-management-api/modules/Catalogs/events/CatalogUpdateRequestEvent';
+export { CatalogDeleteRequestEvent } from '@service-management-api/modules/Catalogs/events/CatalogDeleteRequestEvent';
+export { CatalogRestoreRequestEvent } from '@service-management-api/modules/Catalogs/events/CatalogRestoreRequestEvent';
+export { CatalogGetAllRequestEvent } from '@service-management-api/modules/Catalogs/events/CatalogGetAllRequestEvent';
+export { CatalogGetOneRequestEvent } from '@service-management-api/modules/Catalogs/events/CatalogGetOneRequestEvent';
+
+export {
+  decideCatalogAccess,
+  resolveCatalogCollectionScope,
+  resolveCatalogCreationOrganization
+} from '@service-management-api/modules/Catalogs/domain/security/CatalogAuthorizationPolicy';
+
+// dtos
+export type { RequestCreateCatalog } from '@service-management-api/modules/Catalogs/interface/dto/RequestCreateCatalog';
+export type { RequestUpdateCatalog } from '@service-management-api/modules/Catalogs/interface/dto/RequestUpdateCatalog';
+export type { RequestCatalogListOptions } from '@service-management-api/modules/Catalogs/interface/dto/RequestCatalogListOptions';
+
+export type { ICatalogUseCases } from '@service-management-api/modules/Catalogs/application/ports/ICatalogUseCases';
+export type { ICatalogRepository } from '@service-management-api/modules/Catalogs/service/ports/ICatalogRepository';
+
+export { createCatalog } from '@service-management-api/modules/Catalogs/features/createCatalog';
+export { updateCatalog } from '@service-management-api/modules/Catalogs/features/updateCatalog';
+export { deleteCatalogById } from '@service-management-api/modules/Catalogs/features/deleteCatalogById';
+export { restoreCatalog } from '@service-management-api/modules/Catalogs/features/restoreCatalog';
+export { getCatalogById } from '@service-management-api/modules/Catalogs/features/getCatalogById';
+export { getAllCatalogs } from '@service-management-api/modules/Catalogs/features/getAllCatalogs';
diff --git a/apps/backend-template/src/modules/Catalogs/interface/dto/RequestCatalogListOptions.ts b/apps/service-management-api/src/modules/Catalogs/interface/dto/RequestCatalogListOptions.ts
similarity index 100%
rename from apps/backend-template/src/modules/Catalogs/interface/dto/RequestCatalogListOptions.ts
rename to apps/service-management-api/src/modules/Catalogs/interface/dto/RequestCatalogListOptions.ts
diff --git a/apps/backend-template/src/modules/Catalogs/interface/dto/RequestCreateCatalog.ts b/apps/service-management-api/src/modules/Catalogs/interface/dto/RequestCreateCatalog.ts
similarity index 100%
rename from apps/backend-template/src/modules/Catalogs/interface/dto/RequestCreateCatalog.ts
rename to apps/service-management-api/src/modules/Catalogs/interface/dto/RequestCreateCatalog.ts
diff --git a/apps/backend-template/src/modules/Catalogs/interface/dto/RequestUpdateCatalog.ts b/apps/service-management-api/src/modules/Catalogs/interface/dto/RequestUpdateCatalog.ts
similarity index 100%
rename from apps/backend-template/src/modules/Catalogs/interface/dto/RequestUpdateCatalog.ts
rename to apps/service-management-api/src/modules/Catalogs/interface/dto/RequestUpdateCatalog.ts
diff --git a/apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/express/handlers/createCatalog.ts b/apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/express/handlers/createCatalog.ts
similarity index 88%
rename from apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/express/handlers/createCatalog.ts
rename to apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/express/handlers/createCatalog.ts
index 63058b7aa..7b98289fb 100644
--- a/apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/express/handlers/createCatalog.ts
+++ b/apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/express/handlers/createCatalog.ts
@@ -7,7 +7,7 @@ import type {
   EndPointFactory
 } from '@src/interface/HTTP/ports';
 
-import { CatalogCreateRequestEvent } from '@src/modules/Catalogs/events/CatalogCreateRequestEvent';
+import { CatalogCreateRequestEvent } from '@service-management-api/modules/Catalogs/events/CatalogCreateRequestEvent';
 
 const create: EndPointFactory = (
   {
diff --git a/apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/express/handlers/deleteCatalog.ts b/apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/express/handlers/deleteCatalog.ts
similarity index 89%
rename from apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/express/handlers/deleteCatalog.ts
rename to apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/express/handlers/deleteCatalog.ts
index 804dca2c3..633533086 100644
--- a/apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/express/handlers/deleteCatalog.ts
+++ b/apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/express/handlers/deleteCatalog.ts
@@ -7,7 +7,7 @@ import type {
   EndPointFactory
 } from '@src/interface/HTTP/ports';
 
-import { CatalogDeleteRequestEvent } from '@src/modules/Catalogs/events/CatalogDeleteRequestEvent';
+import { CatalogDeleteRequestEvent } from '@service-management-api/modules/Catalogs/events/CatalogDeleteRequestEvent';
 
 const deleteOne: EndPointFactory = (
   {
diff --git a/apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/express/handlers/getAllCatalogs.ts b/apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/express/handlers/getAllCatalogs.ts
similarity index 90%
rename from apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/express/handlers/getAllCatalogs.ts
rename to apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/express/handlers/getAllCatalogs.ts
index 48b1d79da..4c1aeea23 100644
--- a/apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/express/handlers/getAllCatalogs.ts
+++ b/apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/express/handlers/getAllCatalogs.ts
@@ -7,7 +7,7 @@ import type {
   EndPointFactory
 } from '@src/interface/HTTP/ports';
 
-import { CatalogGetAllRequestEvent } from '@src/modules/Catalogs/events/CatalogGetAllRequestEvent';
+import { CatalogGetAllRequestEvent } from '@service-management-api/modules/Catalogs/events/CatalogGetAllRequestEvent';
 
 const getAll: EndPointFactory = (
   {
diff --git a/apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/express/handlers/getCatalogById.ts b/apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/express/handlers/getCatalogById.ts
similarity index 89%
rename from apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/express/handlers/getCatalogById.ts
rename to apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/express/handlers/getCatalogById.ts
index e86296dca..95b4065aa 100644
--- a/apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/express/handlers/getCatalogById.ts
+++ b/apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/express/handlers/getCatalogById.ts
@@ -7,7 +7,7 @@ import type {
   EndPointFactory
 } from '@src/interface/HTTP/ports';
 
-import { CatalogGetOneRequestEvent } from '@src/modules/Catalogs/events/CatalogGetOneRequestEvent';
+import { CatalogGetOneRequestEvent } from '@service-management-api/modules/Catalogs/events/CatalogGetOneRequestEvent';
 
 const getOneById: EndPointFactory = (
   {
diff --git a/apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/express/handlers/restoreCatalog.ts b/apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/express/handlers/restoreCatalog.ts
similarity index 89%
rename from apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/express/handlers/restoreCatalog.ts
rename to apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/express/handlers/restoreCatalog.ts
index 492e4f11a..b941ef2e7 100644
--- a/apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/express/handlers/restoreCatalog.ts
+++ b/apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/express/handlers/restoreCatalog.ts
@@ -7,7 +7,7 @@ import type {
   EndPointFactory
 } from '@src/interface/HTTP/ports';
 
-import { CatalogRestoreRequestEvent } from '@src/modules/Catalogs/events/CatalogRestoreRequestEvent';
+import { CatalogRestoreRequestEvent } from '@service-management-api/modules/Catalogs/events/CatalogRestoreRequestEvent';
 
 const restore: EndPointFactory = (
   {
diff --git a/apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/express/handlers/updateCatalog.ts b/apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/express/handlers/updateCatalog.ts
similarity index 89%
rename from apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/express/handlers/updateCatalog.ts
rename to apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/express/handlers/updateCatalog.ts
index c274324f5..a47a06afc 100644
--- a/apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/express/handlers/updateCatalog.ts
+++ b/apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/express/handlers/updateCatalog.ts
@@ -7,7 +7,7 @@ import type {
   EndPointFactory
 } from '@src/interface/HTTP/ports';
 
-import { CatalogUpdateRequestEvent } from '@src/modules/Catalogs/events/CatalogUpdateRequestEvent';
+import { CatalogUpdateRequestEvent } from '@service-management-api/modules/Catalogs/events/CatalogUpdateRequestEvent';
 
 const update: EndPointFactory = (
   {
diff --git a/apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/createCatalog.ts b/apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/createCatalog.ts
similarity index 91%
rename from apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/createCatalog.ts
rename to apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/createCatalog.ts
index b9e2aaab2..8d4904343 100644
--- a/apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/createCatalog.ts
+++ b/apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/createCatalog.ts
@@ -7,7 +7,7 @@ import type {
   EndPointFactory
 } from '@src/interface/HTTP/ports';
 
-import { CatalogCreateRequestEvent } from '@src/modules/Catalogs';
+import { CatalogCreateRequestEvent } from '@service-management-api/modules/Catalogs';
 
 const create: EndPointFactory = (
   {
diff --git a/apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/deleteCatalog.ts b/apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/deleteCatalog.ts
similarity index 92%
rename from apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/deleteCatalog.ts
rename to apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/deleteCatalog.ts
index b92966991..78d1da772 100644
--- a/apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/deleteCatalog.ts
+++ b/apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/deleteCatalog.ts
@@ -7,7 +7,7 @@ import type {
   EndPointFactory
 } from '@src/interface/HTTP/ports';
 
-import { CatalogDeleteRequestEvent } from '@src/modules/Catalogs';
+import { CatalogDeleteRequestEvent } from '@service-management-api/modules/Catalogs';
 
 const deleteOne: EndPointFactory = (
   {
diff --git a/apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/getAllCatalogs.ts b/apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/getAllCatalogs.ts
similarity index 92%
rename from apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/getAllCatalogs.ts
rename to apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/getAllCatalogs.ts
index 82648dfd7..02d1a9c70 100644
--- a/apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/getAllCatalogs.ts
+++ b/apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/getAllCatalogs.ts
@@ -7,7 +7,7 @@ import type {
   EndPointFactory
 } from '@src/interface/HTTP/ports';
 
-import { CatalogGetAllRequestEvent } from '@src/modules/Catalogs';
+import { CatalogGetAllRequestEvent } from '@service-management-api/modules/Catalogs';
 
 const getAll: EndPointFactory = (
   {
diff --git a/apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/getCatalogById.ts b/apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/getCatalogById.ts
similarity index 92%
rename from apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/getCatalogById.ts
rename to apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/getCatalogById.ts
index 509d8f116..3bc447209 100644
--- a/apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/getCatalogById.ts
+++ b/apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/getCatalogById.ts
@@ -7,7 +7,7 @@ import type {
   EndPointFactory
 } from '@src/interface/HTTP/ports';
 
-import { CatalogGetOneRequestEvent } from '@src/modules/Catalogs';
+import { CatalogGetOneRequestEvent } from '@service-management-api/modules/Catalogs';
 
 const getOneById: EndPointFactory = (
   {
diff --git a/apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/restoreCatalog.ts b/apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/restoreCatalog.ts
similarity index 92%
rename from apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/restoreCatalog.ts
rename to apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/restoreCatalog.ts
index 2ea22a36b..2495066d1 100644
--- a/apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/restoreCatalog.ts
+++ b/apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/restoreCatalog.ts
@@ -7,7 +7,7 @@ import type {
   EndPointFactory
 } from '@src/interface/HTTP/ports';
 
-import { CatalogRestoreRequestEvent } from '@src/modules/Catalogs';
+import { CatalogRestoreRequestEvent } from '@service-management-api/modules/Catalogs';
 
 const restore: EndPointFactory = (
   {
diff --git a/apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/updateCatalog.ts b/apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/updateCatalog.ts
similarity index 92%
rename from apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/updateCatalog.ts
rename to apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/updateCatalog.ts
index 87f80009a..8a79ae35c 100644
--- a/apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/updateCatalog.ts
+++ b/apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/updateCatalog.ts
@@ -7,7 +7,7 @@ import type {
   EndPointFactory
 } from '@src/interface/HTTP/ports';
 
-import { CatalogUpdateRequestEvent } from '@src/modules/Catalogs';
+import { CatalogUpdateRequestEvent } from '@service-management-api/modules/Catalogs';
 
 const update: EndPointFactory = (
   {
diff --git a/apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/createCatalog.ts b/apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/createCatalog.ts
similarity index 91%
rename from apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/createCatalog.ts
rename to apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/createCatalog.ts
index 0c8209239..e768515fb 100644
--- a/apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/createCatalog.ts
+++ b/apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/createCatalog.ts
@@ -7,7 +7,7 @@ import type {
   EndPointFactory
 } from '@src/interface/HTTP/ports';
 
-import { CatalogCreateRequestEvent } from '@src/modules/Catalogs';
+import { CatalogCreateRequestEvent } from '@service-management-api/modules/Catalogs';
 
 const create: EndPointFactory = (
   {
diff --git a/apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/deleteCatalog.ts b/apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/deleteCatalog.ts
similarity index 92%
rename from apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/deleteCatalog.ts
rename to apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/deleteCatalog.ts
index c05897fec..07495b614 100644
--- a/apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/deleteCatalog.ts
+++ b/apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/deleteCatalog.ts
@@ -7,7 +7,7 @@ import type {
   EndPointFactory
 } from '@src/interface/HTTP/ports';
 
-import { CatalogDeleteRequestEvent } from '@src/modules/Catalogs';
+import { CatalogDeleteRequestEvent } from '@service-management-api/modules/Catalogs';
 
 const deleteOne: EndPointFactory = (
   {
diff --git a/apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/getAllCatalogs.ts b/apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/getAllCatalogs.ts
similarity index 92%
rename from apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/getAllCatalogs.ts
rename to apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/getAllCatalogs.ts
index 16ea474c7..3ed48179a 100644
--- a/apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/getAllCatalogs.ts
+++ b/apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/getAllCatalogs.ts
@@ -7,7 +7,7 @@ import type {
   EndPointFactory
 } from '@src/interface/HTTP/ports';
 
-import { CatalogGetAllRequestEvent } from '@src/modules/Catalogs';
+import { CatalogGetAllRequestEvent } from '@service-management-api/modules/Catalogs';
 
 const getAll: EndPointFactory = (
   {
diff --git a/apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/getCatalogById.ts b/apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/getCatalogById.ts
similarity index 91%
rename from apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/getCatalogById.ts
rename to apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/getCatalogById.ts
index 2dde594d1..fcce0ab5f 100644
--- a/apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/getCatalogById.ts
+++ b/apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/getCatalogById.ts
@@ -7,7 +7,7 @@ import type {
   EndPointFactory
 } from '@src/interface/HTTP/ports';
 
-import { CatalogGetOneRequestEvent } from '@src/modules/Catalogs';
+import { CatalogGetOneRequestEvent } from '@service-management-api/modules/Catalogs';
 
 const getOneById: EndPointFactory = (
   {
diff --git a/apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/restoreCatalog.ts b/apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/restoreCatalog.ts
similarity index 92%
rename from apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/restoreCatalog.ts
rename to apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/restoreCatalog.ts
index 09a02d398..97212914e 100644
--- a/apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/restoreCatalog.ts
+++ b/apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/restoreCatalog.ts
@@ -7,7 +7,7 @@ import type {
   EndPointFactory
 } from '@src/interface/HTTP/ports';
 
-import { CatalogRestoreRequestEvent } from '@src/modules/Catalogs';
+import { CatalogRestoreRequestEvent } from '@service-management-api/modules/Catalogs';
 
 const restore: EndPointFactory = (
   {
diff --git a/apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/updateCatalog.ts b/apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/updateCatalog.ts
similarity index 92%
rename from apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/updateCatalog.ts
rename to apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/updateCatalog.ts
index 28828de30..8360f7876 100644
--- a/apps/backend-template/src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/updateCatalog.ts
+++ b/apps/service-management-api/src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/updateCatalog.ts
@@ -7,7 +7,7 @@ import type {
   EndPointFactory
 } from '@src/interface/HTTP/ports';
 
-import { CatalogUpdateRequestEvent } from '@src/modules/Catalogs';
+import { CatalogUpdateRequestEvent } from '@service-management-api/modules/Catalogs';
 
 const update: EndPointFactory = (
   {
diff --git a/apps/backend-template/src/modules/Catalogs/service/CatalogService.ts b/apps/service-management-api/src/modules/Catalogs/service/CatalogService.ts
similarity index 80%
rename from apps/backend-template/src/modules/Catalogs/service/CatalogService.ts
rename to apps/service-management-api/src/modules/Catalogs/service/CatalogService.ts
index 436dbffb0..815607e37 100644
--- a/apps/backend-template/src/modules/Catalogs/service/CatalogService.ts
+++ b/apps/service-management-api/src/modules/Catalogs/service/CatalogService.ts
@@ -10,18 +10,18 @@ import {
 } from '@src/modules/port';
 import { UUID } from '@src/modules/port/UUID';
 
-import type { ICatalog } from '@src/modules/Catalogs/domain/Entity/ICatalog';
-import type { CatalogDataRepository } from '@src/modules/Catalogs/adapters/out/persistence/CatalogDataRepository';
-import { createCatalog } from '@src/modules/Catalogs/features/createCatalog';
-import { updateCatalog } from '@src/modules/Catalogs/features/updateCatalog';
-import { deleteCatalogById } from '@src/modules/Catalogs/features/deleteCatalogById';
-import { restoreCatalog } from '@src/modules/Catalogs/features/restoreCatalog';
-import { getCatalogById } from '@src/modules/Catalogs/features/getCatalogById';
-import { getAllCatalogs } from '@src/modules/Catalogs/features/getAllCatalogs';
-import { CatalogIntegrationEventName } from '@src/modules/Catalogs/events/contracts/CatalogIntegrationEventName';
-import type { RequestCreateCatalog } from '@src/modules/Catalogs/interface/dto/RequestCreateCatalog';
-import type { RequestUpdateCatalog } from '@src/modules/Catalogs/interface/dto/RequestUpdateCatalog';
-import type { RequestCatalogListOptions } from '@src/modules/Catalogs/interface/dto/RequestCatalogListOptions';
+import type { ICatalog } from '@service-management-api/modules/Catalogs/domain/Entity/ICatalog';
+import type { CatalogDataRepository } from '@service-management-api/modules/Catalogs/adapters/out/persistence/CatalogDataRepository';
+import { createCatalog } from '@service-management-api/modules/Catalogs/features/createCatalog';
+import { updateCatalog } from '@service-management-api/modules/Catalogs/features/updateCatalog';
+import { deleteCatalogById } from '@service-management-api/modules/Catalogs/features/deleteCatalogById';
+import { restoreCatalog } from '@service-management-api/modules/Catalogs/features/restoreCatalog';
+import { getCatalogById } from '@service-management-api/modules/Catalogs/features/getCatalogById';
+import { getAllCatalogs } from '@service-management-api/modules/Catalogs/features/getAllCatalogs';
+import { CatalogIntegrationEventName } from '@service-management-api/modules/Catalogs/events/contracts/CatalogIntegrationEventName';
+import type { RequestCreateCatalog } from '@service-management-api/modules/Catalogs/interface/dto/RequestCreateCatalog';
+import type { RequestUpdateCatalog } from '@service-management-api/modules/Catalogs/interface/dto/RequestUpdateCatalog';
+import type { RequestCatalogListOptions } from '@service-management-api/modules/Catalogs/interface/dto/RequestCatalogListOptions';
 
 import { BaseError } from '@src/infra/exceptions';
 
diff --git a/apps/backend-template/src/modules/Catalogs/service/ports/ICatalogRepository.ts b/apps/service-management-api/src/modules/Catalogs/service/ports/ICatalogRepository.ts
similarity index 58%
rename from apps/backend-template/src/modules/Catalogs/service/ports/ICatalogRepository.ts
rename to apps/service-management-api/src/modules/Catalogs/service/ports/ICatalogRepository.ts
index c3de052eb..40c98e224 100644
--- a/apps/backend-template/src/modules/Catalogs/service/ports/ICatalogRepository.ts
+++ b/apps/service-management-api/src/modules/Catalogs/service/ports/ICatalogRepository.ts
@@ -1,8 +1,8 @@
 import type { IPagingRequest, IPagingResponse } from '@src/modules/port';
-import type { Catalog } from '@src/modules/Catalogs/domain/Model/Catalog';
-import type { RequestCreateCatalog } from '@src/modules/Catalogs/interface/dto/RequestCreateCatalog';
-import type { RequestUpdateCatalog } from '@src/modules/Catalogs/interface/dto/RequestUpdateCatalog';
-import type { RequestCatalogListOptions } from '@src/modules/Catalogs/interface/dto/RequestCatalogListOptions';
+import type { Catalog } from '@service-management-api/modules/Catalogs/domain/Model/Catalog';
+import type { RequestCreateCatalog } from '@service-management-api/modules/Catalogs/interface/dto/RequestCreateCatalog';
+import type { RequestUpdateCatalog } from '@service-management-api/modules/Catalogs/interface/dto/RequestUpdateCatalog';
+import type { RequestCatalogListOptions } from '@service-management-api/modules/Catalogs/interface/dto/RequestCatalogListOptions';
 
 export interface ICatalogRepository {
   create(data: RequestCreateCatalog & { createdBy?: string }): Promise;
diff --git a/apps/service-management-api/src/runtime/catalogCors.ts b/apps/service-management-api/src/runtime/catalogCors.ts
new file mode 100644
index 000000000..e1288a088
--- /dev/null
+++ b/apps/service-management-api/src/runtime/catalogCors.ts
@@ -0,0 +1,55 @@
+const SERVICE_MANAGEMENT_DESIGNER_PORT_BY_ENV: Record = {
+  dev: '3200',
+  development: '3200',
+  local: '3200',
+  test: '3200',
+  ci: '3200',
+  staging: '4200',
+  stage: '4200',
+  prod: '5200',
+  production: '5200'
+};
+
+const splitOrigins = (configured = ''): string[] => configured
+  .split(',')
+  .map((origin) => origin.trim())
+  .filter(Boolean);
+
+const normalizeRuntimeEnv = (value = ''): string => String(value || 'dev').trim().toLowerCase() || 'dev';
+
+const serviceManagementLocalOrigins = (envName = 'dev', port = ''): string[] => {
+  const resolvedPort = String(port || '').trim()
+    || SERVICE_MANAGEMENT_DESIGNER_PORT_BY_ENV[normalizeRuntimeEnv(envName)]
+    || SERVICE_MANAGEMENT_DESIGNER_PORT_BY_ENV.dev;
+  return [
+    `http://localhost:${resolvedPort}`,
+    `http://127.0.0.1:${resolvedPort}`
+  ];
+};
+
+export const normalizeCatalogCorsAllowedOrigins = (
+  configured = '',
+  envName = 'dev',
+  serviceManagementPort = ''
+): string => {
+  const runtimeEnv = normalizeRuntimeEnv(envName);
+  const configuredOrigins = splitOrigins(configured);
+  const origins = new Set(configuredOrigins);
+  const hasExplicitNonDevAllowlist = configuredOrigins.length > 0
+    && !['dev', 'development', 'local', 'test', 'ci'].includes(runtimeEnv);
+  if (!hasExplicitNonDevAllowlist) {
+    serviceManagementLocalOrigins(runtimeEnv, serviceManagementPort)
+      .forEach((origin) => origins.add(origin));
+  }
+  return Array.from(origins).join(',');
+};
+
+export const applyCatalogCorsDefaults = (env: NodeJS.ProcessEnv = process.env): void => {
+  Object.assign(env, {
+    JUMENTIX_CORS_ALLOWED_ORIGINS: normalizeCatalogCorsAllowedOrigins(
+      env.JUMENTIX_CORS_ALLOWED_ORIGINS,
+      env.NODE_ENV,
+      env.JUMENTIX_SERVICE_MANAGEMENT_PORT
+    )
+  });
+};
diff --git a/apps/service-management-api/src/start-service-management-catalog-api.ts b/apps/service-management-api/src/start-service-management-catalog-api.ts
new file mode 100644
index 000000000..133390ef1
--- /dev/null
+++ b/apps/service-management-api/src/start-service-management-catalog-api.ts
@@ -0,0 +1,60 @@
+/* eslint-disable no-console */
+import { Express } from 'express';
+import { ExpressServer } from '@src/interface/HTTP/adapters/express/ExpressServer';
+import { composeUsersAuthServices } from '@src/modules/Users';
+import { MutexService } from '@src/infra/mutex/adapter/MutexService';
+import { compileDatabaseClient } from '@src/infra/persistence/compileDatabaseClient';
+import { JwtService } from '@src/infra/jwt/JwtService';
+import { compileKeyValueStorageClient } from '@src/infra/persistence/KeyValueStorage/compileKeyValueStorageClient';
+import { PasswordCryptoService } from '@src/infra/security/PasswordCryptoService';
+import { compileMessageMediator } from '@src/infra/messages/compileMessageMediator';
+import { compileAdapterRuntime } from '@jumentix/adapter-runtime-bootstrap';
+import { ServiceManagementCatalogAPI } from '@service-management-api/ServiceManagementCatalogAPI';
+import {
+  createServiceManagementCatalogDbClient
+} from '@service-management-api/infra/persistence/InMemoryDatabase/InMemoryCatalogDbClient';
+import { applyCatalogCorsDefaults } from '@service-management-api/runtime/catalogCors';
+
+applyCatalogCorsDefaults();
+
+const webServer = new ExpressServer();
+
+const {
+  databaseClient: baseDatabaseClient,
+  keyValueStorageClient,
+  messageMediator,
+  authService
+} = compileAdapterRuntime({
+  compileDatabaseClient,
+  compileKeyValueStorageClient,
+  compileMutexService: (client) => MutexService.compile(client),
+  compilePasswordCryptoService: () => PasswordCryptoService.compile(),
+  compileJwtService: () => JwtService.compile(),
+  compileMessageMediator: () => compileMessageMediator(),
+  composeAuthServices: composeUsersAuthServices
+});
+
+const catalogAPI = new ServiceManagementCatalogAPI({
+  databaseClient: createServiceManagementCatalogDbClient(baseDatabaseClient),
+  webServer,
+  authService,
+  eventBus: messageMediator,
+  messageMediator
+});
+
+(async () => {
+  await catalogAPI.start();
+  console.log('Service Management catalog API started.');
+})();
+
+const stop = async () => {
+  await catalogAPI.stop();
+  await keyValueStorageClient.disconnect?.();
+};
+
+process.once('SIGTERM', () => {
+  stop().finally(() => process.exit(0));
+});
+process.once('SIGINT', () => {
+  stop().finally(() => process.exit(0));
+});
diff --git a/apps/backend-template/test/integration/ServiceManagement/catalogSync.integration.test.ts b/apps/service-management-api/test/integration/catalogSync.integration.test.ts
similarity index 95%
rename from apps/backend-template/test/integration/ServiceManagement/catalogSync.integration.test.ts
rename to apps/service-management-api/test/integration/catalogSync.integration.test.ts
index e363185bd..cc2e8c922 100644
--- a/apps/backend-template/test/integration/ServiceManagement/catalogSync.integration.test.ts
+++ b/apps/service-management-api/test/integration/catalogSync.integration.test.ts
@@ -25,15 +25,19 @@ import type { IAuthorizationHeader } from '@src/modules/Users/service/ports/IAut
 import { EAuthSchemaType } from '@src/modules/Users/service/ports/EAuthSchemaType';
 // eslint-disable-next-line import/no-unresolved
 import { InMemoryMessageMediatorAdapter } from '@jumentix/message-mediator';
-import { CatalogIntegrationEventName } from '@src/modules/Catalogs/events/contracts/CatalogIntegrationEventName';
+import { CatalogIntegrationEventName } from '@service-management-api/modules/Catalogs/events/contracts/CatalogIntegrationEventName';
+import { ServiceManagementCatalogAPI } from '@service-management-api/ServiceManagementCatalogAPI';
+import {
+  createServiceManagementCatalogDbClient
+} from '@service-management-api/infra/persistence/InMemoryDatabase/InMemoryCatalogDbClient';
 
 /**
  * Multi-user convergence integration suite (JUM-491) — the issue's headline
  * acceptance criterion: TWO designer clients converge after a partition,
  * proven with a REAL disconnection, not mocked latency.
  *
- * Everything structural here is real (Requirement 115): the backend is the
- * REAL RestAPI over the REAL Express adapter listening on an ephemeral
+ * Everything structural here is real (Requirement 115): the catalog backend is the
+ * REAL ServiceManagementCatalogAPI over the REAL Express adapter listening on an ephemeral
  * loopback port, with the REAL AuthService (JWT + TENANT-RBAC scope matrix),
  * the REAL in-memory mediator adapter (integration events observed directly),
  * and the designer side runs the REAL `catalogSyncClient` + `designerState` +
@@ -48,7 +52,7 @@ import { CatalogIntegrationEventName } from '@src/modules/Catalogs/events/contra
 
 jest.setTimeout(60000);
 
-const repoRoot = path.resolve(__dirname, '../../../../..');
+const repoRoot = path.resolve(__dirname, '../../../..');
 const {
   createDesignerState,
   createDefaultView
@@ -67,15 +71,15 @@ const STORE_NAME = 'designerDocuments';
 const [createdUser1] = createdUsers;
 const [orgZero] = organizations;
 
-const webServer = ExpressServer.compile();
-const databaseClient = InMemoryDbClient;
+const webServer = new ExpressServer();
+const databaseClient = createServiceManagementCatalogDbClient(InMemoryDbClient);
 const passwordCryptoService = PasswordCryptoService.compile();
 const jwtService = JwtService.compile();
 const keyValueStorageClient = InMemoryKeyValueStorageClient.compile();
 const mutexService = MutexService.compile(keyValueStorageClient);
 const messageMediator = new InMemoryMessageMediatorAdapter();
 
-const dataRepository = UserDataRepository.compile({ databaseClient: InMemoryDbClient });
+const dataRepository = UserDataRepository.compile({ databaseClient });
 const userService = UserService.compile({
   dataRepository,
   services: { passwordCryptoService, mutexService }
@@ -85,6 +89,7 @@ const authService = AuthService.compile(userProvider, passwordCryptoService, jwt
 
 /* eslint-disable jest/require-hook */
 let API: RestAPI;
+let catalogAPI: ServiceManagementCatalogAPI;
 let listener: any;
 let baseUrl = '';
 let aliceHeader: IAuthorizationHeader;
@@ -248,6 +253,12 @@ describe('jum-491 — two designer clients converge over the real backend', () =
       mutexService,
       messageMediator
     });
+    catalogAPI = new ServiceManagementCatalogAPI({
+      databaseClient,
+      webServer,
+      authService,
+      messageMediator
+    });
     await API.seedData();
 
     const superadminHeader = {
@@ -317,13 +328,14 @@ describe('jum-491 — two designer clients converge over the real backend', () =
     // Test hygiene: every case publishes its own records, so the in-memory
     // Catalog store is reset between cases — records from an earlier case
     // must never leak into another host's read-back.
-    (InMemoryDbClient.stores.Catalog as any).records.clear();
+    (databaseClient.stores.Catalog as any).records.clear();
   });
 
   afterAll(async () => {
     if (listener) {
       await new Promise((resolve) => { listener.close(() => resolve()); });
     }
+    await catalogAPI.stop();
     await databaseClient.disconnect();
     await keyValueStorageClient.disconnect();
   });
diff --git a/apps/backend-template/test/integration/Express/Catalogs/catalogs.test.ts b/apps/service-management-api/test/integration/catalogs.http.integration.test.ts
similarity index 93%
rename from apps/backend-template/test/integration/Express/Catalogs/catalogs.test.ts
rename to apps/service-management-api/test/integration/catalogs.http.integration.test.ts
index bdc7c70d7..d968aa2f6 100644
--- a/apps/backend-template/test/integration/Express/Catalogs/catalogs.test.ts
+++ b/apps/service-management-api/test/integration/catalogs.http.integration.test.ts
@@ -27,12 +27,15 @@ import type { IAuthorizationHeader } from '@src/modules/Users/service/ports/IAut
 import { EAuthSchemaType } from '@src/modules/Users/service/ports/EAuthSchemaType';
 // eslint-disable-next-line import/no-unresolved
 import { InMemoryMessageMediatorAdapter } from '@jumentix/message-mediator';
-import { CatalogIntegrationEventName } from '@src/modules/Catalogs/events/contracts/CatalogIntegrationEventName';
-import { closeServer } from '../closeServer';
+import { CatalogIntegrationEventName } from '@service-management-api/modules/Catalogs/events/contracts/CatalogIntegrationEventName';
+import { ServiceManagementCatalogAPI } from '@service-management-api/ServiceManagementCatalogAPI';
+import {
+  createServiceManagementCatalogDbClient
+} from '@service-management-api/infra/persistence/InMemoryDatabase/InMemoryCatalogDbClient';
 
 /**
  * API integration suite for the shared catalog module (JUM-491), over the
- * REAL Express adapter, the REAL RestAPI/OAS wiring, the REAL AuthService
+ * REAL Express adapter, the REAL ServiceManagementCatalogAPI/OAS wiring, the REAL AuthService
  * (JWT + tenant scope matrix) and the REAL in-memory mediator adapter — no
  * fakes (Requirement 115). It proves the acceptance-critical behaviors
  * end-to-end: server-side authorization aligned to TENANT-RBAC (a client
@@ -44,8 +47,8 @@ import { closeServer } from '../closeServer';
 const [createdUser1] = createdUsers;
 const [orgZero, orgOne] = organizations;
 
-const webServer = ExpressServer.compile();
-const databaseClient = InMemoryDbClient;
+const webServer = new ExpressServer();
+const databaseClient = createServiceManagementCatalogDbClient(InMemoryDbClient);
 const passwordCryptoService = PasswordCryptoService.compile();
 const jwtService = JwtService.compile();
 const keyValueStorageClient = InMemoryKeyValueStorageClient.compile();
@@ -54,7 +57,7 @@ const messageMediator = new InMemoryMessageMediatorAdapter();
 
 // LOCAL IDENTITY PROVIDER
 const dataRepository = UserDataRepository.compile({
-  databaseClient: InMemoryDbClient
+  databaseClient
 });
 const userService = UserService.compile({
   dataRepository,
@@ -75,6 +78,7 @@ const authService = AuthService.compile(
 const serverType = EHTTPFrameworks.express;
 
 let API: RestAPI;
+let catalogAPI: ServiceManagementCatalogAPI;
 let server: any;
 let authorizationHeaderSuperadmin: IAuthorizationHeader;
 let authorizationHeaderAdminOrg0: IAuthorizationHeader;
@@ -120,6 +124,12 @@ describe('express -> Catalogs -> shared catalog sync target', () => {
       mutexService,
       messageMediator
     });
+    catalogAPI = new ServiceManagementCatalogAPI({
+      databaseClient,
+      webServer,
+      authService,
+      messageMediator
+    });
 
     server = API.server.application.listen(0);
 
@@ -185,7 +195,10 @@ describe('express -> Catalogs -> shared catalog sync target', () => {
   });
 
   afterAll(async () => {
-    await closeServer(server);
+    if (server) {
+      await new Promise((resolve) => { server.close(() => resolve()); });
+    }
+    await catalogAPI.stop();
     await databaseClient.disconnect();
     await keyValueStorageClient.disconnect();
   });
@@ -283,7 +296,7 @@ describe('express -> Catalogs -> shared catalog sync target', () => {
       .set('Accept', 'application/json; charset=utf-8')
       .set(authorizationHeaderUserOrg0);
     expect(response.statusCode).toBe(403);
-    expect(response.body.message).toBe('Forbidden - Insufficient permission - user must have the delete_catalog role');
+    expect(response.body.message).toBe('Forbidden - Insufficient permission - missing Service Management scope delete_catalog');
   });
 
   it('an admin soft-deletes: the record leaves the default feed but survives as a tombstone', async () => {
diff --git a/apps/backend-template/test/unit/modules/Catalogs/CatalogModel.test.ts b/apps/service-management-api/test/unit/Catalogs/CatalogModel.test.ts
similarity index 98%
rename from apps/backend-template/test/unit/modules/Catalogs/CatalogModel.test.ts
rename to apps/service-management-api/test/unit/Catalogs/CatalogModel.test.ts
index 17730b7f4..1fe383098 100644
--- a/apps/backend-template/test/unit/modules/Catalogs/CatalogModel.test.ts
+++ b/apps/service-management-api/test/unit/Catalogs/CatalogModel.test.ts
@@ -1,4 +1,4 @@
-import { Catalog } from '@src/modules/Catalogs/domain/Model/Catalog';
+import { Catalog } from '@service-management-api/modules/Catalogs/domain/Model/Catalog';
 
 /**
  * The catalog aggregate constructed and mutated directly (JUM-681/JUM-491).
diff --git a/apps/backend-template/test/unit/modules/Catalogs/adapters/CatalogDataRepositoryDefaults.test.ts b/apps/service-management-api/test/unit/Catalogs/adapters/CatalogDataRepositoryDefaults.test.ts
similarity index 85%
rename from apps/backend-template/test/unit/modules/Catalogs/adapters/CatalogDataRepositoryDefaults.test.ts
rename to apps/service-management-api/test/unit/Catalogs/adapters/CatalogDataRepositoryDefaults.test.ts
index 6fb3833a6..0c38c3d24 100644
--- a/apps/backend-template/test/unit/modules/Catalogs/adapters/CatalogDataRepositoryDefaults.test.ts
+++ b/apps/service-management-api/test/unit/Catalogs/adapters/CatalogDataRepositoryDefaults.test.ts
@@ -1,6 +1,6 @@
-import { CatalogDataRepository } from '@src/modules/Catalogs/adapters/out/persistence/CatalogDataRepository';
+import { CatalogDataRepository } from '@service-management-api/modules/Catalogs/adapters/out/persistence/CatalogDataRepository';
 import { InMemoryRelationalStore } from '@src/infra/persistence/InMemoryDatabase/Stores/InMemoryRelationalStore';
-import type { ICatalog } from '@src/modules/Catalogs/domain/Entity/ICatalog';
+import type { ICatalog } from '@service-management-api/modules/Catalogs/domain/Entity/ICatalog';
 import type { IDatabaseClient } from '@src/infra/persistence/port/IDatabaseClient';
 
 /**
@@ -184,3 +184,31 @@ describe('catalog repository fallbacks (JUM-681)', () => {
       .rejects.toThrow('Record not found');
   });
 });
+
+describe('catalog repository paging fallbacks with a minimal driver', () => {
+  it('reports the paging it applied when the driver omits page, size and result', async () => {
+    expect.hasAssertions();
+
+    // IPagingResponse marks page/size/result optional: a driver that answers
+    // only `total` still satisfies the contract, and the repository must fall
+    // back to the paging it sent rather than echo undefined to the client.
+    const store = {
+      getAll: async () => ({ total: 0 })
+    };
+    const databaseClient = {
+      stores: { Catalog: store },
+      connect: () => Promise.resolve(),
+      disconnect: () => Promise.resolve()
+    } as unknown as IDatabaseClient;
+    const repository = CatalogDataRepository.compile({ databaseClient, limit: 7 });
+
+    const listed = await repository.getAll({}, {} as never);
+
+    expect(listed).toStrictEqual({
+      page: 1,
+      size: 7,
+      total: 0,
+      result: []
+    });
+  });
+});
diff --git a/apps/backend-template/test/unit/modules/Catalogs/adapters/in/http/CatalogController.test.ts b/apps/service-management-api/test/unit/Catalogs/adapters/in/http/CatalogController.test.ts
similarity index 75%
rename from apps/backend-template/test/unit/modules/Catalogs/adapters/in/http/CatalogController.test.ts
rename to apps/service-management-api/test/unit/Catalogs/adapters/in/http/CatalogController.test.ts
index 4387b8b14..041a089ad 100644
--- a/apps/backend-template/test/unit/modules/Catalogs/adapters/in/http/CatalogController.test.ts
+++ b/apps/service-management-api/test/unit/Catalogs/adapters/in/http/CatalogController.test.ts
@@ -2,18 +2,18 @@
 import fs from 'fs';
 import YAML from 'yaml';
 import { OpenAPIV3 } from 'openapi-types';
-import { CatalogController } from '@src/modules/Catalogs/adapters/in/http/controllers/CatalogController';
-import { CatalogDataRepository } from '@src/modules/Catalogs/adapters/out/persistence/CatalogDataRepository';
-import { CatalogService } from '@src/modules/Catalogs/service/CatalogService';
-import { CatalogUseCases } from '@src/modules/Catalogs/application/use-cases/CatalogUseCases';
+import { CatalogController } from '@service-management-api/modules/Catalogs/adapters/in/http/controllers/CatalogController';
+import { CatalogDataRepository } from '@service-management-api/modules/Catalogs/adapters/out/persistence/CatalogDataRepository';
+import { CatalogService } from '@service-management-api/modules/Catalogs/service/CatalogService';
+import { CatalogUseCases } from '@service-management-api/modules/Catalogs/application/use-cases/CatalogUseCases';
 import { InMemoryRelationalStore } from '@src/infra/persistence/InMemoryDatabase/Stores/InMemoryRelationalStore';
-import { CatalogCreateRequestEvent } from '@src/modules/Catalogs/events/CatalogCreateRequestEvent';
-import { CatalogUpdateRequestEvent } from '@src/modules/Catalogs/events/CatalogUpdateRequestEvent';
-import { CatalogDeleteRequestEvent } from '@src/modules/Catalogs/events/CatalogDeleteRequestEvent';
-import { CatalogRestoreRequestEvent } from '@src/modules/Catalogs/events/CatalogRestoreRequestEvent';
-import { CatalogGetAllRequestEvent } from '@src/modules/Catalogs/events/CatalogGetAllRequestEvent';
-import { CatalogGetOneRequestEvent } from '@src/modules/Catalogs/events/CatalogGetOneRequestEvent';
-import type { ICatalog } from '@src/modules/Catalogs/domain/Entity/ICatalog';
+import { CatalogCreateRequestEvent } from '@service-management-api/modules/Catalogs/events/CatalogCreateRequestEvent';
+import { CatalogUpdateRequestEvent } from '@service-management-api/modules/Catalogs/events/CatalogUpdateRequestEvent';
+import { CatalogDeleteRequestEvent } from '@service-management-api/modules/Catalogs/events/CatalogDeleteRequestEvent';
+import { CatalogRestoreRequestEvent } from '@service-management-api/modules/Catalogs/events/CatalogRestoreRequestEvent';
+import { CatalogGetAllRequestEvent } from '@service-management-api/modules/Catalogs/events/CatalogGetAllRequestEvent';
+import { CatalogGetOneRequestEvent } from '@service-management-api/modules/Catalogs/events/CatalogGetOneRequestEvent';
+import type { ICatalog } from '@service-management-api/modules/Catalogs/domain/Entity/ICatalog';
 import type { IDatabaseClient } from '@src/infra/persistence/port/IDatabaseClient';
 import { TENANT_AUTHORIZATION_REASONS } from '@src/modules/Users/domain/security/TenantAuthorizationPolicy';
 
@@ -21,8 +21,8 @@ import { TENANT_AUTHORIZATION_REASONS } from '@src/modules/Users/domain/security
  * Unit suite for the catalog inbound HTTP adapter (JUM-491): the REAL
  * controller runs against the REAL use-case/service/repository stack over the
  * REAL in-memory store, and the OAS operation nodes are the REAL ones parsed
- * from `spec/1.0.0.yml` — the tenant decisions asserted here execute the same
- * contract the deployed route enforces.
+ * from the Service Management API spec — the tenant decisions asserted here
+ * execute the same contract the deployed route enforces.
  *
  * The only double is `IAuthService`, a declared in-memory double of a
  * Jumentix port (Requirement 115): it maps the `authorization` string to a
@@ -32,7 +32,9 @@ import { TENANT_AUTHORIZATION_REASONS } from '@src/modules/Users/domain/security
  * here are the controller's own.
  */
 
-const spec = YAML.parse(fs.readFileSync('spec/1.0.0.yml', 'utf8')) as OpenAPIV3.Document;
+const spec = YAML.parse(
+  fs.readFileSync('apps/service-management-api/spec/1.0.0.yml', 'utf8')
+) as OpenAPIV3.Document;
 const operations = spec.paths as Record>;
 
 const createStack = (principal: Record) => {
@@ -346,3 +348,77 @@ describe('catalogController actor stamping (JUM-721)', () => {
     }))).rejects.toThrow('Record not found');
   });
 });
+
+describe('catalogController scope denial shapes (JUM-821)', () => {
+  it('denies every scope when the token resolves to no principal at all', async () => {
+    expect.hasAssertions();
+
+    // A guard that reads `undefined.roles` would crash; reading the absent
+    // principal as "no roles" is what turns an unattributed request into a
+    // clean 403 rather than a 500.
+    const { controller } = createStack(undefined as never);
+
+    await expect(controller.create(new CatalogCreateRequestEvent({
+      authorization: 'Bearer token',
+      input: { name: 'Billing', design },
+      schemaOAS: operations['/catalogs'].post
+    }))).rejects.toThrow('missing Service Management scope create_catalog');
+  });
+
+  it('denies a principal that carries no roles', async () => {
+    expect.hasAssertions();
+
+    const { controller } = createStack({
+      id: 'user-9', username: 'user@org1.dev', organization: 'org-1'
+    });
+
+    await expect(controller.getAll(new CatalogGetAllRequestEvent({
+      authorization: 'Bearer token',
+      queryString: { page: '1' },
+      schemaOAS: operations['/catalogs'].get
+    }))).rejects.toThrow('missing Service Management scope read_catalog');
+  });
+
+  it('grants nothing for a role the catalog scope matrix does not know', async () => {
+    expect.hasAssertions();
+
+    // A role outside the matrix maps to no scopes at all — a new role must
+    // fail closed, never inherit access by omission.
+    const { controller } = createStack({
+      id: 'audit-1', username: 'auditor@org1.dev', organization: 'org-1', roles: ['auditor']
+    });
+
+    await expect(controller.getAll(new CatalogGetAllRequestEvent({
+      authorization: 'Bearer token',
+      queryString: { page: '1' },
+      schemaOAS: operations['/catalogs'].get
+    }))).rejects.toThrow('missing Service Management scope read_catalog');
+  });
+
+  it('refuses the read when the store answers with neither record nor error', async () => {
+    expect.hasAssertions();
+
+    // `ICatalogUseCases` here is a declared in-memory double of the Jumentix
+    // port (Requirement 135 §5): the real stack always answers a missing
+    // record WITH an error, so the controller's own defensive refusal — a
+    // response that carries neither — is only reachable through the port.
+    // Answering "allowed" against nothing is the failure it prevents.
+    const authService = {
+      authenticate: () => Promise.resolve({}),
+      authorize: () => Promise.resolve(adminOrg1),
+      throwIfUserHasNoAccessToResource: () => true
+    } as any;
+    const controller = new CatalogController({
+      authService,
+      openApiSpecification: spec,
+      databaseClient: { stores: {} } as any,
+      catalogUseCases: { getOneById: () => Promise.resolve({}) } as any
+    } as any);
+
+    await expect(controller.getOneById(new CatalogGetOneRequestEvent({
+      authorization: 'Bearer token',
+      params: { id: '123e4567-e89b-42d3-a456-426614174000' },
+      schemaOAS: operations['/catalogs/{id}'].get
+    }))).rejects.toThrow('target catalog not available');
+  });
+});
diff --git a/apps/backend-template/test/unit/modules/Catalogs/adapters/in/http/restapiHandlers.test.ts b/apps/service-management-api/test/unit/Catalogs/adapters/in/http/restapiHandlers.test.ts
similarity index 87%
rename from apps/backend-template/test/unit/modules/Catalogs/adapters/in/http/restapiHandlers.test.ts
rename to apps/service-management-api/test/unit/Catalogs/adapters/in/http/restapiHandlers.test.ts
index 044bdaa81..b1d4674bb 100644
--- a/apps/backend-template/test/unit/modules/Catalogs/adapters/in/http/restapiHandlers.test.ts
+++ b/apps/service-management-api/test/unit/Catalogs/adapters/in/http/restapiHandlers.test.ts
@@ -1,22 +1,22 @@
 /* eslint-disable jest/max-expects, jest/prefer-expect-assertions */
-import expressGetAll from '@src/modules/Catalogs/interface/restapi/frameworks/express/handlers/getAllCatalogs';
-import expressCreate from '@src/modules/Catalogs/interface/restapi/frameworks/express/handlers/createCatalog';
-import expressGetOne from '@src/modules/Catalogs/interface/restapi/frameworks/express/handlers/getCatalogById';
-import expressUpdate from '@src/modules/Catalogs/interface/restapi/frameworks/express/handlers/updateCatalog';
-import expressDelete from '@src/modules/Catalogs/interface/restapi/frameworks/express/handlers/deleteCatalog';
-import expressRestore from '@src/modules/Catalogs/interface/restapi/frameworks/express/handlers/restoreCatalog';
-import fastifyGetAll from '@src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/getAllCatalogs';
-import fastifyCreate from '@src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/createCatalog';
-import fastifyGetOne from '@src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/getCatalogById';
-import fastifyUpdate from '@src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/updateCatalog';
-import fastifyDelete from '@src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/deleteCatalog';
-import fastifyRestore from '@src/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/restoreCatalog';
-import restifyGetAll from '@src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/getAllCatalogs';
-import restifyCreate from '@src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/createCatalog';
-import restifyGetOne from '@src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/getCatalogById';
-import restifyUpdate from '@src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/updateCatalog';
-import restifyDelete from '@src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/deleteCatalog';
-import restifyRestore from '@src/modules/Catalogs/interface/restapi/frameworks/restify/handlers/restoreCatalog';
+import expressGetAll from '@service-management-api/modules/Catalogs/interface/restapi/frameworks/express/handlers/getAllCatalogs';
+import expressCreate from '@service-management-api/modules/Catalogs/interface/restapi/frameworks/express/handlers/createCatalog';
+import expressGetOne from '@service-management-api/modules/Catalogs/interface/restapi/frameworks/express/handlers/getCatalogById';
+import expressUpdate from '@service-management-api/modules/Catalogs/interface/restapi/frameworks/express/handlers/updateCatalog';
+import expressDelete from '@service-management-api/modules/Catalogs/interface/restapi/frameworks/express/handlers/deleteCatalog';
+import expressRestore from '@service-management-api/modules/Catalogs/interface/restapi/frameworks/express/handlers/restoreCatalog';
+import fastifyGetAll from '@service-management-api/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/getAllCatalogs';
+import fastifyCreate from '@service-management-api/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/createCatalog';
+import fastifyGetOne from '@service-management-api/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/getCatalogById';
+import fastifyUpdate from '@service-management-api/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/updateCatalog';
+import fastifyDelete from '@service-management-api/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/deleteCatalog';
+import fastifyRestore from '@service-management-api/modules/Catalogs/interface/restapi/frameworks/fastify/handlers/restoreCatalog';
+import restifyGetAll from '@service-management-api/modules/Catalogs/interface/restapi/frameworks/restify/handlers/getAllCatalogs';
+import restifyCreate from '@service-management-api/modules/Catalogs/interface/restapi/frameworks/restify/handlers/createCatalog';
+import restifyGetOne from '@service-management-api/modules/Catalogs/interface/restapi/frameworks/restify/handlers/getCatalogById';
+import restifyUpdate from '@service-management-api/modules/Catalogs/interface/restapi/frameworks/restify/handlers/updateCatalog';
+import restifyDelete from '@service-management-api/modules/Catalogs/interface/restapi/frameworks/restify/handlers/deleteCatalog';
+import restifyRestore from '@service-management-api/modules/Catalogs/interface/restapi/frameworks/restify/handlers/restoreCatalog';
 import { ConflictError } from '@src/infra/exceptions';
 
 /**
diff --git a/apps/backend-template/test/unit/modules/Catalogs/application/catalogBoundaries.test.ts b/apps/service-management-api/test/unit/Catalogs/application/catalogBoundaries.test.ts
similarity index 76%
rename from apps/backend-template/test/unit/modules/Catalogs/application/catalogBoundaries.test.ts
rename to apps/service-management-api/test/unit/Catalogs/application/catalogBoundaries.test.ts
index 75ec1643b..576e5e53a 100644
--- a/apps/backend-template/test/unit/modules/Catalogs/application/catalogBoundaries.test.ts
+++ b/apps/service-management-api/test/unit/Catalogs/application/catalogBoundaries.test.ts
@@ -1,15 +1,15 @@
 /* eslint-disable jest/max-expects */
-import { CatalogCreateRequestEvent } from '@src/modules/Catalogs/events/CatalogCreateRequestEvent';
-import { CatalogUpdateRequestEvent } from '@src/modules/Catalogs/events/CatalogUpdateRequestEvent';
-import { CatalogDeleteRequestEvent } from '@src/modules/Catalogs/events/CatalogDeleteRequestEvent';
-import { CatalogRestoreRequestEvent } from '@src/modules/Catalogs/events/CatalogRestoreRequestEvent';
-import { CatalogGetAllRequestEvent } from '@src/modules/Catalogs/events/CatalogGetAllRequestEvent';
-import { CatalogGetOneRequestEvent } from '@src/modules/Catalogs/events/CatalogGetOneRequestEvent';
-import { CatalogService } from '@src/modules/Catalogs/service/CatalogService';
-import { CatalogDataRepository } from '@src/modules/Catalogs/adapters/out/persistence/CatalogDataRepository';
-import { CatalogUseCases } from '@src/modules/Catalogs/application/use-cases/CatalogUseCases';
+import { CatalogCreateRequestEvent } from '@service-management-api/modules/Catalogs/events/CatalogCreateRequestEvent';
+import { CatalogUpdateRequestEvent } from '@service-management-api/modules/Catalogs/events/CatalogUpdateRequestEvent';
+import { CatalogDeleteRequestEvent } from '@service-management-api/modules/Catalogs/events/CatalogDeleteRequestEvent';
+import { CatalogRestoreRequestEvent } from '@service-management-api/modules/Catalogs/events/CatalogRestoreRequestEvent';
+import { CatalogGetAllRequestEvent } from '@service-management-api/modules/Catalogs/events/CatalogGetAllRequestEvent';
+import { CatalogGetOneRequestEvent } from '@service-management-api/modules/Catalogs/events/CatalogGetOneRequestEvent';
+import { CatalogService } from '@service-management-api/modules/Catalogs/service/CatalogService';
+import { CatalogDataRepository } from '@service-management-api/modules/Catalogs/adapters/out/persistence/CatalogDataRepository';
+import { CatalogUseCases } from '@service-management-api/modules/Catalogs/application/use-cases/CatalogUseCases';
 import { InMemoryRelationalStore } from '@src/infra/persistence/InMemoryDatabase/Stores/InMemoryRelationalStore';
-import type { ICatalog } from '@src/modules/Catalogs/domain/Entity/ICatalog';
+import type { ICatalog } from '@service-management-api/modules/Catalogs/domain/Entity/ICatalog';
 import type { IDatabaseClient } from '@src/infra/persistence/port/IDatabaseClient';
 
 /**
diff --git a/apps/backend-template/test/unit/modules/Catalogs/composition/composeCatalogsServices.test.ts b/apps/service-management-api/test/unit/Catalogs/composition/composeCatalogsServices.test.ts
similarity index 80%
rename from apps/backend-template/test/unit/modules/Catalogs/composition/composeCatalogsServices.test.ts
rename to apps/service-management-api/test/unit/Catalogs/composition/composeCatalogsServices.test.ts
index 064e625d6..21d4e65ae 100644
--- a/apps/backend-template/test/unit/modules/Catalogs/composition/composeCatalogsServices.test.ts
+++ b/apps/service-management-api/test/unit/Catalogs/composition/composeCatalogsServices.test.ts
@@ -1,8 +1,8 @@
 /* eslint-disable jest/max-expects */
-import { composeCatalogsServices } from '@src/modules/Catalogs/composition/composeCatalogsServices';
-import { CatalogDataRepository } from '@src/modules/Catalogs/adapters/out/persistence/CatalogDataRepository';
-import { CatalogService } from '@src/modules/Catalogs/service/CatalogService';
-import { CatalogUseCases } from '@src/modules/Catalogs/application/use-cases/CatalogUseCases';
+import { composeCatalogsServices } from '@service-management-api/modules/Catalogs/composition/composeCatalogsServices';
+import { CatalogDataRepository } from '@service-management-api/modules/Catalogs/adapters/out/persistence/CatalogDataRepository';
+import { CatalogService } from '@service-management-api/modules/Catalogs/service/CatalogService';
+import { CatalogUseCases } from '@service-management-api/modules/Catalogs/application/use-cases/CatalogUseCases';
 
 describe('compose catalogs services', () => {
   beforeEach(() => {
diff --git a/apps/backend-template/test/unit/modules/Catalogs/domain/Model/Catalog.test.ts b/apps/service-management-api/test/unit/Catalogs/domain/Model/Catalog.test.ts
similarity index 98%
rename from apps/backend-template/test/unit/modules/Catalogs/domain/Model/Catalog.test.ts
rename to apps/service-management-api/test/unit/Catalogs/domain/Model/Catalog.test.ts
index f72b4f317..cdca70345 100644
--- a/apps/backend-template/test/unit/modules/Catalogs/domain/Model/Catalog.test.ts
+++ b/apps/service-management-api/test/unit/Catalogs/domain/Model/Catalog.test.ts
@@ -1,5 +1,5 @@
 /* eslint-disable jest/max-expects */
-import { Catalog } from '@src/modules/Catalogs/domain/Model/Catalog';
+import { Catalog } from '@service-management-api/modules/Catalogs/domain/Model/Catalog';
 
 /**
  * Unit suite for the shared-catalog aggregate (JUM-491). The model owns the
diff --git a/apps/backend-template/test/unit/modules/Catalogs/domain/security/CatalogAuthorizationPolicy.test.ts b/apps/service-management-api/test/unit/Catalogs/domain/security/CatalogAuthorizationPolicy.test.ts
similarity index 97%
rename from apps/backend-template/test/unit/modules/Catalogs/domain/security/CatalogAuthorizationPolicy.test.ts
rename to apps/service-management-api/test/unit/Catalogs/domain/security/CatalogAuthorizationPolicy.test.ts
index 96d9b204a..9fa22bfaa 100644
--- a/apps/backend-template/test/unit/modules/Catalogs/domain/security/CatalogAuthorizationPolicy.test.ts
+++ b/apps/service-management-api/test/unit/Catalogs/domain/security/CatalogAuthorizationPolicy.test.ts
@@ -3,7 +3,7 @@ import {
   decideCatalogAccess,
   resolveCatalogCollectionScope,
   resolveCatalogCreationOrganization
-} from '@src/modules/Catalogs/domain/security/CatalogAuthorizationPolicy';
+} from '@service-management-api/modules/Catalogs/domain/security/CatalogAuthorizationPolicy';
 import { TENANT_AUTHORIZATION_REASONS } from '@src/modules/Users/domain/security/TenantAuthorizationPolicy';
 
 /**
diff --git a/apps/backend-template/test/unit/modules/Catalogs/index.exports.test.ts b/apps/service-management-api/test/unit/Catalogs/index.exports.test.ts
similarity index 94%
rename from apps/backend-template/test/unit/modules/Catalogs/index.exports.test.ts
rename to apps/service-management-api/test/unit/Catalogs/index.exports.test.ts
index 7ceaa2cac..c77a9badb 100644
--- a/apps/backend-template/test/unit/modules/Catalogs/index.exports.test.ts
+++ b/apps/service-management-api/test/unit/Catalogs/index.exports.test.ts
@@ -1,5 +1,5 @@
 /* eslint-disable jest/max-expects */
-import * as CatalogsModule from '@src/modules/Catalogs';
+import * as CatalogsModule from '@service-management-api/modules/Catalogs';
 
 describe('catalogs module exports', () => {
   it('exposes all runtime exports through the barrel file', () => {
diff --git a/apps/backend-template/test/unit/modules/Catalogs/service/CatalogService.test.ts b/apps/service-management-api/test/unit/Catalogs/service/CatalogService.test.ts
similarity index 92%
rename from apps/backend-template/test/unit/modules/Catalogs/service/CatalogService.test.ts
rename to apps/service-management-api/test/unit/Catalogs/service/CatalogService.test.ts
index 02dd4e1a1..888d84141 100644
--- a/apps/backend-template/test/unit/modules/Catalogs/service/CatalogService.test.ts
+++ b/apps/service-management-api/test/unit/Catalogs/service/CatalogService.test.ts
@@ -1,14 +1,14 @@
 /* eslint-disable jest/max-expects, jest/prefer-expect-assertions */
-import { CatalogService } from '@src/modules/Catalogs/service/CatalogService';
-import { CatalogDataRepository } from '@src/modules/Catalogs/adapters/out/persistence/CatalogDataRepository';
-import { CatalogUseCases } from '@src/modules/Catalogs/application/use-cases/CatalogUseCases';
-import { deleteCatalogById } from '@src/modules/Catalogs/features/deleteCatalogById';
-import { getAllCatalogs } from '@src/modules/Catalogs/features/getAllCatalogs';
-import { restoreCatalog } from '@src/modules/Catalogs/features/restoreCatalog';
-import { updateCatalog } from '@src/modules/Catalogs/features/updateCatalog';
-import { CatalogIntegrationEventName } from '@src/modules/Catalogs/events/contracts/CatalogIntegrationEventName';
+import { CatalogService } from '@service-management-api/modules/Catalogs/service/CatalogService';
+import { CatalogDataRepository } from '@service-management-api/modules/Catalogs/adapters/out/persistence/CatalogDataRepository';
+import { CatalogUseCases } from '@service-management-api/modules/Catalogs/application/use-cases/CatalogUseCases';
+import { deleteCatalogById } from '@service-management-api/modules/Catalogs/features/deleteCatalogById';
+import { getAllCatalogs } from '@service-management-api/modules/Catalogs/features/getAllCatalogs';
+import { restoreCatalog } from '@service-management-api/modules/Catalogs/features/restoreCatalog';
+import { updateCatalog } from '@service-management-api/modules/Catalogs/features/updateCatalog';
+import { CatalogIntegrationEventName } from '@service-management-api/modules/Catalogs/events/contracts/CatalogIntegrationEventName';
 import { InMemoryRelationalStore } from '@src/infra/persistence/InMemoryDatabase/Stores/InMemoryRelationalStore';
-import type { ICatalog } from '@src/modules/Catalogs/domain/Entity/ICatalog';
+import type { ICatalog } from '@service-management-api/modules/Catalogs/domain/Entity/ICatalog';
 import type { IDatabaseClient } from '@src/infra/persistence/port/IDatabaseClient';
 // eslint-disable-next-line import/no-unresolved
 import { InMemoryMessageMediatorAdapter } from '@jumentix/message-mediator';
diff --git a/apps/service-management-api/test/unit/ServiceManagementCatalogAPI.test.ts b/apps/service-management-api/test/unit/ServiceManagementCatalogAPI.test.ts
new file mode 100644
index 000000000..df1538a65
--- /dev/null
+++ b/apps/service-management-api/test/unit/ServiceManagementCatalogAPI.test.ts
@@ -0,0 +1,210 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { ServiceManagementCatalogAPI } from '@service-management-api/ServiceManagementCatalogAPI';
+import { createServiceManagementCatalogDbClient } from '@service-management-api/infra/persistence/InMemoryDatabase/InMemoryCatalogDbClient';
+
+describe('serviceManagementCatalogAPI ownership host', () => {
+  const authService = {
+    authenticate: jest.fn(),
+    authorize: jest.fn(),
+    throwIfUserHasNoAccessToResource: jest.fn()
+  } as any;
+
+  it('registers the catalog routes from the platform-owned spec', () => {
+    expect.hasAssertions();
+    const registered: Array<{ method: string; path: string }> = [];
+    const webServer = {
+      endPointRegister: (handler: { method: string; path: string }) => {
+        registered.push({ method: handler.method, path: handler.path });
+      },
+      start: jest.fn(),
+      stop: jest.fn()
+    } as any;
+
+    const catalogAPI = new ServiceManagementCatalogAPI({
+      databaseClient: createServiceManagementCatalogDbClient(),
+      webServer,
+      authService
+    });
+
+    expect(catalogAPI).toBeInstanceOf(ServiceManagementCatalogAPI);
+    expect(registered).toStrictEqual([
+      { method: 'get', path: '/api/1.0.0/catalogs' },
+      { method: 'post', path: '/api/1.0.0/catalogs' },
+      { method: 'get', path: '/api/1.0.0/catalogs/:id' },
+      { method: 'put', path: '/api/1.0.0/catalogs/:id' },
+      { method: 'delete', path: '/api/1.0.0/catalogs/:id' },
+      { method: 'post', path: '/api/1.0.0/catalogs/:id/restore' }
+    ]);
+  });
+
+  it('adds a Catalog store explicitly instead of relying on backend-template', () => {
+    expect.hasAssertions();
+    const databaseClient = createServiceManagementCatalogDbClient({
+      stores: {
+        User: {} as any,
+        Organization: {} as any
+      },
+      connect: jest.fn(),
+      disconnect: jest.fn()
+    } as any);
+
+    expect(databaseClient.stores.Catalog).toBeDefined();
+    expect(databaseClient.stores.User).toBeDefined();
+    expect(databaseClient.stores.Organization).toBeDefined();
+  });
+});
+
+describe('serviceManagementCatalogAPI lifecycle and spec edge shapes (JUM-821)', () => {
+  const authService = {
+    authenticate: jest.fn(),
+    authorize: jest.fn(),
+    throwIfUserHasNoAccessToResource: jest.fn()
+  } as any;
+
+  const makeWebServer = (events: string[]) => ({
+    endPointRegister: () => undefined,
+    start: async () => { events.push('server:start'); },
+    stop: async () => { events.push('server:stop'); }
+  } as any);
+
+  it('starts once, in dependency order, and stops in reverse', async () => {
+    expect.hasAssertions();
+
+    const events: string[] = [];
+    const databaseClient = createServiceManagementCatalogDbClient({
+      stores: {},
+      connect: async () => { events.push('db:connect'); },
+      disconnect: async () => { events.push('db:disconnect'); }
+    } as any);
+    const catalogAPI = new ServiceManagementCatalogAPI({
+      databaseClient,
+      webServer: makeWebServer(events),
+      authService
+    });
+
+    await catalogAPI.start();
+    // A second start is a no-op, not a reconnect: two `start` calls must not
+    // open two connection lifecycles over the same server.
+    await catalogAPI.start();
+
+    expect(events).toStrictEqual(['db:connect', 'server:start']);
+
+    await catalogAPI.stop();
+    expect(events).toStrictEqual(['db:connect', 'server:start', 'server:stop', 'db:disconnect']);
+
+    // After a stop the API can start again — a restart is a real lifecycle,
+    // not a stuck flag.
+    await catalogAPI.start();
+    expect(events).toStrictEqual([
+      'db:connect', 'server:start', 'server:stop', 'db:disconnect', 'db:connect', 'server:start'
+    ]);
+    await catalogAPI.stop();
+  });
+
+  it('connects and disconnects through the default in-memory client wiring', async () => {
+    expect.hasAssertions();
+
+    const events: string[] = [];
+    const databaseClient = createServiceManagementCatalogDbClient({
+      stores: { User: {} },
+      connect: async () => { events.push('base:connect'); },
+      disconnect: async () => { events.push('base:disconnect'); }
+    } as any);
+
+    await databaseClient.connect();
+    await databaseClient.disconnect();
+
+    expect(events).toStrictEqual(['base:connect', 'base:disconnect']);
+    expect(databaseClient.stores.Catalog).toBeDefined();
+    expect(databaseClient.stores.User).toBeDefined();
+  });
+
+  it('tolerates a base client without connect/disconnect hooks', async () => {
+    expect.hasAssertions();
+
+    // The wrapper's optional chaining exists for base clients that have no
+    // lifecycle at all — a plain store bag must still compose.
+    const databaseClient = createServiceManagementCatalogDbClient({ stores: {} } as any);
+
+    await expect(databaseClient.connect()).resolves.toBeUndefined();
+    await expect(databaseClient.disconnect()).resolves.toBeUndefined();
+    expect(databaseClient.stores.Catalog).toBeDefined();
+  });
+
+  it('reads spec fixtures: .yaml files, specs without paths, and null path entries', () => {
+    expect.hasAssertions();
+
+    // The spec directory is a contract surface, not a guaranteed-clean one:
+    // a `.yaml` spelling, a spec with no `paths` block, a path entry that is
+    // null, and a non-spec file all have to load without taking the boot down.
+    const fs = require('node:fs');
+    const os = require('node:os');
+    const path = require('node:path');
+    const specDir = fs.mkdtempSync(path.join(os.tmpdir(), 'catalog-spec-'));
+    fs.writeFileSync(path.join(specDir, 'catalog.yaml'), [
+      'openapi: 3.0.0',
+      'info:',
+      '  title: Catalog',
+      '  version: 9.9.9',
+      ''
+    ].join('\n'));
+    fs.writeFileSync(path.join(specDir, 'empty-path.yml'), [
+      'openapi: 3.0.0',
+      'info:',
+      '  title: Empty',
+      '  version: 9.9.8',
+      'paths:',
+      '  /nothing: null',
+      ''
+    ].join('\n'));
+    fs.writeFileSync(path.join(specDir, 'notes.txt'), 'not a spec');
+    fs.writeFileSync(path.join(specDir, 'not-openapi.yml'), 'hello: world\n');
+
+    const registered: string[] = [];
+    const events: string[] = [];
+    const catalogAPI = new ServiceManagementCatalogAPI({
+      databaseClient: createServiceManagementCatalogDbClient(),
+      webServer: {
+        endPointRegister: (handler: { path: string }) => { registered.push(handler.path); },
+        start: async () => { events.push('server:start'); },
+        stop: async () => { events.push('server:stop'); }
+      } as any,
+      authService,
+      specDir
+    });
+
+    expect(catalogAPI).toBeInstanceOf(ServiceManagementCatalogAPI);
+    // Neither fixture spec declares an operation, so nothing registers — and
+    // nothing threw.
+    expect(registered).toStrictEqual([]);
+  });
+
+  it('fails the boot when the spec names an operation with no handler', () => {
+    expect.hasAssertions();
+
+    // A spec/route drift that booted silently would serve 404s where the OAS
+    // promises an endpoint; the factory throws instead of registering a gap.
+    const fs = require('node:fs');
+    const os = require('node:os');
+    const path = require('node:path');
+    const specDir = fs.mkdtempSync(path.join(os.tmpdir(), 'catalog-spec-'));
+    fs.writeFileSync(path.join(specDir, 'rogue.yml'), [
+      'openapi: 3.0.0',
+      'info:',
+      '  title: Rogue',
+      '  version: 9.9.7',
+      'paths:',
+      '  /rogue:',
+      '    get:',
+      '      operationId: rogueOperation',
+      ''
+    ].join('\n'));
+
+    expect(() => new ServiceManagementCatalogAPI({
+      databaseClient: createServiceManagementCatalogDbClient(),
+      webServer: makeWebServer([]),
+      authService,
+      specDir
+    })).toThrow('catalog handler not found for rogueOperation');
+  });
+});
diff --git a/apps/service-management-api/test/unit/index.exports.test.ts b/apps/service-management-api/test/unit/index.exports.test.ts
new file mode 100644
index 000000000..b5fbc51fb
--- /dev/null
+++ b/apps/service-management-api/test/unit/index.exports.test.ts
@@ -0,0 +1,11 @@
+import * as ServiceManagementApiModule from '@service-management-api/index';
+
+describe('service-management-api package exports', () => {
+  it('exposes the catalog API host, the db client factory and the Catalogs module', () => {
+    expect.hasAssertions();
+    expect(typeof ServiceManagementApiModule.ServiceManagementCatalogAPI).toBe('function');
+    expect(typeof ServiceManagementApiModule.createServiceManagementCatalogDbClient).toBe('function');
+    expect(ServiceManagementApiModule.InMemoryCatalogDbClient).toBeDefined();
+    expect(typeof ServiceManagementApiModule.composeCatalogsServices).toBe('function');
+  });
+});
diff --git a/apps/service-management-api/test/unit/runtime/catalogCors.test.ts b/apps/service-management-api/test/unit/runtime/catalogCors.test.ts
new file mode 100644
index 000000000..4b55ddc4c
--- /dev/null
+++ b/apps/service-management-api/test/unit/runtime/catalogCors.test.ts
@@ -0,0 +1,139 @@
+import {
+  applyCatalogCorsDefaults,
+  normalizeCatalogCorsAllowedOrigins
+} from '@service-management-api/runtime/catalogCors';
+
+describe('service management catalog API CORS defaults', () => {
+  it('adds the dev designer origins without dropping the inherited backend origin', () => {
+    expect.assertions(1);
+
+    const normalized = normalizeCatalogCorsAllowedOrigins('http://localhost:3000,http://127.0.0.1:3000', 'dev');
+
+    expect(normalized.split(',')).toStrictEqual([
+      'http://localhost:3000',
+      'http://127.0.0.1:3000',
+      'http://localhost:3200',
+      'http://127.0.0.1:3200'
+    ]);
+  });
+
+  it('adds CI designer origins without dropping the inherited backend origin', () => {
+    expect.assertions(1);
+
+    const normalized = normalizeCatalogCorsAllowedOrigins('http://localhost:3000,http://127.0.0.1:3000', 'ci');
+
+    expect(normalized.split(',')).toStrictEqual([
+      'http://localhost:3000',
+      'http://127.0.0.1:3000',
+      'http://localhost:3200',
+      'http://127.0.0.1:3200'
+    ]);
+  });
+
+  it('derives the staging designer origins when no staging allowlist is explicit', () => {
+    expect.assertions(1);
+
+    const normalized = normalizeCatalogCorsAllowedOrigins('', 'staging');
+
+    expect(normalized.split(',')).toStrictEqual([
+      'http://localhost:4200',
+      'http://127.0.0.1:4200'
+    ]);
+  });
+
+  it('derives the production designer origins without carrying dev ports', () => {
+    expect.assertions(1);
+
+    const normalized = normalizeCatalogCorsAllowedOrigins('', 'prod');
+
+    expect(normalized.split(',')).toStrictEqual([
+      'http://localhost:5200',
+      'http://127.0.0.1:5200'
+    ]);
+  });
+
+  it('keeps an explicit production allowlist exact', () => {
+    expect.assertions(1);
+
+    const normalized = normalizeCatalogCorsAllowedOrigins('https://app.jumentix.example', 'production');
+
+    expect(normalized).toBe('https://app.jumentix.example');
+  });
+
+  it.each([
+    ['development', '3200'],
+    ['local', '3200'],
+    ['test', '3200'],
+    ['ci', '3200'],
+    ['stage', '4200'],
+    ['production', '5200'],
+    ['preview', '3200'],
+    ['', '3200'],
+    ['   ', '3200']
+  ])('derives the local designer port for %s', (envName, expectedPort) => {
+    expect.assertions(1);
+
+    const normalized = normalizeCatalogCorsAllowedOrigins('', envName);
+
+    expect(normalized.split(',')).toStrictEqual([
+      `http://localhost:${expectedPort}`,
+      `http://127.0.0.1:${expectedPort}`
+    ]);
+  });
+
+  it('keeps an explicit staging allowlist exact', () => {
+    expect.assertions(1);
+
+    const normalized = normalizeCatalogCorsAllowedOrigins('https://staging.jumentix.example', 'staging');
+
+    expect(normalized).toBe('https://staging.jumentix.example');
+  });
+
+  it('configures process env shape before the shared Express adapter reads CORS policy', () => {
+    expect.assertions(1);
+
+    const env = {
+      NODE_ENV: 'staging',
+      JUMENTIX_SERVICE_MANAGEMENT_PORT: '4300'
+    } as unknown as NodeJS.ProcessEnv;
+
+    applyCatalogCorsDefaults(env);
+
+    expect(env.JUMENTIX_CORS_ALLOWED_ORIGINS).toBe('http://localhost:4300,http://127.0.0.1:4300');
+  });
+
+  it('defaults to process.env when no env object is given', () => {
+    expect.assertions(1);
+
+    const previous = process.env.JUMENTIX_CORS_ALLOWED_ORIGINS;
+    delete process.env.JUMENTIX_CORS_ALLOWED_ORIGINS;
+    try {
+      applyCatalogCorsDefaults();
+
+      expect(process.env.JUMENTIX_CORS_ALLOWED_ORIGINS)
+        .toBe('http://localhost:3200,http://127.0.0.1:3200');
+    } finally {
+      // eslint-disable-next-line jest/no-conditional-in-test
+      if (previous === undefined) delete process.env.JUMENTIX_CORS_ALLOWED_ORIGINS;
+      else process.env.JUMENTIX_CORS_ALLOWED_ORIGINS = previous;
+    }
+  });
+
+  it('falls back to the dev designer origins when the env carries no NODE_ENV', () => {
+    expect.assertions(1);
+
+    const env = {} as unknown as NodeJS.ProcessEnv;
+
+    applyCatalogCorsDefaults(env);
+
+    expect(env.JUMENTIX_CORS_ALLOWED_ORIGINS)
+      .toBe('http://localhost:3200,http://127.0.0.1:3200');
+  });
+
+  it('defaults every parameter to the dev designer origins', () => {
+    expect.assertions(1);
+
+    expect(normalizeCatalogCorsAllowedOrigins())
+      .toBe('http://localhost:3200,http://127.0.0.1:3200');
+  });
+});
diff --git a/apps/service-management-api/test/unit/startServiceManagementCatalogApi.test.ts b/apps/service-management-api/test/unit/startServiceManagementCatalogApi.test.ts
new file mode 100644
index 000000000..4a4b064b9
--- /dev/null
+++ b/apps/service-management-api/test/unit/startServiceManagementCatalogApi.test.ts
@@ -0,0 +1,168 @@
+/* eslint-disable jest/no-untyped-mock-factory */
+
+const mockExpressServerInstance = { name: 'express-server' };
+const mockBaseDatabaseClient = { name: 'base-database-client' };
+const mockCatalogDatabaseClient = { name: 'catalog-database-client' };
+const mockKeyValueDisconnect = jest.fn().mockResolvedValue(undefined);
+const mockKeyValueStorageClient = { disconnect: mockKeyValueDisconnect };
+const mockMessageMediator = { name: 'message-mediator' };
+const mockAuthService = { name: 'auth-service' };
+const mockCatalogStart = jest.fn().mockResolvedValue(undefined);
+const mockCatalogStop = jest.fn().mockResolvedValue(undefined);
+const mockCatalogApiInstance = { start: mockCatalogStart, stop: mockCatalogStop };
+const mockApplyCatalogCorsDefaults = jest.fn();
+const mockCompileAdapterRuntime = jest.fn((_config: Record) => ({
+  databaseClient: mockBaseDatabaseClient,
+  keyValueStorageClient: mockKeyValueStorageClient,
+  messageMediator: mockMessageMediator,
+  authService: mockAuthService
+}));
+const mockCreateDbClient = jest.fn(() => mockCatalogDatabaseClient);
+const mockExpressServer = jest.fn(() => mockExpressServerInstance);
+const mockCatalogAPI = jest.fn(() => mockCatalogApiInstance);
+const mockMutexCompile = jest.fn();
+const mockPasswordCryptoCompile = jest.fn();
+const mockJwtCompile = jest.fn();
+const mockCompileMessageMediator = jest.fn();
+const mockComposeUsersAuthServices = jest.fn();
+const mockCompileDatabaseClient = jest.fn();
+const mockCompileKeyValueStorageClient = jest.fn();
+
+jest.mock('@src/interface/HTTP/adapters/express/ExpressServer', () => ({
+  ExpressServer: mockExpressServer
+}));
+
+jest.mock('@src/modules/Users', () => ({
+  composeUsersAuthServices: mockComposeUsersAuthServices
+}));
+
+jest.mock('@src/infra/mutex/adapter/MutexService', () => ({
+  MutexService: { compile: mockMutexCompile }
+}));
+
+jest.mock('@src/infra/persistence/compileDatabaseClient', () => ({
+  compileDatabaseClient: mockCompileDatabaseClient
+}));
+
+jest.mock('@src/infra/jwt/JwtService', () => ({
+  JwtService: { compile: mockJwtCompile }
+}));
+
+jest.mock('@src/infra/persistence/KeyValueStorage/compileKeyValueStorageClient', () => ({
+  compileKeyValueStorageClient: mockCompileKeyValueStorageClient
+}));
+
+jest.mock('@src/infra/security/PasswordCryptoService', () => ({
+  PasswordCryptoService: { compile: mockPasswordCryptoCompile }
+}));
+
+jest.mock('@src/infra/messages/compileMessageMediator', () => ({
+  compileMessageMediator: mockCompileMessageMediator
+}));
+
+jest.mock('@jumentix/adapter-runtime-bootstrap', () => ({
+  compileAdapterRuntime: mockCompileAdapterRuntime
+}));
+
+jest.mock('@service-management-api/ServiceManagementCatalogAPI', () => ({
+  ServiceManagementCatalogAPI: mockCatalogAPI
+}));
+
+jest.mock(
+  '@service-management-api/infra/persistence/InMemoryDatabase/InMemoryCatalogDbClient',
+  () => ({
+    createServiceManagementCatalogDbClient: mockCreateDbClient,
+    InMemoryCatalogDbClient: mockCatalogDatabaseClient
+  })
+);
+
+jest.mock('@service-management-api/runtime/catalogCors', () => ({
+  applyCatalogCorsDefaults: mockApplyCatalogCorsDefaults
+}));
+
+const flushAsync = () => new Promise((resolve) => { setImmediate(resolve); });
+
+describe('start-service-management-catalog-api entrypoint', () => {
+  const signalHandlers: Record void> = {};
+  let logSpy: jest.SpyInstance;
+  let exitSpy: jest.SpyInstance;
+
+  beforeAll(async () => {
+    logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
+    exitSpy = jest.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
+    jest.spyOn(process, 'once').mockImplementation(((event: string, handler: () => void) => {
+      signalHandlers[event] = handler;
+      return process;
+    }) as typeof process.once);
+    await import('@service-management-api/start-service-management-catalog-api');
+    await flushAsync();
+  });
+
+  afterAll(() => {
+    logSpy.mockRestore();
+    exitSpy.mockRestore();
+    (process.once as jest.Mock).mockRestore();
+  });
+
+  it('applies the catalog CORS defaults and compiles the adapter runtime on boot', () => {
+    expect.hasAssertions();
+    expect(mockApplyCatalogCorsDefaults).toHaveBeenCalledTimes(1);
+    expect(mockExpressServer).toHaveBeenCalledTimes(1);
+    expect(mockCompileAdapterRuntime).toHaveBeenCalledTimes(1);
+  });
+
+  it('passes the backend compilers through to the adapter runtime config', () => {
+    expect.hasAssertions();
+    const runtimeConfig = mockCompileAdapterRuntime.mock.calls[0][0] as Record;
+    expect(runtimeConfig.compileDatabaseClient).toBe(mockCompileDatabaseClient);
+    expect(runtimeConfig.compileKeyValueStorageClient).toBe(mockCompileKeyValueStorageClient);
+    expect(runtimeConfig.composeAuthServices).toBe(mockComposeUsersAuthServices);
+  });
+
+  it('delegates the service compilers through the runtime config lambdas', () => {
+    expect.hasAssertions();
+    const runtimeConfig = mockCompileAdapterRuntime.mock.calls[0][0] as Record;
+    const connector = { name: 'connector' };
+    runtimeConfig.compileMutexService(connector);
+    runtimeConfig.compilePasswordCryptoService();
+    runtimeConfig.compileJwtService();
+    runtimeConfig.compileMessageMediator();
+    expect(mockMutexCompile).toHaveBeenCalledWith(connector);
+    expect(mockPasswordCryptoCompile).toHaveBeenCalledTimes(1);
+    expect(mockJwtCompile).toHaveBeenCalledTimes(1);
+    expect(mockCompileMessageMediator).toHaveBeenCalledTimes(1);
+  });
+
+  it('builds and starts the catalog API over the compiled runtime pieces', () => {
+    expect.hasAssertions();
+    expect(mockCreateDbClient).toHaveBeenCalledWith(mockBaseDatabaseClient);
+    expect(mockCatalogAPI).toHaveBeenCalledWith(expect.objectContaining({
+      databaseClient: mockCatalogDatabaseClient,
+      webServer: mockExpressServerInstance,
+      authService: mockAuthService,
+      eventBus: mockMessageMediator,
+      messageMediator: mockMessageMediator
+    }));
+    expect(mockCatalogStart).toHaveBeenCalledTimes(1);
+    expect(logSpy).toHaveBeenCalledWith('Service Management catalog API started.');
+  });
+
+  it('stops the API and disconnects storage on SIGTERM before exiting', async () => {
+    expect.hasAssertions();
+    expect(typeof signalHandlers.SIGTERM).toBe('function');
+    signalHandlers.SIGTERM();
+    await flushAsync();
+    expect(mockCatalogStop).toHaveBeenCalledTimes(1);
+    expect(mockKeyValueDisconnect).toHaveBeenCalledTimes(1);
+    expect(exitSpy).toHaveBeenCalledWith(0);
+  });
+
+  it('stops the API and disconnects storage on SIGINT before exiting', async () => {
+    expect.hasAssertions();
+    expect(typeof signalHandlers.SIGINT).toBe('function');
+    signalHandlers.SIGINT();
+    await flushAsync();
+    expect(mockCatalogStop).toHaveBeenCalledTimes(2);
+    expect(mockKeyValueDisconnect).toHaveBeenCalledTimes(2);
+  });
+});
diff --git a/apps/service-management-api/tsconfig.json b/apps/service-management-api/tsconfig.json
new file mode 100644
index 000000000..e8b02ca4a
--- /dev/null
+++ b/apps/service-management-api/tsconfig.json
@@ -0,0 +1,4 @@
+{
+  "extends": "../../tsconfig.json",
+  "include": ["src/**/*.ts", "test/**/*.ts"]
+}
diff --git a/apps/service-management/README.md b/apps/service-management/README.md
index 092f3b198..65a5f6333 100644
--- a/apps/service-management/README.md
+++ b/apps/service-management/README.md
@@ -1,19 +1,19 @@
 # Service Management Application
 
-[![CircleCI](https://dl.circleci.com/status-badge/img/gh/XpertMinds/Jumentix/tree/dev.svg?style=svg)](https://dl.circleci.com/status-badge/redirect/gh/XpertMinds/Jumentix/tree/dev)
-[![codecov](https://codecov.io/gh/XpertMinds/Jumentix/branch/dev/graph/badge.svg)](https://codecov.io/gh/XpertMinds/Jumentix)
-[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=Jumentix&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=Jumentix)
+[![GitHub Actions dev](https://github.com/web2solutions/Jumentix/actions/workflows/ci.yml/badge.svg?branch=dev)](https://github.com/web2solutions/Jumentix/actions/workflows/ci.yml?query=branch%3Adev)
+[![Codecov dev map](https://img.shields.io/badge/Codecov-dev%20file%20map-f01f7a?logo=codecov&logoColor=white)](https://app.codecov.io/gh/web2solutions/Jumentix/tree/dev)
+[![SonarCloud quality](https://img.shields.io/badge/SonarCloud-quality%20gate-F3702A?logo=sonarcloud&logoColor=white)](https://sonarcloud.io/summary/new_code?id=Jumentix)
 [![Node](https://img.shields.io/badge/node-22.x-339933?logo=node.js&logoColor=white)](https://nodejs.org/)
 [![OpenAPI](https://img.shields.io/badge/OpenAPI-3.1-6BA539?logo=openapiinitiative&logoColor=white)](../../spec/1.0.0.yml)
 [![AsyncAPI](https://img.shields.io/badge/AsyncAPI-3.0-9146FF)](../../spec)
-[![License](https://img.shields.io/github/license/XpertMinds/Jumentix)](../../LICENSE.md)
-[![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=Jumentix&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=Jumentix)
-[![Bugs](https://sonarcloud.io/api/project_badges/measure?project=Jumentix&metric=bugs)](https://sonarcloud.io/summary/new_code?id=Jumentix)
-[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=Jumentix&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=Jumentix)
+[![License](https://img.shields.io/github/license/web2solutions/Jumentix)](../../LICENSE.md)
+[![SonarCloud maintainability](https://img.shields.io/badge/SonarCloud-maintainability-F3702A?logo=sonarcloud&logoColor=white)](https://sonarcloud.io/summary/new_code?id=Jumentix)
+[![SonarCloud bugs](https://img.shields.io/badge/SonarCloud-bugs-F3702A?logo=sonarcloud&logoColor=white)](https://sonarcloud.io/summary/new_code?id=Jumentix)
+[![SonarCloud vulnerabilities](https://img.shields.io/badge/SonarCloud-vulnerabilities-F3702A?logo=sonarcloud&logoColor=white)](https://sonarcloud.io/summary/new_code?id=Jumentix)
 [![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/)
 ![Made in Brazil with Love](https://img.shields.io/badge/made%20in-%F0%9F%87%A7%F0%9F%87%B7%20Brazil%20with%E2%9D%A4%EF%B8%8F-blue)
 [![#StandWithUkraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/badges/StandWithUkraine.svg)](https://vshymanskyy.github.io/StandWithUkraine)
-[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/XpertMinds/Jumentix)
+[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/web2solutions/Jumentix)
 
 `service-management` is a tabbed local application for engineering setup and design workflows in this boilerplate.
 
@@ -67,11 +67,11 @@ sample is ordinary domain/entity deletion. Content is defined in
    - Exporters: JSON, OpenAPI 3.1, Markdown, JSON Schema, AsyncAPI 3.0 per transport
      (`.websocket.yml` / `.grpc.yml`, canonical `spec/asyncapi/`
      conventions), gRPC proto (`async-api.proto`) and boilerplate bundle.
-   - The JSON export is the versioned full-suite document (JUM-547): it carries all
-     four tabs (`domains`/`relationships`, `interfaces`, `serviceConfiguration`,
-     `deployments`) plus the runtime-environment selection — never its values — and
-     Import JSON restores them, accepting pre-JUM-547 domain-only files and refusing
-     unknown sections or newer major versions clearly.
+   - The JSON export is the versioned full-suite document (JUM-547/JUM-736): it carries all
+     persisted authoring sections (`domains`/`relationships`, `interfaces`, `serviceConfiguration`,
+     `deployments`, `codeWorkspace`) plus the runtime-environment selection — never
+     its values — and Import JSON restores them, accepting pre-JUM-547 domain-only
+     files and refusing unknown sections or newer major versions clearly.
    - OpenAPI composition controls (`oneOf`, `allOf`, `anyOf`, external `$ref`, discriminator) per entity.
    - Domain package export/import for reusable model sharing — versioned
      (JUM-492): packages carry a semantic version and dependency ranges,
@@ -80,6 +80,14 @@ sample is ordinary domain/entity deletion. Content is defined in
      conflict or downgrade, merge preview with user decision for RBAC,
      invariants, removals and narrowings on a newer version).
    - Mini-map navigation and large-canvas performance mode.
+   - Responsive canvas performance pass (JUM-736): entity/domain/note drags
+     update the diagram per animation frame and persist once at drag end;
+     selected relationships expose route and label handles directly on the
+     canvas, so relationship layout is edited visually before the numeric
+     fallback fields are needed.
+   - Every static control carries a keyboard/touch reachable help affordance
+     (JUM-733). Generated rows keep dense in-row labels and inherit group-level
+     guidance, while hidden file inputs remain deliberately exempt.
 2. **Communication Interface Designer**
    - Register inbound interface adapters (`HTTP/REST`, `gRPC`, `WebSocket`, `SSE`).
    - Full adapter lifecycle (JUM-545): every registered adapter edits in place (type,
@@ -134,6 +142,27 @@ sample is ordinary domain/entity deletion. Content is defined in
      name-plus-version runtime pattern (`nodejs22.x`), and region required on
      cloud targets (optional on the self-hosted dedicated server, where the
      field carries host information), with target-type-aware field hints.
+5. **Monitoring**
+   - Runtime PM2 dashboard backed by the real PM2 API (`pm2.list`): process
+     status, CPU, memory, restarts, uptime, watch mode and namespace.
+   - Compares the selected ecosystem (`pm2/ecosystem.*.cjs`) with the live PM2
+     process list so missing expected apps are visible without reading the
+     terminal.
+   - Refreshes manually or every five seconds while the tab is active. Metrics
+     are runtime telemetry only; they are not persisted in `service-management.v1`
+     and are not exported in the suite JSON.
+6. **Code Workspace**
+   - VS Code-style generated worktree: folder explorer, active file titlebar,
+     editable TypeScript/JSON text and Monaco when the editor loader is available.
+   - Regenerate reconciles the latest model output against local edits. Files the
+     user never edited follow the generator automatically; edited files become
+     `stale` when the generator changes underneath them.
+   - Conflict controls make the merge explicit: **Keep Mine** accepts the local
+     edit against the new generated baseline, and **Take Generated** restores the
+     generated file.
+   - Boilerplate bundle export applies edited/stale workspace files, so the
+     downloadable code reflects the reviewed workspace instead of a separate
+     read-only preview.
 
 ## Design System and Accessibility
 
@@ -259,7 +288,7 @@ the `service-management-shell@*` caches and reloads — Cana data is untouched.
 
 ### PWA tests
 
-- Unit: `apps/backend-template/test/unit/service-management/pwaShell.test.ts`
+- Unit: `apps/service-management/test/unit/pwaShell.test.ts`
   (worker handlers, update flow, recovery — with injected fakes).
 - Browser smoke:
   `apps/backend-template/test/integration/ServiceManagement/pwaShell.browser.integration.test.ts`
@@ -279,6 +308,10 @@ Built into `apps/service-management/server.js`:
   commands derived from the ecosystem definition, an explicit
   `exists: false` state when the file is absent, and the honest 500 envelope
   when the file is unreadable or broken)
+- `GET /api/runtime/pm2-metrics?environment=dev|development|staging|production|prod|ci|test`
+  (read-only; live PM2 telemetry collected with the PM2 Node API, including
+  process status, CPU, memory, restarts, uptime, watch mode, namespace, custom
+  metrics and ecosystem-vs-live missing-app comparison)
 
 The full contract (enum sets, write semantics, response hygiene) lives in
 [Runtime Environment Contracts](../../documentation/md/RUNTIME-ENVIRONMENT-CONTRACTS.md).
diff --git a/apps/service-management/README.pt-BR.md b/apps/service-management/README.pt-BR.md
index 96f68e941..bf11b50d3 100644
--- a/apps/service-management/README.pt-BR.md
+++ b/apps/service-management/README.pt-BR.md
@@ -4,20 +4,20 @@ Idioma alvo: Português (Brasil)
 -->
 # Aplicativo de gerenciamento de serviços
 
-[![CircleCI](https://dl.circleci.com/status-badge/img/gh/XpertMinds/Jumentix/tree/dev.svg?style=svg)](https://dl.circleci.com/status-badge/redirect/gh/XpertMinds/Jumentix/tree/dev)
-[![codecov](https://codecov.io/gh/XpertMinds/Jumentix/branch/dev/graph/badge.svg)](https://codecov.io/gh/XpertMinds/Jumentix)
-[![Status do Quality Gate](https://sonarcloud.io/api/project_badges/measure?project=Jumentix&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=Jumentix)
+[![GitHub Actions dev](https://github.com/web2solutions/Jumentix/actions/workflows/ci.yml/badge.svg?branch=dev)](https://github.com/web2solutions/Jumentix/actions/workflows/ci.yml?query=branch%3Adev)
+[![Mapa Codecov dev](https://img.shields.io/badge/Codecov-mapa%20dev-f01f7a?logo=codecov&logoColor=white)](https://app.codecov.io/gh/web2solutions/Jumentix/tree/dev)
+[![Qualidade SonarCloud](https://img.shields.io/badge/SonarCloud-quality%20gate-F3702A?logo=sonarcloud&logoColor=white)](https://sonarcloud.io/summary/new_code?id=Jumentix)
 [![Nó](https://img.shields.io/badge/node-22.x-339933?logo=node.js&logoColor=white)](https://nodejs.org/)
 [![OpenAPI](https://img.shields.io/badge/OpenAPI-3.1-6BA539?logo=openapiinitiative&logoColor=white)](../../spec/1.0.0.yml)
 [![AsyncAPI](https://img.shields.io/badge/AsyncAPI-3.0-9146FF)](../../spec)
-[![Licença](https://img.shields.io/github/license/XpertMinds/Jumentix)](../../LICENSE.md)
-[![Cheiros de código](https://sonarcloud.io/api/project_badges/measure?project=Jumentix&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=Jumentix)
-[![Bugs](https://sonarcloud.io/api/project_badges/measure?project=Jumentix&metric=bugs)](https://sonarcloud.io/summary/new_code?id=Jumentix)
-[![Vulnerabilidades](https://sonarcloud.io/api/project_badges/measure?project=Jumentix&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=Jumentix)
+[![Licença](https://img.shields.io/github/license/web2solutions/Jumentix)](../../LICENSE.md)
+[![Manutenibilidade SonarCloud](https://img.shields.io/badge/SonarCloud-manutenibilidade-F3702A?logo=sonarcloud&logoColor=white)](https://sonarcloud.io/summary/new_code?id=Jumentix)
+[![Bugs SonarCloud](https://img.shields.io/badge/SonarCloud-bugs-F3702A?logo=sonarcloud&logoColor=white)](https://sonarcloud.io/summary/new_code?id=Jumentix)
+[![Vulnerabilidades SonarCloud](https://img.shields.io/badge/SonarCloud-vulnerabilidades-F3702A?logo=sonarcloud&logoColor=white)](https://sonarcloud.io/summary/new_code?id=Jumentix)
 [![Commitizen amigável](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/)
 ![Feito no Brasil com Amor](https://img.shields.io/badge/made%20in-%F0%9F%87%A7%F0%9F%87%B7%20Brasil%20with%E2%9D%A4%EF%B8%8F-blue)
 [![#StandWithUkraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/badges/StandWithUkraine.svg)](https://vshymanskyy.github.io/StandWithUkraine)
-[![Abrir no Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/XpertMinds/Jumentix)
+[![Abrir no Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/web2solutions/Jumentix)
 
 `service-management` é um aplicativo local com guias para configuração de engenharia e fluxos de trabalho de design neste modelo.
 
@@ -71,6 +71,12 @@ definido em `@jumentix/designer-core` (`packages/designer-core/src/model/sampleM
    - Exportadores: JSON, OpenAPI 3.1, Markdown, JSON Schema, AsyncAPI 3.0 por transporte
      (`.websocket.yml` / `.grpc.yml`, convenções canônicas de
      `spec/asyncapi/`), proto gRPC (`async-api.proto`) e pacote padrão.
+   - A exportação JSON é o documento versionado de suíte completa (JUM-547/JUM-736):
+     carrega todas as seções autorais persistidas (`domains`/`relationships`, `interfaces`,
+     `serviceConfiguration`, `deployments`, `codeWorkspace`) mais a seleção de
+     ambiente de runtime — nunca seus valores — e o Import JSON restaura essas
+     seções, aceitando arquivos pré-JUM-547 só de domínio e recusando claramente
+     seções desconhecidas ou versões major mais novas.
    - Controles de composição OpenAPI (`oneOf`, `allOf`, `anyOf`, externo `$ref`, discriminador) por entidade.
    - Exportação/importação de pacotes de domínio para compartilhamento de modelos reutilizáveis — versionada
      (JUM-492): pacotes carregam versão semântica e faixas de dependências,
@@ -80,6 +86,15 @@ definido em `@jumentix/designer-core` (`packages/designer-core/src/model/sampleM
      usuário para RBAC, invariantes, remoções e estreitamentos em uma versão
      mais nova).
    - Navegação em minimapa e modo de desempenho em tela grande.
+   - Passada de performance responsiva do canvas (JUM-736): drags de
+     entidade/domínio/nota atualizam o diagrama por animation frame e
+     persistem uma vez no fim do drag; relacionamentos selecionados exibem
+     handles de rota e label diretamente no canvas, então o layout da relação
+     é editado visualmente antes de recorrer aos campos numéricos.
+   - Todo controle estático tem uma ajuda acessível por teclado e toque
+     (JUM-733). Linhas geradas mantêm rótulos densos no próprio item e herdam
+     a orientação do grupo, enquanto inputs de arquivo ocultos seguem
+     deliberadamente isentos.
 2. **Designer de interface de comunicação**
    - Registrar adaptadores de interface de entrada (`HTTP/REST`, `gRPC`, `WebSocket`, `SSE`).
    - Ciclo de vida completo do adaptador (JUM-545): cada adaptador registrado é editado
@@ -140,6 +155,27 @@ definido em `@jumentix/designer-core` (`packages/designer-core/src/model/sampleM
      obrigatória em alvos de nuvem (opcional no servidor dedicado
      self-hosted, onde o campo carrega informação de host), com dicas de
      campo por tipo de alvo.
+5. **Monitoramento**
+   - Dashboard runtime PM2 alimentado pela API real do PM2 (`pm2.list`): status
+     de processo, CPU, memória, restarts, uptime, modo watch e namespace.
+   - Compara o ecosystem selecionado (`pm2/ecosystem.*.cjs`) com a lista live do
+     PM2 para deixar apps esperados ausentes visíveis sem ler o terminal.
+   - Atualiza manualmente ou a cada cinco segundos enquanto a guia está ativa.
+     Métricas são telemetria runtime; não são persistidas em `service-management.v1`
+     nem exportadas no JSON da suíte.
+6. **Workspace de Código**
+   - Worktree de código gerado em estilo VS Code: explorer de pastas, barra de
+     título do arquivo ativo, texto TypeScript/JSON editável e Monaco quando o
+     loader do editor estiver disponível.
+   - Regenerate reconcilia a saída mais recente do modelo com edições locais.
+     Arquivos nunca editados pelo usuário acompanham o gerador automaticamente;
+     arquivos editados viram `stale` quando a geração muda por baixo deles.
+   - Controles de conflito tornam o merge explícito: **Keep Mine** aceita a
+     edição local contra o novo baseline gerado, e **Take Generated** restaura o
+     arquivo gerado.
+   - A exportação do boilerplate bundle aplica arquivos editados/stale do
+     workspace, então o código baixado reflete o workspace revisado e não uma
+     prévia somente leitura separada.
 
 ## Design System e Acessibilidade
 
@@ -280,7 +316,7 @@ intocados.
 
 ### Testes do PWA
 
-- Unidade: `apps/backend-template/test/unit/service-management/pwaShell.test.ts`
+- Unidade: `apps/service-management/test/unit/pwaShell.test.ts`
   (handlers do worker, fluxo de atualização, recuperação — com fakes
   injetados).
 - Smoke de navegador:
@@ -301,6 +337,10 @@ Integrada em `apps/service-management/server.js`:
   derivados da definição do ecossistema, um estado explícito `exists: false`
   quando o arquivo está ausente, e o envelope 500 honesto quando o arquivo está
   ilegível ou quebrado)
+- `GET /api/runtime/pm2-metrics?environment=dev|development|staging|production|prod|ci|test`
+  (somente leitura; telemetria live coletada pela API Node do PM2, incluindo
+  status de processo, CPU, memória, restarts, uptime, modo watch, namespace,
+  métricas customizadas e comparação ecosystem-vs-live de apps esperados ausentes)
 
 O contrato completo (conjuntos de enum, semântica de escrita, higiene de resposta) está em
 [Contratos de ambiente de tempo de execução](../../documentation/md/RUNTIME-ENVIRONMENT-CONTRACTS.pt-BR.md).
diff --git a/apps/service-management/documentation/guides/CREATING-SPA-PWA-WITH-JUMENTIX.md b/apps/service-management/documentation/guides/CREATING-SPA-PWA-WITH-JUMENTIX.md
index ae54a34d8..635c0bd18 100644
--- a/apps/service-management/documentation/guides/CREATING-SPA-PWA-WITH-JUMENTIX.md
+++ b/apps/service-management/documentation/guides/CREATING-SPA-PWA-WITH-JUMENTIX.md
@@ -69,6 +69,13 @@ Use **Communication Interface Designer** to choose how the SPA talks to backend:
 Contracts become the source for SDK integration — do not hand-write fetch URLs that
 are not in OpenAPI.
 
+The hybrid seed (`apps/frontend`) plugs a generated domain in as a **module**
+(`src/modules/manifest.ts`): navigation lists modules, each open module is a
+taskbar button, entity screens are tabs plus a Dashboard tab. See
+[Frontend Seed and the X-CRUD Kit](/docs/jumentix/FRONTEND-SEED-AND-XCRUD).
+Offline-first Cana boot, sync, outbox and PWA:
+[Frontend offline data layer](/docs/jumentix/reference/frontend-offline-data-layer).
+
 **Success check:** OpenAPI/AsyncAPI files match designer export; operationIds stable.
 
 ### Step 3 — Configure service runtime (< 10 minutes)
diff --git a/apps/service-management/documentation/guides/CREATING-SPA-PWA-WITH-JUMENTIX.pt-BR.md b/apps/service-management/documentation/guides/CREATING-SPA-PWA-WITH-JUMENTIX.pt-BR.md
index ad5d63922..190455f15 100644
--- a/apps/service-management/documentation/guides/CREATING-SPA-PWA-WITH-JUMENTIX.pt-BR.md
+++ b/apps/service-management/documentation/guides/CREATING-SPA-PWA-WITH-JUMENTIX.pt-BR.md
@@ -64,6 +64,13 @@ Para arquitetura PWA/offline-first:
 - portas CI de back-end
 - construção de front-end e verificações de fumaça offline
 
+O seed híbrido (`apps/frontend`) encaixa um domínio gerado como **módulo**
+(`src/modules/manifest.ts`): o menu lista módulos, cada módulo aberto vira botão
+na taskbar, as entidades são abas mais a aba Dashboard. Ver
+[Seed de Frontend e o Kit X-CRUD](/docs/pt-BR/jumentix/FRONTEND-SEED-AND-XCRUD).
+Offline-first Cana, sync, outbox e PWA:
+[Camada de dados offline do frontend](/docs/pt-BR/jumentix/reference/frontend-offline-data-layer).
+
 ## 6. Offline com Cana + designs
 
 
diff --git a/apps/service-management/index.html b/apps/service-management/index.html
index 69ab0f63b..98729d78b 100644
--- a/apps/service-management/index.html
+++ b/apps/service-management/index.html
@@ -8,13 +8,13 @@
   
   
-  
+  
   
   
   
-  
+  
 
 
   
@@ -32,6 +32,8 @@

Service Management

+ + + + +

Domains

@@ -63,7 +79,7 @@

Domains

    -
    +

    Bounded Context

    Metadata for selected domain context and ownership.

    @@ -81,7 +97,7 @@

    Bounded Context

    -
    +

    Entities

    @@ -111,7 +127,7 @@

    Entities

    Select a domain first.

    -
    +

    Relationship

    @@ -168,7 +184,7 @@

    Relationship

    -
    +

    Entity Inspector

    No entity selected

    @@ -278,7 +294,7 @@

    Entity Inspector

      -
      +

      Export

      @@ -317,7 +333,7 @@

      Export

      -
      +

      History

      @@ -325,12 +341,13 @@

      History

      -
      +

      Legend

      • Drag domains and entities
      • -
      • Use Space + drag to pan
      • -
      • Ctrl/Cmd + wheel to zoom
      • +
      • Drag the empty canvas (or Space + drag) to pan
      • +
      • Wheel to zoom, or the + and - keys; 0 resets the view
      • +
      • Drag a domain's bottom-right corner to resize it
      • Alt + L to auto layout
      • Select domain to add entity
      • Drag from entity edge dots to connect entities
      • @@ -339,7 +356,7 @@

        Legend

      -
      +

      Model Check

      @@ -356,7 +373,7 @@

      Model Check

        -
        +

        Schema Diff

        Compare current model against saved baseline and preview migration hints.

        @@ -396,7 +413,51 @@

        Start your domain model

        + +
        No domain selected
        + +
        + + + + + + + +
        + +
        100%
        @@ -680,6 +741,282 @@

        Deployment Target

        + +
        +
        +
        +
        +

        PM2 Monitoring

        +

        Live host + process telemetry over WebSocket (Contract 1e). HTTP one-shot remains for Contract 1c.

        +
        +
        + + + idle +
        +
        + +
        +
        + + Waiting for stream + — +
        +

        Open this tab to connect the PM2 WebSocket stream.

        + -- +
        + +
        +
        +
        +

        Host CPU

        +

        —

        +
        +
        +
        + +

        —

        +
        +
        + +

        —

        +
        +
        + +

        —

        +
        +
        +
        +
        +
        +

        Host memory

        +

        —

        +
        +
        +
        + +

        —

        +
        +
        + +

        —

        +
        +
        + +

        —

        +
        +
        +
        +
        +
        +

        Host disk

        +

        —

        +
        +
        +
        + +

        —

        +
        +
        +
        +
        +
        +

        Process aggregates

        +

        CPU · memory · async ALS

        +
        +
        +
        + +

        —

        +
        +
        + +

        —

        +
        +
        + ALS active Σ + 0 + +

        —

        +
        +
        +
        +
        + +
        +
        +

        CPU stack (filtered)

        + +
          +
          +
          +

          Memory stack (filtered)

          + +
            +
            +
            +

            Status mix

            + +

            —

            +
            +
            + +
            + + + + + +
            + + + + +
            +
            + +
            +
            +
            +

            Processes

            + live stream +
            +
            + + + + + + + + + + + + + + + +
            ProcessStatusCPUMemoryRestartsALSUptimeActions
            +
            +
            + +
            +
            +

            PM2 Commands

            + ops +
            + pm2 monit + pm2 list +
            +
            +
            +
            + +
            +
            + + +
            +
            +
            + +
            + generated +
            +
            No file selected
            +
            + + + +
            +
            + Plain Text + 0 files + 0 edited + 0 conflicts +
            +
            +
            +
            + This document certifies current coverage of implemented requirements by Spec Development Driven resources. @@ -8,9 +8,9 @@ This document certifies current coverage of implemented requirements by Spec Dev Date: `2026-08-05` -1. Requirement files in `.agents/requirements/project/` and `.agents/requirements/software/`: `135` -2. Unique requirement IDs: `135` -3. IDs covered in `SPEC-REQUIREMENTS-TRACEABILITY-LEDGER.md`: `135` +1. Requirement files in `.agents/requirements/project/` and `.agents/requirements/software/`: `136` +2. Unique requirement IDs: `136` +3. IDs covered in `SPEC-REQUIREMENTS-TRACEABILITY-LEDGER.md`: `136` 4. Coverage status: `100%` Notes: @@ -21,9 +21,9 @@ Notes: ## Non-Functional Requirements Coverage -NFR IDs covered (`91`): +NFR IDs covered (`92`): -`001`, `011`, `014`, `015`, `016`, `017`, `018`, `020`, `025`, `029`, `036`, `041`, `042`, `043`, `044`, `050`, `053`, `056`, `057`, `063`, `064`, `065`, `066`, `067`, `068`, `069`, `070`, `071`, `072`, `073`, `074`, `075`, `076`, `077`, `078`, `079`, `080`, `081`, `082`, `083`, `084`, `085`, `086`, `087`, `088`, `089`, `090`, `091`, `092`, `093`, `094`, `095`, `096`, `097`, `098`, `099`, `100`, `101`, `102`, `103`, `104`, `105`, `106`, `107`, `108`, `109`, `110`, `111`, `112`, `113`, `114`, `115`, `116`, `117`, `118`, `119`, `120`, `121`, `122`, `124`, `125`, `126`, `127`, `128`, `129`, `130`, `131`, `132`, `133`, `134`, `135` +`001`, `011`, `014`, `015`, `016`, `017`, `018`, `020`, `025`, `029`, `036`, `041`, `042`, `043`, `044`, `050`, `053`, `056`, `057`, `063`, `064`, `065`, `066`, `067`, `068`, `069`, `070`, `071`, `072`, `073`, `074`, `075`, `076`, `077`, `078`, `079`, `080`, `081`, `082`, `083`, `084`, `085`, `086`, `087`, `088`, `089`, `090`, `091`, `092`, `093`, `094`, `095`, `096`, `097`, `098`, `099`, `100`, `101`, `102`, `103`, `104`, `105`, `106`, `107`, `108`, `109`, `110`, `111`, `112`, `113`, `114`, `115`, `116`, `117`, `118`, `119`, `120`, `121`, `122`, `124`, `125`, `126`, `127`, `128`, `129`, `130`, `131`, `132`, `133`, `134`, `135`, `136` NFR mapping sources: diff --git a/documentation/md/SPEC-REQUIREMENTS-COVERAGE-STATUS.pt-BR.md b/documentation/md/SPEC-REQUIREMENTS-COVERAGE-STATUS.pt-BR.md index 7e7f43c36..bde01f9a0 100644 --- a/documentation/md/SPEC-REQUIREMENTS-COVERAGE-STATUS.pt-BR.md +++ b/documentation/md/SPEC-REQUIREMENTS-COVERAGE-STATUS.pt-BR.md @@ -4,7 +4,7 @@ Idioma alvo: Português (Brasil) --> # Status de cobertura dos requisitos de especificação - + Este documento certifica a cobertura atual dos requisitos implementados pelos recursos orientados ao desenvolvimento de especificações. @@ -12,9 +12,9 @@ Este documento certifica a cobertura atual dos requisitos implementados pelos re Data: `2026-08-05` -1. Arquivos de requisitos em `.agents/requirements/project/` e `.agents/requirements/software/`: `135` -2. IDs de requisitos exclusivos: `135` -3. IDs cobertos em `SPEC-REQUIREMENTS-TRACEABILITY-LEDGER.md`: `135` +1. Arquivos de requisitos em `.agents/requirements/project/` e `.agents/requirements/software/`: `136` +2. IDs de requisitos exclusivos: `136` +3. IDs cobertos em `SPEC-REQUIREMENTS-TRACEABILITY-LEDGER.md`: `136` 4. Status de cobertura: `100%` Notas: @@ -25,9 +25,9 @@ Notas: ## Cobertura de requisitos não funcionais -IDs NFR cobertos (`91`): +IDs NFR cobertos (`92`): -`001`, `011`, `014`, `015`, `016`, `017`, `018`, `020`, `025`, `029`, `036`, `041`, `042`, `043`, `044`, `050`, `053`, `056`, `057`, `063`, `064`, `065`, `066`, `067`, `068`, `069`, `070`, `071`, `072`, `073`, `074`, `075`, `076`, `077`, `078`, `079`, `080`, `081`, `082`, `083`, `084`, `085`, `086`, `087`, `088`, `089`, `090`, `091`, `092`, `093`, `094`, `095`, `096`, `097`, `098`, `099`, `100`, `101`, `102`, `103`, `104`, `105`, `106`, `107`, `108`, `109`, `110`, `111`, `112`, `113`, `114`, `115`, `116`, `117`, `118`, `119`, `120`, `121`, `122`, `124`, `125`, `126`, `127`, `128`, `129`, `130`, `131`, `132`, `133`, `134`, `135` +`001`, `011`, `014`, `015`, `016`, `017`, `018`, `020`, `025`, `029`, `036`, `041`, `042`, `043`, `044`, `050`, `053`, `056`, `057`, `063`, `064`, `065`, `066`, `067`, `068`, `069`, `070`, `071`, `072`, `073`, `074`, `075`, `076`, `077`, `078`, `079`, `080`, `081`, `082`, `083`, `084`, `085`, `086`, `087`, `088`, `089`, `090`, `091`, `092`, `093`, `094`, `095`, `096`, `097`, `098`, `099`, `100`, `101`, `102`, `103`, `104`, `105`, `106`, `107`, `108`, `109`, `110`, `111`, `112`, `113`, `114`, `115`, `116`, `117`, `118`, `119`, `120`, `121`, `122`, `124`, `125`, `126`, `127`, `128`, `129`, `130`, `131`, `132`, `133`, `134`, `135`, `136` Fontes de mapeamento NFR: diff --git a/documentation/md/SPEC-REQUIREMENTS-TRACEABILITY-LEDGER.md b/documentation/md/SPEC-REQUIREMENTS-TRACEABILITY-LEDGER.md index d741c88e2..6c5348ef2 100644 --- a/documentation/md/SPEC-REQUIREMENTS-TRACEABILITY-LEDGER.md +++ b/documentation/md/SPEC-REQUIREMENTS-TRACEABILITY-LEDGER.md @@ -1,6 +1,6 @@ # Spec Requirements Traceability Ledger - + This ledger maps requirement IDs to spec resources and validation evidence expectations. @@ -42,16 +42,27 @@ For any change, identify impacted requirement IDs and ensure: ## C. Contract and Interface Conformance -- `008`, `010`, `021`, `026`, `027`, `028`, `036`, `047` +- `008`, `010`, `021`, `026`, `027`, `028`, `036`, `047`, `136` - Spec resources: - `spec/1.0.0.yml` - `spec/asyncapi/1.0.0.websocket.yml` - `spec/asyncapi/1.0.0.grpc.yml` - `documentation/md/EVENTS-AND-MESSAGES-MAP.md` - `documentation/md/contracts/*` + - `apps/frontend/*` (consumes only the OAS surface and generated SDKs) + - `documentation/md/PAGINATED-LIST-CONTRACT.md` (`x-list-capabilities`, page envelope, tombstones, delta sync) + - `documentation/md/FRONTEND-SEED-AND-XCRUD.md` + - `documentation/md/FRONTEND-OFFLINE-DATA-LAYER.md` + - `documentation/md/OAS-VENDOR-EXTENSIONS.md` (`x-relation`, `x-primary-key`, `x-services`, `x-sync`, `x-metrics-capabilities`) + - `documentation/md/ENTITY-METRICS-CONTRACT.md` - Evidence: - route/channel resolution checks - realtime integration/smoke tests + - workspace boundary checks with the frontend workspace present + - `apps/frontend` unit + component suites (`bun run frontend:test:unit`), the frontend + coverage gate (`bun run frontend:coverage:check`) and the Docker-backed Cypress e2e + (`bun run frontend:test:e2e`) — JUM-776 + - `apps/backend-template/test/integration/Express/Users/getAll.test.ts` for the list contract — JUM-777 ## D. Data Adapter and Persistence Interoperability diff --git a/documentation/md/SPEC-REQUIREMENTS-TRACEABILITY-LEDGER.pt-BR.md b/documentation/md/SPEC-REQUIREMENTS-TRACEABILITY-LEDGER.pt-BR.md index f920e951b..d575fecd5 100644 --- a/documentation/md/SPEC-REQUIREMENTS-TRACEABILITY-LEDGER.pt-BR.md +++ b/documentation/md/SPEC-REQUIREMENTS-TRACEABILITY-LEDGER.pt-BR.md @@ -4,7 +4,7 @@ Idioma alvo: Português (Brasil) --> # Especificações de rastreabilidade de requisitos - + Este livro-razão mapeia IDs de requisitos para especificações de recursos e expectativas de evidências de validação. @@ -46,16 +46,25 @@ Para qualquer alteração, identifique os IDs dos requisitos afetados e garanta: ## C. Conformidade de contrato e interface -- `008`, `010`, `021`, `026`, `027`, `028`, `036`, `047` +- `008`, `010`, `021`, `026`, `027`, `028`, `036`, `047`, `136` - Recursos de especificações: - `spec/1.0.0.yml` - `spec/asyncapi/1.0.0.websocket.yml` - `spec/asyncapi/1.0.0.grpc.yml` - `documentação/md/EVENTS-AND-MESSAGES-MAP.md` - `documentação/md/contratos/*` + - `apps/frontend/*` (consome só a superfície OAS e SDKs gerados) + - `documentation/md/PAGINATED-LIST-CONTRACT.pt-BR.md` (`x-list-capabilities`, envelope de página) + - `documentation/md/FRONTEND-SEED-AND-XCRUD.pt-BR.md` + - `documentation/md/FRONTEND-OFFLINE-DATA-LAYER.pt-BR.md` - Evidência: - verificações de resolução de rota/canal - integração em tempo real/testes de fumaça + - verificações de fronteira de workspace com o workspace frontend presente + - suítes unit + component de `apps/frontend` (`bun run frontend:test:unit`), gate de cobertura + do frontend (`bun run frontend:coverage:check`) e e2e Cypress com backend em Docker + (`bun run frontend:test:e2e`) — JUM-776 + - `apps/backend-template/test/integration/Express/Users/getAll.test.ts` para o contrato de listagem — JUM-777 ## D. Adaptador de dados e interoperabilidade de persistência diff --git a/documentation/md/TESTING-CI-AND-QUALITY.md b/documentation/md/TESTING-CI-AND-QUALITY.md index 6f075e3d7..849f2a2fc 100644 --- a/documentation/md/TESTING-CI-AND-QUALITY.md +++ b/documentation/md/TESTING-CI-AND-QUALITY.md @@ -179,6 +179,31 @@ Remote enforcement: - Storybook is absent from the repository full matrix - `ci:monorepo` remains a compatibility entrypoint but cannot select a reduced docs-only plan +#### Hosted job matrix by context (JUM-786) + +`ci-cd/classify-ci-context.js` (`JOBS_BY_CONTEXT`) and the job-level `if:` guards in +`.github/workflows/ci.yml` must agree. Heavy jobs (`workspace-builds`, +`workspace-tests`, `integration`, `coverage`, `website`, `database-matrix`) run +only for release/`main`/scheduled contexts (Requirements `087`/`113`). They +**intentionally skip** on task-branch pushes, PRs to `dev`, and cheap `dev` +pushes — a skip there is not a pass. + +| Context | Hosted jobs that run | Branch-gate script + preflight | +| --- | --- | --- | +| Task-branch push | `branch-gate` | `ci:gate:task` + lint, `test:integrity`, `arch:check-workspace-boundaries`, `build:dev` | +| PR to `dev` | `branch-gate`, `third-party-review` | same task gate + preflight | +| Push to `dev` | `branch-gate` | `test:unit` + lint, integrity, workspace boundaries, `build:dev` | +| Release PR to `main` / push to `main` / schedule / `workflow_dispatch` | full list (`FULL_JOBS`) | `ci:gate:strict` (+ integrity, workspace boundaries, `build:dev` preflight) | + +`build:dev` (`tsc -p tsconfig.build.json`) typechecks backend/package TypeScript +owned by the root compiler. `apps/frontend/**` is excluded: that workspace owns +`vue-tsc` (`bun run --cwd apps/frontend typecheck`). Service-management unit +tests are excluded like backend-template tests (JUM-785). + +Workspace-boundary and `build:dev` steps are **preflight of every branch-gate +path since JUM-786**, so a red `ci:gate` step cannot hide behind skipped heavy +jobs on a PR to `dev`. + SonarQube Cloud coverage import: - Workflow: `.github/workflows/ci.yml` @@ -192,12 +217,13 @@ SonarQube Cloud coverage import: |------------|---------|-------------------------|-----------------------------| | GitHub Actions (branch gate) | Target-aware CI validation on push/PR | `.github/workflows/ci.yml` | Uses pinned Bun, selects by PR base/pushed branch, stores selected-gate evidence | | GitHub Actions (coverage) | Repository-owned project and patch coverage | `.github/workflows/ci.yml` | Enforces `coverage:check` and `coverage:patch`, then retains JSON/LCOV evidence | -| GitHub Actions (Codecov) | Coverage dashboard publishing | `.github/workflows/ci.yml` | Requires `CODECOV_TOKEN`; uploads LCOV through Codecov CLI after local thresholds pass | +| GitHub Actions (Codecov) | Coverage dashboard publishing | `.github/workflows/ci.yml` | Requires `CODECOV_TOKEN`; uploads LCOV through `codecov/codecov-action@v5` after local thresholds pass | | GitHub Actions (third-party review) | Fail-closed secret and static-analysis review | `.github/workflows/ci.yml` | Runs pinned Gitleaks/Semgrep and retains SARIF evidence | | GitHub Actions (website) | Website-owned Storybook and publication readiness | `.github/workflows/ci.yml` | Runs Storybook build/smoke and prepublish checks independently | | GitHub Actions (SonarQube Cloud) | Static analysis + quality gate + coverage import | `.github/workflows/ci.yml`, `sonar-project.properties` | Requires `SONAR_TOKEN`; imports retained LCOV after coverage passes | | Repository coverage gate | Local hard gate to prevent low-coverage merges | `jest.config.js`, `ci-cd/check-coverage-thresholds.js` | Statements/lines/functions/branches 98%, changed lines 99%; branches under a dated floor (JUM-721) | | Test integrity gate | Blocks suites that assert nothing, assert only on mocks, sleep as synchronisation, or sit outside the map | `ci-cd/check-test-integrity.js`, `ci-cd/run-branch-quality-gate.js` | `bun run test:integrity`; preflight of every branch-gate path (JUM-683) | +| Workspace boundaries + `build:dev` | Fail-closed architecture and root TypeScript emit before cheap gates | `ci-cd/check-workspace-boundaries.js`, `tsconfig.build.json`, `ci-cd/run-branch-quality-gate.js` | `bun run arch:check-workspace-boundaries` + `bun run build:dev`; preflight of every branch-gate path (JUM-786) | | Husky | Local Git hooks for quality checks | `.husky/*` | Installed by `bun run prepare` | | Commitlint + Commitizen | Conventional commits and guided commit flow | `commitlint.config.js`, `package.json` | `bun run commit` | | Changelog sync automation | Keeps `CHANGELOG.md` aligned with Git history | `ci-cd/update-changelog.js`, `.husky/post-commit` | `bun run changelog:update`, `bun run changelog:check` | @@ -211,8 +237,7 @@ SonarQube Cloud coverage import: #### Active hosted provider -GitHub Actions is active by Requirement 113, CircleCI is disabled, and the -workflow runs on the repository-owned `jumentix` self-hosted runner. It runs on +GitHub Actions is active by Requirement 113, CircleCI is enabled as the secondary public CI provider, and the workflow runs on GitHub-hosted `ubuntu-latest` runners. It runs on `dev`, `main`, and pull requests, with Sonar filtered to the two long-lived branches. Codecov publishing runs after the repository-owned coverage gate and never replaces it as the merge authority. diff --git a/documentation/md/TESTING-CI-AND-QUALITY.pt-BR.md b/documentation/md/TESTING-CI-AND-QUALITY.pt-BR.md index b7e22edfd..9dab0d197 100644 --- a/documentation/md/TESTING-CI-AND-QUALITY.pt-BR.md +++ b/documentation/md/TESTING-CI-AND-QUALITY.pt-BR.md @@ -186,6 +186,31 @@ Aplicação remota: - o Storybook não é executado pela matriz global - `ci:monorepo` permanece como entrada de compatibilidade, mas não pode selecionar um plano reduzido somente para documentação +#### Matriz de jobs hospedados por contexto (JUM-786) + +`ci-cd/classify-ci-context.js` (`JOBS_BY_CONTEXT`) e os `if:` em +`.github/workflows/ci.yml` precisam concordar. Jobs pesados (`workspace-builds`, +`workspace-tests`, `integration`, `coverage`, `website`, `database-matrix`) +correm só em release/`main`/agendado (Requisitos `087`/`113`). Em push de +tarefa, PR para `dev` e push barato para `dev` eles **pulam de propósito** — +pular não é passar. + +| Contexto | Jobs que rodam | Script do branch-gate + preflight | +| --- | --- | --- | +| Push de branch de tarefa | `branch-gate` | `ci:gate:task` + lint, `test:integrity`, `arch:check-workspace-boundaries`, `build:dev` | +| PR para `dev` | `branch-gate`, `third-party-review` | mesmo gate de tarefa + preflight | +| Push para `dev` | `branch-gate` | `test:unit` + lint, integrity, boundaries, `build:dev` | +| PR de release para `main` / push `main` / schedule / `workflow_dispatch` | lista completa (`FULL_JOBS`) | `ci:gate:strict` (+ integrity, boundaries, `build:dev` preflight) | + +`build:dev` (`tsc -p tsconfig.build.json`) tipa o TypeScript de backend/pacotes +do compilador raiz. `apps/frontend/**` fica de fora: o workspace dono é +`vue-tsc` (`bun run --cwd apps/frontend typecheck`). Testes unitários de +service-management ficam de fora como os do backend-template (JUM-785). + +Boundaries e `build:dev` são **preflight de todo caminho do branch-gate desde +o JUM-786**, para um passo vermelho de `ci:gate` não esconder atrás de jobs +pesados pulados num PR para `dev`. + Importação de cobertura do SonarQube Cloud: - Fluxo de trabalho: `.github/workflows/ci.yml` @@ -199,12 +224,13 @@ Importação de cobertura do SonarQube Cloud: |------------|---------|----------------------------|-----------------------------| | GitHub Actions (branch gate) | Validação orientada ao destino em push/PR | `.github/workflows/ci.yml` | Seleciona pelo destino do PR ou branch enviada e retém evidência do gate | | GitHub Actions (cobertura) | Cobertura de projeto e patch pertencente ao repositório | `.github/workflows/ci.yml` | Aplica `coverage:check` e `coverage:patch` e retém evidência JSON/LCOV | -| GitHub Actions (Codecov) | Publicação de dashboard de cobertura | `.github/workflows/ci.yml` | Requer `CODECOV_TOKEN`; envia LCOV pelo Codecov CLI após thresholds locais | +| GitHub Actions (Codecov) | Publicação de dashboard de cobertura | `.github/workflows/ci.yml` | Requer `CODECOV_TOKEN`; envia LCOV via `codecov/codecov-action@v5` após thresholds locais | | GitHub Actions (revisão third-party) | Revisão fail-closed de segredos e análise estática | `.github/workflows/ci.yml` | Executa Gitleaks/Semgrep fixados e retém evidência SARIF | | GitHub Actions (website) | Storybook e prontidão de publicação pertencentes ao website | `.github/workflows/ci.yml` | Executa build/smoke do Storybook e prepublish de forma independente | | GitHub Actions (SonarQube Cloud) | Análise estática + quality gate + importação de cobertura | `.github/workflows/ci.yml`, `sonar-project.properties` | Requer `SONAR_TOKEN`; importa LCOV retido após cobertura | | Gate de cobertura do repositório | Hard gate local contra baixa cobertura | `jest.config.js`, `ci-cd/check-coverage-thresholds.js` | Declarações/linhas/funções/ramos 98%, linhas alteradas 99%; ramos sob piso datado (JUM-721) | | Gate de integridade de testes | Bloqueia suíte que não afirma nada, que só afirma sobre mock, que dorme como sincronização, ou que está fora do mapa | `ci-cd/check-test-integrity.js`, `ci-cd/run-branch-quality-gate.js` | `bun run test:integrity`; preflight de todo caminho do branch gate (JUM-683) | +| Boundaries de workspace + `build:dev` | Arquitetura e emit TypeScript raiz falham fechados antes dos gates baratos | `ci-cd/check-workspace-boundaries.js`, `tsconfig.build.json`, `ci-cd/run-branch-quality-gate.js` | `bun run arch:check-workspace-boundaries` + `bun run build:dev`; preflight de todo caminho do branch-gate (JUM-786) | | Husky | Ganchos Git locais para verificações de qualidade | `.husky/*` | Instalado por `bun run prepare` | | Commitlint + Commitizen | Commits convencionais e fluxo de commits guiados | `commitlint.config.js`, `package.json` | `bun run commit` | | Automação de sincronização do changelog | Mantém `CHANGELOG.md` alinhado com a história do Git | `ci-cd/update-changelog.js`, `.husky/post-commit` | `bun run changelog:update`, `bun run changelog:check` | @@ -218,8 +244,7 @@ Importação de cobertura do SonarQube Cloud: #### Provedor hospedado ativo -GitHub Actions está ativo pelo Requisito 113, CircleCI está desabilitado, e o -workflow roda no runner self-hosted `jumentix` pertencente ao repositório. Ele +GitHub Actions está ativo pelo Requisito 113, CircleCI está habilitado como provedor público secundário de CI, e o workflow roda em runners GitHub-hosted `ubuntu-latest`. Ele roda em `dev`, `main` e pull requests, com Sonar filtrado para as duas branches longas. A publicação Codecov roda depois do gate de cobertura do repositório e nunca substitui esse gate como autoridade de merge. diff --git a/documentation/md/domains/users/ORGANIZATION-MODEL.md b/documentation/md/domains/users/ORGANIZATION-MODEL.md index 379444de5..57acf3785 100644 --- a/documentation/md/domains/users/ORGANIZATION-MODEL.md +++ b/documentation/md/domains/users/ORGANIZATION-MODEL.md @@ -25,7 +25,7 @@ Constructor accepts `RequestCreateOrganization` + optional metadata. ## Relationships -- `@hasMany(() => User)` metadata is declared for `userEntities` to support adapter-level relation mapping while preserving domain contract with `users: string[]`. +- `@hasMany('User')` metadata is declared for `userEntities` to support adapter-level relation mapping while preserving domain contract with `users: string[]`. The relation target is referenced by entity name, which keeps `User` and `Organization` decoupled (no circular import between the two models). ## Aggregate invariants diff --git a/documentation/md/domains/users/ORGANIZATION-MODEL.pt-BR.md b/documentation/md/domains/users/ORGANIZATION-MODEL.pt-BR.md index 5a90356a1..cfe3dc2dd 100644 --- a/documentation/md/domains/users/ORGANIZATION-MODEL.pt-BR.md +++ b/documentation/md/domains/users/ORGANIZATION-MODEL.pt-BR.md @@ -29,7 +29,7 @@ O construtor aceita `RequestCreateOrganization` + metadados opcionais. ## Relacionamentos -- Os metadados `@hasMany(() => User)` são declarados para `userEntities` para suportar o mapeamento de relação em nível de adaptador enquanto preserva o contrato de domínio com `users: string[]`. +- Os metadados `@hasMany('User')` são declarados para `userEntities` para suportar o mapeamento de relação em nível de adaptador enquanto preserva o contrato de domínio com `users: string[]`. O alvo da relação é referenciado pelo nome da entidade, mantendo `User` e `Organization` desacoplados (sem import circular entre os dois models). ## Invariantes agregados diff --git a/documentation/md/guides/USING-SERVICE-MANAGER-AND-DOMAIN-DESIGNER.md b/documentation/md/guides/USING-SERVICE-MANAGER-AND-DOMAIN-DESIGNER.md index e298af99a..2b98f9366 100644 --- a/documentation/md/guides/USING-SERVICE-MANAGER-AND-DOMAIN-DESIGNER.md +++ b/documentation/md/guides/USING-SERVICE-MANAGER-AND-DOMAIN-DESIGNER.md @@ -77,30 +77,33 @@ Install the workspace dependencies once: rtk proxy bun install ``` -## 3. Vendored browser bundles — do this before the first run +## 3. Vendored browser bundles The application is a zero-build SPA that resolves two bare specifiers through -the import map in `index.html`. Both targets are gitignored and must be -generated locally before the first boot: +the import map in `index.html`: `@jumentix/cana` and `@jumentix/designer-core/`. +Both targets live under `apps/service-management/vendor/`, are gitignored, and +are generated locally. -```bash -rtk proxy bun ci-cd/sync-service-management-cana-bundle.js -``` +The dev entry points generate them for you — `dev:service-management` and the +`pm2:start:dev:*` scripts run the vendor step before starting the process, so a +fresh clone boots a working designer with no extra command. + +Generate them by hand when you start the server directly, without PM2: ```bash -rtk proxy bun ci-cd/sync-service-management-designer-core.js +rtk proxy bun run service-management:vendor ``` -The first vendors the Cana browser bundle into -`apps/service-management/vendor/cana/index.js`; the second mirrors +That single script writes the Cana browser bundle to +`apps/service-management/vendor/cana/index.js` and mirrors `packages/designer-core/src` into `apps/service-management/vendor/designer-core/`. -Skipping this step is the single most common first-run failure: the page loads, -the shell renders, and every panel stays inert because the module graph never -resolves. The browser integration suites run both scripts before booting the -server, so a stale bundle can never read as a designer outage in CI — but a -manual run has to do it explicitly. +Without the bundles the page loads, the shell renders, and every panel stays +inert because the module graph never resolves, with repeated `/vendor/...` 404s +in the browser console. The browser integration suites generate them before +booting the server, so a stale bundle can never read as a designer outage in +CI. ## 4. Running the application @@ -125,7 +128,7 @@ rtk proxy bun run dev `dev` maps to `pm2:start:dev:restapi`, which starts `jumentix-dev-service-management` and `jumentix-dev-restapi` from -`pm2/ecosystem.dev.cjs`. Use the realtime variants when you also need a +`pm2/ecosystem.dev.config.cjs`. Use the realtime variants when you also need a WebSocket or gRPC process: ```bash @@ -138,7 +141,7 @@ rtk proxy bun run dev:grpc ```mermaid flowchart LR - CMD["bun run dev"] --> PM2["PM2 · pm2/ecosystem.dev.cjs"] + CMD["bun run dev"] --> PM2["PM2 · pm2/ecosystem.dev.config.cjs"] WS["bun run dev:websocket"] --> PM2 GRPC["bun run dev:grpc"] --> PM2 PM2 --> SM["jumentix-dev-service-management
            :3200"] @@ -155,15 +158,18 @@ NODE_ENV=dev bun apps/service-management/server.js ### 4.4 Environment variables +Every variable below is prefixed `JUMENTIX_SERVICE_MANAGEMENT_`, except +`NODE_ENV`. + | Variable | Default | Purpose | | --- | --- | --- | -| `JUMENTIX_SERVICE_MANAGEMENT_PORT` | `3200` | HTTP port | -| `JUMENTIX_SERVICE_MANAGEMENT_HOST` | `127.0.0.1` | Bind address | -| `JUMENTIX_SERVICE_MANAGEMENT_CONFIG_DIR` | `apps/backend-template/src/config` | Directory the runtime env API reads and writes | -| `JUMENTIX_SERVICE_MANAGEMENT_PM2_DIR` | `pm2/` | Directory the PM2 ecosystem preview reads | -| `JUMENTIX_SERVICE_MANAGEMENT_AUTH_TOKEN` | unset | When set, `POST /api/runtime/env` requires `Authorization: Bearer ` | -| `JUMENTIX_SERVICE_MANAGEMENT_STATIC_MANIFEST_REFRESH` | derived from `NODE_ENV` | `on-miss` re-scans the static manifest on a miss; `boot-only` never re-scans | -| `NODE_ENV` | `dev` | Fallback environment when a request does not name one | +| `…_PORT` | `3200` | HTTP port | +| `…_HOST` | `127.0.0.1` | Bind address | +| `…_CONFIG_DIR` | `apps/backend-template/src/config` | Where the runtime env API reads and writes | +| `…_PM2_DIR` | `pm2/` | Where the PM2 ecosystem preview reads | +| `…_AUTH_TOKEN` | unset | Requires `Authorization: Bearer ` on the env `POST` | +| `…_STATIC_MANIFEST_REFRESH` | from `NODE_ENV` | `on-miss` re-scans the static manifest; `boot-only` never does | +| `NODE_ENV` | `dev` | Fallback environment when a request names none | The server **fails closed at boot** when the configured directory does not exist: it prints `Service Management config directory not found: ` on @@ -174,9 +180,9 @@ Ports per PM2 profile: | Ecosystem | Process | Port | | --- | --- | --- | -| `pm2/ecosystem.dev.cjs` | `jumentix-dev-service-management` | `3200` | -| `pm2/ecosystem.staging.cjs` | `jumentix-staging-service-management` | `4200` | -| `pm2/ecosystem.production.cjs` | `jumentix-prod-service-management` | `5200` | +| `pm2/ecosystem.dev.config.cjs` | `jumentix-dev-service-management` | `3200` | +| `pm2/ecosystem.staging.config.cjs` | `jumentix-staging-service-management` | `4200` | +| `pm2/ecosystem.production.config.cjs` | `jumentix-prod-service-management` | `5200` | ### 4.5 Process control @@ -276,7 +282,7 @@ With an entity selected, the Entity Inspector offers `Save Name`, flag and the invariants (one rule per line). Aggregate roots show an `AR` marker on their card. -![Entity Inspector: aggregate root, invariants, the RBAC matrix, a message contract, OpenAPI composition, fields and the generated CRUD API preview](../../images/service-manager/03-entity-inspector.png "Entity Inspector") +![Entity Inspector: the entity name, aggregate-root flag, invariants and the RBAC matrix](../../images/service-manager/03a-entity-inspector-rules-and-rbac.png "Entity Inspector — rules and RBAC") Fields carry OpenAPI-aligned metadata: @@ -326,8 +332,14 @@ a relationship between the same pair cannot be duplicated. ### 6.5 RBAC, message contracts and OpenAPI composition For each entity and action (`list`, `getById`, `create`, `update`, `delete`), -toggle `superadmin`, `admin` and `user`, plus the tenant-scope flag, then click -`Save RBAC Rule`. +toggle `superadmin`, `admin` and `user`, then click `Save RBAC Rule`. Tenant +scope is **derived from the roles**, not set by hand: `admin` and `user` scope to +their organization, `superadmin` is global, which is what the runtime enforces. +Legacy direct scopes still run but are not editable here. + +![Entity Inspector: message contracts and the OpenAPI composition controls](../../images/service-manager/03b-entity-inspector-contracts-and-composition.png "Entity Inspector — contracts and composition") + +![Entity Inspector: the field editor and the generated OpenAPI CRUD preview](../../images/service-manager/03c-entity-inspector-fields-and-api-preview.png "Entity Inspector — fields and API preview") Declare `event`, `command`, `request` and `response` contracts per entity with a name, channel or topic, version, and a JSON payload schema. `Add Contract` @@ -377,7 +389,9 @@ case, controller and handler. `Generate Examples` renders request and response payload examples. Select an entity to scope the output, or leave nothing selected for the whole canvas. -![Export panel with the code skeleton preview and the generated request and response examples](../../images/service-manager/05-export-panel.png "Export panel") +![Export panel: the export, import and generation buttons](../../images/service-manager/05a-export-and-import-targets.png "Export and import targets") + +![The generated code skeletons and the request and response examples](../../images/service-manager/05b-code-preview-and-examples.png "Code preview and generated examples") | Button | Downloaded file | Use | | --- | --- | --- | @@ -558,7 +572,7 @@ the boot surfaces, never a silent fallback. flowchart TB EDIT["You edit the model"] --> STORE[("Cana · IndexedDB")] STORE --> TABS["Other tabs in this browser
            designerSync"] - STORE --> CAT["Shared catalog
            other users, via the backend Catalogs module"] + STORE --> CAT["Shared catalog
            other users, via the Service Management Catalog API"] STORE --> EXPORT["Export JSON
            the portable backup"] CAT -->|409 on a stale write| CONFLICT["Conflict raised
            take-server or take-local
            the local edit is never discarded"] ``` @@ -611,8 +625,9 @@ flowchart LR ### The page loads but every panel is inert -The vendored bundles are missing. Run both sync scripts from section 3, then -reload. The browser console shows repeated 404s for `/vendor/...` in this +The vendored bundles are missing. Run `bun run service-management:vendor`, then +reload. The dev entry points do this for you; starting `server.js` directly does +not. The browser console shows repeated 404s for `/vendor/...` in this state. ### The server exits immediately diff --git a/documentation/md/guides/USING-SERVICE-MANAGER-AND-DOMAIN-DESIGNER.pt-BR.md b/documentation/md/guides/USING-SERVICE-MANAGER-AND-DOMAIN-DESIGNER.pt-BR.md index 98069592c..e6c023e14 100644 --- a/documentation/md/guides/USING-SERVICE-MANAGER-AND-DOMAIN-DESIGNER.pt-BR.md +++ b/documentation/md/guides/USING-SERVICE-MANAGER-AND-DOMAIN-DESIGNER.pt-BR.md @@ -78,30 +78,33 @@ Instale as dependências do workspace uma vez: rtk proxy bun install ``` -## 3. Bundles vendorizados — faça isso antes do primeiro boot +## 3. Bundles vendorizados -A aplicação é uma SPA sem build que resolve dois specifiers bare pelo import -map do `index.html`. Os dois alvos são gitignorados e precisam ser gerados -localmente antes do primeiro boot: +A aplicação é uma SPA sem build que resolve dois specifiers bare pelo import map +do `index.html`: `@jumentix/cana` e `@jumentix/designer-core/`. Os dois alvos +ficam em `apps/service-management/vendor/`, são gitignorados e são gerados +localmente. -```bash -rtk proxy bun ci-cd/sync-service-management-cana-bundle.js -``` +Os pontos de entrada de desenvolvimento geram para você — `dev:service-management` +e os scripts `pm2:start:dev:*` rodam a etapa de vendor antes de subir o +processo, então um clone novo sobe um designer funcional sem comando extra. + +Gere à mão quando subir o servidor diretamente, sem PM2: ```bash -rtk proxy bun ci-cd/sync-service-management-designer-core.js +rtk proxy bun run service-management:vendor ``` -O primeiro vendoriza o bundle de navegador do Cana em -`apps/service-management/vendor/cana/index.js`; o segundo espelha +Esse único script escreve o bundle de navegador do Cana em +`apps/service-management/vendor/cana/index.js` e espelha `packages/designer-core/src` em `apps/service-management/vendor/designer-core/`. -Pular esta etapa é a falha de primeiro boot mais comum: a página carrega, o -shell aparece e todos os painéis ficam inertes porque o grafo de módulos nunca -resolve. As suítes de integração de navegador rodam os dois scripts antes de -subir o servidor, então um bundle desatualizado nunca se disfarça de falha do -designer no CI — mas uma execução manual precisa fazer isso explicitamente. +Sem os bundles, a página carrega, o shell aparece e todos os painéis ficam +inertes porque o grafo de módulos nunca resolve, com 404 repetidos de +`/vendor/...` no console do navegador. As suítes de integração de navegador +geram os bundles antes de subir o servidor, então um bundle desatualizado nunca +se disfarça de falha do designer no CI. ## 4. Executando a aplicação @@ -126,7 +129,7 @@ rtk proxy bun run dev `dev` aponta para `pm2:start:dev:restapi`, que inicia `jumentix-dev-service-management` e `jumentix-dev-restapi` a partir de -`pm2/ecosystem.dev.cjs`. Use as variantes realtime quando também precisar de um +`pm2/ecosystem.dev.config.cjs`. Use as variantes realtime quando também precisar de um processo WebSocket ou gRPC: ```bash @@ -139,7 +142,7 @@ rtk proxy bun run dev:grpc ```mermaid flowchart LR - CMD["bun run dev"] --> PM2["PM2 · pm2/ecosystem.dev.cjs"] + CMD["bun run dev"] --> PM2["PM2 · pm2/ecosystem.dev.config.cjs"] WS["bun run dev:websocket"] --> PM2 GRPC["bun run dev:grpc"] --> PM2 PM2 --> SM["jumentix-dev-service-management
            :3200"] @@ -156,14 +159,17 @@ NODE_ENV=dev bun apps/service-management/server.js ### 4.4 Variáveis de ambiente +Todas as variáveis abaixo têm o prefixo `JUMENTIX_SERVICE_MANAGEMENT_`, exceto +`NODE_ENV`. + | Variável | Padrão | Finalidade | | --- | --- | --- | -| `JUMENTIX_SERVICE_MANAGEMENT_PORT` | `3200` | Porta HTTP | -| `JUMENTIX_SERVICE_MANAGEMENT_HOST` | `127.0.0.1` | Endereço de bind | -| `JUMENTIX_SERVICE_MANAGEMENT_CONFIG_DIR` | `apps/backend-template/src/config` | Diretório que a API de runtime env lê e grava | -| `JUMENTIX_SERVICE_MANAGEMENT_PM2_DIR` | `pm2/` | Diretório que o preview de ecossistema PM2 lê | -| `JUMENTIX_SERVICE_MANAGEMENT_AUTH_TOKEN` | não definida | Quando definida, `POST /api/runtime/env` exige `Authorization: Bearer ` | -| `JUMENTIX_SERVICE_MANAGEMENT_STATIC_MANIFEST_REFRESH` | derivada de `NODE_ENV` | `on-miss` refaz o manifesto estático em uma falha de busca; `boot-only` nunca refaz | +| `…_PORT` | `3200` | Porta HTTP | +| `…_HOST` | `127.0.0.1` | Endereço de bind | +| `…_CONFIG_DIR` | `apps/backend-template/src/config` | Onde a API de runtime env lê e grava | +| `…_PM2_DIR` | `pm2/` | Onde o preview de ecossistema PM2 lê | +| `…_AUTH_TOKEN` | não definida | Exige `Authorization: Bearer ` no `POST` de ambiente | +| `…_STATIC_MANIFEST_REFRESH` | de `NODE_ENV` | `on-miss` refaz o manifesto estático; `boot-only` nunca refaz | | `NODE_ENV` | `dev` | Ambiente de fallback quando a requisição não informa um | O servidor **falha fechado no boot** quando o diretório configurado não existe: @@ -175,9 +181,9 @@ Portas por perfil PM2: | Ecossistema | Processo | Porta | | --- | --- | --- | -| `pm2/ecosystem.dev.cjs` | `jumentix-dev-service-management` | `3200` | -| `pm2/ecosystem.staging.cjs` | `jumentix-staging-service-management` | `4200` | -| `pm2/ecosystem.production.cjs` | `jumentix-prod-service-management` | `5200` | +| `pm2/ecosystem.dev.config.cjs` | `jumentix-dev-service-management` | `3200` | +| `pm2/ecosystem.staging.config.cjs` | `jumentix-staging-service-management` | `4200` | +| `pm2/ecosystem.production.config.cjs` | `jumentix-prod-service-management` | `5200` | ### 4.5 Controle de processos @@ -278,7 +284,7 @@ Com uma entidade selecionada, o Entity Inspector oferece `Save Name`, agregado e as invariantes (uma regra por linha). Raízes de agregado exibem o marcador `AR` no card. -![Entity Inspector: raiz de agregado, invariantes, matriz RBAC, contrato de mensagem, composição OpenAPI, campos e o preview de API CRUD gerado](../../images/service-manager/03-entity-inspector.png "Entity Inspector") +![Entity Inspector: o nome da entidade, a flag de raiz de agregado, as invariantes e a matriz RBAC](../../images/service-manager/03a-entity-inspector-rules-and-rbac.png "Entity Inspector — regras e RBAC") Os campos carregam metadados alinhados ao OpenAPI: @@ -330,8 +336,15 @@ diferentes, e não é possível duplicar um relacionamento entre o mesmo par. ### 6.5 RBAC, contratos de mensagem e composição OpenAPI Para cada entidade e ação (`list`, `getById`, `create`, `update`, `delete`), -marque `superadmin`, `admin` e `user`, além da flag de escopo por tenant, e -clique em `Save RBAC Rule`. +marque `superadmin`, `admin` e `user` e clique em `Save RBAC Rule`. O escopo por +tenant é **derivado dos papéis**, não definido à mão: `admin` e `user` ficam no +escopo da própria organização e `superadmin` é global, que é o que o runtime +aplica. Escopos diretos legados continuam funcionando, mas não são editáveis +aqui. + +![Entity Inspector: contratos de mensagem e os controles de composição OpenAPI](../../images/service-manager/03b-entity-inspector-contracts-and-composition.png "Entity Inspector — contratos e composição") + +![Entity Inspector: o editor de campos e o preview de CRUD OpenAPI gerado](../../images/service-manager/03c-entity-inspector-fields-and-api-preview.png "Entity Inspector — campos e preview de API") Declare contratos `event`, `command`, `request` e `response` por entidade com nome, canal ou tópico, versão e um schema JSON de payload. `Add Contract` @@ -380,7 +393,9 @@ caso de uso, controller e handler. `Generate Examples` renderiza exemplos de payload de request e response. Selecione uma entidade para limitar a saída, ou deixe nada selecionado para gerar a partir de todo o canvas. -![Painel Export com o preview de esqueleto de código e os exemplos de request e response gerados](../../images/service-manager/05-export-panel.png "Painel Export") +![Painel Export: os botões de exportação, importação e geração](../../images/service-manager/05a-export-and-import-targets.png "Alvos de exportação e importação") + +![Os esqueletos de código gerados e os exemplos de request e response](../../images/service-manager/05b-code-preview-and-examples.png "Preview de código e exemplos gerados") | Botão | Arquivo baixado | Uso | | --- | --- | --- | @@ -566,7 +581,7 @@ fallback silencioso. flowchart TB EDIT["Você edita o modelo"] --> STORE[("Cana · IndexedDB")] STORE --> TABS["Outras abas deste navegador
            designerSync"] - STORE --> CAT["Catálogo compartilhado
            outros usuários, via módulo Catalogs do backend"] + STORE --> CAT["Catálogo compartilhado
            outros usuários, via API de Catálogo do Service Management"] STORE --> EXPORT["Export JSON
            o backup portátil"] CAT -->|409 em escrita obsoleta| CONFLICT["Conflito levantado
            take-server ou take-local
            a edição local nunca é descartada"] ``` @@ -621,8 +636,9 @@ flowchart LR ### A página carrega mas todos os painéis ficam inertes -Faltam os bundles vendorizados. Rode os dois scripts de sync da seção 3 e -recarregue. Nesse estado o console do navegador mostra 404 repetidos para +Faltam os bundles vendorizados. Rode `bun run service-management:vendor` e +recarregue. Os pontos de entrada de desenvolvimento fazem isso por você; subir o +`server.js` diretamente, não. Nesse estado o console do navegador mostra 404 repetidos para `/vendor/...`. ### O servidor sai imediatamente diff --git a/jest.config.js b/jest.config.js index 4a31d966c..20d9de93c 100644 --- a/jest.config.js +++ b/jest.config.js @@ -56,6 +56,7 @@ module.exports = { coverageDirectory: 'coverage', testEnvironment: 'node', moduleNameMapper: { + '^@service-management-api/(.*)$': '/apps/service-management-api/src/$1', '@src/(.*)$': '/apps/backend-template/src/$1', '@seed/(.*)$': '/apps/backend-template/seed/$1', '@test/(.*)$': '/apps/backend-template/test/$1', @@ -92,11 +93,18 @@ module.exports = { '/packages/[^/]+/test/', // Test fixtures and helpers are instruments, not product coverage subjects. '/apps/backend-template/test/', + '/apps/service-management/test/', + '/apps/service-management-api/test/', + // apps/frontend/template/ is the vendored CoreUI demo seed, not a product + // coverage subject: apps/frontend/AGENTS.md forbids editing it, and the + // frontend's own coverage run (`coverage/frontend`) does not measure it. + // The patch-coverage checker reads this same list (JUM-821). + '/apps/frontend/template/', // ci-cd is excluded from coverage wholesale, with named opt-ins. Sonar reads // this same lcov, so a new ci-cd file that is not listed here reports as 0% // covered on new code and fails the quality gate even when it has tests. // Keep this list and the suites under test/unit/ci-cd/ in step. - '/ci-cd/(?!(lib/mapped-suites|check-canonical-integrations|check-bun-version|check-commit-authorship|check-coverage-thresholds|check-dependency-override-integrity|check-package-suites|run-full-test-matrix|run-suite)\\.js$)', + '/ci-cd/(?!(lib/mapped-suites|check-canonical-integrations|check-bun-version|check-commit-authorship|check-coverage-thresholds|check-dependency-override-integrity|check-package-suites|merge-coverage-reports|run-full-test-matrix|run-suite)\\.js$)', '/apps/backend-template/src/modules/Users/adapters/out/persistence/UserDataRepository.ts', '/apps/backend-template/src/modules/Users/adapters/out/persistence/OrganizationDataRepository.ts' ], diff --git a/package.json b/package.json index 8e8f74111..e12b4ab42 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,8 @@ "changelog:update": "bun ci-cd/update-changelog.js", "changelog:check": "bun ci-cd/update-changelog.js --check", "oas:check-routes": "bun ci-cd/check-oas-route-resolution.js", + "oas:check-relations": "bun ci-cd/check-oas-relations.js", + "entities:purge-tombstones": "bun ci-cd/purge-tombstones.js", "deps:check-cycles": "bun ci-cd/check-core-import-cycles.js", "arch:check-boundaries": "bun ci-cd/check-hexagonal-boundaries.js", "arch:check-users-legacy-imports": "bun ci-cd/check-users-legacy-imports.js", @@ -72,7 +74,7 @@ "ci:smoke": "JUMENTIX_JWT_TOKEN_SECRET_KEY=${JUMENTIX_JWT_TOKEN_SECRET_KEY:-ci_jwt_secret_key} NODE_ENV=ci bun ci-cd/run-api-smoke.js", "ci:integration": "JUMENTIX_JWT_TOKEN_SECRET_KEY=${JUMENTIX_JWT_TOKEN_SECRET_KEY:-ci_jwt_secret_key} bun ci-cd/run-integration-tests.js", "ci:security-smoke": "NODE_ENV=ci bun ci-cd/run-security-smoke.js", - "ci:gate": "bun run check-bun-version && bun run deps:check-overrides && bun run deps:audit && bun run lint && bun run deps:check-cycles && bun run arch:check-boundaries && bun run arch:check-users-legacy-imports && bun run arch:check-workspace-boundaries && bun run arch:check-http-adapters && bun run workspace:check-quality && bun run workspace:check-coverage-policy && bun run release:governance:check && bun run governance:check-authorship && bun run requirements:check && bun run packages:check-suites && bun run packages:check-build-freshness && bun run website:check-content-routes && bun run rtdb:check-indexes && bun run test:integrity && bun run test-map:check && bun run ci:check-provider && bun run ci:check-third-party-review && bun run integrations:check && bun run integration-migration:check && bun run agent-registry:check && bun run test:unit && bun run ci:security-smoke && bun run oas:check-routes && bun run serverless:check-handlers && bun run build:dev && bun run ci:smoke", + "ci:gate": "bun run check-bun-version && bun run deps:check-overrides && bun run deps:audit && bun run lint && bun run deps:check-cycles && bun run arch:check-boundaries && bun run arch:check-users-legacy-imports && bun run arch:check-workspace-boundaries && bun run arch:check-http-adapters && bun run workspace:check-quality && bun run workspace:check-coverage-policy && bun run release:governance:check && bun run governance:check-authorship && bun run requirements:check && bun run packages:check-suites && bun run packages:check-build-freshness && bun run website:check-content-routes && bun run rtdb:check-indexes && bun run test:integrity && bun run test-map:check && bun run ci:check-provider && bun run ci:check-third-party-review && bun run integrations:check && bun run integration-migration:check && bun run agent-registry:check && bun run test:unit && bun run frontend:test:coverage && bun run frontend:coverage:check && bun run ci:security-smoke && bun run oas:check-routes && bun run oas:check-relations && bun run serverless:check-handlers && bun run build:dev && bun run ci:smoke", "ci:gate:branch": "bun ci-cd/run-branch-quality-gate.js", "ci:gate:task": "bun ci-cd/run-task-change-tests.js", "ci:gate:strict": "bun ci-cd/run-full-test-matrix.js", @@ -102,15 +104,15 @@ "pm2:logs": "pm2 logs", "pm2:stop:all": "pm2 stop all", "pm2:delete:all": "pm2 delete all", - "pm2:start:dev:restapi": "pm2 start ./pm2/ecosystem.dev.cjs --only jumentix-dev-service-management,jumentix-dev-restapi --update-env", - "pm2:start:dev:websocket-rest": "pm2 start ./pm2/ecosystem.dev.cjs --only jumentix-dev-service-management,jumentix-dev-restapi,jumentix-dev-websocketapi --update-env", - "pm2:start:dev:grpc-rest": "pm2 start ./pm2/ecosystem.dev.cjs --only jumentix-dev-service-management,jumentix-dev-restapi,jumentix-dev-grpcapi --update-env", - "pm2:start:staging:restapi": "pm2 start ./pm2/ecosystem.staging.cjs --only jumentix-staging-service-management,jumentix-staging-restapi --update-env", - "pm2:start:staging:websocket-rest": "pm2 start ./pm2/ecosystem.staging.cjs --only jumentix-staging-service-management,jumentix-staging-restapi,jumentix-staging-websocketapi --update-env", - "pm2:start:staging:grpc-rest": "pm2 start ./pm2/ecosystem.staging.cjs --only jumentix-staging-service-management,jumentix-staging-restapi,jumentix-staging-grpcapi --update-env", - "pm2:start:prod:restapi": "pm2 start ./pm2/ecosystem.production.cjs --only jumentix-prod-service-management,jumentix-prod-restapi --update-env", - "pm2:start:prod:websocket-rest": "pm2 start ./pm2/ecosystem.production.cjs --only jumentix-prod-service-management,jumentix-prod-restapi,jumentix-prod-websocketapi --update-env", - "pm2:start:prod:grpc-rest": "pm2 start ./pm2/ecosystem.production.cjs --only jumentix-prod-service-management,jumentix-prod-restapi,jumentix-prod-grpcapi --update-env", + "pm2:start:dev:restapi": "bun run service-management:vendor && pm2 start ./pm2/ecosystem.dev.config.cjs --only jumentix-dev-service-management,jumentix-dev-restapi,jumentix-dev-purge-tombstones --update-env", + "pm2:start:dev:websocket-rest": "bun run service-management:vendor && pm2 start ./pm2/ecosystem.dev.config.cjs --only jumentix-dev-service-management,jumentix-dev-restapi,jumentix-dev-websocketapi,jumentix-dev-purge-tombstones --update-env", + "pm2:start:dev:grpc-rest": "bun run service-management:vendor && pm2 start ./pm2/ecosystem.dev.config.cjs --only jumentix-dev-service-management,jumentix-dev-restapi,jumentix-dev-grpcapi,jumentix-dev-purge-tombstones --update-env", + "pm2:start:staging:restapi": "pm2 start ./pm2/ecosystem.staging.config.cjs --only jumentix-staging-service-management,jumentix-staging-restapi --update-env", + "pm2:start:staging:websocket-rest": "pm2 start ./pm2/ecosystem.staging.config.cjs --only jumentix-staging-service-management,jumentix-staging-restapi,jumentix-staging-websocketapi --update-env", + "pm2:start:staging:grpc-rest": "pm2 start ./pm2/ecosystem.staging.config.cjs --only jumentix-staging-service-management,jumentix-staging-restapi,jumentix-staging-grpcapi --update-env", + "pm2:start:prod:restapi": "pm2 start ./pm2/ecosystem.production.config.cjs --only jumentix-prod-service-management,jumentix-prod-restapi --update-env", + "pm2:start:prod:websocket-rest": "pm2 start ./pm2/ecosystem.production.config.cjs --only jumentix-prod-service-management,jumentix-prod-restapi,jumentix-prod-websocketapi --update-env", + "pm2:start:prod:grpc-rest": "pm2 start ./pm2/ecosystem.production.config.cjs --only jumentix-prod-service-management,jumentix-prod-restapi,jumentix-prod-grpcapi --update-env", "commit": "bun run lint && bun run test && bun ci-cd/bumpPackage.ts && git add . && git-cz", "lint": "eslint . --ext .ts", "lint:fix": "eslint . --ext .ts --fix", @@ -202,7 +204,8 @@ "cli": "bun run dev:cli", "cli:bootstrap": "node ./bin/jumentix-bootstrap.js", "dev:cli": "bun -r tsconfig-paths/register ./apps/backend-template/src/interface/CLI/index.ts", - "dev:service-management": "pm2 start ./apps/service-management/server.js --name jumentix-dev-service-management --interpreter bun --update-env", + "service-management:vendor": "bun ci-cd/sync-service-management-cana-bundle.js && bun ci-cd/sync-service-management-designer-core.js && bun ci-cd/sync-service-management-d3.js", + "dev:service-management": "bun run service-management:vendor && pm2 start ./apps/service-management/server.js --name jumentix-dev-service-management --interpreter bun --update-env", "start:cli": "bun run dev:cli", "prod:http": "pm2 start ./.build/apps/backend-template/src/interface/HTTP/adapters/start-rest-api.js --name jumentix-prod-http --interpreter bun --interpreter-args='--env-file=./.build/apps/backend-template/src/config/.env.prod' --update-env", "prod:express": "JUMENTIX_HTTP_FRAMEWORK=express pm2 start ./.build/apps/backend-template/src/interface/HTTP/adapters/start-rest-api.js --name jumentix-prod-express --interpreter bun --interpreter-args='--env-file=./.build/apps/backend-template/src/config/.env.prod' --update-env", @@ -284,7 +287,7 @@ "coverage:merge": "bun ci-cd/merge-coverage-reports.js", "workspace:test": "bun ci-cd/run-workspace-tests.js", "quarantine:flake": "bun ci-cd/quarantine-flake.js", - "test:coverage": "NODE_ENV=dev bun x jest apps/backend-template/test/unit 'packages/[^/]+/test' --coverage --coverageThreshold='{}' --forceExit", + "test:coverage": "NODE_ENV=dev bun x jest apps/backend-template/test/unit apps/service-management/test/unit apps/service-management-api/test/unit 'packages/[^/]+/test' --coverage --coverageThreshold='{}' --forceExit", "coverage:check": "bun ci-cd/check-coverage-thresholds.js", "test:browser": "bun ci-cd/run-browser-tests.js", "test:integration:key-value": "bun ci-cd/run-redis-key-value-integration.js", @@ -299,7 +302,11 @@ "website:check-content-routes": "bun ci-cd/check-website-content-routes.js", "rtdb:check-indexes": "bun ci-cd/check-rtdb-indexes.js", "test:integrity": "bun ci-cd/check-test-integrity.js", - "rtdb:export-rules": "bun ci-cd/export-database-rules.js" + "rtdb:export-rules": "bun ci-cd/export-database-rules.js", + "frontend:test:unit": "bun run --filter @jumentix/frontend test", + "frontend:test:coverage": "bun run --filter @jumentix/frontend test:coverage", + "frontend:coverage:check": "bun ci-cd/check-frontend-coverage.js", + "frontend:test:e2e": "bun run --filter @jumentix/frontend test:e2e" }, "dependencies": { "@aws-sdk/client-dynamodb": "^3.940.0", @@ -309,6 +316,7 @@ "@fastify/static": "^10.1.1", "@grpc/grpc-js": "^1.14.1", "@grpc/proto-loader": "^0.8.0", + "@loopback/repository": "^8.0.14", "@socket.io/cluster-adapter": "^0.3.0", "@socket.io/redis-streams-adapter": "^0.3.1", "amqplib": "^2.0.1", @@ -407,7 +415,8 @@ }, "patchedDependencies": { "nextra-theme-docs@4.6.1": "patches/nextra-theme-docs@4.6.1.patch", - "@theguild/remark-mermaid@0.3.0": "patches/@theguild%2Fremark-mermaid@0.3.0.patch" + "@theguild/remark-mermaid@0.3.0": "patches/@theguild%2Fremark-mermaid@0.3.0.patch", + "@coreui/icons-vue@2.2.0": "patches/@coreui%2Ficons-vue@2.2.0.patch" }, "config": { "commitizen": { @@ -416,9 +425,9 @@ }, "license": "MIT", "bugs": { - "url": "https://github.com/XpertMinds/Jumentix/issues" + "url": "https://github.com/web2solutions/Jumentix/issues" }, - "homepage": "https://github.com/XpertMinds/Jumentix#readme", + "homepage": "https://github.com/web2solutions/Jumentix#readme", "author": { "name": "Eduardo Almeida", "url": "https://github.com/XpertMinds", @@ -443,6 +452,13 @@ "protobufjs": "^7.6.5", "adm-zip": "^0.6.0", "dompurify": "^3.4.13", - "undici": "^6.28.0" + "undici": "^6.28.0", + "vue-eslint-parser": { + "eslint-scope": "^9.1.2" + } + }, + "repository": { + "type": "git", + "url": "git+https://github.com/web2solutions/Jumentix.git" } } diff --git a/packages/agent-registry/src/firebase-credentials.ts b/packages/agent-registry/src/firebase-credentials.ts index e4a61a9af..2a1b8218f 100644 --- a/packages/agent-registry/src/firebase-credentials.ts +++ b/packages/agent-registry/src/firebase-credentials.ts @@ -17,7 +17,12 @@ export function defaultDatabaseUrl(projectId: string): string { /** Normalize explicit RTDB URLs so a trailing slash does not fork config. */ export function normalizeDatabaseUrl(url: string): string { - return url.trim().replace(/\/+$/, ''); + const trimmed = url.trim(); + let end = trimmed.length; + while (end > 0 && trimmed.charAt(end - 1) === '/') { + end -= 1; + } + return trimmed.slice(0, end); } function parseServiceAccountJson(raw: string, source: string): Record { diff --git a/packages/agent-registry/src/validation.ts b/packages/agent-registry/src/validation.ts index 536b2f2a9..e401747e7 100644 --- a/packages/agent-registry/src/validation.ts +++ b/packages/agent-registry/src/validation.ts @@ -170,6 +170,23 @@ function stringFieldProblem( return undefined; } +/** + * Strip leading and trailing backtick runs without a regex — even anchored + * `x+`/`x+$` patterns are a polynomial-backtracking surface to static + * analysis, and a two-pointer walk is linear by construction. + */ +function stripBackticks(value: string): string { + let start = 0; + let end = value.length; + while (start < end && value.charAt(start) === '`') { + start += 1; + } + while (end > start && value.charAt(end - 1) === '`') { + end -= 1; + } + return value.slice(start, end); +} + /** * The id a corrupt document should have had. * @@ -178,7 +195,7 @@ function stringFieldProblem( * an id nobody understands is how one bad record becomes two. */ export function canonicalAgentId(rawAgentId: string): string { - return String(rawAgentId).trim().replace(/^`+|`+$/g, '').trim(); + return stripBackticks(String(rawAgentId).trim()).trim(); } /** diff --git a/packages/cana-react/test/hooks.test.ts b/packages/cana-react/test/hooks.test.ts index 39ba23236..f03c55ff8 100644 --- a/packages/cana-react/test/hooks.test.ts +++ b/packages/cana-react/test/hooks.test.ts @@ -495,3 +495,83 @@ describe('useCanaLiveQuery (JUM-681)', () => { expect(queries).toBe(1); }); }); + +describe('failure paths at the effect boundary (JUM-821)', () => { + it('swallows a refused close when the component goes away', async () => { + expect.hasAssertions(); + + // A close that rejects during unmount must not become an unhandled + // rejection: the component is already gone, there is no one left to tell. + let closes = 0; + const client = { + open: async () => undefined, + close: async () => { closes += 1; throw new Error('close refused'); }, + subscribe: () => () => undefined, + table: () => ({ query: async () => [] }) + }; + const { latest, unmount } = await renderHook(() => useCanaClient(() => client as never)); + expect(latest.current.status).toBe('ready'); + + await unmount(); + + expect(closes).toBe(1); + }); + + it('surfaces a failed initial load even when the error reporter throws', async () => { + expect.hasAssertions(); + + // `reload` reports through `onError` from inside its own catch; when THAT + // call throws, the rejection crosses to the effect's `.catch`, which reports + // the reporter's failure — the load error is never dropped silently. + const broker = brokerDouble({ failQuery: true }); + const onError = jest.fn() + .mockImplementationOnce(() => { throw new Error('reporting failed'); }) + .mockImplementation(() => undefined); + + const { latest } = await renderHook(() => useCanaLiveQuery({ + client: broker.client, + store: 'rows', + onError + })); + + expect(latest.current.status).toBe('error'); + expect(onError.mock.calls.map(([error]) => (error as Error).message)) + .toStrictEqual(['query refused', 'reporting failed']); + }); + + it('surfaces a failed event-triggered reload even when the error reporter throws', async () => { + expect.hasAssertions(); + + // Same boundary, one level down: the subscription callback's own + // `safeReload().catch(...)` is what must not lose the failure. + const broker = brokerDouble(); + const query = jest.fn() + .mockResolvedValueOnce([{ id: 'a', name: 'A' }]) + .mockRejectedValue(new Error('query refused')); + const client = { + subscribe: (broker.client as unknown as { + subscribe: (next: (event: CanaChangeEvent) => void) => () => void; + }).subscribe, + table: () => ({ query }) + }; + const onError = jest.fn() + .mockImplementationOnce(() => { throw new Error('reporting failed'); }) + .mockImplementation(() => undefined); + + const { latest, flush } = await renderHook(() => useCanaLiveQuery({ + client: client as never, + store: 'rows', + query: { limit: 10 }, + onError + })); + expect(latest.current.status).toBe('ready'); + expect(onError).not.toHaveBeenCalled(); + + await act(async () => { broker.emit({}); }); + await flush(); + + expect(onError.mock.calls.map(([error]) => (error as Error).message)) + .toStrictEqual(['query refused', 'reporting failed']); + expect(latest.current.status).toBe('error'); + }); +}); diff --git a/packages/cana-vue/test/composables.test.ts b/packages/cana-vue/test/composables.test.ts index fa1084545..0c666452e 100644 --- a/packages/cana-vue/test/composables.test.ts +++ b/packages/cana-vue/test/composables.test.ts @@ -425,3 +425,107 @@ describe('connectCanaToPinia (JUM-681)', () => { expect(errors).toHaveLength(1); }); }); + +describe('no-op and reporting boundaries (JUM-821)', () => { + it('stops cleanly when it never subscribed', () => { + expect.hasAssertions(); + + // With no client, mount subscribes nothing; `stop` must still be a safe + // call — a teardown path runs it unconditionally. + const { result } = mounted(() => useCanaSubscription(null, () => undefined)); + + expect(() => result.stop()).not.toThrow(); + }); + + it('stays stopped when stop follows unmount', () => { + expect.hasAssertions(); + + const broker = brokerDouble(); + const { result, unmount } = mounted(() => useCanaSubscription(broker.client, () => undefined)); + + unmount(); + expect(broker.stops()).toBe(1); + + // The second stop is a no-op against the reset handle, not a second + // unsubscribe — a broker that counted it would drop a live listener twice. + expect(() => result.stop()).not.toThrow(); + expect(broker.stops()).toBe(1); + }); + + it('stops a live query that never started, and after unmount', async () => { + expect.hasAssertions(); + + const none = mounted(() => useCanaLiveQuery({ client: null, store: 'rows' })); + expect(none.result.status.value).toBe('idle'); + expect(() => none.result.stop()).not.toThrow(); + + const broker = brokerDouble({ rows: [] }); + const { result, unmount } = mounted(() => useCanaLiveQuery({ + client: broker.client, + store: 'rows' + })); + await settle(); + unmount(); + expect(broker.stops()).toBe(1); + expect(() => result.stop()).not.toThrow(); + expect(broker.stops()).toBe(1); + }); + + it('surfaces a failed mount-time load even when the error reporter throws', async () => { + expect.hasAssertions(); + + // `reload` reports from inside its own catch; when that report throws, the + // rejection crosses to the mount hook's `.catch`, which reports the + // reporter's failure — the load error is never dropped silently. + const broker = brokerDouble({ failQuery: true }); + const onError = jest.fn() + .mockImplementationOnce(() => { throw new Error('reporting failed'); }) + .mockImplementation(() => undefined); + + const { result } = mounted(() => useCanaLiveQuery({ + client: broker.client, + store: 'rows', + onError + })); + await settle(); + + expect(result.status.value).toBe('error'); + expect(onError.mock.calls.map(([error]) => (error as Error).message)) + .toStrictEqual(['query refused', 'reporting failed']); + }); + + it('surfaces a failed event-triggered reload even when the error reporter throws', async () => { + expect.hasAssertions(); + + const broker = brokerDouble(); + const query = jest.fn() + .mockResolvedValueOnce([{ id: 'a', name: 'A' }]) + .mockRejectedValue(new Error('query refused')); + const client = { + subscribe: (broker.client as unknown as { + subscribe: (next: (event: CanaChangeEvent) => void) => () => void; + }).subscribe, + table: () => ({ query }) + }; + const onError = jest.fn() + .mockImplementationOnce(() => { throw new Error('reporting failed'); }) + .mockImplementation(() => undefined); + + const { result } = mounted(() => useCanaLiveQuery({ + client: client as never, + store: 'rows', + query: { limit: 10 }, + onError + })); + await settle(); + expect(result.status.value).toBe('ready'); + expect(onError).not.toHaveBeenCalled(); + + broker.emit({}); + await settle(); + + expect(onError.mock.calls.map(([error]) => (error as Error).message)) + .toStrictEqual(['query refused', 'reporting failed']); + expect(result.status.value).toBe('error'); + }); +}); diff --git a/packages/cana/README.md b/packages/cana/README.md index 5a61db26e..ced7bedf2 100644 --- a/packages/cana/README.md +++ b/packages/cana/README.md @@ -2,6 +2,9 @@ IndexedDB offline database adapter for Jumentix applications. +The frontend seed (`apps/frontend`) opens Cana before login and syncs through +the OAS: [Frontend offline data layer](../../documentation/md/FRONTEND-OFFLINE-DATA-LAYER.md). +
            { backend.deletePersisted(); expect(storage.getItem(`cana.ls.v1:${name}`)).to.equal(null); }); + + it('rejects an autoIncrement keyPath that walks the prototype chain', async () => { + // `writePath` assigns segment by segment; an unguarded `__proto__.polluted` + // keyPath would land the generated key on Object.prototype instead of the + // record. The guard fails the write closed. + const backend = openLocalStorageBackend({ + name: uniqueName('ls-proto'), + schema: { + version: 1, + stores: [{ name: 'entries', keyPath: '__proto__.polluted', autoIncrement: true }] + }, + storage: memoryStorage(), + originId: 'o', + nextCursor: () => 1 + }); + const failure = await rejection(backend.transaction( + 'readwrite', + ['entries'], + async (scope) => scope.table, number>('entries').add({ name: 'x' }), + 'c:proto' + )); + expect(isCanaErrorCode(failure, 'InvalidRequest')).to.equal(true); + expect((Object.prototype as Record).polluted).to.equal(undefined); + }); + + it('refuses a store whose name is a prototype-chain key', async () => { + // `bag()` resolves `snapshot.stores[name]`; with `name === '__proto__'` + // that read is Object.prototype itself and every record write would land + // on the global prototype. + const backend = openLocalStorageBackend({ + name: uniqueName('ls-proto-store'), + schema: { version: 1, stores: [{ name: '__proto__' }] }, + storage: memoryStorage(), + originId: 'o', + nextCursor: () => 1 + }); + const failure = await rejection(backend.transaction( + 'readwrite', + ['__proto__'], + async (scope) => scope.table, string>('__proto__').add({ name: 'x' }, 'k1'), + 'c:proto-store' + )); + expect(isCanaErrorCode(failure, 'InvalidRequest')).to.equal(true); + expect((Object.prototype as Record)['"k1"']).to.equal(undefined); + }); }); diff --git a/packages/cana/src/core/local-storage-backend.ts b/packages/cana/src/core/local-storage-backend.ts index 0cd8acb83..1ccc3e2df 100644 --- a/packages/cana/src/core/local-storage-backend.ts +++ b/packages/cana/src/core/local-storage-backend.ts @@ -105,6 +105,14 @@ function readPath(record: unknown, path: string | readonly string[]): unknown { function writePath(record: Record, path: string, value: unknown): void { const segments = path.split('.'); + for (const segment of segments) { + if (segment === '__proto__' || segment === 'constructor' || segment === 'prototype') { + throw canaError( + 'InvalidRequest', + `keyPath "${path}" is not writable: segment "${segment}" would mutate the prototype chain.` + ); + } + } let cursor: Record = record; for (let i = 0; i < segments.length - 1; i += 1) { const segment = segments[i]!; @@ -479,6 +487,17 @@ export class LocalStorageBackend { const schema = storeSchema(this.options.schema, name); const bag = (): Record => { + // A store named after a prototype-chain key would make the dynamic read + // below resolve to Object.prototype (or the Object constructor) itself, + // and every record write would then land on it — refuse the name before + // the read, instead of relying on the schema never containing it. + if (name === '__proto__' || name === 'constructor' || name === 'prototype') { + throw canaError( + 'InvalidRequest', + `Store name "${name}" cannot be used as a snapshot property key.`, + { store: name } + ); + } // eslint-disable-next-line no-param-reassign -- TxState is the mutable scratch snapshot working.snapshot.stores[name] ??= {}; return working.snapshot.stores[name]!; diff --git a/packages/cli-init/src/bootstrap.js b/packages/cli-init/src/bootstrap.js index 1dc316af2..2c5e1fc0c 100644 --- a/packages/cli-init/src/bootstrap.js +++ b/packages/cli-init/src/bootstrap.js @@ -4,7 +4,7 @@ const path = require('path'); const readline = require('readline'); const { spawnSync } = require('child_process'); -const BOILERPLATE_REPOSITORY = 'https://github.com/XpertMinds/Jumentix.git'; +const BOILERPLATE_REPOSITORY = 'https://github.com/web2solutions/Jumentix.git'; const SERVICE_TYPES = [ { id: 'rest', @@ -249,7 +249,11 @@ async function run(options = {}) { const repository = cliArgs.repository || BOILERPLATE_REPOSITORY; log('\nCloning boilerplate repository...'); - execute('git', ['clone', '--branch', gitBranch, repository, targetPath], workingDirectory); + // The directory argument is the name the operator gave, which git resolves + // against the working directory it already runs in — the resolved absolute + // path adds nothing to the command line. `--` keeps a leading-dash + // repository or folder name from being read as an option. + execute('git', ['clone', '--branch', gitBranch, '--', repository, projectName], workingDirectory); writeBootstrapProfile(targetPath, { generatedAt: new Date().toISOString(), diff --git a/packages/cli-init/test/bootstrap.test.ts b/packages/cli-init/test/bootstrap.test.ts index 687bb86bf..4556d7919 100644 --- a/packages/cli-init/test/bootstrap.test.ts +++ b/packages/cli-init/test/bootstrap.test.ts @@ -609,7 +609,7 @@ describe('run', () => { }); expect(commands.calls[0].args).toStrictEqual([ - 'clone', '--branch', 'main', BOILERPLATE_REPOSITORY, path.join(workspace, 'svc') + 'clone', '--branch', 'main', '--', BOILERPLATE_REPOSITORY, 'svc' ]); }); @@ -630,8 +630,8 @@ describe('run', () => { }); expect(commands.calls[0].args).toStrictEqual([ - 'clone', '--branch', 'develop', BOILERPLATE_REPOSITORY, - path.join(workspace, 'from-prompts') + 'clone', '--branch', 'develop', '--', BOILERPLATE_REPOSITORY, + 'from-prompts' ]); expect(JSON.parse( fs.readFileSync(path.join(workspace, 'from-prompts', '.jumentix', 'service-profile.json'), 'utf8') @@ -729,11 +729,11 @@ describe('repository policy', () => { * fact, not an implementation detail: change it and every service scaffolded * from this template comes from somewhere else. */ - it('clones only from the canonical XpertMinds application repository by default', () => { + it('clones only from the canonical web2solutions application repository by default', () => { expect.hasAssertions(); - expect(BOILERPLATE_REPOSITORY).toBe('https://github.com/XpertMinds/Jumentix.git'); - expect(BOILERPLATE_REPOSITORY).not.toContain('web2solutions'); + expect(BOILERPLATE_REPOSITORY).toBe('https://github.com/web2solutions/Jumentix.git'); + expect(BOILERPLATE_REPOSITORY).not.toContain('XpertMinds/Jumentix'); }); }); diff --git a/packages/dead-letter-queue/test/dead-letter-replay-worker.test.ts b/packages/dead-letter-queue/test/dead-letter-replay-worker.test.ts index 01a106c15..82beda286 100644 --- a/packages/dead-letter-queue/test/dead-letter-replay-worker.test.ts +++ b/packages/dead-letter-queue/test/dead-letter-replay-worker.test.ts @@ -176,4 +176,67 @@ describe('deadLetterReplayWorker (JUM-53)', () => { queue: {} as never, handlers: { a: async () => undefined }, intervalMs: 0 })).toThrow('positive intervalMs'); }); + + it('stopping before ever starting is a no-op, not an error', () => { + expect.hasAssertions(); + + // A shutdown path that stops the worker unconditionally must not trip on + // one that was never started — and must not cancel a timer it never set. + const timers = fakeTimers(); + const queue = queueDouble(); + const clearIntervalSpy = jest.fn(timers.clearIntervalFn); + const worker = new DeadLetterReplayWorker({ + queue: queue as never, + handlers: { update: async () => undefined }, + setIntervalFn: timers.setIntervalFn, + clearIntervalFn: clearIntervalSpy as never + }); + + worker.stop(); + + expect(worker.running).toBe(false); + expect(clearIntervalSpy).not.toHaveBeenCalled(); + }); + + it('survives an error reporter that throws on the interval path', async () => { + expect.hasAssertions(); + + // The interval callback has its own `.catch`: when the drain fails AND the + // `onError` reporter throws while reporting it, the rejection is swallowed + // at the timer boundary instead of becoming an unhandled rejection that + // kills the process — and the worker keeps its schedule. + const timers = fakeTimers(); + const queue = queueDouble(); + queue.replay.mockRejectedValue(new Error('redis unreachable')); + let reports = 0; + const worker = new DeadLetterReplayWorker({ + queue: queue as never, + handlers: { update: async () => undefined }, + setIntervalFn: timers.setIntervalFn, + clearIntervalFn: timers.clearIntervalFn, + onError: () => { reports += 1; throw new Error('reporter down'); } + }); + + worker.start(); + timers.fire(); + // The tick behind the interval callback is a floating promise; two + // microtask turns let it settle before the assertions. + await Promise.resolve(); + await Promise.resolve(); + + expect(queue.replay).toHaveBeenCalledTimes(1); + expect(reports).toBe(1); + expect(worker.running).toBe(true); + + // The failed reporter did not wedge the drain latch: the next tick runs. + queue.replay.mockResolvedValue({ + replayed: ['dlq-9'], retried: [], abandoned: [], skipped: [] + }); + timers.fire(); + await Promise.resolve(); + await Promise.resolve(); + + expect(queue.replay).toHaveBeenCalledTimes(2); + worker.stop(); + }); }); diff --git a/packages/designer-core/package.json b/packages/designer-core/package.json index 869ec1143..2a24672b9 100644 --- a/packages/designer-core/package.json +++ b/packages/designer-core/package.json @@ -33,7 +33,7 @@ }, "repository": { "type": "git", - "url": "https://github.com/XpertMinds/Jumentix.git", + "url": "https://github.com/web2solutions/Jumentix.git", "directory": "packages/designer-core" }, "keywords": [ diff --git a/packages/designer-core/src/codegen/hexagonalCodegen.js b/packages/designer-core/src/codegen/hexagonalCodegen.js index de7f6ce3a..2ddf20b80 100644 --- a/packages/designer-core/src/codegen/hexagonalCodegen.js +++ b/packages/designer-core/src/codegen/hexagonalCodegen.js @@ -36,9 +36,10 @@ * application ports, and only the composition root wires adapters to ports — * so the output passes `ci-cd/check-hexagonal-boundaries.js` and compiles * under `tsc --strict`. Both properties are pinned by - * `apps/backend-template/test/unit/service-management/hexagonalCodegen.test.ts`. + * `apps/service-management/test/unit/hexagonalCodegen.test.ts`. */ +import { isBarePropertyKey } from '../model/propertyKeys.js'; import { getEntityRbacPolicy, toSchemaName } from '../model/modelQueries.js'; const MODULES_ROOT = 'src/modules'; @@ -80,7 +81,9 @@ function toInstanceToken(value, fallback) { /** Object-literal/property key: bare when a valid identifier, quoted otherwise. */ function toPropertyKey(name) { - return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name); + // JUM-731: the rule lives in model/propertyKeys.js so the designer's warning + // and this quoting decision cannot drift apart. + return isBarePropertyKey(name) ? name : JSON.stringify(name); } /** Map an OAS schema (from the JUM-474 export) to a TypeScript type. */ diff --git a/packages/designer-core/src/exporters/designerExporters.js b/packages/designer-core/src/exporters/designerExporters.js index 492087141..567ba5e36 100644 --- a/packages/designer-core/src/exporters/designerExporters.js +++ b/packages/designer-core/src/exporters/designerExporters.js @@ -56,7 +56,7 @@ const DEFAULT_RBAC_POLICY = getDefaultRbacPolicy(); * `exportAsJson` payload: the full-suite document (JUM-547, Requirement 126 * Contract 3). The pre-JUM-547 shape carried `{ domains, relationships, view }` * only — a four-tab design exported as one tab. The document now carries all - * four tabs, schema-versioned (`kind` + `version`, the + * five tabs, schema-versioned (`kind` + `version`, the * boilerplate-bundle/domain-package convention), so import can tell a legacy * domain-only document (no `kind`/`version`) from the full-suite shape and * fail clearly on a document newer than the importer. @@ -81,6 +81,7 @@ export function buildJsonExportDocument(state) { environment: String(state.runtimeEnvironment?.environment || '').trim() || 'dev', fileName: String(state.runtimeEnvironment?.fileName || '').trim() || '.env.dev' }, + codeWorkspace: state.codeWorkspace || { files: {}, activePath: '' }, deployments: Array.isArray(state.deployments) ? state.deployments : [], view: state.view }; @@ -185,6 +186,26 @@ export function buildBoilerplateBundleDocument(state, generatedAt = new Date().t // document is the canonical event-channel source for the codegen. asyncApiDocument: buildAsyncApiTransportDocument(state, 'websocket') }); + const overlays = state?.codeWorkspace?.files || {}; + const applyWorkspaceOverlay = (file) => { + const overlay = overlays[file.path]; + if (!overlay || !['edited', 'stale'].includes(overlay.state)) return file; + return { + ...file, + content: String(overlay.content ?? file.content), + workspaceState: overlay.state + }; + }; + bundle.modules.forEach((module) => { + Object.keys(module.files || {}).forEach((role) => { + module.files[role] = applyWorkspaceOverlay(module.files[role]); + }); + (module.entities || []).forEach((entity) => { + Object.keys(entity.files || {}).forEach((role) => { + entity.files[role] = applyWorkspaceOverlay(entity.files[role]); + }); + }); + }); return { kind: 'boilerplate-bundle', version: '2.0.0', @@ -262,12 +283,22 @@ export function buildOasDocument(state) { const required = []; entity.fields.forEach((field) => { const fieldSchema = toOasFieldSchema(field); - // JUM-478: PK/FK/unique are designer flags OAS cannot express. Fields + // JUM-478: PK/FK/unique/indexed are designer flags OAS cannot express. Fields // that match the importer's name heuristic cross silently; a divergent // field carries its flags explicitly so the crossing stays lossless. - const flags = { pk: Boolean(field.pk), fk: Boolean(field.fk), unique: Boolean(field.unique) }; + const flags = { + pk: Boolean(field.pk), + fk: Boolean(field.fk), + unique: Boolean(field.unique), + indexed: Boolean(field.indexed) + }; const heuristic = oasFieldNameFlags(field.name); - if (flags.pk !== heuristic.pk || flags.fk !== heuristic.fk || flags.unique !== heuristic.unique) { + if ( + flags.pk !== heuristic.pk + || flags.fk !== heuristic.fk + || flags.unique !== heuristic.unique + || flags.indexed + ) { fieldSchema['x-field-flags'] = flags; } properties[field.name] = fieldSchema; diff --git a/packages/designer-core/src/importers/designerImporters.js b/packages/designer-core/src/importers/designerImporters.js index 2882940a3..ee22e56e6 100644 --- a/packages/designer-core/src/importers/designerImporters.js +++ b/packages/designer-core/src/importers/designerImporters.js @@ -256,7 +256,7 @@ function isObjectContractSchema(schemaValue) { function isPortObjectSchema(schemaKey, schemaValue) { if (schemaValue['x-port-object'] === true) return true; if (schemaValue['x-entity'] || schemaValue['x-domain']) return false; - if (schemaKey === 'ResourceDeleteResponse') return true; + if (schemaKey === 'ResourceDeleteResponse' || schemaKey === 'EntityMetricsResponse') return true; if (/^Request[A-Z]/.test(schemaKey) || /ArrayOf$/.test(schemaKey)) return true; const description = String(schemaValue.description || ''); if (/^Port (input|output) object/.test(description) && !/^Port output object for .+ resource\./.test(description)) { @@ -363,13 +363,14 @@ export function buildDomainsFromOas(parsed) { const fields = Object.entries(properties).map(([fieldName, fieldSchema]) => { const field = fieldSchema || {}; - // PK/FK/unique default to the name heuristic; an explicit + // PK/FK/unique default to the name heuristic; indexed defaults to false. An explicit // `x-field-flags` extension (JUM-478) overrides it per flag. const heuristic = oasFieldNameFlags(fieldName); const flagOverrides = field['x-field-flags'] && typeof field['x-field-flags'] === 'object' ? field['x-field-flags'] : {}; const flag = (key) => (typeof flagOverrides[key] === 'boolean' ? flagOverrides[key] : heuristic[key]); + const indexed = typeof flagOverrides.indexed === 'boolean' ? flagOverrides.indexed : false; return normalizeField({ name: fieldName, type: fromOasType(field), @@ -386,7 +387,8 @@ export function buildDomainsFromOas(parsed) { required: required.includes(fieldName), pk: flag('pk'), fk: flag('fk'), - unique: flag('unique') + unique: flag('unique'), + indexed }, 0); }); @@ -452,6 +454,7 @@ const SUITE_EXPORT_KNOWN_SECTIONS = new Set([ 'interfaces', 'serviceConfiguration', 'runtimeEnvironment', + 'codeWorkspace', 'deployments', 'view' ]); diff --git a/packages/designer-core/src/index.js b/packages/designer-core/src/index.js index 4cf539076..91d0a8c18 100644 --- a/packages/designer-core/src/index.js +++ b/packages/designer-core/src/index.js @@ -31,6 +31,7 @@ // The domain model: queries, matrices, RBAC contract and the sample model. export * from './model/modelQueries.js'; +export * from './model/propertyKeys.js'; export * from './model/rbacContract.js'; export * from './model/sampleModel.js'; export * from './model/deployCapabilityMatrix.js'; diff --git a/packages/designer-core/src/model/modelQueries.js b/packages/designer-core/src/model/modelQueries.js index 8bfdd88e3..42fd435bd 100644 --- a/packages/designer-core/src/model/modelQueries.js +++ b/packages/designer-core/src/model/modelQueries.js @@ -23,9 +23,294 @@ import { clampZoom, + DOMAIN_DEFAULT_HEIGHT, + DOMAIN_DEFAULT_WIDTH, + DOMAIN_HEADER_HEIGHT, + DOMAIN_MIN_HEIGHT, + DOMAIN_MIN_WIDTH, + ENTITY_MAX_HEIGHT, + ENTITY_MAX_WIDTH, + ENTITY_MIN_HEIGHT, + ENTITY_MIN_WIDTH, + ENTITY_WIDTH, getDefaultRbacPolicy } from '../state/designerState.js'; + +/** + * Where an entity may sit inside its domain. + * + * The canvas clamped drags against `520 - 200` and `180` written out by hand, + * in two files that had to be edited together. Derived from the box instead, + * so a resized domain immediately gives its entities the room it gained. + */ +export function entityWidth(entity) { + return Number.isFinite(entity?.width) + ? Math.min(ENTITY_MAX_WIDTH, Math.max(ENTITY_MIN_WIDTH, entity.width)) + : ENTITY_WIDTH; +} + +export function entityMinHeight(entity, compactEntities, largeCanvasMode) { + return Math.max(ENTITY_MIN_HEIGHT, entityHeight(entity, compactEntities, largeCanvasMode)); +} + +export function entityBoxHeight(entity, compactEntities, largeCanvasMode) { + return Number.isFinite(entity?.height) + ? Math.min(ENTITY_MAX_HEIGHT, Math.max(entityMinHeight(entity, compactEntities, largeCanvasMode), entity.height)) + : entityHeight(entity, compactEntities, largeCanvasMode); +} + +export function resizeEntityBox(entity, width, height, compactEntities = false, largeCanvasMode = false) { + return { + width: Math.min(ENTITY_MAX_WIDTH, Math.max(ENTITY_MIN_WIDTH, width)), + height: Math.min(ENTITY_MAX_HEIGHT, Math.max(entityMinHeight(entity, compactEntities, largeCanvasMode), height)) + }; +} + +export function clampEntityPosition(domain, x, y, entity = null, compactEntities = false, largeCanvasMode = false) { + void domain; + void entity; + void compactEntities; + void largeCanvasMode; + return { + x: Number.isFinite(x) ? x : 0, + y: Number.isFinite(y) ? y : 0 + }; +} + +/** + * How tall an entity is drawn (JUM-729 follow-up). + * + * The edge geometry used to assume one of two fixed heights — 32 + 24 compact, + * 32 + 58 otherwise — which was already wrong for an entity with four fields + * and is further off now that each field is an editable row with its own + * controls. Every edge that anchored to `bottom` therefore started somewhere + * inside the card instead of on its border, and the further down the field + * list the border was, the worse the miss. + * + * Derived from what the CSS draws: a header, one row per field, and the "Add + * field" row that is only rendered in the full view. + */ +export function entityHeight(entity, compactEntities, largeCanvasMode) { + const headerHeight = 32; + if (compactEntities) return headerHeight + 8; + const fields = Array.isArray(entity?.fields) ? entity.fields.length : 0; + const rowHeight = largeCanvasMode ? 16 : 22; + const addRow = largeCanvasMode ? 0 : 26; + return headerHeight + fields * rowHeight + addRow + 8; +} + +/** + * The smallest box that still holds every entity currently inside the domain. + * + * Shrinking below it would strand entities outside their own container, which + * the canvas cannot render and the user cannot undo by dragging them back. + */ +export function minimumDomainSize(domain) { + const entities = Array.isArray(domain?.entities) ? domain.entities : []; + const minEntityX = entities.reduce( + (leftmost, entity) => Math.min(leftmost, Number(entity?.x) || 0), + 0 + ); + const minEntityY = entities.reduce( + (topmost, entity) => Math.min(topmost, Number(entity?.y) || 0), + 0 + ); + const neededWidth = entities.reduce( + (widest, entity) => Math.max( + widest, + (Number(entity?.x) || 0) - minEntityX + entityWidth(entity) + 16 + ), + DOMAIN_MIN_WIDTH + ); + const neededHeight = entities.reduce( + (tallest, entity) => Math.max( + tallest, + (Number(entity?.y) || 0) - minEntityY + entityBoxHeight(entity, false, false) + 24 + ), + DOMAIN_MIN_HEIGHT + ); + return { width: neededWidth, height: neededHeight }; +} + +/** + * The box a domain occupies, defaulted for models saved before it was + * resizable (JUM-729 follow-up). + * + * Never smaller than its contents. The CSS used to let the box grow on its own + * (`min-height`), so a domain whose entities ran past 280px simply got taller; + * with an explicit height that growth has to be computed, or those entities + * render outside the container they belong to. + */ +export function domainBox(domain) { + if (isDomainCollapsed(domain)) { + const width = Number.isFinite(domain?.width) ? domain.width : DOMAIN_DEFAULT_WIDTH; + return { width, height: DOMAIN_HEADER_HEIGHT }; + } + const floor = minimumDomainSize(domain); + const width = Number.isFinite(domain?.width) ? domain.width : DOMAIN_DEFAULT_WIDTH; + const height = Number.isFinite(domain?.height) ? domain.height : DOMAIN_DEFAULT_HEIGHT; + return { + width: Math.max(floor.width, width), + height: Math.max(floor.height, height) + }; +} + +/** + * A collapsed domain is its header and nothing else (JUM-729 follow-up). + * + * A model outgrows the screen long before it outgrows the design: at fifteen + * domains the canvas is a wall of cards and the one being worked on is + * somewhere inside it. Collapsing is how a domain stays on the diagram — + * present, positioned, still the target of its relationships — without + * spending the space its contents need. + */ +export function isDomainCollapsed(domain) { + return Boolean(domain?.collapsed); +} + +/** + * Everything in the model that matches `query`, in the order a reader scans + * the tree: domain, then its entities, then their fields (JUM-729 follow-up). + * + * Case-insensitive substring. Not fuzzy: the names here are identifiers people + * type from memory, and a fuzzy match on `id` hits every entity in the model, + * which is the same as no search at all. + */ +export function searchModel(domains, query) { + const needle = String(query || '').trim().toLowerCase(); + if (!needle) return []; + const matches = (value) => String(value || '').toLowerCase().includes(needle); + const results = []; + + (domains || []).forEach((domain) => { + if (matches(domain.name)) { + results.push({ kind: 'domain', domainId: domain.id, label: domain.name }); + } + (domain.entities || []).forEach((entity) => { + if (matches(entity.name)) { + results.push({ + kind: 'entity', + domainId: domain.id, + entityId: entity.id, + label: `${domain.name} / ${entity.name}` + }); + } + (entity.fields || []).forEach((field) => { + if (!matches(field.name) && !matches(field.type)) return; + results.push({ + kind: 'field', + domainId: domain.id, + entityId: entity.id, + fieldName: field.name, + label: `${domain.name} / ${entity.name}.${field.name}: ${field.type}` + }); + }); + }); + }); + + return results; +} + +/** + * Every entity whose box overlaps the marquee rectangle (JUM-729 follow-up). + * + * Overlap, not containment: a rubber band that only takes what it fully + * encloses forces the user to start the drag off-canvas to catch an entity + * near the edge, and it silently drops the one element they were aiming at. + * + * `rect` is in canvas coordinates, the same space the entities are placed in. + */ +export function entitiesInMarquee(domains, rect, compactEntities, largeCanvasMode) { + const left = Math.min(rect.x1, rect.x2); + const right = Math.max(rect.x1, rect.x2); + const top = Math.min(rect.y1, rect.y2); + const bottom = Math.max(rect.y1, rect.y2); + const hits = []; + + (domains || []).forEach((domain) => { + if (isDomainCollapsed(domain)) return; + (domain.entities || []).forEach((entity) => { + const boxLeft = domain.x + entity.x; + const boxTop = domain.y + entity.y; + const boxRight = boxLeft + entityWidth(entity); + const boxBottom = boxTop + entityBoxHeight(entity, compactEntities, largeCanvasMode); + const overlaps = boxLeft < right && boxRight > left && boxTop < bottom && boxBottom > top; + if (overlaps) hits.push(entity.id); + }); + }); + + return hits; +} + +/** + * Where a dragged entity lines up with its siblings, and the position that + * snaps it there (JUM-729 follow-up). + * + * Grid snapping keeps positions tidy without making anything line up: two + * entities can both sit on the grid and still be four pixels apart, which is + * exactly the misalignment a diagram reader notices. This compares the moving + * entity's left, centre and right against every sibling's, and the same for + * the vertical edges, and returns the first match inside `tolerance`. + * + * Returns the adjusted position plus the guide lines to draw, in canvas + * coordinates, so the caller can show what it snapped to. + */ +export function alignmentGuidesFor(domain, entity, x, y, options = {}) { + const tolerance = options.tolerance ?? 6; + const compactEntities = options.compactEntities ?? false; + const largeCanvasMode = options.largeCanvasMode ?? false; + const width = entityWidth(entity); + const height = entityBoxHeight(entity, compactEntities, largeCanvasMode); + const siblings = (domain?.entities || []).filter((candidate) => candidate.id !== entity.id); + + const verticalEdges = [x, x + width / 2, x + width]; + const horizontalEdges = [y, y + height / 2, y + height]; + const guides = []; + let snappedX = x; + let snappedY = y; + + siblings.forEach((sibling) => { + const siblingHeight = entityHeight(sibling, compactEntities, largeCanvasMode); + const siblingWidth = entityWidth(sibling); + const siblingVertical = [ + sibling.x, sibling.x + siblingWidth / 2, sibling.x + siblingWidth + ]; + const siblingHorizontal = [ + sibling.y, sibling.y + siblingHeight / 2, sibling.y + siblingHeight + ]; + + verticalEdges.forEach((edge, edgeIndex) => { + siblingVertical.forEach((siblingEdge) => { + if (Math.abs(edge - siblingEdge) > tolerance) return; + if (guides.some((guide) => guide.axis === 'x')) return; + snappedX = siblingEdge - (width / 2) * edgeIndex; + guides.push({ axis: 'x', at: domain.x + siblingEdge }); + }); + }); + + horizontalEdges.forEach((edge, edgeIndex) => { + siblingHorizontal.forEach((siblingEdge) => { + if (Math.abs(edge - siblingEdge) > tolerance) return; + if (guides.some((guide) => guide.axis === 'y')) return; + snappedY = siblingEdge - (height / 2) * edgeIndex; + guides.push({ axis: 'y', at: domain.y + siblingEdge }); + }); + }); + }); + + return { x: snappedX, y: snappedY, guides }; +} + +/** Resize a domain box, never below its minimum or its contents. */ +export function resizeDomainBox(domain, width, height) { + const floor = minimumDomainSize(domain); + return { + width: Math.max(floor.width, Math.round(width)), + height: Math.max(floor.height, Math.round(height)) + }; +} + export function normalizedName(value) { return String(value || '').trim().toLowerCase(); } @@ -79,6 +364,7 @@ export function fieldLabel(field) { if (field.pk) flags.push('PK'); if (field.fk) flags.push('FK'); if (field.unique) flags.push('UQ'); + if (field.indexed) flags.push('IDX'); if (field.required) flags.push('REQ'); if (field.nullable) flags.push('NULL'); return `${field.name}: ${field.type}${field.format ? `(${field.format})` : ''}${flags.length ? ` [${flags.join(', ')}]` : ''}`; @@ -164,22 +450,43 @@ export function fromOasType(schema = {}) { return 'string'; } +/** + * Trim leading and trailing runs of one character without a regex — even + * anchored `x+`/`x+$` patterns are a polynomial-backtracking surface to + * static analysis, and a two-pointer walk is linear by construction. + */ +function trimEdgeCharRuns(value, char) { + let start = 0; + let end = value.length; + while (start < end && value.charAt(start) === char) { + start += 1; + } + while (end > start && value.charAt(end - 1) === char) { + end -= 1; + } + return value.slice(start, end); +} + export function toSchemaName(domainName, entityName) { - const normalize = (value) => String(value || '') - .trim() - .replace(/[^a-zA-Z0-9]+/g, '_') - .replace(/^_+|_+$/g, ''); + const normalize = (value) => trimEdgeCharRuns( + String(value || '') + .trim() + .replace(/[^a-zA-Z0-9]+/g, '_'), + '_' + ); const domainToken = normalize(domainName) || 'Domain'; const entityToken = normalize(entityName) || 'Entity'; return `${domainToken}_${entityToken}`; } export function toPathToken(value) { - return String(value || '') - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); + return trimEdgeCharRuns( + String(value || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-'), + '-' + ); } export function buildExampleValueForField(field) { @@ -228,29 +535,77 @@ export function snapCoordinate(snapToGrid, value) { } /** Entity centre in canvas coordinates (the +95/+32 offsets are unchanged). */ -export function entityCenterPoint(domain, entity) { +export function entityCenterPoint(domain, entity, compactEntities, largeCanvasMode) { + // JUM-729 follow-up: the real centre. `+95` was half of the old 190px card and `+32` + // was its header, so a centre-anchored edge pointed at the top-left of a + // taller entity rather than at its middle. + return { + x: domain.x + entity.x + entityWidth(entity) / 2, + y: domain.y + entity.y + entityBoxHeight(entity, compactEntities, largeCanvasMode) / 2 + }; +} + +/** Row height of one field inside an entity card, as the CSS draws it. */ +export function entityFieldRowHeight(largeCanvasMode) { + return largeCanvasMode ? 16 : 22; +} + +/** + * Where a relationship touches a specific field (JUM-729 follow-up). + * + * Edges used to land on the middle of an entity's side, so a link that means + * "orders.customer_id references customers.id" pointed at two cards and said + * nothing about which columns it joined — the reader had to guess, and with + * three links between the same pair of entities there was nothing to guess + * from. This returns the point on the entity's left or right border level with + * that field's row. + * + * A field that is no longer there (renamed, deleted, or the entity collapsed + * into compact view) has no row to point at, and the caller falls back to the + * side anchor rather than drawing a line to a row that is not on screen. + */ +export function entityFieldAnchorPoint(domain, entity, fieldName, side, compactEntities, largeCanvasMode) { + if (compactEntities) return null; + const fields = Array.isArray(entity?.fields) ? entity.fields : []; + const fieldIndex = fields.findIndex((field) => field.name === fieldName); + if (fieldIndex < 0) return null; + + const headerHeight = 32; + const rowHeight = entityFieldRowHeight(largeCanvasMode); + const originX = domain.x + entity.x; + const originY = domain.y + entity.y; + const y = originY + headerHeight + fieldIndex * rowHeight + rowHeight / 2; return { - x: domain.x + entity.x + 95, - y: domain.y + entity.y + 32 + x: side === 'left' ? originX : originX + entityWidth(entity), + y }; } +/** + * Which side of `target` faces `origin`. + * + * Used when a link is dropped on an entity rather than on one of its four + * anchor dots: the edge should leave and arrive on the sides that face each + * other, which is what a person dragging between two cards means. + */ +export function facingSide(originX, targetX) { + return targetX >= originX ? 'left' : 'right'; +} + /** * Anchor point of an entity side in canvas coordinates. Unknown/absent sides * fall back to the centre, exactly as `entityAnchorOnCanvas` did. */ -export function entityAnchorPoint(domain, entity, side, compactEntities) { +export function entityAnchorPoint(domain, entity, side, compactEntities, largeCanvasMode) { const originX = domain.x + entity.x; const originY = domain.y + entity.y; - const width = 190; - const headerHeight = 32; - const bodyHeight = compactEntities ? 24 : 58; - const height = headerHeight + bodyHeight; + const width = entityWidth(entity); + const height = entityBoxHeight(entity, compactEntities, largeCanvasMode); if (side === 'left') return { x: originX, y: originY + height / 2 }; if (side === 'right') return { x: originX + width, y: originY + height / 2 }; if (side === 'top') return { x: originX + width / 2, y: originY }; if (side === 'bottom') return { x: originX + width / 2, y: originY + height }; - return entityCenterPoint(domain, entity); + return entityCenterPoint(domain, entity, compactEntities, largeCanvasMode); } /** @@ -275,6 +630,22 @@ export function buildPreviewEdgePathD(from, to, orthogonal) { : `M ${from.x} ${from.y} C ${controlX} ${from.y}, ${controlX} ${to.y}, ${to.x} ${to.y}`; } +/** + * Editable relationship route point. + * + * Without a stored bend, the route follows the midpoint between its current + * endpoints. Once the user drags the handle, the explicit point is persisted + * until Straighten clears it. + */ +export function relationshipControlPoint(relationship, from, to) { + const hasBend = Number.isFinite(relationship?.bendX) && Number.isFinite(relationship?.bendY); + return { + x: hasBend ? relationship.bendX : (from.x + to.x) / 2, + y: hasBend ? relationship.bendY : (from.y + to.y) / 2, + explicit: hasBend + }; +} + /** * Pure geometry of `fitView`: zoom and scroll target that frame every domain. * The DOM caller applies them (`renderView`, `scrollTo`, `saveState`). @@ -294,11 +665,12 @@ export function computeFitView(domains, viewportWidth, viewportHeight) { domains.forEach((domain) => { bounds.minX = Math.min(bounds.minX, domain.x); bounds.minY = Math.min(bounds.minY, domain.y); - bounds.maxX = Math.max(bounds.maxX, domain.x + 520); - bounds.maxY = Math.max(bounds.maxY, domain.y + 280); + const box = domainBox(domain); + bounds.maxX = Math.max(bounds.maxX, domain.x + box.width); + bounds.maxY = Math.max(bounds.maxY, domain.y + box.height); }); - const padding = 80; + const padding = domains.length >= 10 ? 32 : 80; const contentWidth = Math.max(300, bounds.maxX - bounds.minX + padding * 2); const contentHeight = Math.max(220, bounds.maxY - bounds.minY + padding * 2); const zoomX = viewportWidth / contentWidth; @@ -316,23 +688,60 @@ export function computeFitView(domains, viewportWidth, viewportHeight) { * and re-renders, as before. */ export function applyAutoLayout(domains) { - const domainWidth = 520; - const domainHeight = 300; - const gapX = 70; - const gapY = 70; - const columns = Math.max(1, Math.floor((3200 - 120) / (domainWidth + gapX))); + const denseOverview = domains.length >= 10; + const padding = denseOverview ? 18 : 24; + const entityGapX = denseOverview ? 18 : 28; + const entityGapY = denseOverview ? 18 : 26; + const domainGapX = denseOverview ? 26 : 72; + const domainGapY = denseOverview ? 26 : 72; + const origin = denseOverview ? 24 : 40; + const maxCanvasWidth = 3200 - (denseOverview ? 80 : 120); + const plannedDomainWidths = domains.map((domain) => { + const entityCount = Math.max(1, domain.entities.length); + const entityColumns = Math.max(1, Math.min(entityCount, Math.ceil(Math.sqrt(entityCount)))); + const widestEntity = Math.max(ENTITY_WIDTH, ...domain.entities.map((entity) => entityWidth(entity))); + return padding * 2 + entityColumns * widestEntity + (entityColumns - 1) * entityGapX; + }); + const widestDomain = Math.max(DOMAIN_MIN_WIDTH, ...plannedDomainWidths); + const columns = Math.max(1, Math.floor(maxCanvasWidth / (widestDomain + domainGapX))); + const rowHeights = []; domains.forEach((domain, index) => { const column = index % columns; const row = Math.floor(index / columns); - domain.x = 40 + column * (domainWidth + gapX); - domain.y = 40 + row * (domainHeight + gapY); - + const entityCount = domain.entities.length; + const entityColumns = entityCount > 0 + ? Math.max(1, Math.min(entityCount, Math.ceil(Math.sqrt(entityCount)))) + : 1; + const rowTops = []; + let nextTop = DOMAIN_HEADER_HEIGHT + padding; domain.entities.forEach((entity, entityIndex) => { - const entityColumn = entityIndex % 2; - const entityRow = Math.floor(entityIndex / 2); - entity.x = 14 + entityColumn * 206; - entity.y = 14 + entityRow * 118; + const entityRow = Math.floor(entityIndex / entityColumns); + if (rowTops[entityRow] === undefined) { + rowTops[entityRow] = nextTop; + const rowEntities = domain.entities.slice( + entityRow * entityColumns, + entityRow * entityColumns + entityColumns + ); + const rowHeight = rowEntities.reduce( + (tallest, member) => Math.max(tallest, entityBoxHeight(member, false, false)), + 0 + ); + nextTop += rowHeight + entityGapY; + } + const columnIndex = entityIndex % entityColumns; + const widestEntity = Math.max(ENTITY_WIDTH, ...domain.entities.map((member) => entityWidth(member))); + entity.x = padding + columnIndex * (widestEntity + entityGapX); + entity.y = rowTops[entityRow]; }); + + if (domain.entities.length > 0) nextTop -= entityGapY; + const domainWidth = Math.max(DOMAIN_MIN_WIDTH, plannedDomainWidths[index]); + const domainHeight = Math.max(DOMAIN_MIN_HEIGHT, nextTop + padding); + rowHeights[row] = Math.max(rowHeights[row] || 0, domainHeight); + domain.x = origin + column * (widestDomain + domainGapX); + domain.y = origin + rowHeights.slice(0, row).reduce((top, height) => top + height + domainGapY, 0); + domain.width = domainWidth; + domain.height = domainHeight; }); } diff --git a/packages/designer-core/src/model/propertyKeys.js b/packages/designer-core/src/model/propertyKeys.js new file mode 100644 index 000000000..b048be571 --- /dev/null +++ b/packages/designer-core/src/model/propertyKeys.js @@ -0,0 +1,48 @@ +/** + * propertyKeys — the one place that decides whether a field name is a bare + * property key (JUM-731). + * + * The codegen already had this rule: `toPropertyKey` emits a bare key when the + * name is a valid identifier and a quoted one otherwise. The model validation + * had no equivalent, so a field named `my field name!` passed `Validate Model` + * with `No issues found.` and travelled into three OAS schemas, the code + * preview and the boilerplate bundle as `"my field name!"?: string;`. + * + * Both sides now read the rule from here, so the warning the designer shows and + * the quoting the generator performs cannot disagree. + * + * The rule is deliberately a WARNING, not an error, and the designer does not + * refuse the name: + * + * - nothing produced is invalid. A JSON Schema property may be any string, and + * a quoted member name is valid TypeScript. This is deliverability — the + * property is reachable only by index — not a spec violation; + * - refusing would remove a legitimate case. Field names that mirror an + * external contract (`content-type`, a vendor's `x-request-id`) are real, and + * the generator already handles them correctly by quoting. + */ + +/** The identifier rule the code generator uses to decide bare vs quoted. */ +const BARE_PROPERTY_KEY = /^[A-Za-z_$][A-Za-z0-9_$]*$/; + +/** + * Is this name emitted as a bare property key? + * + * @param {string} name + * @returns {boolean} + */ +export function isBarePropertyKey(name) { + return BARE_PROPERTY_KEY.test(String(name ?? '')); +} + +/** + * Why a name will be quoted, phrased for the person who typed it. + * + * @param {string} name + * @returns {string} + */ +export function describeQuotedPropertyKey(name) { + return `will be emitted as "${name}" in the OpenAPI schema and the generated code, ` + + 'reachable only by index. A name starting with a letter, `_` or `$` and ' + + 'continuing with letters, digits, `_` or `$` is emitted bare.'; +} diff --git a/packages/designer-core/src/model/sampleModel.js b/packages/designer-core/src/model/sampleModel.js index 9a502cde6..e55a86144 100644 --- a/packages/designer-core/src/model/sampleModel.js +++ b/packages/designer-core/src/model/sampleModel.js @@ -76,6 +76,8 @@ export function buildSampleModelPayload() { color: '#60a5fa', x: 80, y: 80, + width: 780, + height: 900, context: { ubiquitousLanguage: 'identity, organization, tenant, contact point', ownerTeam: 'platform' @@ -142,7 +144,7 @@ export function buildSampleModelPayload() { { id: 'sample-entity-organization', name: 'Organization', - x: 240, + x: 390, y: 14, fields: [ { name: 'id', type: 'uuid', required: true, pk: true, unique: true }, @@ -165,7 +167,7 @@ export function buildSampleModelPayload() { id: 'sample-entity-email', name: 'Email', x: 14, - y: 134, + y: 380, fields: [ { name: 'id', type: 'uuid', required: true, pk: true, unique: true }, { name: 'address', type: 'string', required: true, format: 'email' }, @@ -176,8 +178,8 @@ export function buildSampleModelPayload() { { id: 'sample-entity-phone', name: 'Phone', - x: 240, - y: 134, + x: 390, + y: 380, fields: [ { name: 'id', type: 'uuid', required: true, pk: true, unique: true }, { name: 'number', type: 'string', required: true, pattern: '^\\+[1-9]\\d{7,14}$' }, @@ -188,8 +190,8 @@ export function buildSampleModelPayload() { { id: 'sample-entity-contact-point', name: 'ContactPoint', - x: 127, - y: 254, + x: 220, + y: 650, fields: [ { name: 'id', type: 'uuid', required: true, pk: true, unique: true }, { @@ -209,6 +211,74 @@ export function buildSampleModelPayload() { } } ] + }, + { + id: 'sample-domain-tasks', + name: 'Tasks', + color: '#34d399', + x: 920, + y: 80, + width: 780, + height: 650, + context: { + ubiquitousLanguage: 'project, task, assignee, comment', + ownerTeam: 'delivery' + }, + entities: [ + { + id: 'sample-entity-project', + name: 'Project', + x: 24, + y: 74, + fields: [ + { name: 'id', type: 'uuid', required: true, pk: true, unique: true }, + { name: 'organizationId', type: 'uuid', required: true, fk: true, indexed: true }, + { name: 'name', type: 'string', required: true, minLength: 1 }, + { + name: 'status', + type: 'string', + required: true, + enumValues: ['draft', 'active', 'archived'], + indexed: true + }, + ...auditFields + ], + meta: { + aggregateRoot: true, + invariants: [ + 'a project belongs to exactly one organization', + 'archived projects cannot accept new tasks' + ] + } + }, + { + id: 'sample-entity-task', + name: 'Task', + x: 390, + y: 74, + fields: [ + { name: 'id', type: 'uuid', required: true, pk: true, unique: true }, + { name: 'projectId', type: 'uuid', required: true, fk: true, indexed: true }, + { name: 'assigneeId', type: 'uuid', fk: true, indexed: true, nullable: true }, + { name: 'title', type: 'string', required: true, minLength: 1 }, + { name: 'done', type: 'boolean', required: true, indexed: true }, + { name: 'dueDate', type: 'date', indexed: true, nullable: true } + ] + }, + { + id: 'sample-entity-comment', + name: 'Comment', + x: 390, + y: 318, + fields: [ + { name: 'id', type: 'uuid', required: true, pk: true, unique: true }, + { name: 'taskId', type: 'uuid', required: true, fk: true, indexed: true }, + { name: 'authorId', type: 'uuid', required: true, fk: true, indexed: true }, + { name: 'body', type: 'string', required: true, minLength: 1 }, + { name: 'createdAt', type: 'datetime', required: true, indexed: true } + ] + } + ] } ], relationships: [ @@ -217,6 +287,8 @@ export function buildSampleModelPayload() { fromEntityId: 'sample-entity-user', toEntityId: 'sample-entity-organization', name: 'User belongs to Organization', + fromField: 'organizationId', + toField: 'id', fromCardinality: 'N', toCardinality: '1' }, @@ -225,6 +297,8 @@ export function buildSampleModelPayload() { fromEntityId: 'sample-entity-email', toEntityId: 'sample-entity-user', name: 'Email belongs to User', + fromField: 'userId', + toField: 'id', fromCardinality: 'N', toCardinality: '1' }, @@ -233,6 +307,58 @@ export function buildSampleModelPayload() { fromEntityId: 'sample-entity-phone', toEntityId: 'sample-entity-user', name: 'Phone belongs to User', + fromField: 'userId', + toField: 'id', + fromCardinality: 'N', + toCardinality: '1' + }, + { + id: 'sample-rel-project-organization', + fromEntityId: 'sample-entity-project', + toEntityId: 'sample-entity-organization', + name: 'Project belongs to Organization', + fromField: 'organizationId', + toField: 'id', + fromCardinality: 'N', + toCardinality: '1' + }, + { + id: 'sample-rel-task-project', + fromEntityId: 'sample-entity-task', + toEntityId: 'sample-entity-project', + name: 'Task belongs to Project', + fromField: 'projectId', + toField: 'id', + fromCardinality: 'N', + toCardinality: '1' + }, + { + id: 'sample-rel-task-assignee', + fromEntityId: 'sample-entity-task', + toEntityId: 'sample-entity-user', + name: 'Task assigned to User', + fromField: 'assigneeId', + toField: 'id', + fromCardinality: 'N', + toCardinality: '1' + }, + { + id: 'sample-rel-comment-task', + fromEntityId: 'sample-entity-comment', + toEntityId: 'sample-entity-task', + name: 'Comment belongs to Task', + fromField: 'taskId', + toField: 'id', + fromCardinality: 'N', + toCardinality: '1' + }, + { + id: 'sample-rel-comment-author', + fromEntityId: 'sample-entity-comment', + toEntityId: 'sample-entity-user', + name: 'Comment authored by User', + fromField: 'authorId', + toField: 'id', fromCardinality: 'N', toCardinality: '1' } diff --git a/packages/designer-core/src/packages/packageVersioning.js b/packages/designer-core/src/packages/packageVersioning.js index f424f081a..207404ee1 100644 --- a/packages/designer-core/src/packages/packageVersioning.js +++ b/packages/designer-core/src/packages/packageVersioning.js @@ -309,6 +309,7 @@ function fieldProjection(field) { pk: Boolean(field.pk), fk: Boolean(field.fk), unique: Boolean(field.unique), + indexed: Boolean(field.indexed), nullable: Boolean(field.nullable), format: field.format || '', description: field.description || '', diff --git a/packages/designer-core/src/state/designerState.js b/packages/designer-core/src/state/designerState.js index 951c198c2..e8386cffc 100644 --- a/packages/designer-core/src/state/designerState.js +++ b/packages/designer-core/src/state/designerState.js @@ -35,6 +35,33 @@ import { } from '../model/deployCapabilityMatrix.js'; export const DOMAIN_COLORS = ['#60a5fa', '#34d399', '#f59e0b', '#f472b6', '#22d3ee', '#a78bfa', '#fb7185', '#84cc16']; +/* + * Domain box geometry (JUM-729 follow-up). + * + * The domain was a fixed 520x280 box in CSS, and the canvas clamped entities + * against those two numbers written out by hand in two different files. A + * domain that outgrew its box could not be made bigger, so entities piled up + * against an invisible wall and the diagram stopped matching the model. + * + * The size now lives in the state, defaulted to what the CSS drew, so an older + * saved model opens exactly as it did. The minimums are the smallest box that + * still shows a header and one entity. + */ +export const DOMAIN_DEFAULT_WIDTH = 520; +export const DOMAIN_DEFAULT_HEIGHT = 280; +export const DOMAIN_MIN_WIDTH = 240; +export const DOMAIN_MIN_HEIGHT = 160; +/** Header strip above `.domain-body`, where entities are positioned. */ +export const DOMAIN_HEADER_HEIGHT = 50; +// Widened for the editable field rows (JUM-729 follow-up): a name, a type select and +// three toggles do not fit the 190px the read-only text line needed. Kept in +// step with `.entity { width }` in styles.css. +export const ENTITY_WIDTH = 340; +export const ENTITY_MIN_WIDTH = 320; +export const ENTITY_MAX_WIDTH = 720; +export const ENTITY_MIN_HEIGHT = 96; +export const ENTITY_MAX_HEIGHT = 640; + export const FIELD_TYPES = ['string', 'integer', 'number', 'boolean', 'array', 'object', 'date', 'datetime', 'uuid']; /** @@ -75,7 +102,7 @@ export function normalizeOptionalNumber(value) { } export function clampZoom(value) { - return Math.max(0.5, Math.min(2, value)); + return Math.max(0.25, Math.min(2, value)); } export function fallbackId(prefix, seed) { @@ -102,6 +129,7 @@ export function normalizeField(field, fieldIndex) { pk: Boolean(field?.pk), fk: Boolean(field?.fk), unique: Boolean(field?.unique), + indexed: Boolean(field?.indexed), nullable: Boolean(field?.nullable), format, description, @@ -129,6 +157,25 @@ export function normalizeContractInput(contract, contractIndex = 0) { }; } +/** + * A canvas note (JUM-729 follow-up). + * + * The model records what the system *is*; a note records what the people + * modelling it need to remember while they work — an open question, a decision + * and its reason, a "this mirrors the billing contract". That belongs on the + * diagram, next to the thing it is about, and it deliberately does not enter + * the OAS export or the code generator: it is not part of the contract. + */ +export function normalizeNote(note, noteIndex) { + return { + id: note?.id || fallbackId('note', noteIndex), + text: String(note?.text || '').trim(), + x: Number.isFinite(note?.x) ? note.x : 60 + noteIndex * 24, + y: Number.isFinite(note?.y) ? note.y : 60 + noteIndex * 24, + color: /^#[0-9a-f]{6}$/i.test(note?.color || '') ? note.color : '#fde68a' + }; +} + export function normalizeRelationship(relationship) { return { ...relationship, @@ -137,6 +184,11 @@ export function normalizeRelationship(relationship) { toCardinality: relationship.toCardinality || '1', fromAnchorSide: ['top', 'right', 'bottom', 'left'].includes(relationship.fromAnchorSide) ? relationship.fromAnchorSide : null, toAnchorSide: ['top', 'right', 'bottom', 'left'].includes(relationship.toAnchorSide) ? relationship.toAnchorSide : null, + // JUM-729 follow-up: the columns the link joins. Additive — a relationship saved + // before field anchors existed normalises to null on both ends and is + // drawn from the entity side exactly as it was. + fromField: String(relationship.fromField || '').trim() || null, + toField: String(relationship.toField || '').trim() || null, anchorBehavior: relationship.anchorBehavior === 'center' ? 'center' : 'auto', bendX: normalizeOptionalNumber(relationship.bendX), bendY: normalizeOptionalNumber(relationship.bendY), @@ -145,6 +197,101 @@ export function normalizeRelationship(relationship) { }; } +export function normalizeCodeWorkspaceFile(file) { + const state = ['generated', 'edited', 'stale'].includes(file?.state) ? file.state : 'generated'; + const generatedContent = String(file?.generatedContent || ''); + const baseContent = String(file?.baseContent ?? generatedContent); + const content = String(file?.content ?? generatedContent); + return { + path: String(file?.path || '').trim(), + state, + baseContent, + generatedContent, + content, + updatedAt: String(file?.updatedAt || '') + }; +} + +export function normalizeCodeWorkspaceInput(input) { + const files = {}; + const source = input?.files && typeof input.files === 'object' ? input.files : {}; + Object.entries(source).forEach(([path, file]) => { + const normalized = normalizeCodeWorkspaceFile({ path, ...(file || {}) }); + if (normalized.path) files[normalized.path] = normalized; + }); + return { + files, + activePath: String(input?.activePath || '').trim() + }; +} + +const MONITORING_HISTORY_CAP = 60; +const MONITORING_HISTORY_PROCESS_LIMIT = 40; +const MONITORING_ENVIRONMENTS = new Set(['dev', 'staging', 'production']); + +function clampNumberSeries(input, cap = MONITORING_HISTORY_CAP) { + const source = Array.isArray(input) ? input : []; + return source + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value)) + .slice(-cap); +} + +export function normalizeMonitoringHistoryInput(input) { + const source = input && typeof input === 'object' ? input : {}; + const environment = MONITORING_ENVIRONMENTS.has(String(source.environment || '')) + ? String(source.environment) + : 'dev'; + const samplesInput = Array.isArray(source.samples) ? source.samples : []; + const samples = samplesInput.slice(-MONITORING_HISTORY_CAP).map((sample) => ({ + t: String(sample?.t || ''), + hostCpu: sample?.hostCpu == null || !Number.isFinite(Number(sample.hostCpu)) + ? null + : Number(sample.hostCpu), + hostMemUsedPercent: sample?.hostMemUsedPercent == null + || !Number.isFinite(Number(sample.hostMemUsedPercent)) + ? null + : Number(sample.hostMemUsedPercent), + cpuTotal: Number(sample?.cpuTotal) || 0, + memTotal: Number(sample?.memTotal) || 0, + onlineRatio: Number(sample?.onlineRatio) || 0, + asyncActiveSum: Number(sample?.asyncActiveSum) || 0 + })); + const processes = {}; + const processSource = source.processes && typeof source.processes === 'object' + ? source.processes + : {}; + Object.entries(processSource).slice(0, MONITORING_HISTORY_PROCESS_LIMIT).forEach(([key, bucket]) => { + const name = String(key || '').trim(); + if (!name) return; + processes[name] = { + cpu: clampNumberSeries(bucket?.cpu), + mem: clampNumberSeries(bucket?.mem), + restarts: clampNumberSeries(bucket?.restarts), + asyncActive: clampNumberSeries(bucket?.asyncActive), + diskReadBytes: clampNumberSeries(bucket?.diskReadBytes), + diskWriteBytes: clampNumberSeries(bucket?.diskWriteBytes) + }; + }); + return { + version: 1, + updatedAt: String(source.updatedAt || ''), + environment, + samples, + processes + }; +} + +export function createEmptyMonitoringHistory() { + return { + version: 1, + updatedAt: '', + environment: 'dev', + samples: [], + processes: {} + }; +} + /** * Legacy deploy-target `type` values (the pre-JUM-481 UI select) that differ * from the Requirement 059 `deployTarget` vocabulary. Values already spelled @@ -334,6 +481,12 @@ export function normalizeEntityInput(entity, entityIndex) { name: entityName, x: Number.isFinite(entity?.x) ? entity.x : 14 + (entityIndex % 2) * 206, y: Number.isFinite(entity?.y) ? entity.y : 14 + Math.floor(entityIndex / 2) * 120, + width: Number.isFinite(entity?.width) + ? Math.min(ENTITY_MAX_WIDTH, Math.max(ENTITY_MIN_WIDTH, entity.width)) + : ENTITY_WIDTH, + height: Number.isFinite(entity?.height) + ? Math.min(ENTITY_MAX_HEIGHT, Math.max(ENTITY_MIN_HEIGHT, entity.height)) + : undefined, fields, meta: { aggregateRoot: Boolean(entity?.meta?.aggregateRoot), @@ -364,6 +517,17 @@ export function normalizeDomainInput(domain, domainIndex) { color: /^#[0-9a-f]{6}$/i.test(domain?.color || '') ? domain.color : DOMAIN_COLORS[domainIndex % DOMAIN_COLORS.length], x: Number.isFinite(domain?.x) ? domain.x : 120 + domainIndex * 40, y: Number.isFinite(domain?.y) ? domain.y : 90 + domainIndex * 30, + // JUM-729 follow-up: absent in every model saved before the domain box could be + // resized, so the default is the size the CSS used to draw. + // JUM-729 follow-up: collapsed domains keep their position and their links; only + // their contents are out of the way. + collapsed: Boolean(domain?.collapsed), + width: Number.isFinite(domain?.width) + ? Math.max(DOMAIN_MIN_WIDTH, domain.width) + : DOMAIN_DEFAULT_WIDTH, + height: Number.isFinite(domain?.height) + ? Math.max(DOMAIN_MIN_HEIGHT, domain.height) + : DOMAIN_DEFAULT_HEIGHT, context: { ubiquitousLanguage: String(domain?.context?.ubiquitousLanguage || '').trim(), ownerTeam: String(domain?.context?.ownerTeam || '').trim(), @@ -411,11 +575,12 @@ export function normalizeDomainInput(domain, domainIndex) { /** * Normalise a decoded `service-management.v1` payload (or a full-suite export * document, JUM-547) into the model slice the designer restores. The load - * path restores the domain slice plus `deployments` (migrated forward to the - * Requirement 059 metadata contract by `normalizeDeploymentInput`, JUM-481); - * the remaining pinned sections (`interfaces`, `serviceConfiguration`, - * `runtimeEnvironment`, `activeTab`) are intentionally not restored at load - * time. Since JUM-547 the sections ARE normalised and returned here — the + * path restores the domain slice, deployments (migrated forward to the + * Requirement 059 metadata contract by `normalizeDeploymentInput`, JUM-481) + * and the generated-code workspace (JUM-736); the remaining pinned sections + * (`interfaces`, `serviceConfiguration`, `runtimeEnvironment`, `activeTab`) + * are intentionally not restored at load time. Since JUM-547/JUM-736 the sections + * ARE normalised and returned here — the * full-suite import path (`buildStateFromSuiteExport`) applies them with the * same normalisation discipline as a load — so both crossings share one * normaliser. @@ -428,15 +593,28 @@ export function normalizeStatePayload(parsed) { const relationships = relationshipsInput .map(normalizeRelationship) .filter((relationship) => entityIds.has(relationship.fromEntityId) && entityIds.has(relationship.toEntityId)); + const notesInput = Array.isArray(parsed?.notes) ? parsed.notes : []; + const notes = notesInput.map(normalizeNote); const deploymentsInput = Array.isArray(parsed?.deployments) ? parsed.deployments : []; const deployments = deploymentsInput.map(normalizeDeploymentInput); const interfacesInput = Array.isArray(parsed?.interfaces) ? parsed.interfaces : []; const interfaces = interfacesInput.map(normalizeInterfaceInput); const serviceConfiguration = normalizeServiceConfigurationInput(parsed?.serviceConfiguration); const runtimeEnvironment = normalizeRuntimeEnvironmentInput(parsed?.runtimeEnvironment); + const codeWorkspace = normalizeCodeWorkspaceInput(parsed?.codeWorkspace); + const monitoringHistory = normalizeMonitoringHistoryInput(parsed?.monitoringHistory); const view = { zoom: clampZoom(parsed?.view?.zoom || 1), compactEntities: Boolean(parsed?.view?.compactEntities), + // JUM-729 follow-up: which sidebar group is on screen, so a reload does not throw + // the user back to Model in the middle of an inspector edit. + sidebarGroup: ['model', 'inspector', 'quality', 'share'].includes(parsed?.view?.sidebarGroup) + ? parsed.view.sidebarGroup + : 'model', + // JUM-729 follow-up: the panels overlay the canvas, so whether the drawer is open is + // part of the view. Closed by default — the canvas is what the designer is + // for, and a first run should show it whole. + sidebarOpen: Boolean(parsed?.view?.sidebarOpen), snapToGrid: parsed?.view?.snapToGrid !== false, edgeStyle: ['curved', 'orthogonal'].includes(parsed?.view?.edgeStyle) ? parsed.view.edgeStyle : 'curved', modelCheckMinSeverity: ['info', 'warn', 'error'].includes(parsed?.view?.modelCheckMinSeverity) @@ -448,6 +626,7 @@ export function normalizeStatePayload(parsed) { return { domains, relationships, + notes, selectedDomainId: parsed?.selectedDomainId || domains[0]?.id || null, selectedEntityId: parsed?.selectedEntityId || null, selectedRelationshipId: parsed?.selectedRelationshipId || null, @@ -455,6 +634,8 @@ export function normalizeStatePayload(parsed) { interfaces, serviceConfiguration, runtimeEnvironment, + codeWorkspace, + monitoringHistory, deployments, view }; @@ -464,6 +645,8 @@ export function createDefaultView() { return { zoom: 1, compactEntities: false, + sidebarGroup: 'model', + sidebarOpen: false, snapToGrid: true, edgeStyle: 'curved', modelCheckMinSeverity: 'info', @@ -494,6 +677,7 @@ export function createDesignerState({ store, seed, render, runtimeEnvDefaults = const state = { domains: [], relationships: [], + notes: [], selectedDomainId: null, selectedEntityId: null, selectedRelationshipId: null, @@ -516,6 +700,11 @@ export function createDesignerState({ store, seed, render, runtimeEnvDefaults = fileName: '.env.dev', values: { ...runtimeEnvDefaults } }, + codeWorkspace: { + files: {}, + activePath: '' + }, + monitoringHistory: createEmptyMonitoringHistory(), deployments: [], view: createDefaultView() }; @@ -529,6 +718,7 @@ export function createDesignerState({ store, seed, render, runtimeEnvDefaults = return JSON.parse(JSON.stringify({ domains: state.domains, relationships: state.relationships, + notes: state.notes, selectedDomainId: state.selectedDomainId, selectedEntityId: state.selectedEntityId, selectedRelationshipId: state.selectedRelationshipId, @@ -537,6 +727,8 @@ export function createDesignerState({ store, seed, render, runtimeEnvDefaults = interfaces: state.interfaces, serviceConfiguration: state.serviceConfiguration, runtimeEnvironment: state.runtimeEnvironment, + codeWorkspace: state.codeWorkspace, + monitoringHistory: state.monitoringHistory, deployments: state.deployments, view: state.view })); @@ -545,6 +737,7 @@ export function createDesignerState({ store, seed, render, runtimeEnvDefaults = function applySnapshot(snapshot) { state.domains = snapshot.domains || []; state.relationships = (snapshot.relationships || []).map(normalizeRelationship); + state.notes = (snapshot.notes || []).map(normalizeNote); state.selectedDomainId = snapshot.selectedDomainId || state.domains[0]?.id || null; state.selectedEntityId = snapshot.selectedEntityId || null; state.selectedRelationshipId = snapshot.selectedRelationshipId || null; @@ -559,6 +752,8 @@ export function createDesignerState({ store, seed, render, runtimeEnvDefaults = ...state.runtimeEnvironment, ...(snapshot.runtimeEnvironment || {}) }; + state.codeWorkspace = normalizeCodeWorkspaceInput(snapshot.codeWorkspace); + state.monitoringHistory = normalizeMonitoringHistoryInput(snapshot.monitoringHistory); state.deployments = Array.isArray(snapshot.deployments) ? snapshot.deployments.map(normalizeDeploymentInput) : []; @@ -595,6 +790,7 @@ export function createDesignerState({ store, seed, render, runtimeEnvDefaults = const payload = { domains: state.domains, relationships: state.relationships, + notes: state.notes, selectedDomainId: state.selectedDomainId, selectedEntityId: state.selectedEntityId, selectedRelationshipId: state.selectedRelationshipId, @@ -603,6 +799,8 @@ export function createDesignerState({ store, seed, render, runtimeEnvDefaults = interfaces: state.interfaces, serviceConfiguration: state.serviceConfiguration, runtimeEnvironment: state.runtimeEnvironment, + codeWorkspace: state.codeWorkspace, + monitoringHistory: state.monitoringHistory, deployments: state.deployments, view: state.view }; @@ -699,6 +897,8 @@ export function createDesignerState({ store, seed, render, runtimeEnvDefaults = state.selectedEntityId = parsed.selectedEntityId; state.selectedRelationshipId = parsed.selectedRelationshipId; state.idCounter = parsed.idCounter; + state.codeWorkspace = parsed.codeWorkspace; + state.monitoringHistory = parsed.monitoringHistory; state.deployments = parsed.deployments; state.view = parsed.view; recomputeIdCounter(); @@ -737,6 +937,7 @@ export function createDesignerState({ store, seed, render, runtimeEnvDefaults = pk: Boolean(field.pk), fk: Boolean(field.fk), unique: Boolean(field.unique), + indexed: Boolean(field.indexed), nullable: Boolean(field.nullable), format: field.format || '', itemsType: field.itemsType || '', diff --git a/packages/designer-core/src/store/IDesignerStore.js b/packages/designer-core/src/store/IDesignerStore.js index 4e833658a..4a446e61b 100644 --- a/packages/designer-core/src/store/IDesignerStore.js +++ b/packages/designer-core/src/store/IDesignerStore.js @@ -62,12 +62,14 @@ * is gone for good — a subsequent `load()` reports `'empty'`. * * The payload shape crossing `save()`/`load()` is the `service-management.v1` - * document pinned by Requirement 126 Contract 2 (twelve top-level sections: + * document pinned by Requirement 126 Contract 2 (fourteen top-level sections: * `domains`, `relationships`, `selectedDomainId`, `selectedEntityId`, * `selectedRelationshipId`, `idCounter`, `activeTab`, `interfaces`, - * `serviceConfiguration`, `runtimeEnvironment`, `deployments`, `view`). The + * `serviceConfiguration`, `runtimeEnvironment`, `codeWorkspace`, `deployments`, + * `view`). The * port itself is schema-agnostic; the pinned wire format belongs to the - * Cana adapter and to JUM-484's migration (which moved it unchanged). + * Cana adapter, JUM-484's migration, and additive sections such as JUM-736's + * code workspace. * * This contract is documented externally by JUM-473. */ diff --git a/packages/designer-core/src/validation/deployTargetLifecycleValidation.js b/packages/designer-core/src/validation/deployTargetLifecycleValidation.js index 7b4936b7b..3e411057c 100644 --- a/packages/designer-core/src/validation/deployTargetLifecycleValidation.js +++ b/packages/designer-core/src/validation/deployTargetLifecycleValidation.js @@ -46,8 +46,13 @@ import { * `node20`, `python3.12`, `bun1.3.13`). Free-text values like `latest` — * or a bare version with no runtime name — tell the operator nothing about * what the target runs and are rejected. + * + * The name body excludes `.` on purpose: dots appear only as version-segment + * separators, which keeps every dotted position attributable to exactly one + * branch of the pattern — overlapping repetitions here are a polynomial + * backtracking (ReDoS) surface. */ -export const RUNTIME_VERSION_PATTERN = /^[A-Za-z][A-Za-z0-9+._-]*\d(\.[A-Za-z0-9+_-]+)*$/; +export const RUNTIME_VERSION_PATTERN = /^[A-Za-z][A-Za-z0-9+_-]*\d(\.[A-Za-z0-9+_-]+)*$/; const RUNTIME_EXAMPLE = 'nodejs22.x'; diff --git a/packages/designer-core/src/validation/modelValidation.js b/packages/designer-core/src/validation/modelValidation.js index 5c7278775..ef0f888fe 100644 --- a/packages/designer-core/src/validation/modelValidation.js +++ b/packages/designer-core/src/validation/modelValidation.js @@ -31,6 +31,10 @@ import { toPathToken, toSchemaName } from '../model/modelQueries.js'; +import { + describeQuotedPropertyKey, + isBarePropertyKey +} from '../model/propertyKeys.js'; /** * @typedef {Object} ModelIssue @@ -91,6 +95,21 @@ export function collectModelIssues(state) { pushIssue(`Entity ${domain.name}/${entity.name} has duplicated field: ${field.name}`, entity.id, 'error'); } seenFields.add(fieldKey); + // JUM-731: the codegen quotes a field name that is not a bare + // identifier, and said so nowhere. `my field name!` validated with + // `No issues found.` and travelled into three OAS schemas, the code + // preview and the boilerplate bundle as `"my field name!"?: string;`. + // A warning, not an error: nothing produced is invalid, and names that + // mirror an external contract (`content-type`) are legitimate — the + // person typing it just has to know the property becomes index-only. + if (field.name && !isBarePropertyKey(field.name)) { + pushIssue( + `Field ${domain.name}/${entity.name}.${field.name} ` + + describeQuotedPropertyKey(field.name), + entity.id, + 'warn' + ); + } if (field.type === 'array' && !field.itemsType) { pushIssue(`Field ${domain.name}/${entity.name}.${field.name} is array but has no itemsType.`, entity.id, 'error'); } diff --git a/packages/designer-core/test/fallback-branches.test.ts b/packages/designer-core/test/fallback-branches.test.ts new file mode 100644 index 000000000..8401c6afd --- /dev/null +++ b/packages/designer-core/test/fallback-branches.test.ts @@ -0,0 +1,212 @@ +/* eslint-disable @typescript-eslint/no-var-requires, global-require */ +import path from 'node:path'; + +const packageRoot = path.resolve(__dirname, '..'); + +const { + normalizeCodeWorkspaceInput, + normalizeEntityInput, + normalizeNote +} = require(path.join(packageRoot, 'src', 'state', 'designerState.js')) as { + normalizeCodeWorkspaceInput: (input: unknown) => { + files: Record; + }; + normalizeEntityInput: (input: unknown, index: number) => { width: number; height?: number }; + normalizeNote: (input: unknown, index: number) => { + id: string; + x: number; + y: number; + color: string; + }; +}; + +const { + buildBoilerplateBundleDocument +} = require(path.join(packageRoot, 'src', 'exporters', 'designerExporters.js')) as { + buildBoilerplateBundleDocument: (state: Record, generatedAt?: string) => { + modules: Array<{ + entities: Array<{ + files: Record; + }>; + }>; + }; +}; + +const { + searchModel, + resizeDomainBox +} = require(path.join(packageRoot, 'src', 'model', 'modelQueries.js')) as { + searchModel: (domains: unknown, query: unknown) => unknown[]; + resizeDomainBox: (domain: unknown, width: number, height: number) => { + width: number; + height: number; + }; +}; + +const { + isBarePropertyKey +} = require(path.join(packageRoot, 'src', 'model', 'propertyKeys.js')) as { + isBarePropertyKey: (name: unknown) => boolean; +}; + +describe('designer-core fallback branches', () => { + it('normalizes missing note coordinates and colour inside the package owner suite', () => { + expect.hasAssertions(); + + const note = normalizeNote({}, 2); + + expect(note.id).toMatch(/^note-import-2-/); + expect(note).toMatchObject({ + x: 108, + y: 108, + color: '#fde68a' + }); + }); + + it('keeps explicit note coordinates and colours when they are valid', () => { + expect.hasAssertions(); + + const note = normalizeNote({ + id: 'note-a', + text: ' Decision ', + x: 12, + y: 34, + color: '#abcdef' + }, 0); + + expect(note).toMatchObject({ + id: 'note-a', + text: 'Decision', + x: 12, + y: 34, + color: '#abcdef' + }); + }); + + it('normalizes generated workspace files from sparse overlays', () => { + expect.hasAssertions(); + + const normalized = normalizeCodeWorkspaceInput({ + files: { + 'src/modules/Billing/domain/Model/Invoice.ts': { + state: 'unknown', + generatedContent: 'generated model' + } + } + }); + + expect(normalized.files['src/modules/Billing/domain/Model/Invoice.ts']).toStrictEqual({ + path: 'src/modules/Billing/domain/Model/Invoice.ts', + state: 'generated', + baseContent: 'generated model', + generatedContent: 'generated model', + content: 'generated model', + updatedAt: '' + }); + }); + + it('normalizes an empty workspace file overlay without inheriting a false path', () => { + expect.hasAssertions(); + + const normalized = normalizeCodeWorkspaceInput({ + files: { + 'src/modules/Billing/domain/Model/Invoice.ts': null + } + }); + + expect(normalized.files['src/modules/Billing/domain/Model/Invoice.ts']).toStrictEqual({ + path: 'src/modules/Billing/domain/Model/Invoice.ts', + state: 'generated', + baseContent: '', + generatedContent: '', + content: '', + updatedAt: '' + }); + }); + + it('builds boilerplate bundles when no code workspace overlay exists', () => { + expect.hasAssertions(); + const state = { + domains: [{ + id: 'domain-1', + name: 'Billing', + entities: [{ + id: 'entity-1', + name: 'Invoice', + fields: [{ + id: 'field-1', + name: 'id', + type: 'uuid', + primary: true, + required: true + }] + }] + }], + relationships: [] + }; + + const document = buildBoilerplateBundleDocument(state, '2026-09-07T00:00:00.000Z'); + + expect(document.modules[0].entities[0].files.model.content).toContain('export class Invoice'); + }); + + it('keeps boilerplate overlays safe when a stale file has no edited content', () => { + expect.hasAssertions(); + const state = { + domains: [{ + id: 'domain-1', + name: 'Billing', + entities: [{ + id: 'entity-1', + name: 'Invoice', + fields: [{ + id: 'field-1', + name: 'id', + type: 'uuid', + primary: true, + required: true + }] + }] + }], + relationships: [], + codeWorkspace: { + files: { + 'src/modules/Billing/domain/Model/Invoice.ts': { + path: 'src/modules/Billing/domain/Model/Invoice.ts', + state: 'stale' + } + } + } + }; + + const document = buildBoilerplateBundleDocument(state, '2026-09-07T00:00:00.000Z'); + const file = document.modules[0].entities[0].files.model; + + expect(file.content).toContain('export class Invoice'); + expect(file.workspaceState).toBe('stale'); + }); + + it('defaults absent entity dimensions and clamps sparse domain resize inputs', () => { + expect.hasAssertions(); + + expect(normalizeEntityInput({}, 0).height).toBeUndefined(); + expect(normalizeEntityInput({ height: 444 }, 0).height).toBe(444); + expect(resizeDomainBox({ entities: [{ x: Number.NaN, y: Number.NaN }] }, 12, 16)) + .toStrictEqual({ width: 356, height: 160 }); + }); + + it('keeps model search and property key nullish fallbacks explicit', () => { + expect.hasAssertions(); + + expect(searchModel([], '')).toStrictEqual([]); + expect(isBarePropertyKey(null)).toBe(false); + }); +}); diff --git a/packages/designer-core/test/packaging.test.ts b/packages/designer-core/test/packaging.test.ts index b337102c6..2d5202917 100644 --- a/packages/designer-core/test/packaging.test.ts +++ b/packages/designer-core/test/packaging.test.ts @@ -148,7 +148,7 @@ describe('designer-core packaging manifest', () => { it('carries provenance metadata pointing at the monorepo location', () => { expect.hasAssertions(); expect(manifest.repository?.type).toBe('git'); - expect(manifest.repository?.url).toContain('github.com/XpertMinds/Jumentix'); + expect(manifest.repository?.url).toContain('github.com/web2solutions/Jumentix'); expect(manifest.repository?.directory).toBe('packages/designer-core'); }); diff --git a/packages/external-store-proxy/src/ExternalStoreProxy.ts b/packages/external-store-proxy/src/ExternalStoreProxy.ts index abdb5eb85..7cc40910f 100644 --- a/packages/external-store-proxy/src/ExternalStoreProxy.ts +++ b/packages/external-store-proxy/src/ExternalStoreProxy.ts @@ -2,7 +2,10 @@ import { ConflictError, DataBaseNotFoundError, - DatabasePagingError + applyListFilters, + applyListSearch, + applyListSort, + paginateList } from '@jumentix/persistence-contracts'; import type { IPagingRequest, IPagingResponse, IStore } from '@jumentix/persistence-contracts'; import { BaseExternalDataRepository } from '@jumentix/external-persistence-core'; @@ -68,37 +71,22 @@ const normalize = (value: TPrimitive): string => String(value ?? ''); const normalizeCI = (value: TPrimitive): string => normalize(value).toLowerCase(); +// Filters, search, sort and paging come from `@jumentix/persistence-contracts` +// (JUM-777) so this proxy and the in-memory store answer the REST list contract +// identically; `buildPaging` also applies `paging.q` and `paging.sort`. const applyFilters = ( records: Record[], filters: Record -): Record[] => { - const entries = Object.entries(filters || {}); - if (entries.length === 0) return records; - return records.filter((record) => entries.every(([key, value]) => record[key] === value)); -}; +): Record[] => applyListFilters(records, filters); const buildPaging = ( records: T[], paging: IPagingRequest ): IPagingResponse => { - const page = paging.page ?? paging.currentPage ?? 1; - const size = paging.size ?? paging.perPage ?? 10; - if (page < 1) { - throw new DatabasePagingError('page must be greater than 0'); - } - const total = records.length; - const totalPages = Math.max(1, Math.ceil(total / size)); - if (page > totalPages && total > 0) { - throw new DatabasePagingError('page number must be smaller than the number of total pages'); - } - const startAt = (page * size) - size; - const result = records.slice(startAt, startAt + size); - return { - result, - total, - page, - size - }; + const rows = records as unknown as Record[]; + const searched = applyListSearch(rows, paging.q, paging.searchFields); + const sorted = applyListSort(searched, paging.sort); + return paginateList(sorted as unknown as T[], paging); }; const unsupportedDriverError = (driver: string, entity: string): Error => new Error( @@ -151,23 +139,29 @@ export class ExternalStoreProxy> implements IStore } public async delete(id: string): Promise { - if (this.driver === 'Mongo') return this.mongoDelete(id); - if (SQL_DRIVERS.has(this.driver)) return this.sqlDelete(id); - if (this.driver === DYNAMODB_DRIVER) return this.dynamoDelete(id); - if (this.driver === CASSANDRA_DRIVER) return this.cassandraDelete(id); - if (this.driver === FIREBASE_DRIVER) return this.firebaseDelete(id); - if (this.driver === ORACLE_DRIVER) return this.oracleDelete(id); - throw unsupportedDriverError(this.driver, this.entity); + try { + const existing = await this.getOneById(id, { includeDeleted: true }); + await this.update(id, { ...existing, deletedAt: new Date().toISOString() } as T); + return true; + } catch (error) { + if (error instanceof DataBaseNotFoundError) return false; + throw error; + } } - public async getOneById(id: string): Promise { - if (this.driver === 'Mongo') return this.mongoGetOneById(id); - if (SQL_DRIVERS.has(this.driver)) return this.sqlGetOneById(id); - if (this.driver === DYNAMODB_DRIVER) return this.dynamoGetOneById(id); - if (this.driver === CASSANDRA_DRIVER) return this.cassandraGetOneById(id); - if (this.driver === FIREBASE_DRIVER) return this.firebaseGetOneById(id); - if (this.driver === ORACLE_DRIVER) return this.oracleGetOneById(id); - throw unsupportedDriverError(this.driver, this.entity); + public async getOneById(id: string, options?: { includeDeleted?: boolean }): Promise { + let record: T; + if (this.driver === 'Mongo') record = await this.mongoGetOneById(id); + else if (SQL_DRIVERS.has(this.driver)) record = await this.sqlGetOneById(id); + else if (this.driver === DYNAMODB_DRIVER) record = await this.dynamoGetOneById(id); + else if (this.driver === CASSANDRA_DRIVER) record = await this.cassandraGetOneById(id); + else if (this.driver === FIREBASE_DRIVER) record = await this.firebaseGetOneById(id); + else if (this.driver === ORACLE_DRIVER) record = await this.oracleGetOneById(id); + else throw unsupportedDriverError(this.driver, this.entity); + if ((record as { deletedAt?: unknown }).deletedAt && !options?.includeDeleted) { + throw new DataBaseNotFoundError('Record not found'); + } + return record; } public async getByName(name: string): Promise { diff --git a/packages/key-value-storage/test/key-value-storage.test.ts b/packages/key-value-storage/test/key-value-storage.test.ts index 571811ad0..c9a791398 100644 --- a/packages/key-value-storage/test/key-value-storage.test.ts +++ b/packages/key-value-storage/test/key-value-storage.test.ts @@ -5,13 +5,16 @@ * them rather than in a shared helper away from the reason they exist. */ /* eslint-disable max-classes-per-file */ +import net from 'node:net'; + import type { IServiceResponse } from '../src'; import { BaseKeyValueStorageClient, InMemoryKeyValueStorageClient, RedisKeyValueStorageClient, ServiceResponse, - compileKeyValueStorageClient + compileKeyValueStorageClient, + resetRedisKeyValueStorageClientForTests } from '../src'; /** @@ -435,6 +438,18 @@ describe('the Redis client', () => { expect(typeof socket.reconnectStrategy).toBe('function'); }); + it('defaults the port to 6379 when the environment names none', async () => { + expect.hasAssertions(); + + const client = await withEnvironment( + 'JUMENTIX_REDIS_PORT', + undefined, + () => RedisKeyValueStorageClient.create() + ); + + expect((client.client.options.socket as any).port).toBe(6379); + }); + it('reads the timeout and reconnect budget from the environment', async () => { expect.hasAssertions(); @@ -548,3 +563,260 @@ describe('the Redis client', () => { expect(response.result).toBeUndefined(); }); }); + +/** + * The Redis client's success paths, against a real TCP server speaking enough + * RESP (the Redis wire protocol) to answer GET/SET/DEL/QUIT — written here, in + * the test, so the suite needs no server process and no wall-clock waits. The + * client under test is the real `redis` driver over a real socket: what is + * asserted is OUR client's behaviour (prefixing, state tracking, error + * reporting), never the server's. + * + * The live-server versions of these paths belong to + * `test/integration/redis.integration.test.ts` under `RUN_REDIS_INTEGRATION`; + * this block is what lets the unit run measure the same lines deterministically. + */ + +/** One complete RESP array of bulk strings, or null when more bytes are owed. */ +function readRespCommand(buffer: Buffer): { args: Buffer[]; consumed: number } | null { + if (buffer.length === 0 || buffer[0] !== 0x2a) return null; // '*' + const headerEnd = buffer.indexOf('\r\n'); + if (headerEnd === -1) return null; + const count = Number(buffer.subarray(1, headerEnd).toString()); + if (!Number.isInteger(count)) return null; + const args: Buffer[] = []; + let offset = headerEnd + 2; + for (let index = 0; index < count; index += 1) { + if (buffer.length <= offset || buffer[offset] !== 0x24) return null; // '$' + const lengthEnd = buffer.indexOf('\r\n', offset); + if (lengthEnd === -1) return null; + const length = Number(buffer.subarray(offset + 1, lengthEnd).toString()); + const start = lengthEnd + 2; + if (buffer.length < start + length + 2) return null; + args.push(buffer.subarray(start, start + length)); + offset = start + length + 2; + } + return { args, consumed: offset }; +} + +function fakeRedisServer(initial: Record = {}) { + const data = new Map(Object.entries(initial)); + // When set, every data command is refused with a RESP error — a server that + // is up but failing, the case the per-operation try/catch exists for. + let refusing = false; + const sockets = new Set(); + const server = net.createServer((socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + let pending = Buffer.alloc(0); + socket.on('data', (chunk: Buffer) => { + pending = Buffer.concat([pending, chunk]); + for (;;) { + const parsed = readRespCommand(pending); + if (!parsed) break; + pending = pending.subarray(parsed.consumed); + const [name, ...args] = parsed.args.map((argument) => argument.toString()); + const key = args[0]; + if (refusing && ['GET', 'SET', 'DEL'].includes(name.toUpperCase())) { + socket.write('-ERR storage failure\r\n'); + // eslint-disable-next-line no-continue + continue; + } + switch (name.toUpperCase()) { + case 'GET': { + const value = data.get(key); + socket.write(value === undefined + ? '$-1\r\n' + : `$${Buffer.byteLength(value)}\r\n${value}\r\n`); + break; + } + case 'SET': + data.set(key, args[1]); + socket.write('+OK\r\n'); + break; + case 'DEL': + socket.write(`:${data.delete(key) ? 1 : 0}\r\n`); + break; + case 'QUIT': + // Answer and leave the close to the client: ending the socket + // from here can deliver FIN before the driver has processed the + // +OK, which it reports as an unexpected close (and logs after + // the suite has finished). + socket.write('+OK\r\n'); + break; + case 'PING': + socket.write('+PONG\r\n'); + break; + default: + // AUTH/SELECT/CLIENT SETINFO and anything else: accept and move on. + socket.write('+OK\r\n'); + } + } + }); + }); + + return { + data, + refuse: () => { refusing = true; }, + listen: () => new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + resolve((server.address() as net.AddressInfo).port); + }); + }), + close: async () => { + sockets.forEach((socket) => socket.destroy()); + await new Promise((resolve) => { server.close(() => resolve()); }); + } + }; +} + +describe('the Redis client against an in-process RESP server', () => { + const connectTo = (port: number) => RedisKeyValueStorageClient.create({ + socket: { + host: '127.0.0.1', + port, + connectTimeout: 1000, + reconnectStrategy: () => new Error('no reconnects in this suite') + }, + database: 0 + }); + + it('connects and tracks its state, and reconnecting is a no-op', async () => { + expect.hasAssertions(); + + const server = fakeRedisServer(); + const port = await server.listen(); + const client = connectTo(port); + try { + expect(client.connected).toBe(false); + + const connection = await client.connect(); + + expect(connection).toStrictEqual(new ServiceResponse({ result: { connected: true } })); + expect(client.connected).toBe(true); + + // Connecting a live client again must be a no-op, not a second socket. + await expect(client.connect()).resolves + .toStrictEqual(new ServiceResponse({ result: { connected: true } })); + } finally { + await client.disconnect(); + await server.close(); + } + }); + + it('round-trips a value under the prefixed key', async () => { + expect.hasAssertions(); + + const server = fakeRedisServer(); + const port = await server.listen(); + const client = connectTo(port); + try { + const written = await client.set('round-trip', 'stored'); + expect(written).toStrictEqual(new ServiceResponse({ result: 'OK' })); + // The prefix is on the wire, not only on the argument: the server holds + // the namespaced key. + expect([...server.data.keys()]).toStrictEqual([`${client.prefix}:round-trip`]); + + await expect(client.get('round-trip')).resolves + .toStrictEqual(new ServiceResponse({ result: 'stored' })); + await expect(client.get('never-written')).resolves + .toStrictEqual(new ServiceResponse({ result: null })); + } finally { + await client.disconnect(); + await server.close(); + } + }); + + it('deletes keys and reports how many it removed', async () => { + expect.hasAssertions(); + + const server = fakeRedisServer({ 'jumentix__:to-delete': 'value' }); + const port = await server.listen(); + const client = connectTo(port); + try { + await expect(client.del('to-delete')).resolves + .toStrictEqual(new ServiceResponse({ result: 1 })); + await expect(client.del('to-delete')).resolves + .toStrictEqual(new ServiceResponse({ result: 0 })); + await expect(client.get('to-delete')).resolves + .toStrictEqual(new ServiceResponse({ result: null })); + } finally { + await client.disconnect(); + await server.close(); + } + }); + + it('disconnects and reports the new state', async () => { + expect.hasAssertions(); + + const server = fakeRedisServer(); + const port = await server.listen(); + const client = connectTo(port); + try { + await expect(client.connect()).resolves + .toStrictEqual(new ServiceResponse({ result: { connected: true } })); + + const disconnection = await client.disconnect(); + expect(disconnection).toStrictEqual(new ServiceResponse({ result: { connected: false } })); + expect(client.connected).toBe(false); + } finally { + await server.close(); + } + }); + + it('resets the singleton so a suite can rebuild against another endpoint', async () => { + expect.hasAssertions(); + + const first = RedisKeyValueStorageClient.compile(); + resetRedisKeyValueStorageClientForTests(); + const second = RedisKeyValueStorageClient.compile(); + + expect(second).not.toBe(first); + expect(RedisKeyValueStorageClient.compile()).toBe(second); + }); +}); + +describe('the Redis client when the server refuses commands', () => { + it('reports the refusal from get/set/del instead of throwing it', async () => { + expect.hasAssertions(); + + // Connected, but every command fails: the per-operation try/catch is what + // stands between a caller and a rejected driver promise. + const server = fakeRedisServer({ seeded: 'value' }); + const port = await server.listen(); + const client = RedisKeyValueStorageClient.create({ + socket: { + host: '127.0.0.1', + port, + connectTimeout: 1000, + reconnectStrategy: () => new Error('no reconnects in this suite') + }, + database: 0 + }); + try { + await expect(client.connect()).resolves + .toStrictEqual(new ServiceResponse({ result: { connected: true } })); + + server.refuse(); + + for (const call of [ + () => client.get('seeded'), + () => client.set('k', 'v'), + () => client.del('seeded') + ]) { + // eslint-disable-next-line no-await-in-loop + const response = await call(); + expect(response.result).toBeUndefined(); + expect(response.error).toBeInstanceOf(Error); + expect(String((response.error as Error).message)).toContain('storage failure'); + } + // Nothing was written or removed through the refusal. + expect(server.data.get('seeded')).toBe('value'); + } finally { + // Disconnect BEFORE closing the server: destroying the socket under a + // live client surfaces as an unexpected-close error after the test ends. + await client.disconnect(); + await server.close(); + } + }); +}); diff --git a/packages/message-mediator/test/bullmq-adapter.test.ts b/packages/message-mediator/test/bullmq-adapter.test.ts index 4193122bb..2fc8bbe16 100644 --- a/packages/message-mediator/test/bullmq-adapter.test.ts +++ b/packages/message-mediator/test/bullmq-adapter.test.ts @@ -233,6 +233,54 @@ describe('bullMQ adapter against a queue double (JUM-681)', () => { expect(response.error).toStrictEqual({ name: 'RangeError', message: 'handler exploded' }); }); + it('connects on demand when a request arrives before connect()', async () => { + expect.hasAssertions(); + + // Same on-demand contract as publish: `request` must not crash on an + // adapter nobody called `connect()` on. + const broker = fakeBullMq(); + BullMqMessageMediatorAdapter.importBullMq = async () => broker.lib; + const adapter = new BullMqMessageMediatorAdapter({ connection: {} } as never); + adapter.registerHandler('orders.create', async (incoming) => ({ + contract: incoming.contract, + result: { echoed: (incoming.payload as any).id } + })); + + const response = await adapter.request(message()); + + expect(response.result).toStrictEqual({ echoed: 1 }); + expect(broker.added[0].queue).toBe('app.requests'); + }); + + it('reports a handler that throws a non-Error without losing what it was', async () => { + expect.hasAssertions(); + + // A thrown string is not an Error: it must still cross the wire as a + // readable `{ name, message }`, not as `{}`. + const { adapter } = await connected(); + adapter.registerHandler('orders.create', async () => { + // eslint-disable-next-line no-throw-literal + throw 'handler exploded' as never; + }); + + const response = await adapter.request(message()); + + expect(response.error).toStrictEqual({ name: 'Error', message: 'handler exploded' }); + }); + + it('loads the real bullmq through the default import seam', async () => { + expect.hasAssertions(); + + // The doubles replace the seam in every other test; this one proves the + // untouched seam still resolves to the library whose constructor surface + // (`Queue`, `QueueEvents`, `Worker`) the adapter drives. + const bullmq = await originalImport(); + + expect(typeof bullmq.Queue).toBe('function'); + expect(typeof bullmq.QueueEvents).toBe('function'); + expect(typeof bullmq.Worker).toBe('function'); + }); + it('names the contract when nothing is registered for it', async () => { expect.hasAssertions(); diff --git a/packages/message-mediator/test/rabbitmq-adapter.test.ts b/packages/message-mediator/test/rabbitmq-adapter.test.ts index 02b931ee0..26a5c3439 100644 --- a/packages/message-mediator/test/rabbitmq-adapter.test.ts +++ b/packages/message-mediator/test/rabbitmq-adapter.test.ts @@ -122,6 +122,37 @@ describe('rabbitMQ adapter against a broker double (JUM-681)', () => { expect(connects).toBe(1); }); + it('connects on demand when an operation arrives before connect()', async () => { + expect.hasAssertions(); + + // `ensureConnected` exists so a caller that went straight to `publish` — + // without an explicit `connect()` — still lands on a live channel rather + // than crashing on `undefined.publish`. + const broker = fakeAmqp(); + let connects = 0; + RabbitMqMessageMediatorAdapter.importAmqpLib = async () => ({ + connect: async () => { connects += 1; return broker.lib.connect(); } + }); + const adapter = new RabbitMqMessageMediatorAdapter({ url: 'amqp://localhost' }); + + await adapter.publish({ name: 'orders.created', payload: { id: 1 } } as never); + + expect(connects).toBe(1); + expect(broker.published[0]).toMatchObject({ exchange: 'app.events', key: 'orders.created' }); + }); + + it('loads the real amqplib through the default import seam', async () => { + expect.hasAssertions(); + + // The seam is the whole contract here: the doubles above replace it, so + // only this assertion proves the untouched seam still resolves to the + // broker library the adapter's `connect` drives — a renamed package or a + // broken dependency would otherwise surface only in production. + const amqplib = await originalImport(); + + expect(typeof amqplib.connect).toBe('function'); + }); + it('acknowledges a reply it cannot match instead of leaving it unacked', async () => { expect.hasAssertions(); diff --git a/packages/persistence-contracts/src/IStore.ts b/packages/persistence-contracts/src/IStore.ts index 3e75ede3c..a9aca2aa5 100644 --- a/packages/persistence-contracts/src/IStore.ts +++ b/packages/persistence-contracts/src/IStore.ts @@ -3,6 +3,14 @@ export interface IPagingRequest { perPage?: number; page?: number; size?: number; + /** Ordered sort fields (JUM-777); see `parseListSort` for the wire form. */ + sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; + /** Free-text search term applied over `searchFields` (JUM-777). */ + q?: string; + /** Fields `q` is matched against; declared by the operation's `x-list-capabilities`. */ + searchFields?: string[]; + /** When true, list/get include tombstones (`deletedAt` set). Default false. */ + includeDeleted?: boolean; } export interface IPagingResponse { @@ -147,7 +155,7 @@ export type TStoreAggregationStage = Record; export interface IStore { delete(id: string): Promise; - getOneById(id: string): Promise; + getOneById(id: string, options?: { includeDeleted?: boolean }): Promise; getByName?(name: string): Promise; getByRelation?(field: keyof T, referenceId: string): Promise; create(key: string, value: T): Promise; @@ -168,6 +176,8 @@ export interface IStore { data: Partial, options?: IStoreMutationOptions ): Promise; + /** Physical row removal. Default `delete` stays a tombstone when soft-delete is on. */ + hardDelete?(id: string): Promise; deleteOne?(id: string, options?: IStoreDeleteOptions): Promise; deleteMany?(query: IStoreQuery, options?: IStoreDeleteOptions): Promise; upsertOne?(query: IStoreQuery, data: Partial, options?: IStoreMutationOptions): Promise; diff --git a/packages/persistence-contracts/src/idReservationLedger.ts b/packages/persistence-contracts/src/idReservationLedger.ts new file mode 100644 index 000000000..4447c6c5c --- /dev/null +++ b/packages/persistence-contracts/src/idReservationLedger.ts @@ -0,0 +1,35 @@ +export interface IIdReservation { + entity: string; + id: string; + purgedAt: string; +} + +export interface IIdReservationLedger { + reserve(entry: IIdReservation): void; + has(entity: string, id: string): boolean; + get(entity: string, id: string): IIdReservation | undefined; + list(): IIdReservation[]; +} + +const keyOf = (entity: string, id: string): string => `${entity}:${id}`; + +/** In-process ledger: purged ids stay reserved. dev memory dies on restart. */ +export class InMemoryIdReservationLedger implements IIdReservationLedger { + private readonly rows = new Map(); + + public reserve(entry: IIdReservation): void { + this.rows.set(keyOf(entry.entity, entry.id), entry); + } + + public has(entity: string, id: string): boolean { + return this.rows.has(keyOf(entity, id)); + } + + public get(entity: string, id: string): IIdReservation | undefined { + return this.rows.get(keyOf(entity, id)); + } + + public list(): IIdReservation[] { + return [...this.rows.values()]; + } +} diff --git a/packages/persistence-contracts/src/index.ts b/packages/persistence-contracts/src/index.ts index 7d50c0cf6..49cd4ef9c 100644 --- a/packages/persistence-contracts/src/index.ts +++ b/packages/persistence-contracts/src/index.ts @@ -1,3 +1,7 @@ export * from './IStore'; export * from './IDatabaseClient'; export * from './errors'; +export * from './listQuery'; +export * from './metricsQuery'; +export * from './idReservationLedger'; +export * from './purgeTombstones'; diff --git a/packages/persistence-contracts/src/listQuery.ts b/packages/persistence-contracts/src/listQuery.ts new file mode 100644 index 000000000..2545ec3d5 --- /dev/null +++ b/packages/persistence-contracts/src/listQuery.ts @@ -0,0 +1,262 @@ +import type { + IPagingRequest, + IPagingResponse, + IStoreFilterExpression, + TFilterOperator +} from './IStore'; +import { DatabasePagingError } from './errors'; + +/** + * List query helpers shared by every `IStore.getAll` implementation (JUM-777). + * + * The REST list contract (`page`, `size`, `filter`, `sort`, `q`) used to stop + * at the controller: stores received a flat `Record` + * and compared it with `===`, and neither sorting nor free-text search existed + * anywhere, so every consumer sorted and searched in memory over the whole + * collection. These helpers give the in-memory store and the external-store + * proxy one implementation of the contract, so a driver cannot drift from + * what the OpenAPI document promises. + * + * Drivers that can push the work to the database (SQL `WHERE`/`ORDER BY`, + * Mongo `find().sort()`) may do so and still call `paginate` on the result; + * the observable behaviour must equal these functions, which is what the + * store contract tests assert. + */ + +export type TListFilterScalar = string | number | boolean | null; + +/** A filter value as it arrives from the wire: a scalar (equality) or an expression. */ +export type TListFilterValue = TListFilterScalar | TListFilterScalar[] | IStoreFilterExpression; + +export type TListFilters = Record; + +export interface IListSort { + field: string; + direction: 'asc' | 'desc'; +} + +const isExpression = (value: unknown): value is IStoreFilterExpression => ( + typeof value === 'object' && value !== null && !Array.isArray(value) && 'operator' in value +); + +const comparable = (value: unknown): number | string | null => { + if (value === null || value === undefined) return null; + if (typeof value === 'number' || typeof value === 'boolean') return Number(value); + if (value instanceof Date) return value.getTime(); + const text = String(value); + // ISO-8601 timestamps compare as instants so date-range filters and sorts + // do not depend on lexical ordering of the string. + if (/^\d{4}-\d{2}-\d{2}(T|$)/.test(text)) { + const time = Date.parse(text); + if (!Number.isNaN(time)) return time; + } + const number = Number(text); + if (text.trim() !== '' && !Number.isNaN(number)) return number; + return text; +}; + +const compare = (a: unknown, b: unknown): number => { + const left = comparable(a); + const right = comparable(b); + if (left === null && right === null) return 0; + if (left === null) return 1; + if (right === null) return -1; + if (typeof left === 'number' && typeof right === 'number') return left - right; + return String(left).localeCompare(String(right), undefined, { sensitivity: 'base' }); +}; + +const textOf = (value: unknown): string => { + if (value === null || value === undefined) return ''; + if (Array.isArray(value)) return value.map(textOf).join(' '); + if (typeof value === 'object') return JSON.stringify(value); + return String(value); +}; + +const matchesOperator = ( + actual: unknown, + operator: TFilterOperator, + expected: unknown +): boolean => { + switch (operator) { + case 'eq': + return Array.isArray(actual) + ? actual.some((entry) => String(entry) === String(expected)) + : String(actual ?? '') === String(expected ?? '') || actual === expected; + case 'ne': + return !matchesOperator(actual, 'eq', expected); + case 'gt': + return compare(actual, expected) > 0 && actual !== null && actual !== undefined; + case 'gte': + return compare(actual, expected) >= 0 && actual !== null && actual !== undefined; + case 'lt': + return compare(actual, expected) < 0 && actual !== null && actual !== undefined; + case 'lte': + return compare(actual, expected) <= 0 && actual !== null && actual !== undefined; + case 'in': + return (Array.isArray(expected) ? expected : [expected]) + .some((candidate) => matchesOperator(actual, 'eq', candidate)); + case 'nin': + return !matchesOperator(actual, 'in', expected); + case 'like': + return textOf(actual).includes(String(expected ?? '')); + case 'ilike': + case 'contains': + return textOf(actual).toLowerCase().includes(String(expected ?? '').toLowerCase()); + case 'regex': + return new RegExp(String(expected)).test(textOf(actual)); + case 'exists': + return (actual !== undefined && actual !== null) === Boolean(expected); + case 'overlaps': + return Array.isArray(actual) && (Array.isArray(expected) ? expected : [expected]) + .some((candidate) => actual.some((entry) => String(entry) === String(candidate))); + case 'between': { + const [from, to] = Array.isArray(expected) ? expected : [expected, expected]; + if (actual === null || actual === undefined) return false; + const fromOk = from === null || from === undefined || from === '' || compare(actual, from) >= 0; + const toOk = to === null || to === undefined || to === '' || compare(actual, to) <= 0; + return fromOk && toOk; + } + default: + return false; + } +}; + +/** True when the record satisfies every filter (AND semantics, per field). */ +export const matchesListFilters = ( + record: Record, + filters: TListFilters | Record | undefined +): boolean => Object.entries(filters ?? {}).every(([field, value]) => { + if (value === undefined) return true; + const actual = record[field]; + if (isExpression(value)) { + return matchesOperator(actual, value.operator, value.value); + } + if (Array.isArray(value)) { + return matchesOperator(actual, 'in', value); + } + return matchesOperator(actual, 'eq', value); +}); + +export const applyListFilters = >( + records: T[], + filters: TListFilters | Record | undefined +): T[] => { + if (!filters || Object.keys(filters).length === 0) return records; + return records.filter((record) => matchesListFilters(record, filters)); +}; + +/** Case-insensitive substring search over the declared fields (OR across fields). */ +export const applyListSearch = >( + records: T[], + q: string | undefined, + fields: string[] | undefined +): T[] => { + const needle = (q ?? '').trim().toLowerCase(); + if (!needle || !fields || fields.length === 0) return records; + return records.filter((record) => fields.some( + (field) => textOf(record[field]).toLowerCase().includes(needle) + )); +}; + +const primaryKeyOf = (record: Record): unknown => ( + record.id ?? record._id +); + +/** Stable multi-field sort; nulls last regardless of direction. */ +export const applyListSort = >( + records: T[], + sort: IListSort[] | undefined +): T[] => { + if (!sort || sort.length === 0) return records; + const keys = [...sort]; + const alreadyHasPk = keys.some((entry) => entry.field === 'id' || entry.field === '_id'); + if (!alreadyHasPk) { + keys.push({ field: 'id', direction: 'asc' }); + } + return [...records].sort((a, b) => { + for (const { field, direction } of keys) { + const left = field === 'id' && a[field] === undefined ? primaryKeyOf(a) : a[field]; + const right = field === 'id' && b[field] === undefined ? primaryKeyOf(b) : b[field]; + const aNull = left === null || left === undefined; + const bNull = right === null || right === undefined; + if (!(aNull && bNull)) { + if (aNull) return 1; + if (bNull) return -1; + const result = compare(left, right); + if (result !== 0) return direction === 'desc' ? -result : result; + } + } + return 0; + }); +}; + +/** + * Slices one page out of an already filtered/sorted collection. + * + * Page zero and a page past the last one are malformed requests, not empty + * results: a client paging with a stale total would otherwise render an empty + * grid and report it as "no records". + */ +export const paginateList = ( + records: T[], + paging: IPagingRequest +): IPagingResponse => { + const page = paging.page ?? paging.currentPage ?? 1; + const size = paging.size ?? paging.perPage ?? 10; + if (!Number.isInteger(page) || page < 1) { + throw new DatabasePagingError('page must be greater than 0'); + } + if (!Number.isInteger(size) || size < 1) { + throw new DatabasePagingError('size must be greater than 0'); + } + const total = records.length; + const totalPages = Math.max(1, Math.ceil(total / size)); + if (page > totalPages && total > 0) { + throw new DatabasePagingError('page number must be smaller than the number of total pages'); + } + const startAt = (page * size) - size; + return { + result: records.slice(startAt, startAt + size), + total, + page, + size + }; +}; + +/** + * The whole list contract in one call: filters → search → sort → page. + * `IPagingRequest.sort`, `q` and `searchFields` are read from the paging + * request so `IStore.getAll(filters, paging)` keeps its two-argument shape. + */ +const withoutTombstones = >(records: T[]): T[] => ( + records.filter((record) => record.deletedAt == null || record.deletedAt === '') +); + +export const runListQuery = >( + records: T[], + filters: TListFilters | Record | undefined, + paging: IPagingRequest +): IPagingResponse => { + const live = paging.includeDeleted ? records : withoutTombstones(records); + const filtered = applyListFilters(live, filters); + const searched = applyListSearch(filtered, paging.q, paging.searchFields); + const sorted = applyListSort(searched, paging.sort); + return paginateList(sorted, paging); +}; + +/** + * Parses the wire form of `sort` (`field:asc,other:desc`; direction defaults + * to `asc`). Returns `undefined` for an empty value so callers can pass the + * query string through untouched. + */ +export const parseListSort = (raw: string | undefined | null): IListSort[] | undefined => { + const text = (raw ?? '').trim(); + if (!text) return undefined; + return text.split(',').map((part): IListSort => { + const [field, direction] = part.trim().split(':'); + return { + field: field.trim(), + direction: direction?.trim().toLowerCase() === 'desc' ? 'desc' : 'asc' + }; + }).filter((entry) => entry.field.length > 0); +}; diff --git a/packages/persistence-contracts/src/metricsQuery.ts b/packages/persistence-contracts/src/metricsQuery.ts new file mode 100644 index 000000000..dff801512 --- /dev/null +++ b/packages/persistence-contracts/src/metricsQuery.ts @@ -0,0 +1,127 @@ +import { applyListFilters, type TListFilters } from './listQuery'; + +export type TMetricsKind = 'count' | 'groupBy' | 'series'; +export type TMetricsInterval = 'day' | 'week' | 'month'; + +export interface IMetricsCapabilities { + groupable: string[]; + series: string[]; +} + +export interface IMetricsQuery { + metric: TMetricsKind; + field?: string; + interval?: TMetricsInterval; + filters?: TListFilters | Record; +} + +export interface IMetricsBucket { + key: string; + count: number; +} + +export interface IMetricsResult { + metric: TMetricsKind; + field?: string; + interval?: TMetricsInterval; + buckets: IMetricsBucket[]; +} + +const list = (values: string[]): string => (values.length ? values.join(', ') : '(none)'); + +const asDate = (value: unknown): Date | null => { + if (value instanceof Date && !Number.isNaN(value.getTime())) return value; + if (typeof value === 'string' || typeof value === 'number') { + const parsed = new Date(value); + if (!Number.isNaN(parsed.getTime())) return parsed; + } + return null; +}; + +const seriesKey = (value: unknown, interval: TMetricsInterval): string | null => { + const date = asDate(value); + if (!date) return null; + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); + const day = String(date.getUTCDate()).padStart(2, '0'); + if (interval === 'day') return `${year}-${month}-${day}`; + if (interval === 'month') return `${year}-${month}`; + const jan1 = Date.UTC(year, 0, 1); + const week = Math.floor((date.getTime() - jan1) / (7 * 24 * 60 * 60 * 1000)) + 1; + return `${year}-W${String(week).padStart(2, '0')}`; +}; + +const groupKeys = (value: unknown): string[] => { + if (value === null || value === undefined || value === '') return []; + if (Array.isArray(value)) return value.map((entry) => String(entry)); + return [String(value)]; +}; + +export const runMetricsQuery = >( + records: T[], + query: IMetricsQuery, + capabilities: IMetricsCapabilities +): IMetricsResult => { + const { metric } = query; + if (metric !== 'count' && metric !== 'groupBy' && metric !== 'series') { + throw new Error('The parameter metric is not accepted. Accepted: count, groupBy, series.'); + } + const live = records.filter((record) => record.deletedAt == null || record.deletedAt === ''); + const filtered = applyListFilters(live, query.filters); + + if (metric === 'count') { + return { + metric, + buckets: [{ key: 'total', count: filtered.length }] + }; + } + + const { field } = query; + if (!field) { + throw new Error('The parameter field is required for groupBy and series.'); + } + + if (metric === 'groupBy') { + if (!capabilities.groupable.includes(field)) { + throw new Error( + `The metrics field "${field}" is not groupable. Accepted: ${list(capabilities.groupable)}.` + ); + } + const counts = new Map(); + for (const record of filtered) { + for (const key of groupKeys(record[field])) { + counts.set(key, (counts.get(key) ?? 0) + 1); + } + } + return { + metric, + field, + buckets: [...counts.entries()].map(([key, count]) => ({ key, count })) + }; + } + + if (!capabilities.series.includes(field)) { + throw new Error( + `The metrics field "${field}" is not a series field. Accepted: ${list(capabilities.series)}.` + ); + } + const interval = query.interval ?? 'day'; + if (interval !== 'day' && interval !== 'week' && interval !== 'month') { + throw new Error('The parameter interval is not accepted. Accepted: day, week, month.'); + } + const counts = new Map(); + for (const record of filtered) { + const key = seriesKey(record[field], interval); + if (key) { + counts.set(key, (counts.get(key) ?? 0) + 1); + } + } + return { + metric, + field, + interval, + buckets: [...counts.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, count]) => ({ key, count })) + }; +}; diff --git a/packages/persistence-contracts/src/purgeTombstones.ts b/packages/persistence-contracts/src/purgeTombstones.ts new file mode 100644 index 000000000..24ca8151d --- /dev/null +++ b/packages/persistence-contracts/src/purgeTombstones.ts @@ -0,0 +1,147 @@ +import type { IIdReservationLedger } from './idReservationLedger'; + +export const TOMBSTONE_PURGE_TTL_DAYS = 90; +export const MS_PER_DAY = 86_400_000; + +export const SEED_PURGED_ID_MESSAGE = (entity: string, id: string): string => ( + `Seed id ${id} (${entity}) was purged; cannot recreate. Restart dev or exclude seed ids from purge (--protect-seed).` +); + +export function assertSeedIdNotPurged( + ledger: IIdReservationLedger, + entity: string, + id: string +): void { + if (ledger.has(entity, id)) { + throw new Error(SEED_PURGED_ID_MESSAGE(entity, id)); + } +} + +export function parseInstant(value: unknown): number | null { + if (value instanceof Date && !Number.isNaN(value.getTime())) return value.getTime(); + if (typeof value === 'string' || typeof value === 'number') { + const time = Date.parse(String(value)); + if (!Number.isNaN(time)) return time; + } + return null; +} + +export function isTombstone(record: { deletedAt?: unknown }): boolean { + const { deletedAt } = record; + if (deletedAt == null || deletedAt === '') return false; + return parseInstant(deletedAt) != null; +} + +export interface IPurgeRecord { + id: string; + deletedAt?: unknown; +} + +export interface IPurgeStore { + entity: string; + listTombstones(): Promise; + hardDelete(id: string): Promise; +} + +export interface IPurgeTombstonesInput { + stores: IPurgeStore[]; + ledger: IIdReservationLedger; + now?: Date; + olderThanDays?: number; + commit?: boolean; + excludeIds?: string[]; +} + +export interface IPurgeEvent { + entity: string; + id: string; + deletedAt: string; + purgedAt: string; + dryRun: boolean; +} + +export interface IPurgeReport { + dryRun: boolean; + olderThanDays: number; + events: IPurgeEvent[]; + skippedProtected: number; + skippedTooYoung: number; +} + +const asDeletedAt = (value: unknown): string => ( + value instanceof Date ? value.toISOString() : String(value) +); + +const collectEligible = ( + store: IPurgeStore, + rows: IPurgeRecord[], + cutoff: number, + exclude: Set, + commit: boolean, + purgedAt: string +): { events: IPurgeEvent[]; skippedProtected: number; skippedTooYoung: number } => { + let skippedProtected = 0; + let skippedTooYoung = 0; + const events: IPurgeEvent[] = []; + rows.forEach((row) => { + if (!isTombstone(row)) return; + if (exclude.has(row.id)) { + skippedProtected += 1; + return; + } + const deletedMs = parseInstant(row.deletedAt); + if (deletedMs == null || deletedMs > cutoff) { + skippedTooYoung += 1; + return; + } + events.push({ + entity: store.entity, + id: row.id, + deletedAt: asDeletedAt(row.deletedAt), + purgedAt, + dryRun: !commit + }); + }); + return { events, skippedProtected, skippedTooYoung }; +}; + +export async function purgeTombstones(input: IPurgeTombstonesInput): Promise { + const olderThanDays = input.olderThanDays ?? TOMBSTONE_PURGE_TTL_DAYS; + const now = input.now ?? new Date(); + const cutoff = now.getTime() - olderThanDays * MS_PER_DAY; + const commit = input.commit === true; + const exclude = new Set(input.excludeIds ?? []); + const purgedAt = now.toISOString(); + + const scanned = await Promise.all(input.stores.map(async (store) => { + const rows = await store.listTombstones(); + return { store, ...collectEligible(store, rows, cutoff, exclude, commit, purgedAt) }; + })); + + const events = scanned.flatMap((item) => item.events); + const skippedProtected = scanned.reduce((sum, item) => sum + item.skippedProtected, 0); + const skippedTooYoung = scanned.reduce((sum, item) => sum + item.skippedTooYoung, 0); + + if (commit) { + await Promise.all(events.map(async (event) => { + const store = input.stores.find((item) => item.entity === event.entity); + if (!store) return; + const removed = await store.hardDelete(event.id); + if (removed) { + input.ledger.reserve({ + entity: event.entity, + id: event.id, + purgedAt + }); + } + })); + } + + return { + dryRun: !commit, + olderThanDays, + events, + skippedProtected, + skippedTooYoung + }; +} diff --git a/packages/persistence-contracts/test/contracts.test.ts b/packages/persistence-contracts/test/contracts.test.ts index aff0760ed..c6db96a45 100644 --- a/packages/persistence-contracts/test/contracts.test.ts +++ b/packages/persistence-contracts/test/contracts.test.ts @@ -58,18 +58,39 @@ describe('the package entry point', () => { * * The cost was measured rather than assumed: the compiled module is 1.1 kB * and tree-shakeable, and no browser package imports this one today. + * + * JUM-777 added the list-query helpers (filters, search, sort, paging) so + * the in-memory store and the external-store proxy share one implementation + * of the REST list contract. They are pure functions with no dependencies, + * still tree-shakeable; the list below is the new pinned surface. */ - it('exports exactly the store errors at runtime, and nothing else', () => { + it('exports exactly the store errors and list-query helpers at runtime, and nothing else', () => { expect.hasAssertions(); expect(Object.keys(contracts).sort()).toStrictEqual([ 'ConflictError', 'DataBaseNotFoundError', 'DatabasePagingError', + 'InMemoryIdReservationLedger', + 'MS_PER_DAY', 'PERSISTENCE_ERROR_CODES', 'PERSISTENCE_ERROR_NAMES', 'PersistenceError', + 'SEED_PURGED_ID_MESSAGE', + 'TOMBSTONE_PURGE_TTL_DAYS', + 'applyListFilters', + 'applyListSearch', + 'applyListSort', + 'assertSeedIdNotPurged', 'currentCorrelationId', + 'isTombstone', + 'matchesListFilters', + 'paginateList', + 'parseInstant', + 'parseListSort', + 'purgeTombstones', + 'runListQuery', + 'runMetricsQuery', 'setCorrelationIdResolver' ]); }); diff --git a/packages/persistence-contracts/test/listQuery.test.ts b/packages/persistence-contracts/test/listQuery.test.ts new file mode 100644 index 000000000..7188eff22 --- /dev/null +++ b/packages/persistence-contracts/test/listQuery.test.ts @@ -0,0 +1,389 @@ +import { + DatabasePagingError, + applyListFilters, + applyListSearch, + applyListSort, + matchesListFilters, + paginateList, + parseListSort, + runListQuery +} from '../src'; + +/** + * JUM-777 — the REST list contract (`filter`, `q`, `sort`, `page`, `size`) has + * one implementation for every store. These tests pin the observable rules the + * OpenAPI document promises: equality and operator filters, case-insensitive + * search over declared fields, stable typed sort with nulls last, and paging + * that refuses pages which cannot exist. + */ + +interface Row extends Record { + id: string; + name: string; + age: number | null; + roles: string[]; + createdAt: string; +} + +const rows: Row[] = [ + { + id: '1', name: 'Ana Lima', age: 31, roles: ['admin'], createdAt: '2026-01-05T10:00:00.000Z' + }, + { + id: '2', name: 'bruno costa', age: null, roles: ['user'], createdAt: '2026-03-01T10:00:00.000Z' + }, + { + id: '3', name: 'Carla Souza', age: 28, roles: ['admin', 'user'], createdAt: '2025-12-31T23:59:59.000Z' + }, + { + id: '4', name: 'Dario Alves', age: 45, roles: [], createdAt: '2026-02-10T08:00:00.000Z' + } +]; + +describe('matchesListFilters / applyListFilters', () => { + it('treats a scalar as equality and an array as membership', () => { + expect.hasAssertions(); + expect(applyListFilters(rows, { id: '2' }).map((r) => r.id)).toStrictEqual(['2']); + expect(applyListFilters(rows, { id: ['1', '4'] }).map((r) => r.id)).toStrictEqual(['1', '4']); + }); + + it('matches equality inside array fields (roles=admin)', () => { + expect.hasAssertions(); + expect(applyListFilters(rows, { roles: 'admin' }).map((r) => r.id)).toStrictEqual(['1', '3']); + }); + + it('applies contains case-insensitively and between over ISO dates', () => { + expect.hasAssertions(); + expect(applyListFilters(rows, { name: { operator: 'contains', value: 'LIMA' } }).map((r) => r.id)) + .toStrictEqual(['1']); + expect(applyListFilters(rows, { + createdAt: { operator: 'between', value: ['2026-01-01', '2026-02-28'] } + }).map((r) => r.id)).toStrictEqual(['1', '4']); + expect(applyListFilters(rows, { + createdAt: { operator: 'between', value: ['', '2026-01-01'] } + }).map((r) => r.id)).toStrictEqual(['3']); + }); + + it('compares numbers numerically for gt/gte/lt/lte and never matches null', () => { + expect.hasAssertions(); + expect(applyListFilters(rows, { age: { operator: 'gte', value: 31 } }).map((r) => r.id)) + .toStrictEqual(['1', '4']); + expect(applyListFilters(rows, { age: { operator: 'lt', value: '30' } }).map((r) => r.id)) + .toStrictEqual(['3']); + }); + + it('ands fields together and returns the input untouched for empty filters', () => { + expect.hasAssertions(); + expect(applyListFilters(rows, { roles: 'admin', age: { operator: 'lte', value: 30 } }) + .map((r) => r.id)).toStrictEqual(['3']); + expect(applyListFilters(rows, {})).toBe(rows); + expect(matchesListFilters(rows[0], undefined)).toBe(true); + }); + + it('rejects unknown operators instead of matching everything', () => { + expect.hasAssertions(); + expect(applyListFilters(rows, { name: { operator: 'nope' as never, value: 'x' } })).toStrictEqual([]); + }); +}); + +describe('applyListSearch', () => { + it('is case-insensitive and ORs across the declared fields', () => { + expect.hasAssertions(); + expect(applyListSearch(rows, 'COSTA', ['name']).map((r) => r.id)).toStrictEqual(['2']); + expect(applyListSearch(rows, 'admin', ['name', 'roles']).map((r) => r.id)).toStrictEqual(['1', '3']); + }); + + it('does nothing without a term or without fields', () => { + expect.hasAssertions(); + expect(applyListSearch(rows, ' ', ['name'])).toBe(rows); + expect(applyListSearch(rows, 'ana', [])).toBe(rows); + }); +}); + +describe('applyListSort', () => { + it('sorts text case-insensitively, dates as instants and puts nulls last', () => { + expect.hasAssertions(); + expect(applyListSort(rows, [{ field: 'name', direction: 'asc' }]).map((r) => r.id)) + .toStrictEqual(['1', '2', '3', '4']); + expect(applyListSort(rows, [{ field: 'createdAt', direction: 'desc' }]).map((r) => r.id)) + .toStrictEqual(['2', '4', '1', '3']); + expect(applyListSort(rows, [{ field: 'age', direction: 'asc' }]).map((r) => r.id)) + .toStrictEqual(['3', '1', '4', '2']); + expect(applyListSort(rows, [{ field: 'age', direction: 'desc' }]).map((r) => r.id)) + .toStrictEqual(['4', '1', '3', '2']); + }); + + it('is stable and does not mutate the input', () => { + expect.hasAssertions(); + const copy = [...rows]; + const sorted = applyListSort(rows, [{ field: 'roles', direction: 'asc' }]); + expect(rows).toStrictEqual(copy); + expect(sorted).not.toBe(rows); + expect(applyListSort(rows, [])).toBe(rows); + }); + + it('returns 0 for records tied on every key including the primary-key fallback', () => { + expect.hasAssertions(); + // Duplicate ids tie on the sort field and on the appended pk key, so the + // comparator exhausts every key and the stable sort keeps the input order. + const duplicatedIds = [ + { id: '1', group: 'x', seq: 1 }, + { id: '1', group: 'x', seq: 2 } + ]; + expect(applyListSort(duplicatedIds, [{ field: 'group', direction: 'asc' }]).map((r) => r.seq)) + .toStrictEqual([1, 2]); + }); +}); + +describe('paginateList', () => { + it('slices the page and reports the total across pages', () => { + expect.hasAssertions(); + expect(paginateList(rows, { page: 2, size: 3 })).toStrictEqual({ + result: [rows[3]], total: 4, page: 2, size: 3 + }); + expect(paginateList([], { page: 1, size: 10 })).toStrictEqual({ + result: [], total: 0, page: 1, size: 10 + }); + }); + + it('refuses page 0, size 0 and pages past the last one', () => { + expect.hasAssertions(); + expect(() => paginateList(rows, { page: 0, size: 2 })).toThrow(DatabasePagingError); + expect(() => paginateList(rows, { page: 1, size: 0 })).toThrow(DatabasePagingError); + expect(() => paginateList(rows, { page: 3, size: 2 })).toThrow( + 'page number must be smaller than the number of total pages' + ); + }); +}); + +describe('the remaining filter operators', () => { + it('ne is the exact negation of eq, including the array-membership form', () => { + expect.hasAssertions(); + expect(applyListFilters(rows, { id: { operator: 'ne', value: '2' } }).map((r) => r.id)) + .toStrictEqual(['1', '3', '4']); + expect(applyListFilters(rows, { roles: { operator: 'ne', value: 'admin' } }).map((r) => r.id)) + .toStrictEqual(['2', '4']); + }); + + it('gt compares strictly and never matches a null actual', () => { + expect.hasAssertions(); + expect(applyListFilters(rows, { age: { operator: 'gt', value: 31 } }).map((r) => r.id)) + .toStrictEqual(['4']); + // age is null for row 2: a null is not greater than anything. + expect(applyListFilters(rows, { age: { operator: 'gt', value: -1 } }).map((r) => r.id)) + .toStrictEqual(['1', '3', '4']); + }); + + it('nin is the exact negation of in, and in accepts a scalar candidate', () => { + expect.hasAssertions(); + expect(applyListFilters(rows, { id: { operator: 'nin', value: ['1', '4'] } }).map((r) => r.id)) + .toStrictEqual(['2', '3']); + expect(applyListFilters(rows, { id: { operator: 'in', value: '3' as never } }).map((r) => r.id)) + .toStrictEqual(['3']); + }); + + it('like is case-sensitive where ilike and contains are not', () => { + expect.hasAssertions(); + expect(applyListFilters(rows, { name: { operator: 'like', value: 'LIMA' } })).toStrictEqual([]); + expect(applyListFilters(rows, { name: { operator: 'like', value: 'Lima' } }).map((r) => r.id)) + .toStrictEqual(['1']); + expect(applyListFilters(rows, { name: { operator: 'ilike', value: 'COSTA' } }).map((r) => r.id)) + .toStrictEqual(['2']); + // A nullish needle is the empty string, which everything contains. + expect(applyListFilters(rows, { name: { operator: 'like', value: null as never } })) + .toHaveLength(4); + expect(applyListFilters(rows, { name: { operator: 'ilike', value: null as never } })) + .toHaveLength(4); + }); + + it('regex tests the text form of the value', () => { + expect.hasAssertions(); + expect(applyListFilters(rows, { name: { operator: 'regex', value: '^bruno' } }).map((r) => r.id)) + .toStrictEqual(['2']); + expect(applyListFilters(rows, { id: { operator: 'regex', value: '^[24]$' } }).map((r) => r.id)) + .toStrictEqual(['2', '4']); + }); + + it('exists compares presence with the boolean of the expectation', () => { + expect.hasAssertions(); + expect(applyListFilters(rows, { age: { operator: 'exists', value: false } }).map((r) => r.id)) + .toStrictEqual(['2']); + expect(applyListFilters(rows, { age: { operator: 'exists', value: true } }).map((r) => r.id)) + .toStrictEqual(['1', '3', '4']); + expect(applyListFilters(rows, { missing: { operator: 'exists', value: false } })) + .toHaveLength(4); + }); + + it('overlaps intersects array fields with array or scalar expectations', () => { + expect.hasAssertions(); + expect(applyListFilters(rows, { roles: { operator: 'overlaps', value: ['admin', 'auditor'] } }) + .map((r) => r.id)).toStrictEqual(['1', '3']); + expect(applyListFilters(rows, { roles: { operator: 'overlaps', value: 'user' } }).map((r) => r.id)) + .toStrictEqual(['2', '3']); + // A scalar field has no overlap to give. + expect(applyListFilters(rows, { name: { operator: 'overlaps', value: ['Ana Lima'] } })) + .toStrictEqual([]); + }); + + it('between accepts a scalar as both bounds and refuses a null actual', () => { + expect.hasAssertions(); + expect(applyListFilters(rows, { age: { operator: 'between', value: 31 } }).map((r) => r.id)) + .toStrictEqual(['1']); + expect(applyListFilters(rows, { age: { operator: 'between', value: [null, 100] } }) + .map((r) => r.id)).toStrictEqual(['1', '3', '4']); + expect(applyListFilters(rows, { age: { operator: 'between', value: [1, null] } }) + .map((r) => r.id)).toStrictEqual(['1', '3', '4']); + }); + + it('skips filter entries whose value is undefined', () => { + expect.hasAssertions(); + expect(applyListFilters(rows, { id: undefined as never })).toStrictEqual(rows); + }); + + it('compares nulls against nulls and against values under the order operators', () => { + expect.hasAssertions(); + // compare(null, null) is a tie; compare(31, null) puts the null last — + // either way the null actual/expectation never satisfies gt. + expect(applyListFilters(rows, { age: { operator: 'gt', value: null } })).toStrictEqual([]); + expect(applyListFilters(rows, { age: { operator: 'lte', value: null } }).map((r) => r.id)) + .toStrictEqual(['1', '3', '4']); + }); + + it('eq falls back to the empty string for nullish actual and expectation', () => { + expect.hasAssertions(); + // A missing field equals a null expectation (both read as ''), and only that. + expect(applyListFilters(rows, { missing: { operator: 'eq', value: null } })).toHaveLength(4); + expect(applyListFilters(rows, { id: { operator: 'eq', value: null } })).toStrictEqual([]); + }); +}); + +describe('the comparison primitives', () => { + it('compares dates as instants and nulls against either side', () => { + expect.hasAssertions(); + const dated = [ + { id: 'a', at: new Date('2026-01-02T00:00:00.000Z') }, + { id: 'b', at: new Date('2026-01-01T00:00:00.000Z') } + ]; + expect(applyListSort(dated, [{ field: 'at', direction: 'asc' }]).map((r) => r.id)) + .toStrictEqual(['b', 'a']); + + // Null on the left sinks, null on the right floats: nulls last either way. + const withNulls = [ + { id: 'a', at: null }, { id: 'b', at: '2026-01-01' }, { id: 'c', at: null } + ]; + expect(applyListSort(withNulls, [{ field: 'at', direction: 'asc' }]).map((r) => r.id)) + .toStrictEqual(['b', 'a', 'c']); + }); + + it('falls through to the next sort key and returns 0 for a full tie', () => { + expect.hasAssertions(); + const tied = [ + { id: 'a', group: 'x', rank: 2 }, + { id: 'b', group: 'x', rank: 1 }, + { id: 'c', group: 'x', rank: 2 } + ]; + expect(applyListSort(tied, [ + { field: 'group', direction: 'asc' }, + { field: 'rank', direction: 'asc' } + ]).map((r) => r.id)).toStrictEqual(['b', 'a', 'c']); + // Stability: a full tie keeps the input order. + expect(applyListSort(tied, [{ field: 'group', direction: 'asc' }]).map((r) => r.id)) + .toStrictEqual(['a', 'b', 'c']); + }); + + it('searches array and object fields by their text form', () => { + expect.hasAssertions(); + const records = [ + { id: 'a', tags: ['red', 'blue'], meta: { code: 'x1' } }, + { id: 'b', tags: [], meta: null } + ]; + expect(applyListSearch(records, 'blue', ['tags']).map((r) => r.id)).toStrictEqual(['a']); + expect(applyListSearch(records, '"code":"x1"', ['meta']).map((r) => r.id)).toStrictEqual(['a']); + // A null field carries no text; and an undefined term is no search at all. + expect(applyListSearch(records, 'x1', ['meta']).map((r) => r.id)).toStrictEqual(['a']); + expect(applyListSearch(records, undefined, ['tags'])).toBe(records); + }); +}); + +describe('paginateList aliases', () => { + it('reads currentPage/perPage when page/size are absent, and defaults to 1/10', () => { + expect.hasAssertions(); + expect(paginateList(rows, { currentPage: 2, perPage: 2 })).toStrictEqual({ + result: [rows[2], rows[3]], total: 4, page: 2, size: 2 + }); + const eleven = Array.from({ length: 11 }, (_, index) => ({ id: String(index) })); + expect(paginateList(eleven, {})).toStrictEqual({ + result: eleven.slice(0, 10), total: 11, page: 1, size: 10 + }); + }); +}); + +describe('parseListSort and runListQuery', () => { + it('parses the wire form with asc as the default direction', () => { + expect.hasAssertions(); + expect(parseListSort('name:desc, age ,createdAt:ASC')).toStrictEqual([ + { field: 'name', direction: 'desc' }, + { field: 'age', direction: 'asc' }, + { field: 'createdAt', direction: 'asc' } + ]); + expect(parseListSort('')).toBeUndefined(); + expect(parseListSort(undefined)).toBeUndefined(); + }); + + it('runs filters, search, sort and paging in that order', () => { + expect.hasAssertions(); + const page = runListQuery(rows, { roles: 'admin' }, { + page: 1, size: 1, q: 'a', searchFields: ['name'], sort: [{ field: 'name', direction: 'desc' }] + }); + expect(page).toStrictEqual({ + result: [rows[2]], total: 2, page: 1, size: 1 + }); + }); + + it('excludes tombstones unless includeDeleted is set', () => { + expect.hasAssertions(); + const withTomb = [...rows, { + id: '5', name: 'gone', age: 1, roles: [], createdAt: '2026-01-01T00:00:00.000Z', deletedAt: '2026-04-01T00:00:00.000Z' + }]; + expect(runListQuery(withTomb, {}, { page: 1, size: 10 }).total).toBe(4); + expect(runListQuery(withTomb, {}, { page: 1, size: 10, includeDeleted: true }).total).toBe(5); + }); + + it('breaks equal sort keys with the primary key', () => { + expect.hasAssertions(); + const tied = [ + { + id: 'b', name: 'x', age: 1, roles: [], createdAt: '2026-01-01T00:00:00.000Z' + }, + { + id: 'a', name: 'x', age: 1, roles: [], createdAt: '2026-01-01T00:00:00.000Z' + } + ]; + expect(applyListSort(tied, [{ field: 'createdAt', direction: 'asc' }]).map((r) => r.id)) + .toStrictEqual(['a', 'b']); + }); +}); + +describe('primary key fallback for id-less records', () => { + it('sorts ascending by _id when a record has no id field', () => { + expect.hasAssertions(); + const mixedKeys = [ + { _id: 'b', name: 'second' }, + { id: 'a', name: 'first' }, + { _id: '0', name: 'zero' } + ]; + const sorted = applyListSort(mixedKeys, [{ field: 'id', direction: 'asc' }]); + expect(sorted.map((record) => record.name)).toStrictEqual(['zero', 'first', 'second']); + }); + + it('sorts descending by _id when a record has no id field', () => { + expect.hasAssertions(); + const mixedKeys = [ + { _id: 'b', name: 'second' }, + { id: 'a', name: 'first' }, + { _id: '0', name: 'zero' } + ]; + const sorted = applyListSort(mixedKeys, [{ field: 'id', direction: 'desc' }]); + expect(sorted.map((record) => record.name)).toStrictEqual(['second', 'first', 'zero']); + }); +}); diff --git a/packages/persistence-contracts/test/metricsQuery.test.ts b/packages/persistence-contracts/test/metricsQuery.test.ts new file mode 100644 index 000000000..7f417f4fd --- /dev/null +++ b/packages/persistence-contracts/test/metricsQuery.test.ts @@ -0,0 +1,149 @@ +import { runMetricsQuery } from '../src'; + +const rows = [ + { + id: '1', roles: ['admin'], organization: 'org-a', createdAt: '2026-01-01T10:00:00.000Z' + }, + { + id: '2', roles: ['user'], organization: 'org-a', createdAt: '2026-01-02T10:00:00.000Z' + }, + { + id: '3', + roles: ['admin', 'user'], + organization: 'org-b', + createdAt: '2026-02-01T10:00:00.000Z' + }, + { + id: '4', + roles: ['admin'], + organization: 'org-b', + createdAt: '2026-02-02T10:00:00.000Z', + deletedAt: '2026-03-01T00:00:00.000Z' + } +]; + +const capabilities = { groupable: ['roles', 'organization'], series: ['createdAt'] }; + +describe('runMetricsQuery', () => { + it('counts live records only', () => { + expect.hasAssertions(); + expect(runMetricsQuery(rows, { metric: 'count' }, capabilities)).toStrictEqual({ + metric: 'count', + buckets: [{ key: 'total', count: 3 }] + }); + }); + + it('groups by enum and by array field', () => { + expect.hasAssertions(); + const orgs = runMetricsQuery( + rows, + { metric: 'groupBy', field: 'organization' }, + capabilities + ).buckets; + expect(orgs).toContainEqual({ key: 'org-a', count: 2 }); + expect(orgs).toContainEqual({ key: 'org-b', count: 1 }); + const roles = runMetricsQuery(rows, { metric: 'groupBy', field: 'roles' }, capabilities).buckets; + expect(roles.find((b) => b.key === 'admin')?.count).toBe(2); + expect(roles.find((b) => b.key === 'user')?.count).toBe(2); + }); + + it('builds a day series and applies filters', () => { + expect.hasAssertions(); + const series = runMetricsQuery(rows, { + metric: 'series', + field: 'createdAt', + interval: 'day', + filters: { organization: 'org-a' } + }, capabilities); + expect(series.buckets).toStrictEqual([ + { key: '2026-01-01', count: 1 }, + { key: '2026-01-02', count: 1 } + ]); + }); + + it('names accepted values on invalid field or metric', () => { + expect.hasAssertions(); + expect(() => runMetricsQuery(rows, { metric: 'nope' as never }, capabilities)) + .toThrow('Accepted: count, groupBy, series'); + expect(() => runMetricsQuery(rows, { metric: 'groupBy', field: 'id' }, capabilities)) + .toThrow('Accepted: roles, organization'); + }); + + it('builds month and week series from Date, epoch and string values', () => { + expect.hasAssertions(); + const recs = [ + { id: '1', createdAt: new Date('2026-01-05T00:00:00.000Z') }, + { id: '2', createdAt: Date.parse('2026-01-08T00:00:00.000Z') }, + { id: '3', createdAt: '2026-02-10T00:00:00.000Z' } + ]; + expect(runMetricsQuery(recs, { metric: 'series', field: 'createdAt', interval: 'month' }, capabilities).buckets) + .toStrictEqual([ + { key: '2026-01', count: 2 }, + { key: '2026-02', count: 1 } + ]); + expect(runMetricsQuery(recs, { metric: 'series', field: 'createdAt', interval: 'week' }, capabilities).buckets) + .toStrictEqual([ + { key: '2026-W01', count: 1 }, + { key: '2026-W02', count: 1 }, + { key: '2026-W06', count: 1 } + ]); + const defaulted = runMetricsQuery(recs, { metric: 'series', field: 'createdAt' }, capabilities); + expect(defaulted.interval).toBe('day'); + expect(defaulted.buckets).toStrictEqual([ + { key: '2026-01-05', count: 1 }, + { key: '2026-01-08', count: 1 }, + { key: '2026-02-10', count: 1 } + ]); + }); + + it('skips records whose series field is not a parseable date', () => { + expect.hasAssertions(); + const recs = [ + { id: '1', createdAt: 'not-a-date' }, + { id: '2', createdAt: new Date('not-a-date') }, + { id: '3', createdAt: true }, + { id: '4', createdAt: null }, + { id: '5', createdAt: '2026-01-01T00:00:00.000Z' } + ]; + expect(runMetricsQuery(recs, { metric: 'series', field: 'createdAt', interval: 'day' }, capabilities).buckets) + .toStrictEqual([{ key: '2026-01-01', count: 1 }]); + }); + + it('requires field for groupBy and series and validates the series contract', () => { + expect.hasAssertions(); + expect(() => runMetricsQuery(rows, { metric: 'groupBy' }, capabilities)) + .toThrow('The parameter field is required for groupBy and series.'); + expect(() => runMetricsQuery(rows, { metric: 'series' }, capabilities)) + .toThrow('The parameter field is required for groupBy and series.'); + expect(() => runMetricsQuery(rows, { metric: 'series', field: 'organization' }, capabilities)) + .toThrow('The metrics field "organization" is not a series field. Accepted: createdAt.'); + expect(() => runMetricsQuery(rows, { metric: 'series', field: 'createdAt', interval: 'hour' as never }, capabilities)) + .toThrow('The parameter interval is not accepted. Accepted: day, week, month.'); + }); + + it('names (none) when a capability list is empty', () => { + expect.hasAssertions(); + const empty = { groupable: [], series: [] }; + expect(() => runMetricsQuery(rows, { metric: 'groupBy', field: 'id' }, empty)) + .toThrow('Accepted: (none).'); + expect(() => runMetricsQuery(rows, { metric: 'series', field: 'createdAt' }, empty)) + .toThrow('Accepted: (none).'); + }); + + it('treats empty-string tombstones as live and skips empty group keys', () => { + expect.hasAssertions(); + const recs = [ + { id: '1', organization: '', deletedAt: '' }, + { id: '2', organization: null }, + { id: '3', organization: 'org-a' }, + { id: '4', organization: 7 } + ]; + expect(runMetricsQuery(recs, { metric: 'count' }, capabilities).buckets) + .toStrictEqual([{ key: 'total', count: 4 }]); + expect(runMetricsQuery(recs, { metric: 'groupBy', field: 'organization' }, capabilities).buckets) + .toStrictEqual([ + { key: 'org-a', count: 1 }, + { key: '7', count: 1 } + ]); + }); +}); diff --git a/packages/persistence-contracts/test/purgeTombstones.test.ts b/packages/persistence-contracts/test/purgeTombstones.test.ts new file mode 100644 index 000000000..c134245d3 --- /dev/null +++ b/packages/persistence-contracts/test/purgeTombstones.test.ts @@ -0,0 +1,153 @@ +import { + InMemoryIdReservationLedger, + TOMBSTONE_PURGE_TTL_DAYS, + assertSeedIdNotPurged, + purgeTombstones, + type IPurgeRecord, + type IPurgeStore +} from '../src'; + +const oldTombstone = '2026-01-01T00:00:00.000Z'; +const now = new Date('2026-06-01T00:00:00.000Z'); + +const makeStore = ( + entity: string, + rows: IPurgeRecord[] +): IPurgeStore & { rows: IPurgeRecord[] } => { + const store = { + entity, + rows: [...rows], + async listTombstones() { + return store.rows.filter((row) => row.deletedAt); + }, + async hardDelete(id: string) { + const before = store.rows.length; + store.rows = store.rows.filter((row) => row.id !== id); + return store.rows.length < before; + } + }; + return store; +}; + +describe('purgeTombstones', () => { + it('dry-run lists eligible tombstones and writes nothing', async () => { + expect.hasAssertions(); + const ledger = new InMemoryIdReservationLedger(); + const users = makeStore('User', [ + { id: 'live', deletedAt: null }, + { id: 'old', deletedAt: oldTombstone }, + { id: 'young', deletedAt: '2026-05-20T00:00:00.000Z' } + ]); + const report = await purgeTombstones({ + stores: [users], + ledger, + now, + olderThanDays: TOMBSTONE_PURGE_TTL_DAYS, + commit: false + }); + expect(report).toMatchObject({ + dryRun: true, + skippedTooYoung: 1, + events: [{ entity: 'User', id: 'old', dryRun: true }] + }); + expect(users.rows.some((row) => row.id === 'old')).toBe(true); + expect(ledger.has('User', 'old')).toBe(false); + }); + + it('commit removes PII row and reserves the id', async () => { + expect.hasAssertions(); + const ledger = new InMemoryIdReservationLedger(); + const users = makeStore('User', [{ id: 'old', deletedAt: oldTombstone }]); + const report = await purgeTombstones({ + stores: [users], + ledger, + now, + commit: true + }); + expect(report.dryRun).toBe(false); + expect(users.rows).toHaveLength(0); + expect(ledger.has('User', 'old')).toBe(true); + expect(ledger.get('User', 'old')?.purgedAt).toBe('2026-06-01T00:00:00.000Z'); + }); + + it('protects seed ids and refuse recreate after purge', async () => { + expect.hasAssertions(); + const ledger = new InMemoryIdReservationLedger(); + const users = makeStore('User', [ + { id: 'seed-1', deletedAt: oldTombstone }, + { id: 'other', deletedAt: oldTombstone } + ]); + const dry = await purgeTombstones({ + stores: [users], + ledger, + now, + commit: true, + excludeIds: ['seed-1'] + }); + expect(dry.skippedProtected).toBe(1); + expect(users.rows.map((row) => row.id)).toStrictEqual(['seed-1']); + expect(ledger.has('User', 'other')).toBe(true); + expect(() => assertSeedIdNotPurged(ledger, 'User', 'other')).toThrow( + 'Seed id other (User) was purged; cannot recreate' + ); + expect(() => assertSeedIdNotPurged(ledger, 'User', 'seed-1')).not.toThrow(); + }); + + it('ignores rows whose deletedAt is not a parseable instant', async () => { + expect.hasAssertions(); + const ledger = new InMemoryIdReservationLedger(); + const rows: IPurgeRecord[] = [ + { id: 'garbage', deletedAt: 'not-a-date' }, + { id: 'nan-date', deletedAt: new Date('not-a-date') }, + { id: 'empty', deletedAt: '' }, + { id: 'null', deletedAt: null }, + { id: 'as-date', deletedAt: new Date('2026-01-01T00:00:00.000Z') }, + { id: 'as-epoch', deletedAt: Date.parse('2026-01-02T00:00:00.000Z') } + ]; + const store: IPurgeStore = { + entity: 'User', + async listTombstones() { + return rows; + }, + async hardDelete() { + return true; + } + }; + const report = await purgeTombstones({ + stores: [store], ledger, now, commit: false + }); + expect(report.events.map((event) => event.id)).toStrictEqual(['as-date']); + expect(report.events[0].deletedAt).toBe('2026-01-01T00:00:00.000Z'); + }); + + it('does not reserve the id when hardDelete reports no removal', async () => { + expect.hasAssertions(); + const ledger = new InMemoryIdReservationLedger(); + const store: IPurgeStore = { + entity: 'User', + async listTombstones() { + return [{ id: 'ghost', deletedAt: oldTombstone }]; + }, + async hardDelete() { + return false; + } + }; + const report = await purgeTombstones({ + stores: [store], ledger, now, commit: true + }); + expect(report.events).toHaveLength(1); + expect(report.dryRun).toBe(false); + expect(ledger.has('User', 'ghost')).toBe(false); + }); + + it('defaults now and olderThanDays when the caller omits them', async () => { + expect.hasAssertions(); + const ledger = new InMemoryIdReservationLedger(); + const users = makeStore('User', [{ id: 'ancient', deletedAt: '2000-01-01T00:00:00.000Z' }]); + const report = await purgeTombstones({ stores: [users], ledger }); + expect(report.dryRun).toBe(true); + expect(report.olderThanDays).toBe(TOMBSTONE_PURGE_TTL_DAYS); + expect(report.events).toHaveLength(1); + expect(Number.isNaN(Date.parse(report.events[0].purgedAt))).toBe(false); + }); +}); diff --git a/packages/sdk-rest-client/src/RestApiClient.ts b/packages/sdk-rest-client/src/RestApiClient.ts index a8d64a603..e1494c1d4 100644 --- a/packages/sdk-rest-client/src/RestApiClient.ts +++ b/packages/sdk-rest-client/src/RestApiClient.ts @@ -10,6 +10,24 @@ export interface IRestApiRequest { headers?: Record; } +/** + * Lifecycle of one SDK request (JUM-765). The SDK is the intermediary between + * the UI and the server, so it is the authoritative source for what the UI + * shows about network activity. + */ +export interface RestApiRequestEvent { + type: 'request:start' | 'request:success' | 'request:error'; + operationId: string; + method: HttpMethod; + url: string; + startedAt: number; + durationMs?: number; + status?: number; + error?: string; +} + +export type RestApiEventListener = (event: RestApiRequestEvent) => void; + const compilePath = ( pathTemplate: string, pathParams?: Record @@ -23,7 +41,15 @@ const compilePath = ( export class RestApiClient { private readonly baseUrl: string; - private readonly operationToRoute: Map = new Map(); + private readonly serviceUrls = new Map(); + + private readonly operationToRoute: Map = new Map(); + + private readonly listeners: Set = new Set(); /** * @param baseUrl Overrides the server declared in the spec. @@ -37,19 +63,58 @@ export class RestApiClient { const serverUrl = openApi?.servers?.[0]?.url || 'http://localhost:3000/api/1.0.0'; this.baseUrl = baseUrl || serverUrl; + const services = Array.isArray(openApi?.['x-services']) ? openApi['x-services'] : []; + for (const service of services) { + if (service?.id && service?.url) { + this.serviceUrls.set(String(service.id), String(service.url)); + } + } + for (const server of openApi?.servers || []) { + const serviceId = server?.['x-service-id']; + if (serviceId && server?.url && !this.serviceUrls.has(String(serviceId))) { + this.serviceUrls.set(String(serviceId), String(server.url)); + } + } + for (const [routePath, methods] of Object.entries(openApi.paths || {})) { for (const [method, config] of Object.entries(methods as Record)) { const operationId = config?.operationId; if (operationId) { this.operationToRoute.set(operationId, { method: method as HttpMethod, - path: routePath + path: routePath, + serviceId: config['x-service'] }); } } } } + private resolveBaseUrl(serviceId?: string): string { + if (this.serviceUrls.size <= 1 && this.baseUrl) { + return this.baseUrl; + } + if (serviceId && this.serviceUrls.has(serviceId)) { + return this.serviceUrls.get(serviceId)!; + } + if (this.serviceUrls.has('core')) { + return this.serviceUrls.get('core')!; + } + return this.baseUrl; + } + + /** Subscribe to request lifecycle events; returns the unsubscribe function. */ + public subscribe(listener: RestApiEventListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private emit(event: RestApiRequestEvent): void { + for (const listener of this.listeners) { + listener(event); + } + } + public async request(request: IRestApiRequest): Promise { const route = this.operationToRoute.get(request.operationId); if (!route) { @@ -57,32 +122,79 @@ export class RestApiClient { } const path = compilePath(route.path, request.pathParams); - const url = new URL(`${this.baseUrl}${path}`); + const url = new URL(`${this.resolveBaseUrl(route.serviceId)}${path}`); if (request.query) { for (const [key, value] of Object.entries(request.query)) { url.searchParams.set(key, String(value)); } } - const response = await fetch(url.toString(), { - method: route.method.toUpperCase(), - headers: { - 'content-type': 'application/json', - ...(request.headers || {}) - }, - body: request.body === undefined ? undefined : JSON.stringify(request.body) + const startedAt = Date.now(); + this.emit({ + type: 'request:start', + operationId: request.operationId, + method: route.method, + url: url.toString(), + startedAt }); - if (!response.ok) { - const message = await response.text(); - throw new Error(`REST request failed: ${response.status} ${message}`); - } + try { + const response = await fetch(url.toString(), { + method: route.method.toUpperCase(), + headers: { + 'content-type': 'application/json', + ...(request.headers || {}) + }, + body: request.body === undefined ? undefined : JSON.stringify(request.body) + }); + + const durationMs = Date.now() - startedAt; + + if (!response.ok) { + const message = await response.text(); + this.emit({ + type: 'request:error', + operationId: request.operationId, + method: route.method, + url: url.toString(), + startedAt, + durationMs, + status: response.status, + error: `REST request failed: ${response.status}` + }); + throw new Error(`REST request failed: ${response.status} ${message}`); + } - const contentType = response.headers.get('content-type') || ''; - if (contentType.includes('application/json')) { - return response.json() as Promise; + this.emit({ + type: 'request:success', + operationId: request.operationId, + method: route.method, + url: url.toString(), + startedAt, + durationMs, + status: response.status + }); + + const contentType = response.headers.get('content-type') || ''; + if (contentType.includes('application/json')) { + return response.json() as Promise; + } + return response.text() as TResponse; + } catch (error) { + if (error instanceof Error && error.message.startsWith('REST request failed:')) { + throw error; + } + this.emit({ + type: 'request:error', + operationId: request.operationId, + method: route.method, + url: url.toString(), + startedAt, + durationMs: Date.now() - startedAt, + error: error instanceof Error ? error.message : String(error) + }); + throw error; } - return response.text() as TResponse; } } diff --git a/packages/sdk-rest-client/test/RestApiClient.events.test.ts b/packages/sdk-rest-client/test/RestApiClient.events.test.ts new file mode 100644 index 000000000..06b30bb29 --- /dev/null +++ b/packages/sdk-rest-client/test/RestApiClient.events.test.ts @@ -0,0 +1,113 @@ +import { RestApiClient } from '../src'; + +/** + * JUM-765 — request lifecycle events. The SDK is the intermediary of every + * UI↔server call; these events are what the frontend's NetworkActivity widget + * renders. Fetch is replaced at the global boundary, same pattern as the + * package's routing suite. + */ + +type Recorded = { url: string; init: RequestInit }; + +function withFetch(response: Response | (() => Response)) { + const calls: Recorded[] = []; + const original = globalThis.fetch; + + globalThis.fetch = (async (url: string, init: RequestInit) => { + calls.push({ url: String(url), init }); + return typeof response === 'function' ? response() : response; + }) as unknown as typeof fetch; + + return { + calls, + restore: () => { globalThis.fetch = original; } + }; +} + +const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' } +}); + +describe('request lifecycle events (JUM-765)', () => { + const collect = (client: RestApiClient) => { + const events: Array> = []; + const push = (event: unknown) => events.push(event as Record); + return { events, unsubscribe: client.subscribe(push) }; + }; + + it('emits start and success with url, status and duration', async () => { + expect.hasAssertions(); + const client = new RestApiClient('http://api.test'); + const { events, unsubscribe } = collect(client); + + const stub = withFetch(json({ ok: true }, 201)); + try { + await client.request({ operationId: 'register', body: { firstName: 'A', username: 'a@b.c', password: 'StrongPass#1' } }); + } finally { + stub.restore(); + } + unsubscribe(); + + expect(events).toHaveLength(2); + expect(events[0]).toMatchObject({ type: 'request:start', operationId: 'register', method: 'post' }); + expect(events[1]).toMatchObject({ + type: 'request:success', operationId: 'register', method: 'post', status: 201 + }); + expect(typeof events[1].durationMs).toBe('number'); + }); + + it('emits start and error with status on non-ok responses', async () => { + expect.hasAssertions(); + const client = new RestApiClient('http://api.test'); + const { events } = collect(client); + + const stub = withFetch(new Response('not found', { status: 404 })); + try { + await expect( + client.request({ operationId: 'login', body: { username: 'a@b.c', password: 'x' } }) + ).rejects.toThrow('REST request failed: 404 not found'); + } finally { + stub.restore(); + } + + expect(events).toHaveLength(2); + expect(events[1]).toMatchObject({ type: 'request:error', operationId: 'login', status: 404 }); + }); + + it('emits error without status when fetch itself fails', async () => { + expect.hasAssertions(); + const client = new RestApiClient('http://api.test'); + const { events } = collect(client); + + const original = globalThis.fetch; + globalThis.fetch = (() => Promise.reject(new TypeError('fetch failed'))) as unknown as typeof fetch; + try { + await expect( + client.request({ operationId: 'login', body: { username: 'a@b.c', password: 'x' } }) + ).rejects.toThrow('fetch failed'); + } finally { + globalThis.fetch = original; + } + + expect(events).toHaveLength(2); + expect(events[1].type).toBe('request:error'); + expect(events[1].status).toBeUndefined(); + }); + + it('unsubscribe stops further events', async () => { + expect.hasAssertions(); + const client = new RestApiClient('http://api.test'); + const { events, unsubscribe } = collect(client); + unsubscribe(); + + const stub = withFetch(json({ ok: true })); + try { + await client.request({ operationId: 'login', body: { username: 'a@b.c', password: 'x' } }); + } finally { + stub.restore(); + } + + expect(events).toHaveLength(0); + }); +}); diff --git a/packages/sdk-rest-client/test/RestApiClient.test.ts b/packages/sdk-rest-client/test/RestApiClient.test.ts index 4fa194d61..5f3c5401c 100644 --- a/packages/sdk-rest-client/test/RestApiClient.test.ts +++ b/packages/sdk-rest-client/test/RestApiClient.test.ts @@ -103,7 +103,7 @@ describe('operation routing', () => { try { await new RestApiClient('http://api.test').request({ operationId: anOperationId() }); - expect(stub.calls[0].url.startsWith('http://api.test')).toBe(true); + expect(new URL(stub.calls[0].url).origin).toBe('http://api.test'); } finally { stub.restore(); } @@ -128,6 +128,28 @@ describe('operation routing', () => { } }); + it('resolves the base URL from x-service when more than one service exists', async () => { + expect.hasAssertions(); + const stub = withFetch(json({ ok: true })); + const multi = () => ({ + openApi: { + 'x-services': [ + { id: 'core', url: 'http://core.test/api' }, + { id: 'billing', url: 'http://billing.test/api' } + ], + paths: { + '/invoices': { get: { operationId: 'listInvoices', 'x-service': 'billing' } } + } + } + }); + try { + await new RestApiClient(undefined, multi as never).request({ operationId: 'listInvoices' }); + expect(stub.calls[0].url).toBe('http://billing.test/api/invoices'); + } finally { + stub.restore(); + } + }); + /** * A spec with no `servers` block. The client falls back to a hardcoded * localhost, which is what a developer running the SDK against a local API @@ -321,3 +343,110 @@ describe('responses', () => { } }); }); + +describe('service routing', () => { + const serviceSpecs = (() => ({ + openApi: { + servers: [ + { url: 'https://core.test', 'x-service-id': 'core' }, + { url: 'https://billing.test', 'x-service-id': 'billing' } + ], + 'x-services': [ + { id: 'core', url: 'https://core.test' } + ], + paths: { + '/invoices': { get: { operationId: 'listInvoices', 'x-service': 'billing' } }, + '/health': { get: { operationId: 'getHealth' } } + } + } + }) as unknown) as typeof loadSpecs; + + it('routes operations to servers named by x-service-id, not only x-services', async () => { + expect.hasAssertions(); + + const stub = withFetch(json({ ok: true })); + try { + const client = new RestApiClient(undefined, serviceSpecs); + await client.request({ operationId: 'listInvoices' }); + expect(stub.calls[0].url).toBe('https://billing.test/invoices'); + } finally { + stub.restore(); + } + }); + + it('falls back to the core service when an operation names none', async () => { + expect.hasAssertions(); + + const stub = withFetch(json({ ok: true })); + try { + const client = new RestApiClient('http://api.test', serviceSpecs); + await client.request({ operationId: 'getHealth' }); + expect(stub.calls[0].url).toBe('https://core.test/health'); + } finally { + stub.restore(); + } + }); + + it('falls back to the base URL when an operation names a service the spec does not host', async () => { + expect.hasAssertions(); + const unknownServiceSpecs = (() => ({ + openApi: { + servers: [ + { url: 'https://billing.test', 'x-service-id': 'billing' }, + { url: 'https://shipping.test', 'x-service-id': 'shipping' } + ], + paths: { + '/legacy': { get: { operationId: 'getLegacy', 'x-service': 'unknown-service' } } + } + } + }) as unknown) as typeof loadSpecs; + + const stub = withFetch(json({ ok: true })); + try { + const client = new RestApiClient(undefined, unknownServiceSpecs); + await client.request({ operationId: 'getLegacy' }); + expect(stub.calls[0].url).toBe('https://billing.test/legacy'); + } finally { + stub.restore(); + } + }); +}); + +describe('failure shapes', () => { + it('returns text when the response carries no content-type header', async () => { + expect.hasAssertions(); + + const stub = withFetch(new Response(null, { status: 200 })); + try { + await expect( + new RestApiClient('http://api.test').request({ operationId: anOperationId() }) + ).resolves.toBe(''); + } finally { + stub.restore(); + } + }); + + it('stringifies non-Error rejections in the error event and rethrows them', async () => { + expect.hasAssertions(); + + const original = globalThis.fetch; + globalThis.fetch = (async () => { + // eslint-disable-next-line no-throw-literal + throw 'socket gone'; + }) as unknown as typeof fetch; + + const events: Array<{ type: string; error?: unknown }> = []; + try { + const client = new RestApiClient('http://api.test'); + const unsubscribe = client.subscribe((event) => { events.push(event); }); + await expect(client.request({ operationId: anOperationId() })).rejects.toBe('socket gone'); + unsubscribe(); + expect(events).toContainEqual(expect.objectContaining({ + type: 'request:error', + error: 'socket gone' + })); + } finally { + globalThis.fetch = original; + } + }); +}); diff --git a/packages/security-scanner/src/index.js b/packages/security-scanner/src/index.js index fbdf367a4..1dcb181f5 100644 --- a/packages/security-scanner/src/index.js +++ b/packages/security-scanner/src/index.js @@ -99,7 +99,7 @@ function severityOf(vuln) { const cvss = (vuln?.severity || []).find((entry) => String(entry.type || '').startsWith('CVSS')); if (cvss && typeof cvss.score === 'string') { - const match = /\/AV:.*$/.test(cvss.score) ? null : Number.parseFloat(cvss.score); + const match = cvss.score.includes('/AV:') ? null : Number.parseFloat(cvss.score); if (Number.isFinite(match)) { if (match >= 9) return 'CRITICAL'; if (match >= 7) return 'HIGH'; diff --git a/patches/@coreui%2Ficons-vue@2.2.0.patch b/patches/@coreui%2Ficons-vue@2.2.0.patch new file mode 100644 index 000000000..80b581b12 --- /dev/null +++ b/patches/@coreui%2Ficons-vue@2.2.0.patch @@ -0,0 +1,26 @@ +diff --git a/dist/index.es.js b/dist/index.es.js +index 5a238c043c91dfc39242ee82924db0fb7264dac1..8003d6b7000c3ee83ad03c3a89ff0138251cba96 100644 +--- a/dist/index.es.js ++++ b/dist/index.es.js +@@ -99,7 +99,7 @@ const CIcon = defineComponent({ + ? toCamelCase(_icon.value) + : _icon.value + : ''); +- const titleCode = props.title ? `${props.title}` : 'undefined'; ++ const titleCode = props.title ? `${props.title}` : ''; + const code = computed(() => Array.isArray(_icon.value) + ? _icon.value + : typeof _icon.value === 'string' && iconName.value && icons[iconName.value] +diff --git a/dist/index.js b/dist/index.js +index 85fefbdee57bca131ac354241300b9dda3cfcf77..43875fa0149648537a1f246de273b29b8774e5f0 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -103,7 +103,7 @@ const CIcon = vue.defineComponent({ + ? toCamelCase(_icon.value) + : _icon.value + : ''); +- const titleCode = props.title ? `${props.title}` : 'undefined'; ++ const titleCode = props.title ? `${props.title}` : ''; + const code = vue.computed(() => Array.isArray(_icon.value) + ? _icon.value + : typeof _icon.value === 'string' && iconName.value && icons[iconName.value] diff --git a/pm2/ecosystem.dev.cjs b/pm2/ecosystem.dev.config.cjs similarity index 65% rename from pm2/ecosystem.dev.cjs rename to pm2/ecosystem.dev.config.cjs index 98d90356e..661f64fd7 100644 --- a/pm2/ecosystem.dev.cjs +++ b/pm2/ecosystem.dev.config.cjs @@ -36,13 +36,36 @@ module.exports = { JUMENTIX_DISABLE_FALLBACK_REST: 'true' } }, + { + name: 'jumentix-dev-service-management-api', + script: './apps/service-management-api/src/start-service-management-catalog-api.ts', + interpreter: 'bun', + interpreter_args: '-r tsconfig-paths/register --env-file=./apps/backend-template/src/config/.env.dev', + env: { + NODE_ENV: 'dev', + JUMENTIX_HTTP_PORT: '3003' + } + }, { name: 'jumentix-dev-service-management', script: './apps/service-management/server.js', interpreter: 'bun', env: { NODE_ENV: 'dev', - JUMENTIX_SERVICE_MANAGEMENT_PORT: '3200' + JUMENTIX_SERVICE_MANAGEMENT_PORT: '3200', + JUMENTIX_SERVICE_MANAGEMENT_CATALOG_API_URL: 'http://127.0.0.1:3003' + } + }, + { + name: 'jumentix-dev-purge-tombstones', + script: './ci-cd/purge-tombstones.js', + interpreter: 'bun', + args: '--via-loopback --older-than 90 --protect-seed --dry-run', + cron_restart: '0 4 * * *', + autorestart: false, + env: { + NODE_ENV: 'dev', + JUMENTIX_HTTP_PORT: '3000' } } ] diff --git a/pm2/ecosystem.production.cjs b/pm2/ecosystem.production.config.cjs similarity index 75% rename from pm2/ecosystem.production.cjs rename to pm2/ecosystem.production.config.cjs index 7b44ceb5a..dd68c38e7 100644 --- a/pm2/ecosystem.production.cjs +++ b/pm2/ecosystem.production.config.cjs @@ -36,13 +36,24 @@ module.exports = { JUMENTIX_DISABLE_FALLBACK_REST: 'true' } }, + { + name: 'jumentix-prod-service-management-api', + script: './.build/apps/service-management-api/src/start-service-management-catalog-api.js', + interpreter: 'bun', + interpreter_args: '--env-file=./.build/apps/backend-template/src/config/.env.prod', + env: { + NODE_ENV: 'prod', + JUMENTIX_HTTP_PORT: '5003' + } + }, { name: 'jumentix-prod-service-management', script: './apps/service-management/server.js', interpreter: 'bun', env: { NODE_ENV: 'prod', - JUMENTIX_SERVICE_MANAGEMENT_PORT: '5200' + JUMENTIX_SERVICE_MANAGEMENT_PORT: '5200', + JUMENTIX_SERVICE_MANAGEMENT_CATALOG_API_URL: 'http://127.0.0.1:5003' } } ] diff --git a/pm2/ecosystem.staging.cjs b/pm2/ecosystem.staging.config.cjs similarity index 75% rename from pm2/ecosystem.staging.cjs rename to pm2/ecosystem.staging.config.cjs index b43069869..e9e3399b5 100644 --- a/pm2/ecosystem.staging.cjs +++ b/pm2/ecosystem.staging.config.cjs @@ -36,13 +36,24 @@ module.exports = { JUMENTIX_DISABLE_FALLBACK_REST: 'true' } }, + { + name: 'jumentix-staging-service-management-api', + script: './apps/service-management-api/src/start-service-management-catalog-api.ts', + interpreter: 'bun', + interpreter_args: '-r tsconfig-paths/register --env-file=./apps/backend-template/src/config/.env.staging', + env: { + NODE_ENV: 'staging', + JUMENTIX_HTTP_PORT: '4003' + } + }, { name: 'jumentix-staging-service-management', script: './apps/service-management/server.js', interpreter: 'bun', env: { NODE_ENV: 'staging', - JUMENTIX_SERVICE_MANAGEMENT_PORT: '4200' + JUMENTIX_SERVICE_MANAGEMENT_PORT: '4200', + JUMENTIX_SERVICE_MANAGEMENT_CATALOG_API_URL: 'http://127.0.0.1:4003' } } ] diff --git a/sonar-project.properties b/sonar-project.properties index bf3d7289f..82b2288f3 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,6 +1,7 @@ -sonar.organization=xpertminds -sonar.projectKey=Jumentix +sonar.organization=web2solutions +sonar.projectKey=web2solutions_Jumentix sonar.projectName=Jumentix +sonar.sourceEncoding=UTF-8 sonar.sources=apps,packages,ci-cd sonar.tests=apps/backend-template/test,packages @@ -9,7 +10,7 @@ sonar.tests=apps/backend-template/test,packages # Coverage on New Code below the gate while the real cana report sat at 99%. sonar.test.inclusions=**/*.test.ts,**/*.spec.ts,**/*.cy.ts,**/cypress/**/* -sonar.exclusions=**/node_modules/**,**/.build/**,**/dist/**,**/.serverless/**,**/coverage/**,**/.browser-tests/**,**/*.d.ts,apps/service-management/**,apps/jumentix-website/**,apps/backend-template/OASdoc/**,**/docker/**,**/AsyncAPIdoc/**,sdk-clients/**,documentation/**,spec/** +sonar.exclusions=**/node_modules/**,**/.build/**,**/dist/**,**/.serverless/**,**/coverage/**,**/.browser-tests/**,**/*.d.ts,**/*.png,**/*.jpg,**/*.jpeg,**/*.gif,**/*.ico,**/*.webp,**/*.avif,**/*.woff,**/*.woff2,**/*.ttf,**/*.eot,apps/service-management/**,apps/jumentix-website/**,apps/backend-template/OASdoc/**,**/docker/**,**/AsyncAPIdoc/**,sdk-clients/**,documentation/**,spec/** # Coverage exclusions: what the measured reports do not claim to cover. # @@ -47,4 +48,5 @@ sonar.coverage.exclusions=\ sonar.cpd.exclusions=apps/backend-template/src/infra/**/*,**/test/**/*,**/cypress/**/* # Jest unit/integration LCOV + browser LCOV for packages/cana (Req 112 §4). -sonar.javascript.lcov.reportPaths=./coverage/lcov.info,./coverage/browser/lcov.info +# coverage/frontend/lcov.info: apps/frontend under bun:test (unit + component), JUM-776. +sonar.javascript.lcov.reportPaths=./coverage/lcov.info,./coverage/browser/lcov.info,./coverage/frontend/lcov.info diff --git a/spec/1.0.0.yml b/spec/1.0.0.yml index 09a9f869a..43313d956 100644 --- a/spec/1.0.0.yml +++ b/spec/1.0.0.yml @@ -9,11 +9,38 @@ info: name: Unlicensed url: http://www.apache.org/licenses/LICENSE-2.0.html version: 1.0.0 + # RBAC matrix consumed by generated/OAS-driven frontends (JUM-772): the UI + # computes effective scopes from the user's roles using this map and the + # per-operation `security` scopes below — it never reads backend code + # (requirement 136). Only superadmins manage multiple organizations. + x-rbac: + description: Role-to-scope matrix enforced by the backend and mirrored here for OAS-driven clients. + roles: + superadmin: + - "*" + admin: + - access_allow + - read_user + - create_user + - update_user + - delete_user + - read_organization + - update_organization + user: + - access_allow + - read_user externalDocs: description: Find out more about Xpertminds url: http://xpertminds.dev servers: - url: http://localhost:3000/api/1.0.0 + x-service-id: core +x-services: + - id: core + name: Core + kind: core + url: http://localhost:3000/api/1.0.0 + description: Merged Core service that serves every operation in this document (monolith). tags: - name: user description: System users @@ -136,16 +163,86 @@ paths: tags: - user summary: Get all users - description: Returns a collection of all users + description: >- + Returns a page of users. Delta sync page: sort=`updatedAt:asc,id:asc`, + filter `{"updatedAt":{"operator":"gt","value":""}}`, page through + `page`/`size`, set `includeDeleted=true` so tombstones travel with the + cursor (`x-sync`). operationId: getAll + x-service: core + x-sync: + cursorField: updatedAt + includeDeletedParam: includeDeleted + x-list-capabilities: + sortable: [firstName, lastName, username, organization, createdAt, updatedAt, id] + filterable: + firstName: text + lastName: text + username: text + organization: uuid + roles: enum + createdAt: date + updatedAt: date + searchable: [firstName, lastName, username] + defaultSize: 30 + maxSize: 100 + includeDeleted: true + x-metrics-capabilities: + groupable: [roles, organization] + series: [createdAt, updatedAt] parameters: - name: page - in: path - description: Page number to fetch + in: query + description: Page number to fetch (1-based). + required: false + schema: + type: integer + minimum: 1 + default: 1 + - name: size + in: query + description: Page size; bounded by `x-list-capabilities.maxSize`. + required: false + schema: + type: integer + minimum: 1 + maximum: 100 + default: 30 + - name: filter + in: query + description: >- + Base64-encoded JSON object keyed by a field listed in + `x-list-capabilities.filterable`. A scalar value means equality; an + object `{ "operator": "contains" | "eq" | "ne" | "in" | "gte" | "lte" | "between", "value": ... }` + applies that operator. Unknown fields or operators are rejected with 400. + required: false + schema: + type: string + maxLength: 4096 + - name: sort + in: query + description: >- + Comma-separated `field:asc|desc` pairs, fields limited to + `x-list-capabilities.sortable`. Unknown fields are rejected with 400. + required: false + schema: + type: string + maxLength: 256 + pattern: "^[A-Za-z0-9_.]+(:(asc|desc))?(,[A-Za-z0-9_.]+(:(asc|desc))?)*$" + - name: q + in: query + description: Case-insensitive free-text search over `x-list-capabilities.searchable`. required: false schema: type: string - default: 1 + maxLength: 200 + - name: includeDeleted + in: query + description: When true, list includes tombstones (`deletedAt` set). Default false. + required: false + schema: + type: boolean + default: false responses: "200": description: successful operation @@ -195,6 +292,57 @@ paths: security: - bearerAuth: - create_user + /users/metrics: + get: + tags: + - user + summary: User metrics + description: Generic aggregates for User (count, groupBy, time series). + operationId: getUsersMetrics + x-service: core + x-metrics-capabilities: + groupable: [roles, organization] + series: [createdAt, updatedAt] + parameters: + - name: metric + in: query + required: true + schema: + type: string + enum: [count, groupBy, series] + - name: field + in: query + required: false + schema: + type: string + - name: interval + in: query + required: false + schema: + type: string + enum: [day, week, month] + - name: filter + in: query + required: false + schema: + type: string + maxLength: 4096 + responses: + "200": + description: Metrics buckets + content: + application/json: + schema: + $ref: "#/components/schemas/EntityMetricsResponse" + "400": + description: Invalid request + "401": + description: Unauthorized + "403": + description: Forbidden + security: + - bearerAuth: + - read_user /users/{id}: delete: tags: @@ -553,7 +701,7 @@ paths: security: - bearerAuth: - update_user - /users/{id}/documentDelete/{documentId}: + /users/{id}/deleteDocument/{documentId}: delete: tags: - user @@ -679,7 +827,7 @@ paths: security: - bearerAuth: - update_user - /users/{id}/phoneDelete/{phoneId}: + /users/{id}/deletePhone/{phoneId}: delete: tags: - user @@ -722,16 +870,81 @@ paths: tags: - organization summary: Get all organizations - description: Returns a collection of organizations + description: >- + Returns a page of organizations. Delta sync page: sort=`updatedAt:asc,id:asc`, + filter `{"updatedAt":{"operator":"gt","value":""}}`, page through + `page`/`size`, set `includeDeleted=true` so tombstones travel with the + cursor (`x-sync`). operationId: getAllOrganizations + x-service: core + x-sync: + cursorField: updatedAt + includeDeletedParam: includeDeleted + x-list-capabilities: + sortable: [name, createdAt, updatedAt, id] + filterable: + name: text + createdAt: date + updatedAt: date + searchable: [name] + defaultSize: 30 + maxSize: 100 + includeDeleted: true + x-metrics-capabilities: + groupable: [] + series: [createdAt, updatedAt] parameters: - name: page - in: path - description: Page number to fetch + in: query + description: Page number to fetch (1-based). + required: false + schema: + type: integer + minimum: 1 + default: 1 + - name: size + in: query + description: Page size; bounded by `x-list-capabilities.maxSize`. + required: false + schema: + type: integer + minimum: 1 + maximum: 100 + default: 30 + - name: filter + in: query + description: >- + Base64-encoded JSON object keyed by a field listed in + `x-list-capabilities.filterable`. A scalar value means equality; an + object `{ "operator": "contains" | "eq" | "ne" | "in" | "gte" | "lte" | "between", "value": ... }` + applies that operator. Unknown fields or operators are rejected with 400. + required: false + schema: + type: string + maxLength: 4096 + - name: sort + in: query + description: >- + Comma-separated `field:asc|desc` pairs, fields limited to + `x-list-capabilities.sortable`. Unknown fields are rejected with 400. + required: false + schema: + type: string + maxLength: 256 + pattern: "^[A-Za-z0-9_.]+(:(asc|desc))?(,[A-Za-z0-9_.]+(:(asc|desc))?)*$" + - name: q + in: query + description: Case-insensitive free-text search over `x-list-capabilities.searchable`. required: false schema: type: string - default: 1 + maxLength: 200 + - name: includeDeleted + in: query + required: false + schema: + type: boolean + default: false responses: "200": description: successful operation @@ -779,6 +992,57 @@ paths: security: - bearerAuth: - create_organization + /organizations/metrics: + get: + tags: + - organization + summary: Organization metrics + description: Generic aggregates for Organization (count, groupBy, time series). + operationId: getOrganizationsMetrics + x-service: core + x-metrics-capabilities: + groupable: [] + series: [createdAt, updatedAt] + parameters: + - name: metric + in: query + required: true + schema: + type: string + enum: [count, groupBy, series] + - name: field + in: query + required: false + schema: + type: string + - name: interval + in: query + required: false + schema: + type: string + enum: [day, week, month] + - name: filter + in: query + required: false + schema: + type: string + maxLength: 4096 + responses: + "200": + description: Metrics buckets + content: + application/json: + schema: + $ref: "#/components/schemas/EntityMetricsResponse" + "400": + description: Invalid request + "401": + description: Unauthorized + "403": + description: Forbidden + security: + - bearerAuth: + - read_organization /organizations/{id}: get: tags: @@ -1248,349 +1512,8 @@ paths: security: - bearerAuth: - update_organization - /catalogs: - get: - tags: - - catalog - summary: Get all shared catalog records - description: Returns the shared domain designs visible to the caller's tenant scope; tombstoned records are excluded unless includeDeleted=true - operationId: getAllCatalogs - parameters: - - name: page - in: query - description: Page number to fetch - required: false - schema: - type: string - default: 1 - - name: size - in: query - description: Page size to fetch - required: false - schema: - type: string - - name: includeDeleted - in: query - description: Include soft-deleted (tombstoned) records so deletions propagate to sync clients - required: false - schema: - type: string - enum: - - "true" - - "false" - default: "false" - responses: - "200": - description: successful operation - content: - application/json: - schema: - $ref: "#/components/schemas/CatalogArrayOf" - "400": - description: Invalid request - "401": - description: Unauthorized - "403": - description: Forbidden - security: - - bearerAuth: - - read_catalog - post: - tags: - - catalog - summary: Publish a domain design into the shared catalog - description: Creates a shared catalog record with version 1 - operationId: createCatalog - requestBody: - description: Domain design to share - content: - application/json: - schema: - $ref: "#/components/schemas/RequestCreateCatalog" - required: true - responses: - "201": - description: Catalog record created successfully - content: - application/json: - schema: - $ref: "#/components/schemas/Catalog" - "400": - description: Invalid request - "401": - description: Unauthorized - "403": - description: Forbidden - "409": - description: Conflict - security: - - bearerAuth: - - create_catalog - /catalogs/{id}: - get: - tags: - - catalog - summary: Get a shared catalog record by ID - description: Returns a single shared catalog record - operationId: getCatalogById - parameters: - - name: id - in: path - description: ID of the catalog record to return - required: true - schema: - type: string - responses: - "200": - description: successful operation - content: - application/json: - schema: - $ref: "#/components/schemas/Catalog" - "400": - description: Invalid ID supplied - "401": - description: Unauthorized - "403": - description: Forbidden - "404": - description: Catalog record not found - security: - - bearerAuth: - - read_catalog - put: - tags: - - catalog - summary: Update a shared catalog record - description: Updates a shared catalog record when the caller's expected version is current; a stale version is rejected with 409 and the current server version so the edit can be reconciled - operationId: updateCatalog - parameters: - - name: id - in: path - description: ID of the catalog record to update - required: true - schema: - type: string - requestBody: - description: New content plus the expected version (optimistic-concurrency token) - content: - application/json: - schema: - $ref: "#/components/schemas/RequestUpdateCatalog" - required: true - responses: - "200": - description: successful operation - content: - application/json: - schema: - $ref: "#/components/schemas/Catalog" - "400": - description: Invalid ID supplied - "401": - description: Unauthorized - "403": - description: Forbidden - "404": - description: Catalog record not found - "409": - description: Stale version conflict - security: - - bearerAuth: - - update_catalog - delete: - tags: - - catalog - summary: Soft-delete a shared catalog record - description: Tombstones a shared catalog record so the deletion propagates to every sync client; recoverable through the restore operation - operationId: deleteCatalog - parameters: - - name: id - in: path - description: ID of the catalog record to delete - required: true - schema: - type: string - - name: version - in: query - description: Expected current version (optimistic-concurrency token) - required: true - schema: - type: string - responses: - "200": - description: successful operation - content: - application/json: - schema: - $ref: "#/components/schemas/ResourceDeleteResponse" - "400": - description: Invalid ID supplied - "401": - description: Unauthorized - "403": - description: Forbidden - "404": - description: Catalog record not found - "409": - description: Stale version conflict - security: - - bearerAuth: - - delete_catalog - /catalogs/{id}/restore: - post: - tags: - - catalog - summary: Restore a soft-deleted shared catalog record - description: Recovers a tombstoned shared catalog record; a restore is a write, so the version bumps and a restore event is published - operationId: restoreCatalog - parameters: - - name: id - in: path - description: ID of the catalog record to restore - required: true - schema: - type: string - requestBody: - description: Expected version of the tombstoned record - content: - application/json: - schema: - $ref: "#/components/schemas/RequestRestoreCatalog" - required: true - responses: - "200": - description: successful operation - content: - application/json: - schema: - $ref: "#/components/schemas/Catalog" - "400": - description: Invalid ID supplied - "401": - description: Unauthorized - "403": - description: Forbidden - "404": - description: Catalog record not found - "409": - description: Stale version conflict - security: - - bearerAuth: - - update_catalog components: schemas: - Catalog: - description: Port output object for one shared catalog record — a shared domain design with its optimistic-concurrency token (JUM-491). - required: - - id - - organization - - name - - version - - design - type: object - properties: - id: - type: string - format: uuid - description: Catalog record id - organization: - type: string - description: Tenant organization that owns and shares this record - name: - type: string - minLength: 1 - description: Human name of the shared domain design - description: - type: string - description: Optional summary of the shared domain design - version: - type: integer - minimum: 1 - description: Server-managed optimistic-concurrency token (etag); every write bumps it - design: - type: object - description: The shared domain design document (designer domain serialization) - provenance: - type: object - description: Domain-package provenance stamp (package name/version, JUM-492) - createdBy: - type: string - description: Actor that published the record - updatedBy: - type: string - description: Actor of the last write - createdAt: - type: string - format: date-time - updatedAt: - type: string - format: date-time - deletedAt: - type: string - description: Tombstone — empty when active, ISO timestamp when soft-deleted - CatalogArrayOf: - description: Port output array of shared catalog records. - type: array - items: - $ref: "#/components/schemas/Catalog" - RequestCreateCatalog: - description: Port input object for publishing a domain design into the shared catalog. - required: - - name - - design - type: object - properties: - name: - type: string - minLength: 1 - description: Human name of the shared domain design - description: - type: string - description: Optional summary of the shared domain design - organization: - type: string - nullable: true - description: Owning organization — superadmin only; tenant users are bound to their own organization - design: - type: object - description: The domain design document to share (designer domain serialization) - provenance: - type: object - description: Domain-package provenance stamp (package name/version, JUM-492) - RequestUpdateCatalog: - description: Port input object for updating a shared catalog record with optimistic concurrency. - required: - - version - type: object - properties: - name: - type: string - minLength: 1 - description: Human name of the shared domain design - description: - type: string - description: Optional summary of the shared domain design - design: - type: object - description: The domain design document to share (designer domain serialization) - provenance: - type: object - description: Domain-package provenance stamp (package name/version, JUM-492) - version: - type: integer - minimum: 1 - description: Expected current version — a stale value is rejected with 409 - RequestRestoreCatalog: - description: Port input object for recovering a soft-deleted shared catalog record. - required: - - version - type: object - properties: - version: - type: integer - minimum: 1 - description: Expected current version of the tombstoned record — a stale value is rejected with 409 ResourceDeleteResponse: description: Port output object for delete operations. required: @@ -1598,6 +1521,9 @@ components: type: object properties: data: + x-label: + en: Number + pt-BR: Número type: boolean default: false description: Result of request to delete resource @@ -1609,20 +1535,30 @@ components: type: object properties: username: + x-label: + en: Username + pt-BR: Usuário type: string minLength: 1 example: me@mydomain.com description: Username password: + x-label: + en: Password + pt-BR: Senha type: string description: Password minLength: 2 format: password schemaType: + x-label: + en: Schema type + pt-BR: Tipo de esquema type: string example: Bearer default: Bearer description: HTTP token schema type + x-hide: true enum: - Basic - Bearer @@ -1633,6 +1569,9 @@ components: type: object properties: username: + x-label: + en: Username + pt-BR: Usuário type: string minLength: 1 example: me@mydomain.com @@ -1646,20 +1585,37 @@ components: type: object properties: firstName: + x-label: + en: First name + pt-BR: Nome type: string minLength: 1 example: Abraham description: User's first name username: + x-label: + en: Username + pt-BR: Usuário type: string minLength: 1 description: Username to access the system organization: + x-label: + en: Organization + pt-BR: Organização type: string format: uuid nullable: true description: Organization id for tenant users (admin/user) + x-relation: + entity: Organization + match: id + display: name + kind: belongsTo password: + x-label: + en: Password + pt-BR: Senha type: string minLength: 8 format: password @@ -1684,53 +1640,94 @@ components: type: object properties: id: + x-label: + en: ID + pt-BR: ID type: string + format: uuid + description: Optional client-supplied uuid. Duplicate ids answer 409. firstName: + x-label: + en: First name + pt-BR: Nome type: string minLength: 1 example: Abraham description: User's first name lastName: + x-label: + en: Last name + pt-BR: Sobrenome type: string example: Abraham nullable: true description: User's last name avatar: + x-label: + en: Avatar + pt-BR: Avatar type: string + maxLength: 255 example: avatar.png default: avatar.png description: User's avatar username: + x-label: + en: Username + pt-BR: Usuário type: string minLength: 1 description: Username to access the system organization: + x-label: + en: Organization + pt-BR: Organização type: string format: uuid nullable: true description: Organization id for tenant users (admin/user) + x-relation: + entity: Organization + match: id + display: name + kind: belongsTo password: + x-label: + en: Password + pt-BR: Senha type: string minLength: 8 format: password description: Password to access the system emails: + x-label: + en: Emails + pt-BR: E-mails type: array items: $ref: "#/components/schemas/Email" description: User's e-mail addresses minItems: 1 documents: + x-label: + en: Documents + pt-BR: Documentos type: array items: $ref: "#/components/schemas/Document" description: User's attached documents phones: + x-label: + en: Phones + pt-BR: Telefones type: array items: $ref: "#/components/schemas/Phone" - description: User's attached documents + description: User's attached phone numbers roles: + x-label: + en: Roles + pt-BR: Papéis type: array items: type: string @@ -1742,48 +1739,84 @@ components: - id properties: id: + x-label: + en: ID + pt-BR: ID type: string firstName: + x-label: + en: First name + pt-BR: Nome type: string minLength: 1 example: Abraham description: User's first name lastName: + x-label: + en: Last name + pt-BR: Sobrenome type: string example: Abraham nullable: true description: User's last name avatar: + x-label: + en: Avatar + pt-BR: Avatar type: string + maxLength: 255 example: avatar.png default: avatar.png description: User's avatar username: + x-label: + en: Username + pt-BR: Usuário type: string minLength: 1 description: Username to access the system organization: + x-label: + en: Organization + pt-BR: Organização type: string format: uuid nullable: true description: Organization id for tenant users (admin/user) + x-relation: + entity: Organization + match: id + display: name + kind: belongsTo emails: + x-label: + en: Emails + pt-BR: E-mails type: array items: $ref: "#/components/schemas/Email" description: User's e-mail addresses minItems: 1 documents: + x-label: + en: Documents + pt-BR: Documentos type: array items: $ref: "#/components/schemas/Document" description: User's attached documents phones: + x-label: + en: Phones + pt-BR: Telefones type: array items: $ref: "#/components/schemas/Phone" - description: User's attached documents + description: User's attached phone numbers roles: + x-label: + en: Roles + pt-BR: Papéis type: array items: type: string @@ -1795,24 +1828,42 @@ components: type: object properties: id: + x-label: + en: ID + pt-BR: ID type: string format: uuid name: + x-label: + en: Name + pt-BR: Nome type: string minLength: 1 address: + x-label: + en: Addresses + pt-BR: Endereços type: array items: $ref: "#/components/schemas/Address" phone: + x-label: + en: Phones + pt-BR: Telefones type: array items: $ref: "#/components/schemas/Phone" email: + x-label: + en: Email + pt-BR: E-mail type: array items: $ref: "#/components/schemas/Email" users: + x-label: + en: Members + pt-BR: Membros type: array items: type: string @@ -1823,24 +1874,42 @@ components: type: object properties: id: + x-label: + en: ID + pt-BR: ID type: string format: uuid name: + x-label: + en: Name + pt-BR: Nome type: string minLength: 1 address: + x-label: + en: Addresses + pt-BR: Endereços type: array items: $ref: "#/components/schemas/Address" phone: + x-label: + en: Phones + pt-BR: Telefones type: array items: $ref: "#/components/schemas/Phone" email: + x-label: + en: Email + pt-BR: E-mail type: array items: $ref: "#/components/schemas/Email" users: + x-label: + en: Members + pt-BR: Membros type: array items: type: string @@ -1853,12 +1922,199 @@ components: - number properties: countryCode: + x-label: + en: Country code + pt-BR: Código do país type: string + maxLength: 4 + description: E.164 country calling code + enum: + - '+1' + - '+7' + - '+20' + - '+27' + - '+30' + - '+31' + - '+32' + - '+33' + - '+34' + - '+36' + - '+39' + - '+40' + - '+41' + - '+43' + - '+44' + - '+45' + - '+46' + - '+47' + - '+48' + - '+49' + - '+51' + - '+52' + - '+53' + - '+54' + - '+55' + - '+56' + - '+57' + - '+58' + - '+60' + - '+61' + - '+62' + - '+63' + - '+64' + - '+65' + - '+66' + - '+81' + - '+82' + - '+84' + - '+86' + - '+90' + - '+91' + - '+92' + - '+93' + - '+94' + - '+95' + - '+98' + - '+211' + - '+212' + - '+213' + - '+216' + - '+218' + - '+220' + - '+221' + - '+224' + - '+225' + - '+226' + - '+227' + - '+228' + - '+230' + - '+233' + - '+234' + - '+236' + - '+241' + - '+243' + - '+244' + - '+248' + - '+249' + - '+251' + - '+252' + - '+254' + - '+255' + - '+256' + - '+258' + - '+260' + - '+261' + - '+263' + - '+264' + - '+265' + - '+267' + - '+268' + - '+269' + - '+290' + - '+297' + - '+298' + - '+299' + - '+350' + - '+351' + - '+352' + - '+353' + - '+354' + - '+355' + - '+356' + - '+357' + - '+358' + - '+359' + - '+370' + - '+371' + - '+372' + - '+373' + - '+374' + - '+375' + - '+376' + - '+377' + - '+380' + - '+381' + - '+385' + - '+386' + - '+387' + - '+389' + - '+420' + - '+421' + - '+423' + - '+500' + - '+501' + - '+502' + - '+503' + - '+504' + - '+505' + - '+506' + - '+507' + - '+509' + - '+590' + - '+591' + - '+592' + - '+593' + - '+594' + - '+595' + - '+597' + - '+598' + - '+599' + - '+670' + - '+673' + - '+675' + - '+676' + - '+677' + - '+678' + - '+679' + - '+680' + - '+685' + - '+687' + - '+689' + - '+850' + - '+852' + - '+853' + - '+855' + - '+856' + - '+880' + - '+886' + - '+960' + - '+961' + - '+962' + - '+963' + - '+964' + - '+965' + - '+966' + - '+968' + - '+970' + - '+971' + - '+972' + - '+973' + - '+974' + - '+975' + - '+976' + - '+977' + - '+992' + - '+993' + - '+994' + - '+995' + - '+996' + - '+998' localCode: + x-label: + en: Area code + pt-BR: DDD type: string + pattern: '^\d{2,3}$' + maxLength: 3 number: + x-label: + en: Number + pt-BR: Número type: string isPrimary: + x-label: + en: Primary + pt-BR: Principal type: boolean example: true description: Is this the primary User's phone number? @@ -1870,14 +2126,204 @@ components: - id properties: id: + x-label: + en: ID + pt-BR: ID type: string countryCode: + x-label: + en: Country code + pt-BR: Código do país type: string + maxLength: 4 + description: E.164 country calling code + enum: + - '+1' + - '+7' + - '+20' + - '+27' + - '+30' + - '+31' + - '+32' + - '+33' + - '+34' + - '+36' + - '+39' + - '+40' + - '+41' + - '+43' + - '+44' + - '+45' + - '+46' + - '+47' + - '+48' + - '+49' + - '+51' + - '+52' + - '+53' + - '+54' + - '+55' + - '+56' + - '+57' + - '+58' + - '+60' + - '+61' + - '+62' + - '+63' + - '+64' + - '+65' + - '+66' + - '+81' + - '+82' + - '+84' + - '+86' + - '+90' + - '+91' + - '+92' + - '+93' + - '+94' + - '+95' + - '+98' + - '+211' + - '+212' + - '+213' + - '+216' + - '+218' + - '+220' + - '+221' + - '+224' + - '+225' + - '+226' + - '+227' + - '+228' + - '+230' + - '+233' + - '+234' + - '+236' + - '+241' + - '+243' + - '+244' + - '+248' + - '+249' + - '+251' + - '+252' + - '+254' + - '+255' + - '+256' + - '+258' + - '+260' + - '+261' + - '+263' + - '+264' + - '+265' + - '+267' + - '+268' + - '+269' + - '+290' + - '+297' + - '+298' + - '+299' + - '+350' + - '+351' + - '+352' + - '+353' + - '+354' + - '+355' + - '+356' + - '+357' + - '+358' + - '+359' + - '+370' + - '+371' + - '+372' + - '+373' + - '+374' + - '+375' + - '+376' + - '+377' + - '+380' + - '+381' + - '+385' + - '+386' + - '+387' + - '+389' + - '+420' + - '+421' + - '+423' + - '+500' + - '+501' + - '+502' + - '+503' + - '+504' + - '+505' + - '+506' + - '+507' + - '+509' + - '+590' + - '+591' + - '+592' + - '+593' + - '+594' + - '+595' + - '+597' + - '+598' + - '+599' + - '+670' + - '+673' + - '+675' + - '+676' + - '+677' + - '+678' + - '+679' + - '+680' + - '+685' + - '+687' + - '+689' + - '+850' + - '+852' + - '+853' + - '+855' + - '+856' + - '+880' + - '+886' + - '+960' + - '+961' + - '+962' + - '+963' + - '+964' + - '+965' + - '+966' + - '+968' + - '+970' + - '+971' + - '+972' + - '+973' + - '+974' + - '+975' + - '+976' + - '+977' + - '+992' + - '+993' + - '+994' + - '+995' + - '+996' + - '+998' localCode: + x-label: + en: Area code + pt-BR: DDD type: string + pattern: '^\d{2,3}$' + maxLength: 3 number: + x-label: + en: Number + pt-BR: Número type: string isPrimary: + x-label: + en: Primary + pt-BR: Principal type: boolean example: true description: Is this the primary User's phone number? @@ -1889,15 +2335,24 @@ components: - type properties: email: + x-label: + en: Email + pt-BR: E-mail type: string format: email type: + x-label: + en: Type + pt-BR: Tipo type: string enum: - work - home - vacation isPrimary: + x-label: + en: Primary + pt-BR: Principal type: boolean default: false RequestUpdateAddress: @@ -1907,18 +2362,30 @@ components: - id properties: id: + x-label: + en: ID + pt-BR: ID type: string format: uuid email: + x-label: + en: Email + pt-BR: E-mail type: string format: email type: + x-label: + en: Type + pt-BR: Tipo type: string enum: - work - home - vacation isPrimary: + x-label: + en: Primary + pt-BR: Principal type: boolean RequestCreateEmail: description: Port input object for email create endpoint. @@ -1928,13 +2395,25 @@ components: - type properties: email: + x-label: + en: Email + pt-BR: E-mail type: string + format: email + pattern: '^[^@\s]+@[^@\s]+\.[^@\s]+$' + maxLength: 254 type: + x-label: + en: Type + pt-BR: Tipo type: string enum: - work - personal isPrimary: + x-label: + en: Primary + pt-BR: Principal type: boolean example: true description: Is this the primary User's e-mail address? @@ -1946,15 +2425,30 @@ components: - id properties: id: + x-label: + en: ID + pt-BR: ID type: string email: + x-label: + en: Email + pt-BR: E-mail type: string + format: email + pattern: '^[^@\s]+@[^@\s]+\.[^@\s]+$' + maxLength: 254 type: + x-label: + en: Type + pt-BR: Tipo type: string enum: - work - personal isPrimary: + x-label: + en: Primary + pt-BR: Principal type: boolean example: true description: Is this the primary User's e-mail address? @@ -1967,6 +2461,9 @@ components: - countryIssue properties: type: + x-label: + en: Type + pt-BR: Tipo type: string enum: - CPF @@ -1976,11 +2473,20 @@ components: example: CPF description: Type of document countryIssue: + x-label: + en: Issuing country + pt-BR: País emissor type: string + pattern: '^[A-Z]{2}$' + maxLength: 2 example: BR description: Country which emitted the document data: + x-label: + en: Number + pt-BR: Número type: string + maxLength: 20 RequestUpdateDocument: description: Port input object for document update endpoint. type: object @@ -1988,8 +2494,14 @@ components: - id properties: id: + x-label: + en: ID + pt-BR: ID type: string type: + x-label: + en: Type + pt-BR: Tipo type: string enum: - CPF @@ -1999,11 +2511,20 @@ components: example: CPF description: Type of document countryIssue: + x-label: + en: Issuing country + pt-BR: País emissor type: string + pattern: '^[A-Z]{2}$' + maxLength: 2 example: BR description: Country which emitted the document data: + x-label: + en: Number + pt-BR: Número type: string + maxLength: 20 RequestUpdatePassword: description: Port input object for password update endpoint. type: object @@ -2011,19 +2532,54 @@ components: - password properties: password: + x-label: + en: Password + pt-BR: Senha type: string minLength: 8 format: password UserArrayOf: - description: Port output array of users. - type: array - items: - $ref: "#/components/schemas/User" + description: >- + Port output page of users: the `result` slice selected by + `page`/`size`/`filter`/`sort`/`q` plus the matching `total` (JUM-777). + type: object + required: [result, page, size, total] + properties: + result: + type: array + items: + $ref: "#/components/schemas/User" + page: + type: integer + minimum: 1 + size: + type: integer + minimum: 1 + total: + type: integer + minimum: 0 + description: Number of records matching the filters and search, across all pages. OrganizationArrayOf: - description: Port output array of organizations. - type: array - items: - $ref: "#/components/schemas/Organization" + description: >- + Port output page of organizations: the `result` slice selected by + `page`/`size`/`filter`/`sort`/`q` plus the matching `total` (JUM-777). + type: object + required: [result, page, size, total] + properties: + result: + type: array + items: + $ref: "#/components/schemas/Organization" + page: + type: integer + minimum: 1 + size: + type: integer + minimum: 1 + total: + type: integer + minimum: 0 + description: Number of records matching the filters and search, across all pages. Document: description: Value object contract for Document. type: object @@ -2032,8 +2588,14 @@ components: - type properties: id: + x-label: + en: ID + pt-BR: ID type: string type: + x-label: + en: Type + pt-BR: Tipo type: string enum: - CPF @@ -2043,11 +2605,36 @@ components: example: CPF description: Type of document countryIssue: + x-label: + en: Issuing country + pt-BR: País emissor type: string + pattern: '^[A-Z]{2}$' + maxLength: 2 example: BR description: Country which emitted the document data: - type: string + x-label: + en: Number + pt-BR: Número + type: string + maxLength: 20 + x-validation: + description: Format rules per document type and issuing country. CPF uses the modulo-11 checksum with two verification digits; SSN uses the US AAA-GG-SSSS layout. + rules: + - when: { type: CPF, countryIssue: BR } + pattern: '^\d{3}\.?\d{3}\.?\d{3}-?\d{2}$' + mask: '000.000.000-00' + checksum: cpf-mod11 + example: '123.456.789-09' + - when: { type: SSN, countryIssue: US } + pattern: '^\d{3}-?\d{2}-?\d{4}$' + mask: '000-00-0000' + example: '123-45-6789' + - when: { type: RG } + pattern: '^[A-Za-z0-9.-]{4,20}$' + - when: { type: passport } + pattern: '^[A-Za-z0-9]{5,20}$' Email: description: Value object contract for Email. type: object @@ -2056,15 +2643,30 @@ components: - type properties: id: + x-label: + en: ID + pt-BR: ID type: string email: + x-label: + en: Email + pt-BR: E-mail type: string + format: email + pattern: '^[^@\s]+@[^@\s]+\.[^@\s]+$' + maxLength: 254 type: + x-label: + en: Type + pt-BR: Tipo type: string enum: - work - personal isPrimary: + x-label: + en: Primary + pt-BR: Principal type: boolean example: true description: Is this the primary User's e-mail address? @@ -2077,17 +2679,29 @@ components: - type properties: id: + x-label: + en: ID + pt-BR: ID type: string format: uuid email: + x-label: + en: Email + pt-BR: E-mail type: string type: + x-label: + en: Type + pt-BR: Tipo type: string enum: - work - home - vacation isPrimary: + x-label: + en: Primary + pt-BR: Principal type: boolean default: false Phone: @@ -2099,20 +2713,223 @@ components: - number properties: id: + x-label: + en: ID + pt-BR: ID type: string countryCode: + x-label: + en: Country code + pt-BR: Código do país type: string + maxLength: 4 + description: E.164 country calling code + enum: + - '+1' + - '+7' + - '+20' + - '+27' + - '+30' + - '+31' + - '+32' + - '+33' + - '+34' + - '+36' + - '+39' + - '+40' + - '+41' + - '+43' + - '+44' + - '+45' + - '+46' + - '+47' + - '+48' + - '+49' + - '+51' + - '+52' + - '+53' + - '+54' + - '+55' + - '+56' + - '+57' + - '+58' + - '+60' + - '+61' + - '+62' + - '+63' + - '+64' + - '+65' + - '+66' + - '+81' + - '+82' + - '+84' + - '+86' + - '+90' + - '+91' + - '+92' + - '+93' + - '+94' + - '+95' + - '+98' + - '+211' + - '+212' + - '+213' + - '+216' + - '+218' + - '+220' + - '+221' + - '+224' + - '+225' + - '+226' + - '+227' + - '+228' + - '+230' + - '+233' + - '+234' + - '+236' + - '+241' + - '+243' + - '+244' + - '+248' + - '+249' + - '+251' + - '+252' + - '+254' + - '+255' + - '+256' + - '+258' + - '+260' + - '+261' + - '+263' + - '+264' + - '+265' + - '+267' + - '+268' + - '+269' + - '+290' + - '+297' + - '+298' + - '+299' + - '+350' + - '+351' + - '+352' + - '+353' + - '+354' + - '+355' + - '+356' + - '+357' + - '+358' + - '+359' + - '+370' + - '+371' + - '+372' + - '+373' + - '+374' + - '+375' + - '+376' + - '+377' + - '+380' + - '+381' + - '+385' + - '+386' + - '+387' + - '+389' + - '+420' + - '+421' + - '+423' + - '+500' + - '+501' + - '+502' + - '+503' + - '+504' + - '+505' + - '+506' + - '+507' + - '+509' + - '+590' + - '+591' + - '+592' + - '+593' + - '+594' + - '+595' + - '+597' + - '+598' + - '+599' + - '+670' + - '+673' + - '+675' + - '+676' + - '+677' + - '+678' + - '+679' + - '+680' + - '+685' + - '+687' + - '+689' + - '+850' + - '+852' + - '+853' + - '+855' + - '+856' + - '+880' + - '+886' + - '+960' + - '+961' + - '+962' + - '+963' + - '+964' + - '+965' + - '+966' + - '+968' + - '+970' + - '+971' + - '+972' + - '+973' + - '+974' + - '+975' + - '+976' + - '+977' + - '+992' + - '+993' + - '+994' + - '+995' + - '+996' + - '+998' localCode: + x-label: + en: Area code + pt-BR: DDD type: string + pattern: '^\d{2,3}$' + maxLength: 3 number: - type: string + x-label: + en: Number + pt-BR: Número + type: string + x-validation: + description: National number format per country (countryCode selects the rule). + rules: + - when: { countryCode: '+55' } + pattern: '^\d{4,5}-?\d{4}$' + mask: '00000-0000' + example: '99805-4033' + - when: { countryCode: '+1' } + pattern: '^\d{3}-?\d{4}$' + mask: '000-0000' + example: '555-0100' isPrimary: + x-label: + en: Primary + pt-BR: Principal type: boolean example: true description: Is this the primary User's phone number? default: false User: description: Port output object for User resource. + x-primary-key: id + x-service: core required: - id - createdAt @@ -2124,60 +2941,113 @@ components: type: object properties: id: + x-label: + en: ID + pt-BR: ID type: string + format: uuid firstName: + x-label: + en: First name + pt-BR: Nome type: string minLength: 1 example: Abraham description: User's first name lastName: + x-label: + en: Last name + pt-BR: Sobrenome type: string example: Abraham nullable: true description: User's last name username: + x-label: + en: Username + pt-BR: Usuário type: string minLength: 1 description: Username to access the system organization: + x-label: + en: Organization + pt-BR: Organização type: string format: uuid nullable: true description: Organization id for tenant users (admin/user) + x-relation: + entity: Organization + match: id + display: name + kind: belongsTo password: + x-label: + en: Password + pt-BR: Senha type: string minLength: 8 format: password description: Password to access the system emails: + x-label: + en: Emails + pt-BR: E-mails type: array items: $ref: "#/components/schemas/Email" description: User's e-mail addresses minItems: 1 documents: + x-label: + en: Documents + pt-BR: Documentos type: array items: $ref: "#/components/schemas/Document" description: User's attached documents phones: + x-label: + en: Phones + pt-BR: Telefones type: array items: $ref: "#/components/schemas/Phone" - description: User's attached documents + description: User's attached phone numbers roles: + x-label: + en: Roles + pt-BR: Papéis type: array items: type: string description: User's access roles createdAt: + x-label: + en: Created at + pt-BR: Criado em type: string format: date-time updatedAt: + x-label: + en: Updated at + pt-BR: Atualizado em + type: string + format: date-time + deletedAt: + x-label: + en: Deleted at + pt-BR: Excluído em type: string format: date-time + nullable: true + readOnly: true + description: Tombstone timestamp. Null while the record is alive. Organization: description: Port output object for Organization resource. + x-primary-key: id + x-service: core required: - id - createdAt @@ -2186,32 +3056,95 @@ components: type: object properties: id: + x-label: + en: ID + pt-BR: ID type: string format: uuid name: + x-label: + en: Name + pt-BR: Nome type: string address: + x-label: + en: Addresses + pt-BR: Endereços type: array items: $ref: "#/components/schemas/Address" phone: + x-label: + en: Phones + pt-BR: Telefones type: array items: $ref: "#/components/schemas/Phone" email: + x-label: + en: Email + pt-BR: E-mail type: array items: $ref: "#/components/schemas/Email" users: + x-label: + en: Members + pt-BR: Membros type: array + description: Member user ids. + x-relation: + entity: User + match: id + display: username + kind: hasMany items: type: string createdAt: + x-label: + en: Created at + pt-BR: Criado em type: string format: date-time updatedAt: + x-label: + en: Updated at + pt-BR: Atualizado em type: string format: date-time + deletedAt: + x-label: + en: Deleted at + pt-BR: Excluído em + type: string + format: date-time + nullable: true + readOnly: true + description: Tombstone timestamp. Null while the record is alive. + EntityMetricsResponse: + description: Generic entity metrics envelope (JUM-793). + required: [metric, buckets] + type: object + properties: + metric: + type: string + enum: [count, groupBy, series] + field: + type: string + interval: + type: string + enum: [day, week, month] + buckets: + type: array + items: + type: object + required: [key, count] + properties: + key: + type: string + count: + type: integer + minimum: 0 BooleanStringResult: description: Port output object for legacy text/plain success responses (`true`/`false`). diff --git a/test-map.json b/test-map.json index 74041f0a5..4805d1246 100644 --- a/test-map.json +++ b/test-map.json @@ -83,9 +83,21 @@ "tier": "gate", "kind": "non-hexagonal" }, + "service-management/catalog-api": { + "dependsOn": [ + "contracts" + ], + "sourceGlobs": [ + "apps/service-management-api/**" + ], + "runner": "bun", + "tier": "gate", + "kind": "non-hexagonal" + }, "service-management/designer": { "dependsOn": [ - "service-management/server" + "service-management/server", + "service-management/catalog-api" ], "sourceGlobs": [ "apps/service-management/script.js", @@ -107,6 +119,19 @@ "tier": "gate", "kind": "non-hexagonal" }, + "frontend": { + "dependsOn": [ + "contracts" + ], + "sourceGlobs": [ + "apps/frontend/**", + "packages/sdk-rest-client/src/**", + "spec/1.0.0.yml" + ], + "runner": "bun", + "tier": "gate", + "kind": "non-hexagonal" + }, "browser-harness": { "dependsOn": [], "sourceGlobs": [ @@ -126,6 +151,7 @@ "tooling/**", "apps/jumentix-website/**", ".github/**", + ".circleci/**", "test-map.json", "jest.config.js", "sonar-project.properties", @@ -268,8 +294,18 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/interface/Async/RealtimeAPIBase.catalogs.test.ts", - "path": "apps/backend-template/test/unit/interface/Async/RealtimeAPIBase.catalogs.test.ts", + "id": "apps/backend-template/test/unit/interface/Async/RealtimeAPIBase.moduleResolution.test.ts", + "path": "apps/backend-template/test/unit/interface/Async/RealtimeAPIBase.moduleResolution.test.ts", + "layer": "interface/runtime", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/interface/Async/RealtimeAPIBase.loadFailure.test.ts", + "path": "apps/backend-template/test/unit/interface/Async/RealtimeAPIBase.loadFailure.test.ts", "layer": "interface/runtime", "kind": "hexagonal", "type": "unit", @@ -369,6 +405,26 @@ "tier": "gate", "timeoutMs": 60000 }, + { + "id": "apps/backend-template/test/unit/interface/HTTP/RestAPI.composition.test.ts", + "path": "apps/backend-template/test/unit/interface/HTTP/RestAPI.composition.test.ts", + "layer": "interface/runtime", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/interface/HTTP/RestAPI.test.ts", + "path": "apps/backend-template/test/unit/interface/HTTP/RestAPI.test.ts", + "layer": "interface/runtime", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, { "id": "apps/backend-template/test/unit/interface/HTTP/RestAPI.deadLetterReplay.test.ts", "path": "apps/backend-template/test/unit/interface/HTTP/RestAPI.deadLetterReplay.test.ts", @@ -380,8 +436,8 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/interface/HTTP/RestAPI.catalogs.test.ts", - "path": "apps/backend-template/test/unit/interface/HTTP/RestAPI.catalogs.test.ts", + "id": "apps/backend-template/test/unit/interface/HTTP/RestAPI.loadFailure.test.ts", + "path": "apps/backend-template/test/unit/interface/HTTP/RestAPI.loadFailure.test.ts", "layer": "interface/runtime", "kind": "hexagonal", "type": "unit", @@ -399,6 +455,16 @@ "tier": "gate", "timeoutMs": 60000 }, + { + "id": "apps/backend-template/test/unit/interface/HTTP/adapters/express/usersMetrics.handler.test.ts", + "path": "apps/backend-template/test/unit/interface/HTTP/adapters/express/usersMetrics.handler.test.ts", + "layer": "interface/runtime", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, { "id": "apps/backend-template/test/unit/interface/HTTP/adapters/express/ExpressServer.start.test.ts", "path": "apps/backend-template/test/unit/interface/HTTP/adapters/express/ExpressServer.start.test.ts", @@ -441,6 +507,16 @@ "tier": "gate", "timeoutMs": 60000 }, + { + "id": "apps/backend-template/test/unit/interface/HTTP/adapters/restify/metricsHandlers.test.ts", + "path": "apps/backend-template/test/unit/interface/HTTP/adapters/restify/metricsHandlers.test.ts", + "layer": "interface/runtime", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, { "id": "apps/backend-template/test/unit/interface/HTTP/adapters/vercel-functions/vercelFunctionsSecurity.test.ts", "path": "apps/backend-template/test/unit/interface/HTTP/adapters/vercel-functions/vercelFunctionsSecurity.test.ts", @@ -461,6 +537,26 @@ "tier": "gate", "timeoutMs": 60000 }, + { + "id": "apps/backend-template/test/unit/interface/HTTP/adapters/fastify/metricsHandlers.test.ts", + "path": "apps/backend-template/test/unit/interface/HTTP/adapters/fastify/metricsHandlers.test.ts", + "layer": "interface/runtime", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/interface/HTTP/adapters/fastify/FastifyServer.edgeCases.test.ts", + "path": "apps/backend-template/test/unit/interface/HTTP/adapters/fastify/FastifyServer.edgeCases.test.ts", + "layer": "interface/runtime", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, { "id": "apps/backend-template/test/unit/interface/HTTP/validators/oasInputMessages.test.ts", "path": "apps/backend-template/test/unit/interface/HTTP/validators/oasInputMessages.test.ts", @@ -481,6 +577,16 @@ "tier": "gate", "timeoutMs": 60000 }, + { + "id": "apps/backend-template/test/unit/interface/HTTP/validators/validateRequestParams.edgeCases.test.ts", + "path": "apps/backend-template/test/unit/interface/HTTP/validators/validateRequestParams.edgeCases.test.ts", + "layer": "interface/runtime", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, { "id": "apps/backend-template/test/unit/interface/HTTP/ports/BaseController.test.ts", "path": "apps/backend-template/test/unit/interface/HTTP/ports/BaseController.test.ts", @@ -541,6 +647,26 @@ "tier": "gate", "timeoutMs": 60000 }, + { + "id": "apps/backend-template/test/unit/infra/context/contextInstrumentation.test.ts", + "path": "apps/backend-template/test/unit/infra/context/contextInstrumentation.test.ts", + "layer": "adapters/out+infra", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/infra/context/asyncContextMetrics.test.ts", + "path": "apps/backend-template/test/unit/infra/context/asyncContextMetrics.test.ts", + "layer": "adapters/out+infra", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, { "id": "apps/backend-template/test/unit/infra/cache/CacheService.test.ts", "path": "apps/backend-template/test/unit/infra/cache/CacheService.test.ts", @@ -637,6 +763,16 @@ "tier": "gate", "timeoutMs": 60000 }, + { + "id": "apps/backend-template/test/unit/infra/persistence/purgeStores.test.ts", + "path": "apps/backend-template/test/unit/infra/persistence/purgeStores.test.ts", + "layer": "adapters/out+infra", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, { "id": "apps/backend-template/test/unit/infra/persistence/external/ExternalStoreProxy.test.ts", "path": "apps/backend-template/test/unit/infra/persistence/external/ExternalStoreProxy.test.ts", @@ -739,6 +875,26 @@ "tier": "gate", "timeoutMs": 60000 }, + { + "id": "apps/backend-template/test/unit/ServiceManagement/serverHarnessPorts.test.ts", + "path": "apps/backend-template/test/unit/ServiceManagement/serverHarnessPorts.test.ts", + "layer": "tooling", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/ServiceManagement/rbacContract.test.ts", + "path": "apps/backend-template/test/unit/ServiceManagement/rbacContract.test.ts", + "layer": "tooling", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, { "id": "apps/backend-template/test/unit/domains/validators/index.test.ts", "path": "apps/backend-template/test/unit/domains/validators/index.test.ts", @@ -820,9 +976,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/service-management/deployTargetLifecycle.test.ts", - "path": "apps/backend-template/test/unit/service-management/deployTargetLifecycle.test.ts", - "layer": "service-management/designer", + "id": "apps/backend-template/test/unit/ownership/backendTemplateCatalogOwnership.test.ts", + "path": "apps/backend-template/test/unit/ownership/backendTemplateCatalogOwnership.test.ts", + "layer": "tooling", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -830,9 +986,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/service-management/pm2EcosystemUi.contract.test.ts", - "path": "apps/backend-template/test/unit/service-management/pm2EcosystemUi.contract.test.ts", - "layer": "service-management/designer", + "id": "apps/backend-template/test/unit/ci-cd/run-branch-quality-gate.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/run-branch-quality-gate.test.ts", + "layer": "tooling", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -840,9 +996,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/service-management/designerState.test.ts", - "path": "apps/backend-template/test/unit/service-management/designerState.test.ts", - "layer": "service-management/designer", + "id": "apps/backend-template/test/unit/ci-cd/sync-service-management-designer-core.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/sync-service-management-designer-core.test.ts", + "layer": "tooling", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -850,9 +1006,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/service-management/designerAsyncApiExport.test.ts", - "path": "apps/backend-template/test/unit/service-management/designerAsyncApiExport.test.ts", - "layer": "service-management/designer", + "id": "apps/backend-template/test/unit/ci-cd/merge-browser-coverage.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/merge-browser-coverage.test.ts", + "layer": "tooling", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -860,9 +1016,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/service-management/designerNormalizers.test.ts", - "path": "apps/backend-template/test/unit/service-management/designerNormalizers.test.ts", - "layer": "service-management/designer", + "id": "apps/backend-template/test/unit/ci-cd/check-ci-provider.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/check-ci-provider.test.ts", + "layer": "tooling", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -870,9 +1026,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/service-management/modelValidation.test.ts", - "path": "apps/backend-template/test/unit/service-management/modelValidation.test.ts", - "layer": "service-management/designer", + "id": "apps/backend-template/test/unit/ci-cd/agent-registry-cli.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/agent-registry-cli.test.ts", + "layer": "tooling", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -880,9 +1036,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/service-management/catalogSyncClientDefaults.test.ts", - "path": "apps/backend-template/test/unit/service-management/catalogSyncClientDefaults.test.ts", - "layer": "service-management/designer", + "id": "apps/backend-template/test/unit/ci-cd/check-dependency-override-integrity.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/check-dependency-override-integrity.test.ts", + "layer": "tooling", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -890,9 +1046,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/service-management/pwaShell.test.ts", - "path": "apps/backend-template/test/unit/service-management/pwaShell.test.ts", - "layer": "service-management/designer", + "id": "apps/backend-template/test/unit/ci-cd/check-commit-authorship.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/check-commit-authorship.test.ts", + "layer": "tooling", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -900,9 +1056,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/service-management/mvp.roadmap.features.test.ts", - "path": "apps/backend-template/test/unit/service-management/mvp.roadmap.features.test.ts", - "layer": "service-management/designer", + "id": "apps/backend-template/test/unit/ci-cd/check-test-map.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/check-test-map.test.ts", + "layer": "tooling", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -910,9 +1066,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/service-management/interfaceAdapterValidation.test.ts", - "path": "apps/backend-template/test/unit/service-management/interfaceAdapterValidation.test.ts", - "layer": "service-management/designer", + "id": "apps/backend-template/test/unit/ci-cd/check-hexagonal-boundaries.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/check-hexagonal-boundaries.test.ts", + "layer": "tooling", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -920,9 +1076,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/service-management/serverHarnessPorts.test.ts", - "path": "apps/backend-template/test/unit/service-management/serverHarnessPorts.test.ts", - "layer": "service-management/designer", + "id": "apps/backend-template/test/unit/ci-cd/check-third-party-review.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/check-third-party-review.test.ts", + "layer": "tooling", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -930,9 +1086,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/service-management/catalogSyncClient.test.ts", - "path": "apps/backend-template/test/unit/service-management/catalogSyncClient.test.ts", - "layer": "service-management/designer", + "id": "apps/backend-template/test/unit/ci-cd/merge-coverage-reports.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/merge-coverage-reports.test.ts", + "layer": "tooling", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -940,9 +1096,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/service-management/deployTargetValidation.test.ts", - "path": "apps/backend-template/test/unit/service-management/deployTargetValidation.test.ts", - "layer": "service-management/designer", + "id": "apps/backend-template/test/unit/ci-cd/guard-defaults.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/guard-defaults.test.ts", + "layer": "tooling", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -950,9 +1106,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/service-management/designerSync.test.ts", - "path": "apps/backend-template/test/unit/service-management/designerSync.test.ts", - "layer": "service-management/designer", + "id": "apps/backend-template/test/unit/ci-cd/run-full-test-matrix.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/run-full-test-matrix.test.ts", + "layer": "tooling", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -960,9 +1116,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/service-management/runtimeEnvUi.contract.test.ts", - "path": "apps/backend-template/test/unit/service-management/runtimeEnvUi.contract.test.ts", - "layer": "service-management/designer", + "id": "apps/backend-template/test/unit/ci-cd/check-canonical-integrations.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/check-canonical-integrations.test.ts", + "layer": "tooling", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -970,9 +1126,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/service-management/designerRoundTrip.test.ts", - "path": "apps/backend-template/test/unit/service-management/designerRoundTrip.test.ts", - "layer": "service-management/designer", + "id": "apps/backend-template/test/unit/ci-cd/migrate-agent-registry-to-firestore.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/migrate-agent-registry-to-firestore.test.ts", + "layer": "tooling", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -980,9 +1136,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/service-management/serviceConfigurationValidation.test.ts", - "path": "apps/backend-template/test/unit/service-management/serviceConfigurationValidation.test.ts", - "layer": "service-management/designer", + "id": "apps/backend-template/test/unit/ci-cd/check-rtdb-indexes.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/check-rtdb-indexes.test.ts", + "layer": "tooling", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -990,9 +1146,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/service-management/designerOasCompliance.test.ts", - "path": "apps/backend-template/test/unit/service-management/designerOasCompliance.test.ts", - "layer": "service-management/designer", + "id": "apps/backend-template/test/unit/ci-cd/run-suite-resolution.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/run-suite-resolution.test.ts", + "layer": "tooling", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1000,9 +1156,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/service-management/designerImporters.test.ts", - "path": "apps/backend-template/test/unit/service-management/designerImporters.test.ts", - "layer": "service-management/designer", + "id": "apps/backend-template/test/unit/ci-cd/run-suite-execution.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/run-suite-execution.test.ts", + "layer": "tooling", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1010,9 +1166,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/service-management/catalogSyncTransport.test.ts", - "path": "apps/backend-template/test/unit/service-management/catalogSyncTransport.test.ts", - "layer": "service-management/designer", + "id": "apps/backend-template/test/unit/ci-cd/run-unit-tests.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/run-unit-tests.test.ts", + "layer": "tooling", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1020,9 +1176,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/service-management/modelQueries.test.ts", - "path": "apps/backend-template/test/unit/service-management/modelQueries.test.ts", - "layer": "service-management/designer", + "id": "apps/backend-template/test/unit/ci-cd/readme-badges.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/readme-badges.test.ts", + "layer": "tooling", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1030,9 +1186,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/service-management/designerStore.test.ts", - "path": "apps/backend-template/test/unit/service-management/designerStore.test.ts", - "layer": "service-management/designer", + "id": "apps/backend-template/test/unit/ci-cd/check-integration-migration.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/check-integration-migration.test.ts", + "layer": "tooling", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1040,9 +1196,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/service-management/rbacContract.test.ts", - "path": "apps/backend-template/test/unit/service-management/rbacContract.test.ts", - "layer": "service-management/designer", + "id": "apps/backend-template/test/unit/ci-cd/check-requirements-registry.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/check-requirements-registry.test.ts", + "layer": "tooling", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1050,9 +1206,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/service-management/canaMigrationFailures.test.ts", - "path": "apps/backend-template/test/unit/service-management/canaMigrationFailures.test.ts", - "layer": "service-management/designer", + "id": "apps/backend-template/test/unit/ci-cd/check-oas-relations.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/check-oas-relations.test.ts", + "layer": "tooling", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1060,9 +1216,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/service-management/canaMigration.test.ts", - "path": "apps/backend-template/test/unit/service-management/canaMigration.test.ts", - "layer": "service-management/designer", + "id": "apps/backend-template/test/unit/ci-cd/entry-point.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/entry-point.test.ts", + "layer": "tooling", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1070,9 +1226,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/service-management/hexagonalCodegen.test.ts", - "path": "apps/backend-template/test/unit/service-management/hexagonalCodegen.test.ts", - "layer": "service-management/designer", + "id": "apps/backend-template/test/unit/ci-cd/check-coverage-thresholds.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/check-coverage-thresholds.test.ts", + "layer": "tooling", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1080,9 +1236,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/service-management/canaDesignerStore.test.ts", - "path": "apps/backend-template/test/unit/service-management/canaDesignerStore.test.ts", - "layer": "service-management/designer", + "id": "apps/backend-template/test/unit/ci-cd/run-monorepo-ci.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/run-monorepo-ci.test.ts", + "layer": "tooling", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1090,9 +1246,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/service-management/sampleModel.test.ts", - "path": "apps/backend-template/test/unit/service-management/sampleModel.test.ts", - "layer": "service-management/designer", + "id": "apps/backend-template/test/unit/ci-cd/classify-ci-context.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/classify-ci-context.test.ts", + "layer": "tooling", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1100,9 +1256,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/service-management/designerPackageVersioning.test.ts", - "path": "apps/backend-template/test/unit/service-management/designerPackageVersioning.test.ts", - "layer": "service-management/designer", + "id": "apps/backend-template/test/unit/ci-cd/check-affected-workspaces.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/check-affected-workspaces.test.ts", + "layer": "tooling", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1110,9 +1266,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/service-management/designerExporters.test.ts", - "path": "apps/backend-template/test/unit/service-management/designerExporters.test.ts", - "layer": "service-management/designer", + "id": "apps/backend-template/test/unit/ci-cd/run-integration-tests.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/run-integration-tests.test.ts", + "layer": "tooling", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1120,8 +1276,8 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/run-branch-quality-gate.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/run-branch-quality-gate.test.ts", + "id": "apps/backend-template/test/unit/ci-cd/check-workspace-coverage-policy.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/check-workspace-coverage-policy.test.ts", "layer": "tooling", "kind": "non-hexagonal", "type": "unit", @@ -1130,8 +1286,8 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/sync-service-management-designer-core.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/sync-service-management-designer-core.test.ts", + "id": "apps/backend-template/test/unit/ci-cd/service-management-vendor-wiring.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/service-management-vendor-wiring.test.ts", "layer": "tooling", "kind": "non-hexagonal", "type": "unit", @@ -1140,8 +1296,8 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/merge-browser-coverage.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/merge-browser-coverage.test.ts", + "id": "apps/backend-template/test/unit/ci-cd/report-sonar-findings.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/report-sonar-findings.test.ts", "layer": "tooling", "kind": "non-hexagonal", "type": "unit", @@ -1150,8 +1306,8 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/check-ci-provider.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/check-ci-provider.test.ts", + "id": "apps/backend-template/test/unit/ci-cd/check-agent-registry-source.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/check-agent-registry-source.test.ts", "layer": "tooling", "kind": "non-hexagonal", "type": "unit", @@ -1160,8 +1316,8 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/agent-registry-cli.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/agent-registry-cli.test.ts", + "id": "apps/backend-template/test/unit/ci-cd/check-workspace-quality.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/check-workspace-quality.test.ts", "layer": "tooling", "kind": "non-hexagonal", "type": "unit", @@ -1170,8 +1326,8 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/check-dependency-override-integrity.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/check-dependency-override-integrity.test.ts", + "id": "apps/backend-template/test/unit/ci-cd/check-frontend-coverage.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/check-frontend-coverage.test.ts", "layer": "tooling", "kind": "non-hexagonal", "type": "unit", @@ -1180,19 +1336,757 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/check-commit-authorship.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/check-commit-authorship.test.ts", + "id": "apps/backend-template/test/unit/ci-cd/check-pr-governance.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/check-pr-governance.test.ts", + "layer": "tooling", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/ci-cd/check-workspace-boundaries.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/check-workspace-boundaries.test.ts", + "layer": "tooling", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/ci-cd/check-test-integrity.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/check-test-integrity.test.ts", + "layer": "tooling", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/ci-cd/check-fail-closed.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/check-fail-closed.test.ts", + "layer": "tooling", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/ci-cd/generate-test-map.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/generate-test-map.test.ts", + "layer": "tooling", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/ci-cd/check-bun-version.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/check-bun-version.test.ts", + "layer": "tooling", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "ciRunner": "node", + "bunCompat": "pending-resetModules-or-native-gap", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/ci-cd/run-task-change-tests.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/run-task-change-tests.test.ts", + "layer": "tooling", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/ci-cd/sync-service-management-cana-bundle.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/sync-service-management-cana-bundle.test.ts", + "layer": "tooling", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/ci-cd/check-prevention-gates.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/check-prevention-gates.test.ts", + "layer": "tooling", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/ci-cd/check-release-governance.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/check-release-governance.test.ts", + "layer": "tooling", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/ci-cd/test-map-completeness.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/test-map-completeness.test.ts", + "layer": "tooling", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/ci-cd/check-package-suites.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/check-package-suites.test.ts", + "layer": "tooling", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/ci-cd/run-service-management-integration.test.ts", + "path": "apps/backend-template/test/unit/ci-cd/run-service-management-integration.test.ts", + "layer": "tooling", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/modules/ddd/valueObjects/DocumentValueObject.test.ts", + "path": "apps/backend-template/test/unit/modules/ddd/valueObjects/DocumentValueObject.test.ts", + "layer": "application", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/modules/ddd/valueObjects/AddressValueObject.test.ts", + "path": "apps/backend-template/test/unit/modules/ddd/valueObjects/AddressValueObject.test.ts", + "layer": "application", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/modules/Users/interface/restapi/frameworks/express/handlers.test.ts", + "path": "apps/backend-template/test/unit/modules/Users/interface/restapi/frameworks/express/handlers.test.ts", + "layer": "adapters/in", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/modules/Users/interface/restapi/frameworks/derby-js/handlers.test.ts", + "path": "apps/backend-template/test/unit/modules/Users/interface/restapi/frameworks/derby-js/handlers.test.ts", + "layer": "adapters/in", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/modules/Users/factories.test.ts", + "path": "apps/backend-template/test/unit/modules/Users/factories.test.ts", + "layer": "application", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/modules/Users/composition/composeUserDeadLetterReplay.test.ts", + "path": "apps/backend-template/test/unit/modules/Users/composition/composeUserDeadLetterReplay.test.ts", + "layer": "application", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/modules/Users/composition/composeUsersAuthServices.test.ts", + "path": "apps/backend-template/test/unit/modules/Users/composition/composeUsersAuthServices.test.ts", + "layer": "application", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "ciRunner": "node", + "bunCompat": "pending-resetModules-or-native-gap", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/modules/Users/adapters/in/websocketapi/frameworks/socket-io/handlers/createWebSocketOperationHandler.test.ts", + "path": "apps/backend-template/test/unit/modules/Users/adapters/in/websocketapi/frameworks/socket-io/handlers/createWebSocketOperationHandler.test.ts", + "layer": "adapters/in", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/modules/Users/adapters/in/controller/controllers.test.ts", + "path": "apps/backend-template/test/unit/modules/Users/adapters/in/controller/controllers.test.ts", + "layer": "adapters/in", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/modules/Users/index.exports.test.ts", + "path": "apps/backend-template/test/unit/modules/Users/index.exports.test.ts", + "layer": "application", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/modules/Users/application/useCases.test.ts", + "path": "apps/backend-template/test/unit/modules/Users/application/useCases.test.ts", + "layer": "application", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/modules/Users/application/features/index.test.ts", + "path": "apps/backend-template/test/unit/modules/Users/application/features/index.test.ts", + "layer": "application", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/modules/Users/application/OrganizationUseCases.test.ts", + "path": "apps/backend-template/test/unit/modules/Users/application/OrganizationUseCases.test.ts", + "layer": "application", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "ciRunner": "node", + "bunCompat": "pending-resetModules-or-native-gap", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/modules/Users/application/service/UserService.lockRelease.test.ts", + "path": "apps/backend-template/test/unit/modules/Users/application/service/UserService.lockRelease.test.ts", + "layer": "application", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/modules/Users/application/service/UserService.test.ts", + "path": "apps/backend-template/test/unit/modules/Users/application/service/UserService.test.ts", + "layer": "application", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/modules/Users/application/service/UserProviderLocal.test.ts", + "path": "apps/backend-template/test/unit/modules/Users/application/service/UserProviderLocal.test.ts", + "layer": "application", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/modules/Users/application/service/UserService.organizationMembership.test.ts", + "path": "apps/backend-template/test/unit/modules/Users/application/service/UserService.organizationMembership.test.ts", + "layer": "application", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/modules/Users/application/service/OrganizationService.test.ts", + "path": "apps/backend-template/test/unit/modules/Users/application/service/OrganizationService.test.ts", + "layer": "application", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/modules/Users/application/service/UserService.sanitization.test.ts", + "path": "apps/backend-template/test/unit/modules/Users/application/service/UserService.sanitization.test.ts", + "layer": "application", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/modules/Users/application/service/AuthService.rbac.test.ts", + "path": "apps/backend-template/test/unit/modules/Users/application/service/AuthService.rbac.test.ts", + "layer": "application", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/modules/Users/application/service/AuthService.branches.test.ts", + "path": "apps/backend-template/test/unit/modules/Users/application/service/AuthService.branches.test.ts", + "layer": "application", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/modules/Users/application/service/AuthService.audit.test.ts", + "path": "apps/backend-template/test/unit/modules/Users/application/service/AuthService.audit.test.ts", + "layer": "application", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/modules/Users/application/service/UserService.deadLetter.test.ts", + "path": "apps/backend-template/test/unit/modules/Users/application/service/UserService.deadLetter.test.ts", + "layer": "application", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/modules/Users/application/events/registerUserMessageHandlers.test.ts", + "path": "apps/backend-template/test/unit/modules/Users/application/events/registerUserMessageHandlers.test.ts", + "layer": "application", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/modules/Users/application/events/events.test.ts", + "path": "apps/backend-template/test/unit/modules/Users/application/events/events.test.ts", + "layer": "application", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/modules/Users/application/events/registerUserEventListeners.test.ts", + "path": "apps/backend-template/test/unit/modules/Users/application/events/registerUserEventListeners.test.ts", + "layer": "application", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/modules/Users/lambdaRuntime.test.ts", + "path": "apps/backend-template/test/unit/modules/Users/lambdaRuntime.test.ts", + "layer": "adapters/in", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/modules/Users/domain/security/TenantAuthorizationPolicy.test.ts", + "path": "apps/backend-template/test/unit/modules/Users/domain/security/TenantAuthorizationPolicy.test.ts", + "layer": "domain", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/modules/Users/domain/security/Rbac.test.ts", + "path": "apps/backend-template/test/unit/modules/Users/domain/security/Rbac.test.ts", + "layer": "domain", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/modules/Users/domain/Model/User.test.ts", + "path": "apps/backend-template/test/unit/modules/Users/domain/Model/User.test.ts", + "layer": "domain", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/modules/Users/domain/Model/Organization.test.ts", + "path": "apps/backend-template/test/unit/modules/Users/domain/Model/Organization.test.ts", + "layer": "domain", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/modules/port/core.test.ts", + "path": "apps/backend-template/test/unit/modules/port/core.test.ts", + "layer": "application", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/modules/port/setListQuery.test.ts", + "path": "apps/backend-template/test/unit/modules/port/setListQuery.test.ts", + "layer": "application", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/modules/port/BaseService.test.ts", + "path": "apps/backend-template/test/unit/modules/port/BaseService.test.ts", + "layer": "application", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/modules/port/relations.test.ts", + "path": "apps/backend-template/test/unit/modules/port/relations.test.ts", + "layer": "application", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/modules/port/setMetricsQuery.test.ts", + "path": "apps/backend-template/test/unit/modules/port/setMetricsQuery.test.ts", + "layer": "application", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/modules/port/index.exports.test.ts", + "path": "apps/backend-template/test/unit/modules/port/index.exports.test.ts", + "layer": "application", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/backend-template/test/unit/sdk-clients/grpc/GrpcApiClient.test.ts", + "path": "apps/backend-template/test/unit/sdk-clients/grpc/GrpcApiClient.test.ts", "layer": "tooling", "kind": "non-hexagonal", "type": "unit", "runner": "bun", + "ciRunner": "node", + "bunCompat": "pending-resetModules-or-native-gap", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/service-management/test/unit/hostMetrics.test.ts", + "path": "apps/service-management/test/unit/hostMetrics.test.ts", + "layer": "service-management/designer", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/service-management/test/unit/deployTargetLifecycle.test.ts", + "path": "apps/service-management/test/unit/deployTargetLifecycle.test.ts", + "layer": "service-management/designer", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/service-management/test/unit/processHelpCatalog.test.ts", + "path": "apps/service-management/test/unit/processHelpCatalog.test.ts", + "layer": "service-management/designer", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/service-management/test/unit/pm2EcosystemUi.contract.test.ts", + "path": "apps/service-management/test/unit/pm2EcosystemUi.contract.test.ts", + "layer": "service-management/designer", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/service-management/test/unit/designerState.test.ts", + "path": "apps/service-management/test/unit/designerState.test.ts", + "layer": "service-management/designer", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/service-management/test/unit/controlHelp.test.ts", + "path": "apps/service-management/test/unit/controlHelp.test.ts", + "layer": "service-management/designer", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/service-management/test/unit/designerAsyncApiExport.test.ts", + "path": "apps/service-management/test/unit/designerAsyncApiExport.test.ts", + "layer": "service-management/designer", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/service-management/test/unit/pm2Lifecycle.test.ts", + "path": "apps/service-management/test/unit/pm2Lifecycle.test.ts", + "layer": "service-management/designer", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/service-management/test/unit/designerNormalizers.test.ts", + "path": "apps/service-management/test/unit/designerNormalizers.test.ts", + "layer": "service-management/designer", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/service-management/test/unit/pm2DaemonLock.test.ts", + "path": "apps/service-management/test/unit/pm2DaemonLock.test.ts", + "layer": "service-management/designer", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/service-management/test/unit/modelValidation.test.ts", + "path": "apps/service-management/test/unit/modelValidation.test.ts", + "layer": "service-management/designer", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/service-management/test/unit/hostMetrics.compute.test.ts", + "path": "apps/service-management/test/unit/hostMetrics.compute.test.ts", + "layer": "service-management/designer", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/service-management/test/unit/monitoringChartsDraw.test.ts", + "path": "apps/service-management/test/unit/monitoringChartsDraw.test.ts", + "layer": "service-management/designer", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/service-management/test/unit/catalogSyncClientDefaults.test.ts", + "path": "apps/service-management/test/unit/catalogSyncClientDefaults.test.ts", + "layer": "service-management/designer", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/service-management/test/unit/pwaShell.test.ts", + "path": "apps/service-management/test/unit/pwaShell.test.ts", + "layer": "service-management/designer", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/service-management/test/unit/mvp.roadmap.features.test.ts", + "path": "apps/service-management/test/unit/mvp.roadmap.features.test.ts", + "layer": "service-management/designer", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/service-management/test/unit/interfaceAdapterValidation.test.ts", + "path": "apps/service-management/test/unit/interfaceAdapterValidation.test.ts", + "layer": "service-management/designer", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/service-management/test/unit/controlHelp.dom.test.ts", + "path": "apps/service-management/test/unit/controlHelp.dom.test.ts", + "layer": "service-management/designer", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/service-management/test/unit/catalogSyncClient.test.ts", + "path": "apps/service-management/test/unit/catalogSyncClient.test.ts", + "layer": "service-management/designer", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/service-management/test/unit/deployTargetValidation.test.ts", + "path": "apps/service-management/test/unit/deployTargetValidation.test.ts", + "layer": "service-management/designer", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/service-management/test/unit/designerSync.test.ts", + "path": "apps/service-management/test/unit/designerSync.test.ts", + "layer": "service-management/designer", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/service-management/test/unit/helpPopoverState.test.ts", + "path": "apps/service-management/test/unit/helpPopoverState.test.ts", + "layer": "service-management/designer", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/service-management/test/unit/runtimeEnvUi.contract.test.ts", + "path": "apps/service-management/test/unit/runtimeEnvUi.contract.test.ts", + "layer": "service-management/designer", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/check-test-map.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/check-test-map.test.ts", - "layer": "tooling", + "id": "apps/service-management/test/unit/designerRoundTrip.test.ts", + "path": "apps/service-management/test/unit/designerRoundTrip.test.ts", + "layer": "service-management/designer", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1200,9 +2094,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/check-hexagonal-boundaries.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/check-hexagonal-boundaries.test.ts", - "layer": "tooling", + "id": "apps/service-management/test/unit/processDiskIo.test.ts", + "path": "apps/service-management/test/unit/processDiskIo.test.ts", + "layer": "service-management/designer", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1210,9 +2104,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/check-third-party-review.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/check-third-party-review.test.ts", - "layer": "tooling", + "id": "apps/service-management/test/unit/monitoringCharts.test.ts", + "path": "apps/service-management/test/unit/monitoringCharts.test.ts", + "layer": "service-management/designer", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1220,9 +2114,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/guard-defaults.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/guard-defaults.test.ts", - "layer": "tooling", + "id": "apps/service-management/test/unit/serviceConfigurationValidation.test.ts", + "path": "apps/service-management/test/unit/serviceConfigurationValidation.test.ts", + "layer": "service-management/designer", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1230,9 +2124,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/run-full-test-matrix.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/run-full-test-matrix.test.ts", - "layer": "tooling", + "id": "apps/service-management/test/unit/designerOasCompliance.test.ts", + "path": "apps/service-management/test/unit/designerOasCompliance.test.ts", + "layer": "service-management/designer", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1240,9 +2134,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/check-canonical-integrations.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/check-canonical-integrations.test.ts", - "layer": "tooling", + "id": "apps/service-management/test/unit/designerImporters.test.ts", + "path": "apps/service-management/test/unit/designerImporters.test.ts", + "layer": "service-management/designer", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1250,9 +2144,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/migrate-agent-registry-to-firestore.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/migrate-agent-registry-to-firestore.test.ts", - "layer": "tooling", + "id": "apps/service-management/test/unit/catalogSyncTransport.test.ts", + "path": "apps/service-management/test/unit/catalogSyncTransport.test.ts", + "layer": "service-management/designer", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1260,9 +2154,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/check-rtdb-indexes.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/check-rtdb-indexes.test.ts", - "layer": "tooling", + "id": "apps/service-management/test/unit/modelQueries.test.ts", + "path": "apps/service-management/test/unit/modelQueries.test.ts", + "layer": "service-management/designer", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1270,9 +2164,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/run-suite-resolution.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/run-suite-resolution.test.ts", - "layer": "tooling", + "id": "apps/service-management/test/unit/monitoringHistory.normalize.test.ts", + "path": "apps/service-management/test/unit/monitoringHistory.normalize.test.ts", + "layer": "service-management/designer", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1280,9 +2174,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/run-suite-execution.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/run-suite-execution.test.ts", - "layer": "tooling", + "id": "apps/service-management/test/unit/designerStore.test.ts", + "path": "apps/service-management/test/unit/designerStore.test.ts", + "layer": "service-management/designer", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1290,9 +2184,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/run-unit-tests.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/run-unit-tests.test.ts", - "layer": "tooling", + "id": "apps/service-management/test/unit/canvasWorkspace.test.ts", + "path": "apps/service-management/test/unit/canvasWorkspace.test.ts", + "layer": "service-management/designer", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1300,9 +2194,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/readme-badges.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/readme-badges.test.ts", - "layer": "tooling", + "id": "apps/service-management/test/unit/processDiskIo.platforms.test.ts", + "path": "apps/service-management/test/unit/processDiskIo.platforms.test.ts", + "layer": "service-management/designer", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1310,9 +2204,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/check-integration-migration.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/check-integration-migration.test.ts", - "layer": "tooling", + "id": "apps/service-management/test/unit/monitoringChartFormat.test.ts", + "path": "apps/service-management/test/unit/monitoringChartFormat.test.ts", + "layer": "service-management/designer", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1320,9 +2214,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/check-requirements-registry.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/check-requirements-registry.test.ts", - "layer": "tooling", + "id": "apps/service-management/test/unit/canaMigrationFailures.test.ts", + "path": "apps/service-management/test/unit/canaMigrationFailures.test.ts", + "layer": "service-management/designer", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1330,9 +2224,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/entry-point.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/entry-point.test.ts", - "layer": "tooling", + "id": "apps/service-management/test/unit/canaMigration.test.ts", + "path": "apps/service-management/test/unit/canaMigration.test.ts", + "layer": "service-management/designer", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1340,9 +2234,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/check-coverage-thresholds.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/check-coverage-thresholds.test.ts", - "layer": "tooling", + "id": "apps/service-management/test/unit/hexagonalCodegen.test.ts", + "path": "apps/service-management/test/unit/hexagonalCodegen.test.ts", + "layer": "service-management/designer", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1350,9 +2244,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/run-monorepo-ci.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/run-monorepo-ci.test.ts", - "layer": "tooling", + "id": "apps/service-management/test/unit/pm2Lifecycle.startPaths.test.ts", + "path": "apps/service-management/test/unit/pm2Lifecycle.startPaths.test.ts", + "layer": "service-management/designer", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1360,9 +2254,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/classify-ci-context.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/classify-ci-context.test.ts", - "layer": "tooling", + "id": "apps/service-management/test/unit/canaDesignerStore.test.ts", + "path": "apps/service-management/test/unit/canaDesignerStore.test.ts", + "layer": "service-management/designer", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1370,9 +2264,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/check-affected-workspaces.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/check-affected-workspaces.test.ts", - "layer": "tooling", + "id": "apps/service-management/test/unit/hostMetrics.mockedHost.test.ts", + "path": "apps/service-management/test/unit/hostMetrics.mockedHost.test.ts", + "layer": "service-management/designer", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1380,9 +2274,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/run-integration-tests.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/run-integration-tests.test.ts", - "layer": "tooling", + "id": "apps/service-management/test/unit/sampleModel.test.ts", + "path": "apps/service-management/test/unit/sampleModel.test.ts", + "layer": "service-management/designer", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1390,9 +2284,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/check-workspace-coverage-policy.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/check-workspace-coverage-policy.test.ts", - "layer": "tooling", + "id": "apps/service-management/test/unit/darwinProcessDiskIo.test.ts", + "path": "apps/service-management/test/unit/darwinProcessDiskIo.test.ts", + "layer": "service-management/designer", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1400,9 +2294,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/report-sonar-findings.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/report-sonar-findings.test.ts", - "layer": "tooling", + "id": "apps/service-management/test/unit/designerPackageVersioning.test.ts", + "path": "apps/service-management/test/unit/designerPackageVersioning.test.ts", + "layer": "service-management/designer", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1410,9 +2304,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/check-agent-registry-source.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/check-agent-registry-source.test.ts", - "layer": "tooling", + "id": "apps/service-management/test/unit/renderGuard.test.ts", + "path": "apps/service-management/test/unit/renderGuard.test.ts", + "layer": "service-management/designer", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1420,9 +2314,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/check-workspace-quality.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/check-workspace-quality.test.ts", - "layer": "tooling", + "id": "apps/service-management/test/unit/designerExporters.test.ts", + "path": "apps/service-management/test/unit/designerExporters.test.ts", + "layer": "service-management/designer", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1430,9 +2324,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/check-pr-governance.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/check-pr-governance.test.ts", - "layer": "tooling", + "id": "apps/service-management-api/test/unit/startServiceManagementCatalogApi.test.ts", + "path": "apps/service-management-api/test/unit/startServiceManagementCatalogApi.test.ts", + "layer": "service-management/catalog-api", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1440,9 +2334,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/check-workspace-boundaries.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/check-workspace-boundaries.test.ts", - "layer": "tooling", + "id": "apps/service-management-api/test/unit/runtime/catalogCors.test.ts", + "path": "apps/service-management-api/test/unit/runtime/catalogCors.test.ts", + "layer": "service-management/catalog-api", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1450,9 +2344,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/check-test-integrity.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/check-test-integrity.test.ts", - "layer": "tooling", + "id": "apps/service-management-api/test/unit/ServiceManagementCatalogAPI.test.ts", + "path": "apps/service-management-api/test/unit/ServiceManagementCatalogAPI.test.ts", + "layer": "service-management/catalog-api", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1460,9 +2354,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/check-fail-closed.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/check-fail-closed.test.ts", - "layer": "tooling", + "id": "apps/service-management-api/test/unit/index.exports.test.ts", + "path": "apps/service-management-api/test/unit/index.exports.test.ts", + "layer": "service-management/catalog-api", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1470,9 +2364,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/generate-test-map.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/generate-test-map.test.ts", - "layer": "tooling", + "id": "apps/service-management-api/test/unit/Catalogs/CatalogModel.test.ts", + "path": "apps/service-management-api/test/unit/Catalogs/CatalogModel.test.ts", + "layer": "service-management/catalog-api", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1480,21 +2374,19 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/check-bun-version.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/check-bun-version.test.ts", - "layer": "tooling", + "id": "apps/service-management-api/test/unit/Catalogs/composition/composeCatalogsServices.test.ts", + "path": "apps/service-management-api/test/unit/Catalogs/composition/composeCatalogsServices.test.ts", + "layer": "service-management/catalog-api", "kind": "non-hexagonal", "type": "unit", "runner": "bun", - "ciRunner": "node", - "bunCompat": "pending-resetModules-or-native-gap", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/run-task-change-tests.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/run-task-change-tests.test.ts", - "layer": "tooling", + "id": "apps/service-management-api/test/unit/Catalogs/adapters/CatalogDataRepositoryDefaults.test.ts", + "path": "apps/service-management-api/test/unit/Catalogs/adapters/CatalogDataRepositoryDefaults.test.ts", + "layer": "service-management/catalog-api", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1502,9 +2394,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/sync-service-management-cana-bundle.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/sync-service-management-cana-bundle.test.ts", - "layer": "tooling", + "id": "apps/service-management-api/test/unit/Catalogs/adapters/in/http/CatalogController.test.ts", + "path": "apps/service-management-api/test/unit/Catalogs/adapters/in/http/CatalogController.test.ts", + "layer": "service-management/catalog-api", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1512,9 +2404,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/check-prevention-gates.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/check-prevention-gates.test.ts", - "layer": "tooling", + "id": "apps/service-management-api/test/unit/Catalogs/adapters/in/http/restapiHandlers.test.ts", + "path": "apps/service-management-api/test/unit/Catalogs/adapters/in/http/restapiHandlers.test.ts", + "layer": "service-management/catalog-api", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1522,9 +2414,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/check-release-governance.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/check-release-governance.test.ts", - "layer": "tooling", + "id": "apps/service-management-api/test/unit/Catalogs/index.exports.test.ts", + "path": "apps/service-management-api/test/unit/Catalogs/index.exports.test.ts", + "layer": "service-management/catalog-api", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1532,9 +2424,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/test-map-completeness.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/test-map-completeness.test.ts", - "layer": "tooling", + "id": "apps/service-management-api/test/unit/Catalogs/application/catalogBoundaries.test.ts", + "path": "apps/service-management-api/test/unit/Catalogs/application/catalogBoundaries.test.ts", + "layer": "service-management/catalog-api", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1542,9 +2434,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/check-package-suites.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/check-package-suites.test.ts", - "layer": "tooling", + "id": "apps/service-management-api/test/unit/Catalogs/service/CatalogService.test.ts", + "path": "apps/service-management-api/test/unit/Catalogs/service/CatalogService.test.ts", + "layer": "service-management/catalog-api", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1552,9 +2444,9 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/ci-cd/run-service-management-integration.test.ts", - "path": "apps/backend-template/test/unit/ci-cd/run-service-management-integration.test.ts", - "layer": "tooling", + "id": "apps/service-management-api/test/unit/Catalogs/domain/security/CatalogAuthorizationPolicy.test.ts", + "path": "apps/service-management-api/test/unit/Catalogs/domain/security/CatalogAuthorizationPolicy.test.ts", + "layer": "service-management/catalog-api", "kind": "non-hexagonal", "type": "unit", "runner": "bun", @@ -1562,468 +2454,631 @@ "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/ddd/valueObjects/DocumentValueObject.test.ts", - "path": "apps/backend-template/test/unit/modules/ddd/valueObjects/DocumentValueObject.test.ts", - "layer": "application", - "kind": "hexagonal", + "id": "apps/service-management-api/test/unit/Catalogs/domain/Model/Catalog.test.ts", + "path": "apps/service-management-api/test/unit/Catalogs/domain/Model/Catalog.test.ts", + "layer": "service-management/catalog-api", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/ddd/valueObjects/AddressValueObject.test.ts", - "path": "apps/backend-template/test/unit/modules/ddd/valueObjects/AddressValueObject.test.ts", - "layer": "application", - "kind": "hexagonal", + "id": "apps/frontend/test/unit/shell/breakpoints.test.ts", + "path": "apps/frontend/test/unit/shell/breakpoints.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/Users/factories.test.ts", - "path": "apps/backend-template/test/unit/modules/Users/factories.test.ts", - "layer": "application", - "kind": "hexagonal", + "id": "apps/frontend/test/unit/shell/toolbarWidgets.test.ts", + "path": "apps/frontend/test/unit/shell/toolbarWidgets.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/Users/composition/composeUserDeadLetterReplay.test.ts", - "path": "apps/backend-template/test/unit/modules/Users/composition/composeUserDeadLetterReplay.test.ts", - "layer": "application", - "kind": "hexagonal", + "id": "apps/frontend/test/unit/shell/viewport.test.ts", + "path": "apps/frontend/test/unit/shell/viewport.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/Users/composition/composeUsersAuthServices.test.ts", - "path": "apps/backend-template/test/unit/modules/Users/composition/composeUsersAuthServices.test.ts", - "layer": "application", - "kind": "hexagonal", + "id": "apps/frontend/test/unit/contracts/errors.test.ts", + "path": "apps/frontend/test/unit/contracts/errors.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", - "ciRunner": "node", - "bunCompat": "pending-resetModules-or-native-gap", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/Users/adapters/in/websocketapi/frameworks/socket-io/handlers/createWebSocketOperationHandler.test.ts", - "path": "apps/backend-template/test/unit/modules/Users/adapters/in/websocketapi/frameworks/socket-io/handlers/createWebSocketOperationHandler.test.ts", - "layer": "adapters/in", - "kind": "hexagonal", + "id": "apps/frontend/test/unit/contracts/labels.test.ts", + "path": "apps/frontend/test/unit/contracts/labels.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/Users/adapters/in/controller/controllers.test.ts", - "path": "apps/backend-template/test/unit/modules/Users/adapters/in/controller/controllers.test.ts", - "layer": "adapters/in", - "kind": "hexagonal", + "id": "apps/frontend/test/unit/contracts/apiClientBase.test.ts", + "path": "apps/frontend/test/unit/contracts/apiClientBase.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/Users/index.exports.test.ts", - "path": "apps/backend-template/test/unit/modules/Users/index.exports.test.ts", - "layer": "application", - "kind": "hexagonal", + "id": "apps/frontend/test/unit/contracts/sessionGuard.test.ts", + "path": "apps/frontend/test/unit/contracts/sessionGuard.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/Users/application/useCases.test.ts", - "path": "apps/backend-template/test/unit/modules/Users/application/useCases.test.ts", - "layer": "application", - "kind": "hexagonal", + "id": "apps/frontend/test/unit/contracts/browserShim.test.ts", + "path": "apps/frontend/test/unit/contracts/browserShim.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/Users/application/features/index.test.ts", - "path": "apps/backend-template/test/unit/modules/Users/application/features/index.test.ts", - "layer": "application", - "kind": "hexagonal", + "id": "apps/frontend/test/unit/contracts/appOperations.test.ts", + "path": "apps/frontend/test/unit/contracts/appOperations.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/Users/application/OrganizationUseCases.test.ts", - "path": "apps/backend-template/test/unit/modules/Users/application/OrganizationUseCases.test.ts", - "layer": "application", - "kind": "hexagonal", + "id": "apps/frontend/test/unit/contracts/formSchema.test.ts", + "path": "apps/frontend/test/unit/contracts/formSchema.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", - "ciRunner": "node", - "bunCompat": "pending-resetModules-or-native-gap", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/Users/application/service/UserService.lockRelease.test.ts", - "path": "apps/backend-template/test/unit/modules/Users/application/service/UserService.lockRelease.test.ts", - "layer": "application", - "kind": "hexagonal", + "id": "apps/frontend/test/unit/contracts/sessionGuardInstall.test.ts", + "path": "apps/frontend/test/unit/contracts/sessionGuardInstall.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/frontend/test/unit/contracts/usePermissions.test.ts", + "path": "apps/frontend/test/unit/contracts/usePermissions.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/frontend/test/unit/contracts/edgeCases.test.ts", + "path": "apps/frontend/test/unit/contracts/edgeCases.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/frontend/test/unit/contracts/listSchema.test.ts", + "path": "apps/frontend/test/unit/contracts/listSchema.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/frontend/test/unit/contracts/engineGuards.test.ts", + "path": "apps/frontend/test/unit/contracts/engineGuards.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/frontend/test/unit/contracts/rbac.test.ts", + "path": "apps/frontend/test/unit/contracts/rbac.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/frontend/test/unit/contracts/patternRules.test.ts", + "path": "apps/frontend/test/unit/contracts/patternRules.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "apps/frontend/test/unit/contracts/apiClient.test.ts", + "path": "apps/frontend/test/unit/contracts/apiClient.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/Users/application/service/UserService.test.ts", - "path": "apps/backend-template/test/unit/modules/Users/application/service/UserService.test.ts", - "layer": "application", - "kind": "hexagonal", + "id": "apps/frontend/test/unit/contracts/validation.test.ts", + "path": "apps/frontend/test/unit/contracts/validation.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/Users/application/service/UserProviderLocal.test.ts", - "path": "apps/backend-template/test/unit/modules/Users/application/service/UserProviderLocal.test.ts", - "layer": "application", - "kind": "hexagonal", + "id": "apps/frontend/test/unit/features/useSectionNotify.test.ts", + "path": "apps/frontend/test/unit/features/useSectionNotify.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/Users/application/service/UserService.organizationMembership.test.ts", - "path": "apps/backend-template/test/unit/modules/Users/application/service/UserService.organizationMembership.test.ts", - "layer": "application", - "kind": "hexagonal", + "id": "apps/frontend/test/unit/stores/profile.test.ts", + "path": "apps/frontend/test/unit/stores/profile.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/Users/application/service/OrganizationService.test.ts", - "path": "apps/backend-template/test/unit/modules/Users/application/service/OrganizationService.test.ts", - "layer": "application", - "kind": "hexagonal", + "id": "apps/frontend/test/unit/stores/auth.test.ts", + "path": "apps/frontend/test/unit/stores/auth.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/Users/application/service/UserService.sanitization.test.ts", - "path": "apps/backend-template/test/unit/modules/Users/application/service/UserService.sanitization.test.ts", - "layer": "application", - "kind": "hexagonal", + "id": "apps/frontend/test/unit/stores/notifications.test.ts", + "path": "apps/frontend/test/unit/stores/notifications.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/Users/application/service/AuthService.rbac.test.ts", - "path": "apps/backend-template/test/unit/modules/Users/application/service/AuthService.rbac.test.ts", - "layer": "application", - "kind": "hexagonal", + "id": "apps/frontend/test/unit/stores/network.test.ts", + "path": "apps/frontend/test/unit/stores/network.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/Users/application/service/AuthService.branches.test.ts", - "path": "apps/backend-template/test/unit/modules/Users/application/service/AuthService.branches.test.ts", - "layer": "application", - "kind": "hexagonal", + "id": "apps/frontend/test/unit/stores/profileOffline.test.ts", + "path": "apps/frontend/test/unit/stores/profileOffline.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/Users/application/service/AuthService.audit.test.ts", - "path": "apps/backend-template/test/unit/modules/Users/application/service/AuthService.audit.test.ts", - "layer": "application", - "kind": "hexagonal", + "id": "apps/frontend/test/unit/stores/tasks.test.ts", + "path": "apps/frontend/test/unit/stores/tasks.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/Users/application/service/UserService.deadLetter.test.ts", - "path": "apps/backend-template/test/unit/modules/Users/application/service/UserService.deadLetter.test.ts", - "layer": "application", - "kind": "hexagonal", + "id": "apps/frontend/test/unit/stores/entityStore.test.ts", + "path": "apps/frontend/test/unit/stores/entityStore.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/Users/application/events/registerUserMessageHandlers.test.ts", - "path": "apps/backend-template/test/unit/modules/Users/application/events/registerUserMessageHandlers.test.ts", - "layer": "application", - "kind": "hexagonal", + "id": "apps/frontend/test/unit/stores/theme.test.ts", + "path": "apps/frontend/test/unit/stores/theme.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/Users/application/events/events.test.ts", - "path": "apps/backend-template/test/unit/modules/Users/application/events/events.test.ts", - "layer": "application", - "kind": "hexagonal", + "id": "apps/frontend/test/unit/xcrud/useXCrud.test.ts", + "path": "apps/frontend/test/unit/xcrud/useXCrud.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/Users/application/events/registerUserEventListeners.test.ts", - "path": "apps/backend-template/test/unit/modules/Users/application/events/registerUserEventListeners.test.ts", - "layer": "application", - "kind": "hexagonal", + "id": "apps/frontend/test/unit/xcrud/useXCrudOffline.test.ts", + "path": "apps/frontend/test/unit/xcrud/useXCrudOffline.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/Users/lambdaRuntime.test.ts", - "path": "apps/backend-template/test/unit/modules/Users/lambdaRuntime.test.ts", - "layer": "adapters/in", - "kind": "hexagonal", + "id": "apps/frontend/test/unit/xcrud/xCrudFormat.test.ts", + "path": "apps/frontend/test/unit/xcrud/xCrudFormat.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/Users/domain/security/TenantAuthorizationPolicy.test.ts", - "path": "apps/backend-template/test/unit/modules/Users/domain/security/TenantAuthorizationPolicy.test.ts", - "layer": "domain", - "kind": "hexagonal", + "id": "apps/frontend/test/unit/i18n/i18n.test.ts", + "path": "apps/frontend/test/unit/i18n/i18n.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/Users/domain/security/Rbac.test.ts", - "path": "apps/backend-template/test/unit/modules/Users/domain/security/Rbac.test.ts", - "layer": "domain", - "kind": "hexagonal", + "id": "apps/frontend/test/unit/modules/manifest.test.ts", + "path": "apps/frontend/test/unit/modules/manifest.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/Users/domain/Model/User.test.ts", - "path": "apps/backend-template/test/unit/modules/Users/domain/Model/User.test.ts", - "layer": "domain", - "kind": "hexagonal", + "id": "apps/frontend/test/unit/data/pwa.test.ts", + "path": "apps/frontend/test/unit/data/pwa.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/Users/domain/Model/Organization.test.ts", - "path": "apps/backend-template/test/unit/modules/Users/domain/Model/Organization.test.ts", - "layer": "domain", - "kind": "hexagonal", + "id": "apps/frontend/test/unit/data/offlineLayer.test.ts", + "path": "apps/frontend/test/unit/data/offlineLayer.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/Catalogs/CatalogModel.test.ts", - "path": "apps/backend-template/test/unit/modules/Catalogs/CatalogModel.test.ts", - "layer": "application", - "kind": "hexagonal", + "id": "apps/frontend/test/unit/data/db.test.ts", + "path": "apps/frontend/test/unit/data/db.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/Catalogs/composition/composeCatalogsServices.test.ts", - "path": "apps/backend-template/test/unit/modules/Catalogs/composition/composeCatalogsServices.test.ts", - "layer": "application", - "kind": "hexagonal", + "id": "apps/frontend/test/unit/data/localRepository.test.ts", + "path": "apps/frontend/test/unit/data/localRepository.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/Catalogs/adapters/CatalogDataRepositoryDefaults.test.ts", - "path": "apps/backend-template/test/unit/modules/Catalogs/adapters/CatalogDataRepositoryDefaults.test.ts", - "layer": "application", - "kind": "hexagonal", + "id": "apps/frontend/test/unit/data/syncDelta.test.ts", + "path": "apps/frontend/test/unit/data/syncDelta.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/Catalogs/adapters/in/http/CatalogController.test.ts", - "path": "apps/backend-template/test/unit/modules/Catalogs/adapters/in/http/CatalogController.test.ts", - "layer": "application", - "kind": "hexagonal", + "id": "apps/frontend/test/unit/data/outboxExtra.test.ts", + "path": "apps/frontend/test/unit/data/outboxExtra.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/Catalogs/adapters/in/http/restapiHandlers.test.ts", - "path": "apps/backend-template/test/unit/modules/Catalogs/adapters/in/http/restapiHandlers.test.ts", - "layer": "application", - "kind": "hexagonal", + "id": "apps/frontend/test/unit/main.test.ts", + "path": "apps/frontend/test/unit/main.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/Catalogs/index.exports.test.ts", - "path": "apps/backend-template/test/unit/modules/Catalogs/index.exports.test.ts", - "layer": "application", - "kind": "hexagonal", + "id": "apps/frontend/test/unit/router/guardsExtra.test.ts", + "path": "apps/frontend/test/unit/router/guardsExtra.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/Catalogs/application/catalogBoundaries.test.ts", - "path": "apps/backend-template/test/unit/modules/Catalogs/application/catalogBoundaries.test.ts", - "layer": "application", - "kind": "hexagonal", + "id": "apps/frontend/test/unit/router/index.test.ts", + "path": "apps/frontend/test/unit/router/index.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/Catalogs/service/CatalogService.test.ts", - "path": "apps/backend-template/test/unit/modules/Catalogs/service/CatalogService.test.ts", - "layer": "application", - "kind": "hexagonal", + "id": "apps/frontend/test/unit/router/scopeGuard.test.ts", + "path": "apps/frontend/test/unit/router/scopeGuard.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/Catalogs/domain/security/CatalogAuthorizationPolicy.test.ts", - "path": "apps/backend-template/test/unit/modules/Catalogs/domain/security/CatalogAuthorizationPolicy.test.ts", - "layer": "application", - "kind": "hexagonal", + "id": "apps/frontend/test/unit/router/guard.test.ts", + "path": "apps/frontend/test/unit/router/guard.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites with app-scoped @/ aliases; a root jest batch cannot execute them.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/Catalogs/domain/Model/Catalog.test.ts", - "path": "apps/backend-template/test/unit/modules/Catalogs/domain/Model/Catalog.test.ts", - "layer": "application", - "kind": "hexagonal", + "id": "apps/frontend/test/component/Profile.test.ts", + "path": "apps/frontend/test/component/Profile.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites mounting shipped .vue components through @vue/test-utils + happy-dom (JUM-776); the app-scoped bunfig preload registers the SFC loader.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/port/core.test.ts", - "path": "apps/backend-template/test/unit/modules/port/core.test.ts", - "layer": "application", - "kind": "hexagonal", + "id": "apps/frontend/test/component/Shell.test.ts", + "path": "apps/frontend/test/component/Shell.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites mounting shipped .vue components through @vue/test-utils + happy-dom (JUM-776); the app-scoped bunfig preload registers the SFC loader.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/port/BaseService.test.ts", - "path": "apps/backend-template/test/unit/modules/port/BaseService.test.ts", - "layer": "application", - "kind": "hexagonal", + "id": "apps/frontend/test/component/OasFormField.test.ts", + "path": "apps/frontend/test/component/OasFormField.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites mounting shipped .vue components through @vue/test-utils + happy-dom (JUM-776); the app-scoped bunfig preload registers the SFC loader.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/port/relations.test.ts", - "path": "apps/backend-template/test/unit/modules/port/relations.test.ts", - "layer": "application", - "kind": "hexagonal", + "id": "apps/frontend/test/component/ModuleLayout.test.ts", + "path": "apps/frontend/test/component/ModuleLayout.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites mounting shipped .vue components through @vue/test-utils + happy-dom (JUM-776); the app-scoped bunfig preload registers the SFC loader.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/modules/port/index.exports.test.ts", - "path": "apps/backend-template/test/unit/modules/port/index.exports.test.ts", - "layer": "application", - "kind": "hexagonal", + "id": "apps/frontend/test/component/AppTaskbar.test.ts", + "path": "apps/frontend/test/component/AppTaskbar.test.ts", + "layer": "frontend", + "kind": "non-hexagonal", "type": "unit", "runner": "bun", + "script": "frontend:test:unit", + "reason": "bun:test suites mounting shipped .vue components through @vue/test-utils + happy-dom (JUM-776); the app-scoped bunfig preload registers the SFC loader.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/unit/sdk-clients/grpc/GrpcApiClient.test.ts", - "path": "apps/backend-template/test/unit/sdk-clients/grpc/GrpcApiClient.test.ts", - "layer": "tooling", + "id": "apps/frontend/test/component/XCrud.test.ts", + "path": "apps/frontend/test/component/XCrud.test.ts", + "layer": "frontend", "kind": "non-hexagonal", "type": "unit", "runner": "bun", - "ciRunner": "node", - "bunCompat": "pending-resetModules-or-native-gap", + "script": "frontend:test:unit", + "reason": "bun:test suites mounting shipped .vue components through @vue/test-utils + happy-dom (JUM-776); the app-scoped bunfig preload registers the SFC loader.", "tier": "gate", "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/integration/ServiceManagement/pm2Ecosystem.integration.test.ts", - "path": "apps/backend-template/test/integration/ServiceManagement/pm2Ecosystem.integration.test.ts", - "layer": "service-management/server", + "id": "apps/frontend/test/component/OfflineWidgets.test.ts", + "path": "apps/frontend/test/component/OfflineWidgets.test.ts", + "layer": "frontend", "kind": "non-hexagonal", - "type": "integration", - "adapter": "service-management", - "script": "test:integration:service-management", + "type": "unit", "runner": "bun", - "ciRunner": "node", + "script": "frontend:test:unit", + "reason": "bun:test suites mounting shipped .vue components through @vue/test-utils + happy-dom (JUM-776); the app-scoped bunfig preload registers the SFC loader.", "tier": "gate", - "timeoutMs": 120000 + "timeoutMs": 60000 }, { - "id": "apps/backend-template/test/integration/ServiceManagement/catalogSync.integration.test.ts", - "path": "apps/backend-template/test/integration/ServiceManagement/catalogSync.integration.test.ts", - "layer": "service-management/designer", + "id": "apps/backend-template/test/integration/ServiceManagement/pm2Ecosystem.integration.test.ts", + "path": "apps/backend-template/test/integration/ServiceManagement/pm2Ecosystem.integration.test.ts", + "layer": "service-management/server", "kind": "non-hexagonal", "type": "integration", "adapter": "service-management", @@ -2098,6 +3153,19 @@ "tier": "gate", "timeoutMs": 120000 }, + { + "id": "apps/backend-template/test/integration/ServiceManagement/statusOutcome.browser.integration.test.ts", + "path": "apps/backend-template/test/integration/ServiceManagement/statusOutcome.browser.integration.test.ts", + "layer": "service-management/designer", + "kind": "non-hexagonal", + "type": "integration", + "adapter": "service-management", + "script": "test:integration:service-management", + "runner": "bun", + "ciRunner": "node", + "tier": "gate", + "timeoutMs": 120000 + }, { "id": "apps/backend-template/test/integration/ServiceManagement/firstRun.browser.integration.test.ts", "path": "apps/backend-template/test/integration/ServiceManagement/firstRun.browser.integration.test.ts", @@ -2111,6 +3179,19 @@ "tier": "gate", "timeoutMs": 120000 }, + { + "id": "apps/backend-template/test/integration/ServiceManagement/accessibleNames.browser.integration.test.ts", + "path": "apps/backend-template/test/integration/ServiceManagement/accessibleNames.browser.integration.test.ts", + "layer": "service-management/designer", + "kind": "non-hexagonal", + "type": "integration", + "adapter": "service-management", + "script": "test:integration:service-management", + "runner": "bun", + "ciRunner": "node", + "tier": "gate", + "timeoutMs": 120000 + }, { "id": "apps/backend-template/test/integration/ServiceManagement/multiTabSync.browser.integration.test.ts", "path": "apps/backend-template/test/integration/ServiceManagement/multiTabSync.browser.integration.test.ts", @@ -2502,8 +3583,21 @@ "timeoutMs": 300000 }, { - "id": "apps/backend-template/test/integration/Express/Catalogs/catalogs.test.ts", - "path": "apps/backend-template/test/integration/Express/Catalogs/catalogs.test.ts", + "id": "apps/backend-template/test/integration/Express/purgeTombstones.test.ts", + "path": "apps/backend-template/test/integration/Express/purgeTombstones.test.ts", + "layer": "adapters/in", + "kind": "hexagonal", + "type": "integration", + "adapter": "express", + "script": "test:integration:express", + "runner": "bun", + "ciRunner": "node", + "tier": "gate", + "timeoutMs": 300000 + }, + { + "id": "apps/backend-template/test/integration/Express/get.async-context-metrics.test.ts", + "path": "apps/backend-template/test/integration/Express/get.async-context-metrics.test.ts", "layer": "adapters/in", "kind": "hexagonal", "type": "integration", @@ -3290,6 +4384,32 @@ "tier": "gate", "timeoutMs": 300000 }, + { + "id": "apps/service-management-api/test/integration/catalogSync.integration.test.ts", + "path": "apps/service-management-api/test/integration/catalogSync.integration.test.ts", + "layer": "service-management/catalog-api", + "kind": "non-hexagonal", + "type": "integration", + "adapter": "service-management-api", + "script": "test:integration:service-management", + "runner": "bun", + "ciRunner": "node", + "tier": "gate", + "timeoutMs": 120000 + }, + { + "id": "apps/service-management-api/test/integration/catalogs.http.integration.test.ts", + "path": "apps/service-management-api/test/integration/catalogs.http.integration.test.ts", + "layer": "service-management/catalog-api", + "kind": "non-hexagonal", + "type": "integration", + "adapter": "service-management-api", + "script": "test:integration:service-management", + "runner": "bun", + "ciRunner": "node", + "tier": "gate", + "timeoutMs": 120000 + }, { "id": "apps/backend-template/test/smoke/database/DatabaseDrivers.smoke.test.ts", "path": "apps/backend-template/test/smoke/database/DatabaseDrivers.smoke.test.ts", @@ -3535,6 +4655,16 @@ "tier": "gate", "timeoutMs": 60000 }, + { + "id": "packages/designer-core/test/fallback-branches.test.ts", + "path": "packages/designer-core/test/fallback-branches.test.ts", + "layer": "service-management/designer", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, { "id": "packages/designer-core/test/packaging.test.ts", "path": "packages/designer-core/test/packaging.test.ts", @@ -3684,6 +4814,36 @@ "tier": "gate", "timeoutMs": 60000 }, + { + "id": "packages/persistence-contracts/test/listQuery.test.ts", + "path": "packages/persistence-contracts/test/listQuery.test.ts", + "layer": "adapters/out+infra", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "packages/persistence-contracts/test/metricsQuery.test.ts", + "path": "packages/persistence-contracts/test/metricsQuery.test.ts", + "layer": "adapters/out+infra", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, + { + "id": "packages/persistence-contracts/test/purgeTombstones.test.ts", + "path": "packages/persistence-contracts/test/purgeTombstones.test.ts", + "layer": "adapters/out+infra", + "kind": "hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, { "id": "packages/runtime-infra/test/compileRuntimeInfra.test.ts", "path": "packages/runtime-infra/test/compileRuntimeInfra.test.ts", @@ -3704,6 +4864,16 @@ "tier": "gate", "timeoutMs": 120000 }, + { + "id": "packages/sdk-rest-client/test/RestApiClient.events.test.ts", + "path": "packages/sdk-rest-client/test/RestApiClient.events.test.ts", + "layer": "tooling", + "kind": "non-hexagonal", + "type": "unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000 + }, { "id": "packages/sdk-rest-client/test/RestApiClient.test.ts", "path": "packages/sdk-rest-client/test/RestApiClient.test.ts", @@ -3780,6 +4950,18 @@ "timeoutMs": 60000, "reason": "Runs under the website's own Jest config; the root runner cannot execute it directly." }, + { + "id": "apps/jumentix-website/components/architecture/HexagonalArchitectureMap.test.tsx", + "path": "apps/jumentix-website/components/architecture/HexagonalArchitectureMap.test.tsx", + "layer": "website", + "kind": "non-hexagonal", + "type": "unit", + "script": "website:test:unit", + "runner": "bun", + "tier": "gate", + "timeoutMs": 60000, + "reason": "Runs under the website's own Jest config; the root runner cannot execute it directly." + }, { "id": "apps/jumentix-website/components/cana-framework/CanaFrameworkPlayground.test.tsx", "path": "apps/jumentix-website/components/cana-framework/CanaFrameworkPlayground.test.tsx", @@ -4352,6 +5534,7 @@ "apps/backend-template/test", "apps/service-management", "apps/jumentix-website", + "apps/frontend", "packages", "ci-cd", "tooling" @@ -4382,9 +5565,9 @@ "shadowEnv": "JUMENTIX_GATE_V2_SHADOW" }, "stats": { - "unit": 226, - "integration": 97, + "unit": 325, + "integration": 101, "smoke": 2, - "suites": 373 + "suites": 477 } } diff --git a/tsconfig.build.json b/tsconfig.build.json index d0a609f81..df2090dc0 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,5 +1,12 @@ { "extends": "./tsconfig.json", + // JUM-785: `build:dev` (`tsc -p tsconfig.build.json`) typechecks the backend + // and package TypeScript that this root compiler owns. `apps/frontend/**` is + // excluded because that workspace owns its own check via `vue-tsc` + // (`bun run --cwd apps/frontend typecheck`). `apps/service-management/test/**` + // is excluded the same way as `apps/backend-template/test/**` — unit suites + // are exercised by Jest/`bun test`, not by the root emit build. Do not paper + // over alias/`import.meta` failures with `skipLibCheck` or blanket suppressions. "exclude": [ "node_modules", ".build", @@ -7,6 +14,8 @@ "packages/*/cypress/**/*", "**/*.cy.ts", "apps/backend-template/test/**/*", - "apps/jumentix-website/**/*" + "apps/jumentix-website/**/*", + "apps/frontend/**/*", + "apps/service-management/test/**/*" ] } diff --git a/tsconfig.example b/tsconfig.example index 65e508c77..9b466ce9a 100644 --- a/tsconfig.example +++ b/tsconfig.example @@ -33,6 +33,8 @@ "paths": { "@src": ["src"], "@src/*": ["src/*"], + "@service-management-api": ["apps/service-management-api/src"], + "@service-management-api/*": ["apps/service-management-api/src/*"], "@infra": ["infra"], "@infra/*": ["infra/*"], "@seed": ["seed"], diff --git a/tsconfig.json b/tsconfig.json index d967b2207..f6b329cb4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,6 +33,8 @@ "paths": { "@src": ["./apps/backend-template/src", "./src"], "@src/*": ["./apps/backend-template/src/*", "./src/*"], + "@service-management-api": ["./apps/service-management-api/src"], + "@service-management-api/*": ["./apps/service-management-api/src/*"], "@infra": ["./infra"], "@infra/*": ["./infra/*"], "@seed": ["./apps/backend-template/seed"],