diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5685bdb283..0344fa8d79 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: fetch-depth: 0 persist-credentials: false - - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 # This enables task distribution via Nx Cloud # Run this command as early as possible, before dependencies are installed @@ -78,6 +78,14 @@ jobs: ./node_modules/.bin/nubx --node nx run-many -t typecheck --projects="$projects" # Nx Affected runs only tasks affected by the changes in this PR/commit. Learn more: https://nx.dev/ci/features/affected - run: ./node_modules/.bin/nubx --node nx affected -t lint test + - name: Typecheck affected UI package + if: contains(steps.determine_affected.outputs.projects_csv, ',ui-kit,') + run: pnpm --dir libs/ui exec tsc --noEmit -p tsconfig.json + - name: Test affected UI asset replay + if: contains(steps.determine_affected.outputs.projects_csv, ',ui-kit,') + run: | + timeout 420 pnpm exec playwright install --only-shell chromium + pnpm --dir libs/ui test:visual-assets - name: Check affected UI package output if: contains(steps.determine_affected.outputs.projects_csv, ',ui-kit,') run: pnpm --dir libs/ui check:package diff --git a/.github/workflows/storybook-a11y-baseline.yml b/.github/workflows/storybook-a11y-baseline.yml index 56f3227e89..030c6c93f7 100644 --- a/.github/workflows/storybook-a11y-baseline.yml +++ b/.github/workflows/storybook-a11y-baseline.yml @@ -8,7 +8,7 @@ on: - "libs/ui/.storybook/**" - "libs/ui/assets/**" - "libs/ui/package.json" - - "libs/ui/playwright.config.cts" + - "libs/ui/playwright.config.mts" - "libs/ui/postcss.config.mjs" - "libs/ui/project.json" - "libs/ui/rslib.config.ts" @@ -20,119 +20,59 @@ on: - ".github/workflows/storybook-a11y-baseline.yml" - "package.json" - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" workflow_dispatch: permissions: contents: read jobs: - build-storybook: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - fetch-depth: 1 - persist-credentials: false - - - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4 - - - name: Set up Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: 24 - cache: pnpm - - - name: Install dependencies - run: pnpm install --frozen-lockfile --prefer-offline --filter=@techsio/ui-kit... - - - name: Build Storybook (UI) - run: pnpm -C libs/ui run build:storybook - - - name: Upload Storybook static - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: storybook-static - path: libs/ui/storybook-static - retention-days: 7 - compression-level: 6 - storybook-a11y-baseline: runs-on: ubuntu-latest - needs: build-storybook - strategy: - fail-fast: false - matrix: - theme: [light, dark] - env: - A11Y_REPORT_FAIL_ON_VIOLATIONS: "false" - A11Y_REPORT_WRITE_JUNIT: "true" - A11Y_REPORT_WAIT_MS: "30000" steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 1 persist-credentials: false - - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - name: Set up Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 24 + node-version: 26.11.1 cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile --prefer-offline --filter='.' --filter='@techsio/ui-kit...' - name: Install Playwright browsers - # Storybook's test runner can use a different Playwright version than - # the workspace root. Install its exact matching browser revision. - run: timeout 420 node scripts/install-storybook-playwright.mjs libs/ui install --only-shell chromium + # pnpm-workspace.yaml pins the test runner's Playwright to the + # workspace version, so the root CLI installs the matching browser. + run: timeout 420 pnpm exec playwright install --only-shell chromium - - name: Download Storybook static - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: storybook-static - path: libs/ui/storybook-static - - - name: Normalize Storybook static path - run: | - if [ -d "libs/ui/storybook-static/libs/ui/storybook-static" ]; then - rm -rf libs/ui/storybook-static.tmp - mv libs/ui/storybook-static/libs/ui/storybook-static libs/ui/storybook-static.tmp - rm -rf libs/ui/storybook-static - mv libs/ui/storybook-static.tmp libs/ui/storybook-static - fi - - - name: Run a11y tests (${{ matrix.theme }} theme) + # Same entry point as local runs: build, serve, then test both themes. + - name: Run a11y tests (light and dark themes) id: a11y - continue-on-error: true env: - A11Y_REPORT_OUTPUT_DIR: a11y-report/${{ matrix.theme }} - run: | - mkdir -p "libs/ui/a11y-report/${{ matrix.theme }}" - python3 -m http.server 6006 --directory libs/ui/storybook-static & - SERVER_PID=$! - cleanup() { - kill "${SERVER_PID}" >/dev/null 2>&1 || true - } - trap cleanup EXIT - for _ in {1..20}; do - if curl -sf http://127.0.0.1:6006/ >/dev/null; then - break - fi - sleep 1 - done - if ! curl -sf http://127.0.0.1:6006/ >/dev/null; then - echo "Storybook server failed to start within timeout." >&2 - exit 1 - fi - pnpm -C libs/ui exec test-storybook --url "http://127.0.0.1:6006/?globals=theme:${{ matrix.theme }}" --config-dir .storybook --maxWorkers 2 --testTimeout 60000 + A11Y_REPORT_FAIL_ON_VIOLATIONS: "false" + run: pnpm -C libs/ui storybook:a11y + + # The PR workflow downloads - and reads report.json at its root. + - name: Upload baseline report (light) + if: always() && steps.a11y.outputs.report-dir != '' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: storybook-a11y-baseline-light + path: ${{ steps.a11y.outputs.report-dir }}/light/report.json + if-no-files-found: ignore + retention-days: 30 - - name: Upload baseline report (${{ matrix.theme }}) - if: always() && hashFiles(format('libs/ui/a11y-report/{0}/report.json', matrix.theme)) != '' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + - name: Upload baseline report (dark) + if: always() && steps.a11y.outputs.report-dir != '' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: storybook-a11y-baseline-${{ matrix.theme }} - path: libs/ui/a11y-report/${{ matrix.theme }}/report.json + name: storybook-a11y-baseline-dark + path: ${{ steps.a11y.outputs.report-dir }}/dark/report.json + if-no-files-found: ignore retention-days: 30 diff --git a/.github/workflows/storybook-a11y.yml b/.github/workflows/storybook-a11y.yml index db25b4cde3..86dfc6ded0 100644 --- a/.github/workflows/storybook-a11y.yml +++ b/.github/workflows/storybook-a11y.yml @@ -8,7 +8,7 @@ on: - ".github/workflows/storybook-a11y-baseline.yml" - "package.json" - "pnpm-lock.yaml" - - "scripts/install-storybook-playwright.mjs" + - "pnpm-workspace.yaml" workflow_dispatch: inputs: failOnViolations: @@ -25,7 +25,7 @@ jobs: outputs: should-run: ${{ steps.changed-paths.outputs.should-run }} steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false @@ -46,11 +46,11 @@ jobs: .github/workflows/storybook-a11y-baseline.yml|\ package.json|\ pnpm-lock.yaml|\ - scripts/install-storybook-playwright.mjs|\ + pnpm-workspace.yaml|\ libs/ui/.storybook/**|\ libs/ui/assets/**|\ libs/ui/package.json|\ - libs/ui/playwright.config.cts|\ + libs/ui/playwright.config.mts|\ libs/ui/postcss.config.mjs|\ libs/ui/project.json|\ libs/ui/rslib.config.ts|\ @@ -79,7 +79,7 @@ jobs: checks: write uses: TechsioCZ/storybook-addons/.github/workflows/storybook-a11y.yml@b52eaf44688051b19eb989fab7b6ab721ce1a2d1 # storybook-a11y-workflow-v0.1.9 with: - node-version: "24" + node-version: "26.11.1" pnpm-version: "" working-directory: libs/ui # Install the browser as part of the dependency step. The reusable @@ -91,20 +91,26 @@ jobs: # is all the headless a11y test run needs, and avoids the stuck extract. # `timeout` is a safety net so a future regression fails fast instead of # hanging. ubuntu-latest already ships Chromium's system libs (no - # --with-deps needed). Install through Storybook's own Playwright so the - # downloaded browser revision always matches the test-runner runtime. - install-command: pnpm install --frozen-lockfile --prefer-offline --filter='.' --filter='@techsio/ui-kit...' && timeout 420 node scripts/install-storybook-playwright.mjs {{workdir}} install --only-shell chromium + # --with-deps needed). pnpm-workspace.yaml pins the test runner's + # Playwright to the workspace version, so the root CLI installs the + # browser revision the test runner launches. + install-command: pnpm install --frozen-lockfile --prefer-offline --filter='.' --filter='@techsio/ui-kit...' && timeout 420 pnpm exec playwright install --only-shell chromium build-command: pnpm -C {{workdir}} run build:storybook + # The reusable workflow evaluates this command inside its theme loop. + # The test timeout comes from .storybook/test-runner-jest.config.mjs. + test-command: >- + A11Y_STORYBOOK_MODE="$theme" pnpm -C {{workdir}} exec test-storybook + --index-json --url "{{url}}" --config-dir {{configDir}} + --maxWorkers {{maxWorkers}} storybook-static-dir: storybook-static storybook-config-dir: .storybook themes: '["light","dark"]' - globals-template: 'theme:{{theme}}' + globals-template: "" a11y-output-dir: a11y-report fail-on-violations: ${{ fromJSON(github.event.inputs.failOnViolations || vars.A11Y_REPORT_FAIL_ON_VIOLATIONS || 'false') }} write-junit: true wait-ms: 30000 # 30s based on observed Storybook startup time in CI test-workers: 2 # keep load stable on GitHub-hosted runners - test-timeout: 60000 # 60s to avoid flakes in slower CI install-playwright: false # browser installed via install-command (without --with-deps) to avoid the apt hang upload-artifacts: true post-pr-comment: true diff --git a/.github/workflows/ui-release.yml b/.github/workflows/ui-release.yml index 5613688ef1..9e68677627 100644 --- a/.github/workflows/ui-release.yml +++ b/.github/workflows/ui-release.yml @@ -27,24 +27,24 @@ jobs: is_affected: ${{ steps.check.outputs.is_affected }} steps: - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false - - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - name: Set up Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 24 + node-version: 26.11.1 cache: 'pnpm' - name: Install dependencies run: pnpm install --frozen-lockfile --prefer-offline - name: Set Nx SHAs - uses: nrwl/nx-set-shas@3e9ad7370203c1e93d109be57f3b72eb0eb511b1 # v4 + uses: nrwl/nx-set-shas@afb73a62d26e41464e9254689e1fd6122ee683c1 # v5.0.1 with: main-branch-name: master @@ -80,17 +80,17 @@ jobs: id-token: write steps: - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false - - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - name: Set up Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 24 + node-version: 26.11.1 registry-url: https://registry.npmjs.org scope: '@techsio' diff --git a/.gitignore b/.gitignore index b4f2917904..8f74db54d4 100644 --- a/.gitignore +++ b/.gitignore @@ -19,12 +19,8 @@ node_modules .settings/ *.sublime-workspace -# IDE - VSCode +# Local editor configuration .vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json # misc /.sass-cache diff --git a/.mise.toml b/.mise.toml index 7f116d72d0..c4f419fba3 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,7 +1,7 @@ [tools] "aqua:mikefarah/yq" = "4.45.4" "aqua:jqlang/jq" = "1.8.1" -node = "24" +node = "26.10.0" [tasks.dev] description = "Incremental local startup after `dev:init`: resources -> meili provisioning -> backend phase -> frontend phase" @@ -67,9 +67,9 @@ run = "bash ./scripts/dev/mise-dev-helpers.sh local-helper up adminer" description = "Stop and remove the local Adminer helper" run = "bash ./scripts/dev/mise-dev-helpers.sh local-helper down adminer" -[tasks."dev:corepack:update"] -description = "Update Corepack inside the repo pnpm toolchain container" -run = "bash ./scripts/dev/run-pnpm-toolchain.sh corepack up" +[tasks."dev:pnpm:update"] +description = "Update the workspace's pinned pnpm version inside the toolchain container" +run = "bash ./scripts/dev/run-pnpm-toolchain.sh pnpm self-update" [tasks."dev:install"] description = "Install workspace dependencies inside the repo pnpm toolchain container" diff --git a/.prototools b/.prototools index 9f4dd3cdb0..ab3e9c78ff 100644 --- a/.prototools +++ b/.prototools @@ -1,4 +1,4 @@ bun = "1.3.3" -node = "~25" +node = "26.10.0" npm = "~11" -pnpm = "~10" +pnpm = "12.9.0" diff --git a/.vscode/extensions.json b/.vscode/extensions.json deleted file mode 100644 index d26dc862c0..0000000000 --- a/.vscode/extensions.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "recommendations": ["nrwl.angular-console", "ms-playwright.playwright"] -} diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 5cdb6b65ec..0000000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "conventionalCommits.scopes": ["deps"], - "tailwindCSS.experimental.classRegex": [ - ["([\"'`][^\"'`]*.*?[\"'`])", "[\"'`]([^\"'`]*).*?[\"'`]"] - ], - "typescript.tsdk": "node_modules/typescript/lib", - "editor.defaultFormatter": "biomejs.biome", - "editor.formatOnSave": true, - "editor.formatOnPaste": true, - "emmet.showExpandedAbbreviation": "never", - "editor.codeActionsOnSave": { - "source.fixAll.biome": "explicit", - "source.organizeImports.biome": "explicit" - }, - "[typescript]": { - "editor.defaultFormatter": "biomejs.biome" - }, - "[json]": { - "editor.defaultFormatter": "biomejs.biome" - }, - "[javascript]": { - "editor.defaultFormatter": "biomejs.biome" - }, - "[jsonc]": { - "editor.defaultFormatter": "biomejs.biome" - }, - "[typescriptreact]": { - "editor.defaultFormatter": "biomejs.biome" - }, - "files.watcherExclude": { - "**/.git/objects/**": true, - "**/.git/subtree-cache/**": true, - "**/.hg/store/**": true, - "**/.git/**": true, - "**/node_modules/**": true, - "**/.vscode/**": true - } -} diff --git a/apps/herbatika/package.json b/apps/herbatika/package.json index 8508f12dc8..150a889d75 100644 --- a/apps/herbatika/package.json +++ b/apps/herbatika/package.json @@ -59,7 +59,7 @@ "server-only": "^0.0.1" }, "devDependencies": { - "@biomejs/biome": "2.3.8", + "@biomejs/biome": "^2.5.15", "@iconify/tailwind4": "^1.0.6", "@playwright/test": "1.61.0", "@tailwindcss/postcss": "^4", diff --git a/apps/herbatika/public/file.svg b/apps/herbatika/public/file.svg index b90e45db07..b5ffb56279 100644 --- a/apps/herbatika/public/file.svg +++ b/apps/herbatika/public/file.svg @@ -1,13 +1,72 @@ - + Obrázok nie je dostupný - Produktový obrázok sa nepodarilo načítať. - - - - - - - - Obrázok nie je - dostupný + + Produktový obrázok sa nepodarilo načítať. + + + + + + + + + + Obrázok nie je + + + dostupný + diff --git a/apps/herbatika/public/globe.svg b/apps/herbatika/public/globe.svg index 567f17b0d7..77d55e8052 100644 --- a/apps/herbatika/public/globe.svg +++ b/apps/herbatika/public/globe.svg @@ -1 +1,13 @@ - \ No newline at end of file + + + + + + + + diff --git a/apps/herbatika/public/herbatika-logo.svg b/apps/herbatika/public/herbatika-logo.svg index 100e0af4dc..6a914cf1f4 100644 --- a/apps/herbatika/public/herbatika-logo.svg +++ b/apps/herbatika/public/herbatika-logo.svg @@ -1,10 +1,30 @@ - + - + - + @@ -18,9 +38,21 @@ - + - + @@ -28,21 +60,72 @@ - - - - - + + + + + - + herbatica - + Zdravie. Krása. - + Príroda! diff --git a/apps/herbatika/public/next.svg b/apps/herbatika/public/next.svg index 5174b28c56..9634879a83 100644 --- a/apps/herbatika/public/next.svg +++ b/apps/herbatika/public/next.svg @@ -1 +1,10 @@ - \ No newline at end of file + + + + diff --git a/apps/herbatika/public/vercel.svg b/apps/herbatika/public/vercel.svg index 7705396033..da0798d40a 100644 --- a/apps/herbatika/public/vercel.svg +++ b/apps/herbatika/public/vercel.svg @@ -1 +1,3 @@ - \ No newline at end of file + + + diff --git a/apps/herbatika/public/window.svg b/apps/herbatika/public/window.svg index b2b2a44f6e..8f6660f4b4 100644 --- a/apps/herbatika/public/window.svg +++ b/apps/herbatika/public/window.svg @@ -1 +1,8 @@ - \ No newline at end of file + + + diff --git a/apps/herbatika/src/app/api/blog/route.test.ts b/apps/herbatika/src/app/api/blog/route.test.ts index 2502f0d2d3..7e854591ec 100644 --- a/apps/herbatika/src/app/api/blog/route.test.ts +++ b/apps/herbatika/src/app/api/blog/route.test.ts @@ -46,26 +46,27 @@ describe("blog listing API localization", () => { vi.restoreAllMocks() }) - it.each( - MARKET_CASES - )("localizes temporary failure from %s", async (host, market, locale, message) => { - mocks.resolveBinding.mockReturnValue({ locale, market }) - mocks.fetchListing.mockRejectedValue( - new Error("private CMS credential failure") - ) - vi.spyOn(console, "error").mockImplementation(() => { - // Expected CMS failure is intentionally silent in this route test. - }) + it.each(MARKET_CASES)( + "localizes temporary failure from %s", + async (host, market, locale, message) => { + mocks.resolveBinding.mockReturnValue({ locale, market }) + mocks.fetchListing.mockRejectedValue( + new Error("private CMS credential failure") + ) + vi.spyOn(console, "error").mockImplementation(() => { + // Expected CMS failure is intentionally silent in this route test. + }) - const response = await GET(requestFor(host)) + const response = await GET(requestFor(host)) - expect(response.status).toBe(502) - expect(response.headers.get("cache-control")).toBe("private, no-store") - await expect(response.json()).resolves.toEqual({ message }) - expect(mocks.fetchListing).toHaveBeenCalledWith( - expect.objectContaining({ locale }) - ) - }) + expect(response.status).toBe(502) + expect(response.headers.get("cache-control")).toBe("private, no-store") + await expect(response.json()).resolves.toEqual({ message }) + expect(mocks.fetchListing).toHaveBeenCalledWith( + expect.objectContaining({ locale }) + ) + } + ) it("returns a generic 421 for an unknown Host", async () => { mocks.resolveBinding.mockReturnValue(null) diff --git a/apps/herbatika/src/app/api/internal/url-registry/content-projections/route.test.ts b/apps/herbatika/src/app/api/internal/url-registry/content-projections/route.test.ts index 5d7fea0638..c530122855 100644 --- a/apps/herbatika/src/app/api/internal/url-registry/content-projections/route.test.ts +++ b/apps/herbatika/src/app/api/internal/url-registry/content-projections/route.test.ts @@ -58,12 +58,15 @@ describe("URL registry content projection route wiring", () => { it.each([ ["0", "1"], ["1", "0"], - ])("stays disabled unless both gates are enabled (%s/%s)", async (registry, projection) => { - vi.stubEnv("URL_REGISTRY_ENABLED", registry) - vi.stubEnv("URL_REGISTRY_CONTENT_PROJECTION_ENABLED", projection) + ])( + "stays disabled unless both gates are enabled (%s/%s)", + async (registry, projection) => { + vi.stubEnv("URL_REGISTRY_ENABLED", registry) + vi.stubEnv("URL_REGISTRY_CONTENT_PROJECTION_ENABLED", projection) - await POST(request) + await POST(request) - expect(mocks.handle.mock.calls[0]?.[1]).toMatchObject({ enabled: false }) - }) + expect(mocks.handle.mock.calls[0]?.[1]).toMatchObject({ enabled: false }) + } + ) }) diff --git a/apps/herbatika/src/app/api/internal/url-registry/product-lifecycle/route.test.ts b/apps/herbatika/src/app/api/internal/url-registry/product-lifecycle/route.test.ts index c37c4bd1aa..933ba6e7dc 100644 --- a/apps/herbatika/src/app/api/internal/url-registry/product-lifecycle/route.test.ts +++ b/apps/herbatika/src/app/api/internal/url-registry/product-lifecycle/route.test.ts @@ -97,16 +97,19 @@ describe("product lifecycle route wiring", () => { it.each([ ["0", "1"], ["1", "0"], - ])("stays hidden unless both feature gates are enabled (%s/%s)", async (registryEnabled, lifecycleEnabled) => { - vi.stubEnv("URL_REGISTRY_ENABLED", registryEnabled) - vi.stubEnv("URL_REGISTRY_PRODUCT_LIFECYCLE_ENABLED", lifecycleEnabled) - const { POST } = await import("./route") + ])( + "stays hidden unless both feature gates are enabled (%s/%s)", + async (registryEnabled, lifecycleEnabled) => { + vi.stubEnv("URL_REGISTRY_ENABLED", registryEnabled) + vi.stubEnv("URL_REGISTRY_PRODUCT_LIFECYCLE_ENABLED", lifecycleEnabled) + const { POST } = await import("./route") - const response = await POST(request()) + const response = await POST(request()) - expect(response.status).toBe(404) - expect(mocks.getUrlRegistryRuntime).not.toHaveBeenCalled() - }) + expect(response.status).toBe(404) + expect(mocks.getUrlRegistryRuntime).not.toHaveBeenCalled() + } + ) it("accepts only the dedicated lifecycle token", async () => { const { POST } = await import("./route") diff --git a/apps/herbatika/src/app/api/search-autocomplete/route.test.ts b/apps/herbatika/src/app/api/search-autocomplete/route.test.ts index d2d2f0a07c..475f8e7f4d 100644 --- a/apps/herbatika/src/app/api/search-autocomplete/route.test.ts +++ b/apps/herbatika/src/app/api/search-autocomplete/route.test.ts @@ -89,27 +89,28 @@ describe("search autocomplete market authority", () => { }) }) - it.each( - MARKET_CASES - )("derives $market country, currency, locale, and region from $host", async (marketCase) => { - const response = await GET(requestFor(marketCase.host)) + it.each(MARKET_CASES)( + "derives $market country, currency, locale, and region from $host", + async (marketCase) => { + const response = await GET(requestFor(marketCase.host)) - expect(response.status).toBe(200) - expect(response.headers.get("cache-control")).toBe( - "private, no-store, max-age=0" - ) - expect(response.headers.get("vary")).toBe("Host, Cookie") - expect(mocks.resolveMarketBinding).toHaveBeenCalledWith(marketCase.host) - expect(mocks.fetchSearchAutocomplete).toHaveBeenCalledWith({ - authToken: "private.session.token", - countryCode: marketCase.countryCode.toLowerCase(), - currencyCode: marketCase.currencyCode, - locale: marketCase.locale, - market: marketCase.market, - query: "herbs", - regionId: `reg_${marketCase.market}`, - }) - }) + expect(response.status).toBe(200) + expect(response.headers.get("cache-control")).toBe( + "private, no-store, max-age=0" + ) + expect(response.headers.get("vary")).toBe("Host, Cookie") + expect(mocks.resolveMarketBinding).toHaveBeenCalledWith(marketCase.host) + expect(mocks.fetchSearchAutocomplete).toHaveBeenCalledWith({ + authToken: "private.session.token", + countryCode: marketCase.countryCode.toLowerCase(), + currencyCode: marketCase.currencyCode, + locale: marketCase.locale, + market: marketCase.market, + query: "herbs", + regionId: `reg_${marketCase.market}`, + }) + } + ) it("ignores a foreign market parameter set for a trusted host", async () => { const response = await GET( diff --git a/apps/herbatika/src/app/api/storefront-auth/_messages.test.ts b/apps/herbatika/src/app/api/storefront-auth/_messages.test.ts index 7f79d5b55d..a7fcac4760 100644 --- a/apps/herbatika/src/app/api/storefront-auth/_messages.test.ts +++ b/apps/herbatika/src/app/api/storefront-auth/_messages.test.ts @@ -30,15 +30,14 @@ describe("resolveStorefrontAuthMessages", () => { ) }) - it.each([ - "de", - "__proto__", - "toString", - ])("fails closed for unsupported runtime market %s", (market) => { - expect(() => - resolveStorefrontAuthMessages( - market as Parameters[0] - ) - ).toThrow("Unsupported storefront auth market") - }) + it.each(["de", "__proto__", "toString"])( + "fails closed for unsupported runtime market %s", + (market) => { + expect(() => + resolveStorefrontAuthMessages( + market as Parameters[0] + ) + ).toThrow("Unsupported storefront auth market") + } + ) }) diff --git a/apps/herbatika/src/app/api/storefront-medusa/[...path]/route.test.ts b/apps/herbatika/src/app/api/storefront-medusa/[...path]/route.test.ts index feee74e71b..5c3c60e4f3 100644 --- a/apps/herbatika/src/app/api/storefront-medusa/[...path]/route.test.ts +++ b/apps/herbatika/src/app/api/storefront-medusa/[...path]/route.test.ts @@ -173,90 +173,91 @@ describe("storefront Medusa gateway", () => { expect(skHeaders.get("x-publishable-api-key")).toBe("pk_server_sk_secret") }) - it.each( - MARKET_CASES - )("allows only the exact path-bound region for %s", async (host, binding) => { - resolveBinding.mockImplementation(resolveByHost) - const upstreamFetch = vi - .fn() - .mockResolvedValue(Response.json({ region: { id: binding.regionId } })) - vi.stubGlobal("fetch", upstreamFetch) - - const response = await callGateway(`/store/regions/${binding.regionId}`, { - host, - }) + it.each(MARKET_CASES)( + "allows only the exact path-bound region for %s", + async (host, binding) => { + resolveBinding.mockImplementation(resolveByHost) + const upstreamFetch = vi + .fn() + .mockResolvedValue(Response.json({ region: { id: binding.regionId } })) + vi.stubGlobal("fetch", upstreamFetch) - expect(response.status).toBe(200) - expect(upstreamFetch).toHaveBeenCalledOnce() - }) + const response = await callGateway(`/store/regions/${binding.regionId}`, { + host, + }) - it.each( - FOREIGN_MARKET_PAIRS - )("rejects a foreign path-bound region on %s", async ([host], [ - , - foreignBinding, - ]) => { - resolveBinding.mockImplementation(resolveByHost) - const upstreamFetch = vi.fn() - vi.stubGlobal("fetch", upstreamFetch) + expect(response.status).toBe(200) + expect(upstreamFetch).toHaveBeenCalledOnce() + } + ) + + it.each(FOREIGN_MARKET_PAIRS)( + "rejects a foreign path-bound region on %s", + async ([host], [, foreignBinding]) => { + resolveBinding.mockImplementation(resolveByHost) + const upstreamFetch = vi.fn() + vi.stubGlobal("fetch", upstreamFetch) + + const response = await callGateway( + `/store/regions/${foreignBinding.regionId}`, + { host } + ) - const response = await callGateway( - `/store/regions/${foreignBinding.regionId}`, - { host } - ) + expect(response.status).toBe(400) + expect(upstreamFetch).not.toHaveBeenCalled() + } + ) + + it.each(MARKET_CASES)( + "preflights cart ids against the exact market on %s", + async (host, binding) => { + resolveBinding.mockImplementation(resolveByHost) + const cartId = `cart_${binding.market}` + const upstreamFetch = vi + .fn() + .mockResolvedValueOnce( + Response.json({ + cart: { id: cartId, sales_channel_id: binding.salesChannelId }, + }) + ) + .mockResolvedValueOnce(Response.json({ cart: { id: cartId } })) + vi.stubGlobal("fetch", upstreamFetch) - expect(response.status).toBe(400) - expect(upstreamFetch).not.toHaveBeenCalled() - }) + const response = await callGateway(`/store/carts/${cartId}`, { host }) - it.each( - MARKET_CASES - )("preflights cart ids against the exact market on %s", async (host, binding) => { - resolveBinding.mockImplementation(resolveByHost) - const cartId = `cart_${binding.market}` - const upstreamFetch = vi - .fn() - .mockResolvedValueOnce( + expect(response.status).toBe(200) + expect(upstreamFetch).toHaveBeenCalledTimes(2) + const [authorityUrl, authorityInit] = upstreamFetch.mock.calls[0] + expect(new URL(String(authorityUrl)).searchParams.get("fields")).toBe( + "id,sales_channel_id" + ) + expect( + new Headers(authorityInit?.headers).get("x-publishable-api-key") + ).toBe(binding.publishableApiKey) + } + ) + + it.each(FOREIGN_MARKET_PAIRS)( + "rejects a foreign cart id on %s", + async ([host], [, foreignBinding]) => { + resolveBinding.mockImplementation(resolveByHost) + const cartId = `cart_${foreignBinding.market}` + const upstreamFetch = vi.fn().mockResolvedValue( Response.json({ - cart: { id: cartId, sales_channel_id: binding.salesChannelId }, + cart: { + id: cartId, + sales_channel_id: foreignBinding.salesChannelId, + }, }) ) - .mockResolvedValueOnce(Response.json({ cart: { id: cartId } })) - vi.stubGlobal("fetch", upstreamFetch) - - const response = await callGateway(`/store/carts/${cartId}`, { host }) - - expect(response.status).toBe(200) - expect(upstreamFetch).toHaveBeenCalledTimes(2) - const [authorityUrl, authorityInit] = upstreamFetch.mock.calls[0] - expect(new URL(String(authorityUrl)).searchParams.get("fields")).toBe( - "id,sales_channel_id" - ) - expect( - new Headers(authorityInit?.headers).get("x-publishable-api-key") - ).toBe(binding.publishableApiKey) - }) - - it.each(FOREIGN_MARKET_PAIRS)("rejects a foreign cart id on %s", async ([ - host, - ], [, foreignBinding]) => { - resolveBinding.mockImplementation(resolveByHost) - const cartId = `cart_${foreignBinding.market}` - const upstreamFetch = vi.fn().mockResolvedValue( - Response.json({ - cart: { - id: cartId, - sales_channel_id: foreignBinding.salesChannelId, - }, - }) - ) - vi.stubGlobal("fetch", upstreamFetch) + vi.stubGlobal("fetch", upstreamFetch) - const response = await callGateway(`/store/carts/${cartId}`, { host }) + const response = await callGateway(`/store/carts/${cartId}`, { host }) - expect(response.status).toBe(404) - expect(upstreamFetch).toHaveBeenCalledOnce() - }) + expect(response.status).toBe(404) + expect(upstreamFetch).toHaveBeenCalledOnce() + } + ) it("prefers exact signed cart authority and forwards it without exposing the cookie", async () => { resolveBinding.mockImplementation(resolveByHost) @@ -305,212 +306,217 @@ describe("storefront Medusa gateway", () => { expect(upstreamFetch).toHaveBeenCalledOnce() }) - it.each( - MARKET_CASES - )("binds payment-collection creation to the exact signed cart on %s", async (host, binding) => { - resolveBinding.mockImplementation(resolveByHost) - const cartId = `cart_${binding.market}` - const upstreamFetch = vi - .fn() - .mockResolvedValueOnce(Response.json({ cart_id: cartId })) - .mockResolvedValueOnce( - Response.json({ - cart: { - customer_id: null, - id: cartId, - region_id: binding.regionId, - sales_channel_id: binding.salesChannelId, - }, - }) - ) - .mockResolvedValueOnce( - Response.json({ - payment_collection: { id: `paycol_${binding.market}` }, - }) - ) - vi.stubGlobal("fetch", upstreamFetch) - - const response = await callGateway("/store/payment-collections", { - body: JSON.stringify({ cart_id: cartId }), - headers: { - "content-type": "application/json", - cookie: `__Host-herbatika-cart-session=Signed.${binding.market}.Cart`, - }, - host, - method: "POST", - }) - - expect(response.status).toBe(200) - expect(upstreamFetch).toHaveBeenCalledTimes(3) - expect(new URL(String(upstreamFetch.mock.calls[0][0])).pathname).toBe( - "/store/cart-session/resolve" - ) - const cartAuthorityUrl = new URL(String(upstreamFetch.mock.calls[1][0])) - expect(cartAuthorityUrl.pathname).toBe(`/store/carts/${cartId}`) - expect(cartAuthorityUrl.searchParams.get("fields")).toBe( - "id,customer_id,region_id,sales_channel_id,payment_collection.id,customer.id,customer.has_account" - ) - expect( - new Headers(upstreamFetch.mock.calls[1][1]?.headers).get( - "x-publishable-api-key" - ) - ).toBe(binding.publishableApiKey) - expect(new URL(String(upstreamFetch.mock.calls[2][0])).pathname).toBe( - "/store/payment-collections" - ) - }) - - it.each( - FOREIGN_MARKET_PAIRS - )("rejects a checkout cart bound to a foreign market on %s", async ([ - host, - binding, - ], [, foreignBinding]) => { - resolveBinding.mockImplementation(resolveByHost) - const cartId = `cart_${foreignBinding.market}` - const upstreamFetch = vi - .fn() - .mockResolvedValueOnce(Response.json({ cart_id: cartId })) - .mockResolvedValueOnce( - Response.json({ - cart: { - customer_id: null, - id: cartId, - region_id: foreignBinding.regionId, - sales_channel_id: foreignBinding.salesChannelId, - }, - }) - ) - vi.stubGlobal("fetch", upstreamFetch) - - const response = await callGateway("/store/payment-collections", { - body: JSON.stringify({ cart_id: cartId }), - headers: { - "content-type": "application/json", - cookie: "__Host-herbatika-cart-session=Signed.Cart.Session", - }, - host, - method: "POST", - }) - - expect(response.status).toBe(404) - expect(upstreamFetch).toHaveBeenCalledTimes(2) - expect( - new Headers(upstreamFetch.mock.calls[1][1]?.headers).get( - "x-publishable-api-key" - ) - ).toBe(binding.publishableApiKey) - }) - - it.each( - MARKET_CASES - )("binds payment sessions to their cart, collection, provider, and market on %s", async (host, binding) => { - resolveBinding.mockImplementation(resolveByHost) - const cartId = `cart_${binding.market}` - const collectionId = `paycol_${binding.market}` - const providerId = `pp_${binding.market}` - const upstreamFetch = vi - .fn() - .mockResolvedValueOnce(Response.json({ cart_id: cartId })) - .mockResolvedValueOnce( - Response.json({ - cart: { - customer_id: null, - id: cartId, - payment_collection: { id: collectionId }, - region_id: binding.regionId, - sales_channel_id: binding.salesChannelId, - }, - }) - ) - .mockResolvedValueOnce( - Response.json({ payment_providers: [{ id: providerId }] }) - ) - .mockResolvedValueOnce( - Response.json({ payment_collection: { id: collectionId } }) - ) - vi.stubGlobal("fetch", upstreamFetch) + it.each(MARKET_CASES)( + "binds payment-collection creation to the exact signed cart on %s", + async (host, binding) => { + resolveBinding.mockImplementation(resolveByHost) + const cartId = `cart_${binding.market}` + const upstreamFetch = vi + .fn() + .mockResolvedValueOnce(Response.json({ cart_id: cartId })) + .mockResolvedValueOnce( + Response.json({ + cart: { + customer_id: null, + id: cartId, + region_id: binding.regionId, + sales_channel_id: binding.salesChannelId, + }, + }) + ) + .mockResolvedValueOnce( + Response.json({ + payment_collection: { id: `paycol_${binding.market}` }, + }) + ) + vi.stubGlobal("fetch", upstreamFetch) - const response = await callGateway( - `/store/payment-collections/${collectionId}/payment-sessions`, - { - body: JSON.stringify({ - data: { - cart_id: cartId, - metadata: { cart_id: cartId, provider_id: providerId }, - }, - provider_id: providerId, - }), + const response = await callGateway("/store/payment-collections", { + body: JSON.stringify({ cart_id: cartId }), headers: { "content-type": "application/json", cookie: `__Host-herbatika-cart-session=Signed.${binding.market}.Cart`, }, host, method: "POST", - } - ) - - expect(response.status).toBe(200) - expect(upstreamFetch).toHaveBeenCalledTimes(4) - const providerAuthorityUrl = new URL(String(upstreamFetch.mock.calls[2][0])) - expect(providerAuthorityUrl.pathname).toBe("/store/payment-providers") - expect(providerAuthorityUrl.searchParams.get("region_id")).toBe( - binding.regionId - ) - expect(new URL(String(upstreamFetch.mock.calls[3][0])).pathname).toBe( - `/store/payment-collections/${collectionId}/payment-sessions` - ) - }) + }) - it.each( - MARKET_CASES - )("binds calculated shipping options to the exact signed cart on %s", async (host, binding) => { - resolveBinding.mockImplementation(resolveByHost) - const cartId = `cart_${binding.market}` - const optionId = `so_${binding.market}` - const upstreamFetch = vi - .fn() - .mockResolvedValueOnce(Response.json({ cart_id: cartId })) - .mockResolvedValueOnce( - Response.json({ - cart: { - customer_id: null, - id: cartId, - region_id: binding.regionId, - sales_channel_id: binding.salesChannelId, - }, - }) + expect(response.status).toBe(200) + expect(upstreamFetch).toHaveBeenCalledTimes(3) + expect(new URL(String(upstreamFetch.mock.calls[0][0])).pathname).toBe( + "/store/cart-session/resolve" ) - .mockResolvedValueOnce( - Response.json({ shipping_options: [{ id: optionId }] }) + const cartAuthorityUrl = new URL(String(upstreamFetch.mock.calls[1][0])) + expect(cartAuthorityUrl.pathname).toBe(`/store/carts/${cartId}`) + expect(cartAuthorityUrl.searchParams.get("fields")).toBe( + "id,customer_id,region_id,sales_channel_id,payment_collection.id,customer.id,customer.has_account" ) - .mockResolvedValueOnce( - Response.json({ shipping_option: { id: optionId } }) + expect( + new Headers(upstreamFetch.mock.calls[1][1]?.headers).get( + "x-publishable-api-key" + ) + ).toBe(binding.publishableApiKey) + expect(new URL(String(upstreamFetch.mock.calls[2][0])).pathname).toBe( + "/store/payment-collections" ) - vi.stubGlobal("fetch", upstreamFetch) + } + ) + + it.each(FOREIGN_MARKET_PAIRS)( + "rejects a checkout cart bound to a foreign market on %s", + async ([host, binding], [, foreignBinding]) => { + resolveBinding.mockImplementation(resolveByHost) + const cartId = `cart_${foreignBinding.market}` + const upstreamFetch = vi + .fn() + .mockResolvedValueOnce(Response.json({ cart_id: cartId })) + .mockResolvedValueOnce( + Response.json({ + cart: { + customer_id: null, + id: cartId, + region_id: foreignBinding.regionId, + sales_channel_id: foreignBinding.salesChannelId, + }, + }) + ) + vi.stubGlobal("fetch", upstreamFetch) - const response = await callGateway( - `/store/shipping-options/${optionId}/calculate`, - { - body: JSON.stringify({ cart_id: cartId, data: {} }), + const response = await callGateway("/store/payment-collections", { + body: JSON.stringify({ cart_id: cartId }), headers: { "content-type": "application/json", - cookie: `__Host-herbatika-cart-session=Signed.${binding.market}.Cart`, + cookie: "__Host-herbatika-cart-session=Signed.Cart.Session", }, host, method: "POST", - } - ) + }) - expect(response.status).toBe(200) - expect(upstreamFetch).toHaveBeenCalledTimes(4) - const shippingAuthorityUrl = new URL(String(upstreamFetch.mock.calls[2][0])) - expect(shippingAuthorityUrl.pathname).toBe("/store/shipping-options") - expect(shippingAuthorityUrl.searchParams.get("cart_id")).toBe(cartId) - expect(new URL(String(upstreamFetch.mock.calls[3][0])).pathname).toBe( - `/store/shipping-options/${optionId}/calculate` - ) - }) + expect(response.status).toBe(404) + expect(upstreamFetch).toHaveBeenCalledTimes(2) + expect( + new Headers(upstreamFetch.mock.calls[1][1]?.headers).get( + "x-publishable-api-key" + ) + ).toBe(binding.publishableApiKey) + } + ) + + it.each(MARKET_CASES)( + "binds payment sessions to their cart, collection, provider, and market on %s", + async (host, binding) => { + resolveBinding.mockImplementation(resolveByHost) + const cartId = `cart_${binding.market}` + const collectionId = `paycol_${binding.market}` + const providerId = `pp_${binding.market}` + const upstreamFetch = vi + .fn() + .mockResolvedValueOnce(Response.json({ cart_id: cartId })) + .mockResolvedValueOnce( + Response.json({ + cart: { + customer_id: null, + id: cartId, + payment_collection: { id: collectionId }, + region_id: binding.regionId, + sales_channel_id: binding.salesChannelId, + }, + }) + ) + .mockResolvedValueOnce( + Response.json({ payment_providers: [{ id: providerId }] }) + ) + .mockResolvedValueOnce( + Response.json({ payment_collection: { id: collectionId } }) + ) + vi.stubGlobal("fetch", upstreamFetch) + + const response = await callGateway( + `/store/payment-collections/${collectionId}/payment-sessions`, + { + body: JSON.stringify({ + data: { + cart_id: cartId, + metadata: { cart_id: cartId, provider_id: providerId }, + }, + provider_id: providerId, + }), + headers: { + "content-type": "application/json", + cookie: `__Host-herbatika-cart-session=Signed.${binding.market}.Cart`, + }, + host, + method: "POST", + } + ) + + expect(response.status).toBe(200) + expect(upstreamFetch).toHaveBeenCalledTimes(4) + const providerAuthorityUrl = new URL( + String(upstreamFetch.mock.calls[2][0]) + ) + expect(providerAuthorityUrl.pathname).toBe("/store/payment-providers") + expect(providerAuthorityUrl.searchParams.get("region_id")).toBe( + binding.regionId + ) + expect(new URL(String(upstreamFetch.mock.calls[3][0])).pathname).toBe( + `/store/payment-collections/${collectionId}/payment-sessions` + ) + } + ) + + it.each(MARKET_CASES)( + "binds calculated shipping options to the exact signed cart on %s", + async (host, binding) => { + resolveBinding.mockImplementation(resolveByHost) + const cartId = `cart_${binding.market}` + const optionId = `so_${binding.market}` + const upstreamFetch = vi + .fn() + .mockResolvedValueOnce(Response.json({ cart_id: cartId })) + .mockResolvedValueOnce( + Response.json({ + cart: { + customer_id: null, + id: cartId, + region_id: binding.regionId, + sales_channel_id: binding.salesChannelId, + }, + }) + ) + .mockResolvedValueOnce( + Response.json({ shipping_options: [{ id: optionId }] }) + ) + .mockResolvedValueOnce( + Response.json({ shipping_option: { id: optionId } }) + ) + vi.stubGlobal("fetch", upstreamFetch) + + const response = await callGateway( + `/store/shipping-options/${optionId}/calculate`, + { + body: JSON.stringify({ cart_id: cartId, data: {} }), + headers: { + "content-type": "application/json", + cookie: `__Host-herbatika-cart-session=Signed.${binding.market}.Cart`, + }, + host, + method: "POST", + } + ) + + expect(response.status).toBe(200) + expect(upstreamFetch).toHaveBeenCalledTimes(4) + const shippingAuthorityUrl = new URL( + String(upstreamFetch.mock.calls[2][0]) + ) + expect(shippingAuthorityUrl.pathname).toBe("/store/shipping-options") + expect(shippingAuthorityUrl.searchParams.get("cart_id")).toBe(cartId) + expect(new URL(String(upstreamFetch.mock.calls[3][0])).pathname).toBe( + `/store/shipping-options/${optionId}/calculate` + ) + } + ) it("rejects conflicting checkout resource ids before proxying", async () => { resolveBinding.mockImplementation(resolveByHost) @@ -654,52 +660,55 @@ describe("storefront Medusa gateway", () => { ) }) - it.each( - MARKET_CASES - )("allows an anonymous guest cart Medusa attached a guest customer to on %s", async (host, binding) => { - resolveBinding.mockImplementation(resolveByHost) - const cartId = `cart_${binding.market}` - const upstreamFetch = vi - .fn() - .mockResolvedValueOnce(Response.json({ cart_id: cartId })) - .mockResolvedValueOnce( - Response.json({ - cart: { - customer: { has_account: false, id: "cus_guest" }, - customer_id: "cus_guest", - id: cartId, - region_id: binding.regionId, - sales_channel_id: binding.salesChannelId, - }, - }) - ) - .mockResolvedValueOnce( - Response.json({ - payment_collection: { id: `paycol_${binding.market}` }, - }) - ) - vi.stubGlobal("fetch", upstreamFetch) + it.each(MARKET_CASES)( + "allows an anonymous guest cart Medusa attached a guest customer to on %s", + async (host, binding) => { + resolveBinding.mockImplementation(resolveByHost) + const cartId = `cart_${binding.market}` + const upstreamFetch = vi + .fn() + .mockResolvedValueOnce(Response.json({ cart_id: cartId })) + .mockResolvedValueOnce( + Response.json({ + cart: { + customer: { has_account: false, id: "cus_guest" }, + customer_id: "cus_guest", + id: cartId, + region_id: binding.regionId, + sales_channel_id: binding.salesChannelId, + }, + }) + ) + .mockResolvedValueOnce( + Response.json({ + payment_collection: { id: `paycol_${binding.market}` }, + }) + ) + vi.stubGlobal("fetch", upstreamFetch) - const response = await callGateway("/store/payment-collections", { - body: JSON.stringify({ cart_id: cartId }), - headers: { - "content-type": "application/json", - cookie: `__Host-herbatika-cart-session=Signed.${binding.market}.Cart`, - }, - host, - method: "POST", - }) + const response = await callGateway("/store/payment-collections", { + body: JSON.stringify({ cart_id: cartId }), + headers: { + "content-type": "application/json", + cookie: `__Host-herbatika-cart-session=Signed.${binding.market}.Cart`, + }, + host, + method: "POST", + }) - expect(response.status).toBe(200) - expect(upstreamFetch).toHaveBeenCalledTimes(3) - // A guest cart must never trigger an authenticated-customer lookup. - for (const call of upstreamFetch.mock.calls) { - expect(new URL(String(call[0])).pathname).not.toBe("/store/customers/me") + expect(response.status).toBe(200) + expect(upstreamFetch).toHaveBeenCalledTimes(3) + // A guest cart must never trigger an authenticated-customer lookup. + for (const call of upstreamFetch.mock.calls) { + expect(new URL(String(call[0])).pathname).not.toBe( + "/store/customers/me" + ) + } + expect(new URL(String(upstreamFetch.mock.calls[2][0])).pathname).toBe( + "/store/payment-collections" + ) } - expect(new URL(String(upstreamFetch.mock.calls[2][0])).pathname).toBe( - "/store/payment-collections" - ) - }) + ) it.each([ ["the customer relation is missing", undefined], @@ -712,117 +721,123 @@ describe("storefront Medusa gateway", () => { "the expanded customer is a different customer", { has_account: false, id: "cus_someone_else" }, ], - ])("fails closed for an unauthenticated caller when %s", async (_label, customer) => { - resolveBinding.mockImplementation(resolveByHost) - const upstreamFetch = vi - .fn() - .mockResolvedValueOnce(Response.json({ cart_id: "cart_cz" })) - .mockResolvedValueOnce( - Response.json({ - cart: { - customer, - customer_id: "cus_owner", - id: "cart_cz", - region_id: CZ_BINDING.regionId, - sales_channel_id: CZ_BINDING.salesChannelId, - }, - }) - ) - vi.stubGlobal("fetch", upstreamFetch) - - const response = await callGateway("/store/payment-collections", { - body: JSON.stringify({ cart_id: "cart_cz" }), - headers: { - "content-type": "application/json", - cookie: "__Host-herbatika-cart-session=Signed.Cart.Session", - }, - method: "POST", - }) - - // No authorization header, so the owner check rejects before any lookup. - expect(response.status).toBe(404) - expect(upstreamFetch).toHaveBeenCalledTimes(2) - }) + ])( + "fails closed for an unauthenticated caller when %s", + async (_label, customer) => { + resolveBinding.mockImplementation(resolveByHost) + const upstreamFetch = vi + .fn() + .mockResolvedValueOnce(Response.json({ cart_id: "cart_cz" })) + .mockResolvedValueOnce( + Response.json({ + cart: { + customer, + customer_id: "cus_owner", + id: "cart_cz", + region_id: CZ_BINDING.regionId, + sales_channel_id: CZ_BINDING.salesChannelId, + }, + }) + ) + vi.stubGlobal("fetch", upstreamFetch) - it.each( - MARKET_CASES - )("requires trusted auth and exact market authority for order ids on %s", async (host, binding) => { - resolveBinding.mockImplementation(resolveByHost) - const orderId = `order_${binding.market}` - const customerId = `cus_${binding.market}` - const upstreamFetch = vi - .fn() - .mockResolvedValueOnce(Response.json({ customer: { id: customerId } })) - .mockResolvedValueOnce( - Response.json({ - order: { - customer_id: customerId, - id: orderId, - sales_channel_id: binding.salesChannelId, - }, - }) - ) - .mockResolvedValueOnce(Response.json({ order: { id: orderId } })) - vi.stubGlobal("fetch", upstreamFetch) + const response = await callGateway("/store/payment-collections", { + body: JSON.stringify({ cart_id: "cart_cz" }), + headers: { + "content-type": "application/json", + cookie: "__Host-herbatika-cart-session=Signed.Cart.Session", + }, + method: "POST", + }) - const response = await callGateway(`/store/orders/${orderId}`, { - headers: { - cookie: `herbatika_auth_session_token=session-token-${binding.market}`, - }, - host, - }) + // No authorization header, so the owner check rejects before any lookup. + expect(response.status).toBe(404) + expect(upstreamFetch).toHaveBeenCalledTimes(2) + } + ) + + it.each(MARKET_CASES)( + "requires trusted auth and exact market authority for order ids on %s", + async (host, binding) => { + resolveBinding.mockImplementation(resolveByHost) + const orderId = `order_${binding.market}` + const customerId = `cus_${binding.market}` + const upstreamFetch = vi + .fn() + .mockResolvedValueOnce(Response.json({ customer: { id: customerId } })) + .mockResolvedValueOnce( + Response.json({ + order: { + customer_id: customerId, + id: orderId, + sales_channel_id: binding.salesChannelId, + }, + }) + ) + .mockResolvedValueOnce(Response.json({ order: { id: orderId } })) + vi.stubGlobal("fetch", upstreamFetch) - expect(response.status).toBe(200) - expect(upstreamFetch).toHaveBeenCalledTimes(3) - expect(new URL(String(upstreamFetch.mock.calls[0][0])).pathname).toBe( - "/store/customers/me" - ) - const preflightHeaders = new Headers( - upstreamFetch.mock.calls[1][1]?.headers - ) - expect(preflightHeaders.get("authorization")).toBe( - `Bearer session-token-${binding.market}` - ) - expect(preflightHeaders.get("x-publishable-api-key")).toBe( - binding.publishableApiKey - ) - expect( - new URL(String(upstreamFetch.mock.calls[1][0])).searchParams.get("fields") - ).toBe("id,customer_id,sales_channel_id") - }) + const response = await callGateway(`/store/orders/${orderId}`, { + headers: { + cookie: `herbatika_auth_session_token=session-token-${binding.market}`, + }, + host, + }) - it.each(FOREIGN_MARKET_PAIRS)("rejects a foreign order id on %s", async ([ - host, - binding, - ], [, foreignBinding]) => { - resolveBinding.mockImplementation(resolveByHost) - const orderId = `order_${foreignBinding.market}` - const upstreamFetch = vi - .fn() - .mockResolvedValueOnce( - Response.json({ customer: { id: `cus_${binding.market}` } }) + expect(response.status).toBe(200) + expect(upstreamFetch).toHaveBeenCalledTimes(3) + expect(new URL(String(upstreamFetch.mock.calls[0][0])).pathname).toBe( + "/store/customers/me" ) - .mockResolvedValueOnce( - Response.json({ - order: { - customer_id: `cus_${binding.market}`, - id: orderId, - sales_channel_id: foreignBinding.salesChannelId, - }, - }) + const preflightHeaders = new Headers( + upstreamFetch.mock.calls[1][1]?.headers ) - vi.stubGlobal("fetch", upstreamFetch) + expect(preflightHeaders.get("authorization")).toBe( + `Bearer session-token-${binding.market}` + ) + expect(preflightHeaders.get("x-publishable-api-key")).toBe( + binding.publishableApiKey + ) + expect( + new URL(String(upstreamFetch.mock.calls[1][0])).searchParams.get( + "fields" + ) + ).toBe("id,customer_id,sales_channel_id") + } + ) + + it.each(FOREIGN_MARKET_PAIRS)( + "rejects a foreign order id on %s", + async ([host, binding], [, foreignBinding]) => { + resolveBinding.mockImplementation(resolveByHost) + const orderId = `order_${foreignBinding.market}` + const upstreamFetch = vi + .fn() + .mockResolvedValueOnce( + Response.json({ customer: { id: `cus_${binding.market}` } }) + ) + .mockResolvedValueOnce( + Response.json({ + order: { + customer_id: `cus_${binding.market}`, + id: orderId, + sales_channel_id: foreignBinding.salesChannelId, + }, + }) + ) + vi.stubGlobal("fetch", upstreamFetch) - const response = await callGateway(`/store/orders/${orderId}`, { - headers: { - cookie: `herbatika_auth_session_token=session-token-${binding.market}`, - }, - host, - }) + const response = await callGateway(`/store/orders/${orderId}`, { + headers: { + cookie: `herbatika_auth_session_token=session-token-${binding.market}`, + }, + host, + }) - expect(response.status).toBe(404) - expect(upstreamFetch).toHaveBeenCalledTimes(2) - }) + expect(response.status).toBe(404) + expect(upstreamFetch).toHaveBeenCalledTimes(2) + } + ) it("rejects a syntactically valid forged auth cookie before reading an order", async () => { resolveBinding.mockImplementation(resolveByHost) @@ -870,28 +885,29 @@ describe("storefront Medusa gateway", () => { expect(upstreamFetch).toHaveBeenCalledTimes(2) }) - it.each( - MARKET_CASES - )("keeps own-order list queries clean for backend market middleware on %s", async (host, binding) => { - resolveBinding.mockImplementation(resolveByHost) - const upstreamFetch = vi - .fn() - .mockResolvedValue(Response.json({ orders: [] })) - vi.stubGlobal("fetch", upstreamFetch) + it.each(MARKET_CASES)( + "keeps own-order list queries clean for backend market middleware on %s", + async (host, binding) => { + resolveBinding.mockImplementation(resolveByHost) + const upstreamFetch = vi + .fn() + .mockResolvedValue(Response.json({ orders: [] })) + vi.stubGlobal("fetch", upstreamFetch) - const response = await callGateway("/store/orders?limit=20", { - headers: { - cookie: `herbatika_auth_session_token=session-token-${binding.market}`, - }, - host, - }) + const response = await callGateway("/store/orders?limit=20", { + headers: { + cookie: `herbatika_auth_session_token=session-token-${binding.market}`, + }, + host, + }) - expect(response.status).toBe(200) - await expect(response.json()).resolves.toEqual({ orders: [] }) - const upstreamUrl = new URL(String(upstreamFetch.mock.calls[0][0])) - expect(upstreamUrl.searchParams.get("limit")).toBe("20") - expect(upstreamUrl.searchParams.has("sales_channel_id")).toBe(false) - }) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ orders: [] }) + const upstreamUrl = new URL(String(upstreamFetch.mock.calls[0][0])) + expect(upstreamUrl.searchParams.get("limit")).toBe("20") + expect(upstreamUrl.searchParams.has("sales_channel_id")).toBe(false) + } + ) it("does not expose order-by-id without a trusted auth session", async () => { resolveBinding.mockImplementation(resolveByHost) @@ -919,50 +935,53 @@ describe("storefront Medusa gateway", () => { expect(upstreamFetch).not.toHaveBeenCalled() }) - it.each( - GATEWAY_ERROR_CASES - )("localizes rejected API paths for %s", async (host, message) => { - resolveBinding.mockImplementation(resolveByHost) - const upstreamFetch = vi.fn() - vi.stubGlobal("fetch", upstreamFetch) + it.each(GATEWAY_ERROR_CASES)( + "localizes rejected API paths for %s", + async (host, message) => { + resolveBinding.mockImplementation(resolveByHost) + const upstreamFetch = vi.fn() + vi.stubGlobal("fetch", upstreamFetch) - const response = await callGateway("/store/unknown", { host }) - - expect(response.status).toBe(404) - await expect(response.json()).resolves.toEqual({ message }) - expect(upstreamFetch).not.toHaveBeenCalled() - }) + const response = await callGateway("/store/unknown", { host }) - it.each( - GATEWAY_ERROR_CASES - )("localizes unavailable resources for %s", async (host, _pathMessage, message) => { - resolveBinding.mockImplementation(resolveByHost) - const upstreamFetch = vi.fn() - vi.stubGlobal("fetch", upstreamFetch) + expect(response.status).toBe(404) + await expect(response.json()).resolves.toEqual({ message }) + expect(upstreamFetch).not.toHaveBeenCalled() + } + ) - const response = await callGateway("/store/orders/order_case", { host }) + it.each(GATEWAY_ERROR_CASES)( + "localizes unavailable resources for %s", + async (host, _pathMessage, message) => { + resolveBinding.mockImplementation(resolveByHost) + const upstreamFetch = vi.fn() + vi.stubGlobal("fetch", upstreamFetch) - expect(response.status).toBe(404) - await expect(response.json()).resolves.toEqual({ message }) - expect(upstreamFetch).not.toHaveBeenCalled() - }) + const response = await callGateway("/store/orders/order_case", { host }) - it.each( - GATEWAY_ERROR_CASES - )("localizes upstream failures for %s without leaking internals", async (host, _pathMessage, _resourceMessage, message) => { - resolveBinding.mockImplementation(resolveByHost) - vi.stubGlobal( - "fetch", - vi.fn().mockRejectedValue(new Error("private backend credential")) - ) + expect(response.status).toBe(404) + await expect(response.json()).resolves.toEqual({ message }) + expect(upstreamFetch).not.toHaveBeenCalled() + } + ) + + it.each(GATEWAY_ERROR_CASES)( + "localizes upstream failures for %s without leaking internals", + async (host, _pathMessage, _resourceMessage, message) => { + resolveBinding.mockImplementation(resolveByHost) + vi.stubGlobal( + "fetch", + vi.fn().mockRejectedValue(new Error("private backend credential")) + ) - const response = await callGateway("/store/products", { host }) - const payload = await response.json() + const response = await callGateway("/store/products", { host }) + const payload = await response.json() - expect(response.status).toBe(502) - expect(payload).toEqual({ message }) - expect(JSON.stringify(payload)).not.toContain("credential") - }) + expect(response.status).toBe(502) + expect(payload).toEqual({ message }) + expect(JSON.stringify(payload)).not.toContain("credential") + } + ) it("forwards and returns a safe request correlation id", async () => { resolveBinding.mockImplementation(resolveByHost) @@ -1071,16 +1090,19 @@ describe("storefront Medusa gateway", () => { ["/store/unknown", "GET", 404], ["/store/products", "DELETE", 405], ["/store/carts/cart_1/complete", "GET", 405], - ] as const)("rejects unknown or disallowed %s %s requests", async (path, method, expectedStatus) => { - resolveBinding.mockImplementation(resolveByHost) - const upstreamFetch = vi.fn() - vi.stubGlobal("fetch", upstreamFetch) + ] as const)( + "rejects unknown or disallowed %s %s requests", + async (path, method, expectedStatus) => { + resolveBinding.mockImplementation(resolveByHost) + const upstreamFetch = vi.fn() + vi.stubGlobal("fetch", upstreamFetch) - const response = await callGateway(path, { method }) + const response = await callGateway(path, { method }) - expect(response.status).toBe(expectedStatus) - expect(upstreamFetch).not.toHaveBeenCalled() - }) + expect(response.status).toBe(expectedStatus) + expect(upstreamFetch).not.toHaveBeenCalled() + } + ) it("rejects traversal and encoded separators before route matching", async () => { resolveBinding.mockImplementation(resolveByHost) @@ -1111,34 +1133,42 @@ describe("storefront Medusa gateway", () => { ["/store/gls/branches?cart_id=cart_1&limit=20&q=Praha", "GET"], ["/store/packeta/widget-config", "GET"], ["/store/ppl/widget-config", "GET"], - ] as const)("allows the consumed browser route %s %s", async (path, method) => { - resolveBinding.mockImplementation(resolveByHost) - const routeFetch = vi.fn() - if (path.startsWith("/store/carts/cart_1/")) { - routeFetch - .mockResolvedValueOnce( - Response.json({ - cart: { id: "cart_1", sales_channel_id: CZ_BINDING.salesChannelId }, - }) - ) - .mockResolvedValueOnce(Response.json({ ok: true })) - } else { - routeFetch.mockResolvedValue(Response.json({ ok: true })) - } - vi.stubGlobal("fetch", routeFetch) - - const response = await callGateway(path, { - body: method === "POST" ? JSON.stringify({ value: "safe" }) : undefined, - headers: - method === "POST" ? { "content-type": "application/json" } : undefined, - method, - }) + ] as const)( + "allows the consumed browser route %s %s", + async (path, method) => { + resolveBinding.mockImplementation(resolveByHost) + const routeFetch = vi.fn() + if (path.startsWith("/store/carts/cart_1/")) { + routeFetch + .mockResolvedValueOnce( + Response.json({ + cart: { + id: "cart_1", + sales_channel_id: CZ_BINDING.salesChannelId, + }, + }) + ) + .mockResolvedValueOnce(Response.json({ ok: true })) + } else { + routeFetch.mockResolvedValue(Response.json({ ok: true })) + } + vi.stubGlobal("fetch", routeFetch) + + const response = await callGateway(path, { + body: method === "POST" ? JSON.stringify({ value: "safe" }) : undefined, + headers: + method === "POST" + ? { "content-type": "application/json" } + : undefined, + method, + }) - expect(response.status).toBe(200) - expect(routeFetch).toHaveBeenCalledTimes( - path.startsWith("/store/carts/cart_1/") ? 2 : 1 - ) - }) + expect(response.status).toBe(200) + expect(routeFetch).toHaveBeenCalledTimes( + path.startsWith("/store/carts/cart_1/") ? 2 : 1 + ) + } + ) it("requires exact same-origin evidence for unsafe requests", async () => { resolveBinding.mockImplementation(resolveByHost) @@ -1171,20 +1201,23 @@ describe("storefront Medusa gateway", () => { ["/store/carts", { locale: "sk-SK" }], ["/store/carts", { sales_channel_id: "sc_cz" }], ["/store/carts", { metadata: { sales_channel_id: "sc_cz" } }], - ] as const)("rejects foreign or caller-controlled market scope for %s", async (path, body) => { - resolveBinding.mockImplementation(resolveByHost) - const upstreamFetch = vi.fn() - vi.stubGlobal("fetch", upstreamFetch) - - const response = await callGateway(path, { - body: body ? JSON.stringify(body) : undefined, - headers: body ? { "content-type": "application/json" } : undefined, - method: body ? "POST" : "GET", - }) + ] as const)( + "rejects foreign or caller-controlled market scope for %s", + async (path, body) => { + resolveBinding.mockImplementation(resolveByHost) + const upstreamFetch = vi.fn() + vi.stubGlobal("fetch", upstreamFetch) + + const response = await callGateway(path, { + body: body ? JSON.stringify(body) : undefined, + headers: body ? { "content-type": "application/json" } : undefined, + method: body ? "POST" : "GET", + }) - expect(response.status).toBe(400) - expect(upstreamFetch).not.toHaveBeenCalled() - }) + expect(response.status).toBe(400) + expect(upstreamFetch).not.toHaveBeenCalled() + } + ) it("allows the category-tree limit and rejects larger requests", async () => { resolveBinding.mockImplementation(resolveByHost) diff --git a/apps/herbatika/src/app/api/storefront/checkout/preferences/route.test.ts b/apps/herbatika/src/app/api/storefront/checkout/preferences/route.test.ts index 23c268c2e1..711bd05100 100644 --- a/apps/herbatika/src/app/api/storefront/checkout/preferences/route.test.ts +++ b/apps/herbatika/src/app/api/storefront/checkout/preferences/route.test.ts @@ -163,19 +163,22 @@ describe("checkout consent preferences route", () => { ["cz", "Požadavek musí pocházet ze stejného webu."], ["hu", "A kérésnek ugyanarról a webhelyről kell érkeznie."], ["ro", "Cererea trebuie să provină de pe același site."], - ] as const)("localizes cross-origin %s rejection", async (market, message) => { - testContext.market = market - const response = await handleCheckoutConsentPut( - createRequest("PUT", { - body: body(false, true), - origin: "https://attacker.example", - }), - dependencies - ) + ] as const)( + "localizes cross-origin %s rejection", + async (market, message) => { + testContext.market = market + const response = await handleCheckoutConsentPut( + createRequest("PUT", { + body: body(false, true), + origin: "https://attacker.example", + }), + dependencies + ) - expect(response.status).toBe(403) - await expect(response.json()).resolves.toEqual({ message }) - }) + expect(response.status).toBe(403) + await expect(response.json()).resolves.toEqual({ message }) + } + ) it("keeps an unknown Host response generic", async () => { const response = handleCheckoutConsentGet(createRequest("GET"), { diff --git a/apps/herbatika/src/app/api/storefront/orders/[id]/qr-payment/route.test.ts b/apps/herbatika/src/app/api/storefront/orders/[id]/qr-payment/route.test.ts index 26c3663278..8f7f2af7b1 100644 --- a/apps/herbatika/src/app/api/storefront/orders/[id]/qr-payment/route.test.ts +++ b/apps/herbatika/src/app/api/storefront/orders/[id]/qr-payment/route.test.ts @@ -261,116 +261,107 @@ describe("order QR payment bridge", () => { expect(await response.text()).not.toContain("Guest.Cookie.Token") }) - it.each( - MARKET_CASES - )("returns exact $currencyCode QR data for the $binding.market Host binding", async ({ - binding, - currencyCode, - host, - }) => { - resolveBinding.mockReturnValue(binding) - const upstreamFetch = vi - .fn() - .mockResolvedValueOnce(Response.json({ order: { id: "order_Case" } })) - .mockResolvedValueOnce( - authorizedOrderResponse({ - currency_code: currencyCode, - payment_collections: [ - { - payments: [ - { - data: { - payment_qr_spayd: `SPD*1.0*ACC:CZ6508000000192000145399*AM:123.45*CC:${currencyCode}*X-VS:42`, + it.each(MARKET_CASES)( + "returns exact $currencyCode QR data for the $binding.market Host binding", + async ({ binding, currencyCode, host }) => { + resolveBinding.mockReturnValue(binding) + const upstreamFetch = vi + .fn() + .mockResolvedValueOnce(Response.json({ order: { id: "order_Case" } })) + .mockResolvedValueOnce( + authorizedOrderResponse({ + currency_code: currencyCode, + payment_collections: [ + { + payments: [ + { + data: { + payment_qr_spayd: `SPD*1.0*ACC:CZ6508000000192000145399*AM:123.45*CC:${currencyCode}*X-VS:42`, + }, + provider_id: "pp_qr_manual_default", }, - provider_id: "pp_qr_manual_default", - }, - ], - }, - ], - region_id: binding.regionId, - sales_channel_id: binding.salesChannelId, - }) - ) - vi.stubGlobal("fetch", upstreamFetch) + ], + }, + ], + region_id: binding.regionId, + sales_channel_id: binding.salesChannelId, + }) + ) + vi.stubGlobal("fetch", upstreamFetch) - const response = await callQr( - { order_token: "Guest.Token-Exact" }, - { host } - ) + const response = await callQr( + { order_token: "Guest.Token-Exact" }, + { host } + ) - expect(response.status).toBe(200) - expect(await response.json()).toMatchObject({ - qr_payment: { currency_code: currencyCode }, - status: "ready", - }) - }) + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + qr_payment: { currency_code: currencyCode }, + status: "ready", + }) + } + ) - it.each( - MARKET_CASES - )("rejects a tampered $currencyCode QR amount for the $binding.market Host binding", async ({ - binding, - currencyCode, - host, - }) => { - resolveBinding.mockReturnValue(binding) - const upstreamFetch = vi - .fn() - .mockResolvedValueOnce(Response.json({ order: { id: "order_Case" } })) - .mockResolvedValueOnce( - authorizedOrderResponse({ - currency_code: currencyCode, - payment_collections: [ - { - payments: [ - { - data: { - payment_qr_spayd: `SPD*1.0*ACC:CZ6508000000192000145399*AM:123.46*CC:${currencyCode}*X-VS:42`, + it.each(MARKET_CASES)( + "rejects a tampered $currencyCode QR amount for the $binding.market Host binding", + async ({ binding, currencyCode, host }) => { + resolveBinding.mockReturnValue(binding) + const upstreamFetch = vi + .fn() + .mockResolvedValueOnce(Response.json({ order: { id: "order_Case" } })) + .mockResolvedValueOnce( + authorizedOrderResponse({ + currency_code: currencyCode, + payment_collections: [ + { + payments: [ + { + data: { + payment_qr_spayd: `SPD*1.0*ACC:CZ6508000000192000145399*AM:123.46*CC:${currencyCode}*X-VS:42`, + }, + provider_id: "pp_qr_manual_default", }, - provider_id: "pp_qr_manual_default", - }, - ], - }, - ], - region_id: binding.regionId, - sales_channel_id: binding.salesChannelId, - total: 123.45, - }) - ) - vi.stubGlobal("fetch", upstreamFetch) + ], + }, + ], + region_id: binding.regionId, + sales_channel_id: binding.salesChannelId, + total: 123.45, + }) + ) + vi.stubGlobal("fetch", upstreamFetch) - const response = await callQr( - { order_token: "Guest.Token-Exact" }, - { host } - ) + const response = await callQr( + { order_token: "Guest.Token-Exact" }, + { host } + ) - expect(response.status).toBe(200) - expect(await response.json()).toEqual({ - qr_payment: null, - status: "unavailable", - }) - }) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + qr_payment: null, + status: "unavailable", + }) + } + ) - it.each( - MARKET_CASES - )("localizes private failures for the $binding.market Host", async ({ - binding, - failureMessage, - host, - }) => { - resolveBinding.mockReturnValue(binding) - const upstreamFetch = vi.fn() - vi.stubGlobal("fetch", upstreamFetch) + it.each(MARKET_CASES)( + "localizes private failures for the $binding.market Host", + async ({ binding, failureMessage, host }) => { + resolveBinding.mockReturnValue(binding) + const upstreamFetch = vi.fn() + vi.stubGlobal("fetch", upstreamFetch) - expect( - await readFailureProjection( - await callQr({ order_token: " invalid" }, { host }) - ) - ).toEqual({ - ...CZ_FAILURE_PROJECTION, - body: { message: failureMessage }, - }) - expect(upstreamFetch).not.toHaveBeenCalled() - }) + expect( + await readFailureProjection( + await callQr({ order_token: " invalid" }, { host }) + ) + ).toEqual({ + ...CZ_FAILURE_PROJECTION, + body: { message: failureMessage }, + }) + expect(upstreamFetch).not.toHaveBeenCalled() + } + ) it("returns a generic 421 for an unknown Host", async () => { resolveBinding.mockReturnValue(null) @@ -398,38 +389,41 @@ describe("order QR payment bridge", () => { ["foreign order currency", "EUR", "CC:CZK"], ["foreign SPAYD currency", "CZK", "CC:EUR"], ["ambiguous SPAYD currency", "CZK", "CC:CZK*CC:CZK"], - ] as const)("returns unavailable QR data for %s", async (_name, orderCurrencyCode, spaydCurrencyField) => { - resolveBinding.mockReturnValue(CZ_BINDING) - const upstreamFetch = vi - .fn() - .mockResolvedValueOnce(Response.json({ order: { id: "order_Case" } })) - .mockResolvedValueOnce( - authorizedOrderResponse({ - currency_code: orderCurrencyCode, - payment_collections: [ - { - payments: [ - { - data: { - payment_qr_spayd: `SPD*1.0*ACC:CZ6508000000192000145399*AM:123.45*${spaydCurrencyField}*X-VS:42`, + ] as const)( + "returns unavailable QR data for %s", + async (_name, orderCurrencyCode, spaydCurrencyField) => { + resolveBinding.mockReturnValue(CZ_BINDING) + const upstreamFetch = vi + .fn() + .mockResolvedValueOnce(Response.json({ order: { id: "order_Case" } })) + .mockResolvedValueOnce( + authorizedOrderResponse({ + currency_code: orderCurrencyCode, + payment_collections: [ + { + payments: [ + { + data: { + payment_qr_spayd: `SPD*1.0*ACC:CZ6508000000192000145399*AM:123.45*${spaydCurrencyField}*X-VS:42`, + }, + provider_id: "pp_qr_manual_default", }, - provider_id: "pp_qr_manual_default", - }, - ], - }, - ], - }) - ) - vi.stubGlobal("fetch", upstreamFetch) + ], + }, + ], + }) + ) + vi.stubGlobal("fetch", upstreamFetch) - const response = await callQr({ order_token: "Guest.Token-Exact" }) + const response = await callQr({ order_token: "Guest.Token-Exact" }) - expect(response.status).toBe(200) - expect(await response.json()).toEqual({ - qr_payment: null, - status: "unavailable", - }) - }) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + qr_payment: null, + status: "unavailable", + }) + } + ) it.each([ ["missing authority", {}, { host: "herbatica.cz" }], @@ -446,18 +440,21 @@ describe("order QR payment bridge", () => { {}, ], ["non-exact token", { order_token: " Guest.Token-Exact" }, {}], - ] as const)("fails closed before Medusa for %s", async (_name, body, options) => { - resolveBinding.mockImplementation((host) => - host === "herbatica.cz" ? CZ_BINDING : null - ) - const upstreamFetch = vi.fn() - vi.stubGlobal("fetch", upstreamFetch) + ] as const)( + "fails closed before Medusa for %s", + async (_name, body, options) => { + resolveBinding.mockImplementation((host) => + host === "herbatica.cz" ? CZ_BINDING : null + ) + const upstreamFetch = vi.fn() + vi.stubGlobal("fetch", upstreamFetch) - expect(await readFailureProjection(await callQr(body, options))).toEqual( - CZ_FAILURE_PROJECTION - ) - expect(upstreamFetch).not.toHaveBeenCalled() - }) + expect(await readFailureProjection(await callQr(body, options))).toEqual( + CZ_FAILURE_PROJECTION + ) + expect(upstreamFetch).not.toHaveBeenCalled() + } + ) it.each([ ["ownership not found", [new Response(null, { status: 404 })]], @@ -487,18 +484,21 @@ describe("order QR payment bridge", () => { authorizedOrderResponse({ region_id: "reg_sk" }), ], ], - ] as const)("uses the same private failure for %s", async (_name, responses) => { - resolveBinding.mockReturnValue(CZ_BINDING) - const upstreamFetch = vi.fn() - for (const response of responses) { - upstreamFetch.mockResolvedValueOnce(response) - } - vi.stubGlobal("fetch", upstreamFetch) + ] as const)( + "uses the same private failure for %s", + async (_name, responses) => { + resolveBinding.mockReturnValue(CZ_BINDING) + const upstreamFetch = vi.fn() + for (const response of responses) { + upstreamFetch.mockResolvedValueOnce(response) + } + vi.stubGlobal("fetch", upstreamFetch) - expect( - await readFailureProjection( - await callQr({ order_token: "Guest.Token-Exact" }) - ) - ).toEqual(CZ_FAILURE_PROJECTION) - }) + expect( + await readFailureProjection( + await callQr({ order_token: "Guest.Token-Exact" }) + ) + ).toEqual(CZ_FAILURE_PROJECTION) + } + ) }) diff --git a/apps/herbatika/src/app/api/url-registry/dispatcher-health/route.test.ts b/apps/herbatika/src/app/api/url-registry/dispatcher-health/route.test.ts index c2357061ac..b26e119a3b 100644 --- a/apps/herbatika/src/app/api/url-registry/dispatcher-health/route.test.ts +++ b/apps/herbatika/src/app/api/url-registry/dispatcher-health/route.test.ts @@ -23,22 +23,25 @@ describe("URL registry invalidation dispatcher health route", () => { ["starting", 200], ["disabled", 200], ["degraded", 503], - ] as const)("returns %s without cacheable or secret state", async (status, code) => { - mocks.health.mockReturnValue({ - backlog: { delivered: 4, failed: 1, pending: 2, processing: 0 }, - consecutiveFailures: status === "degraded" ? 1 : 0, - enabled: status !== "disabled", - lastResult: null, - lastSuccessAt: null, - startedAt: null, - status, - }) - const response = GET() - expect(response.status).toBe(code) - expect(response.headers.get("cache-control")).toBe("no-store, max-age=0") - expect(response.headers.get("x-robots-tag")).toContain("noindex") - const body = await response.text() - expect(JSON.parse(body)).toMatchObject({ status }) - expect(body).not.toContain("token") - }) + ] as const)( + "returns %s without cacheable or secret state", + async (status, code) => { + mocks.health.mockReturnValue({ + backlog: { delivered: 4, failed: 1, pending: 2, processing: 0 }, + consecutiveFailures: status === "degraded" ? 1 : 0, + enabled: status !== "disabled", + lastResult: null, + lastSuccessAt: null, + startedAt: null, + status, + }) + const response = GET() + expect(response.status).toBe(code) + expect(response.headers.get("cache-control")).toBe("no-store, max-age=0") + expect(response.headers.get("x-robots-tag")).toContain("noindex") + const body = await response.text() + expect(JSON.parse(body)).toMatchObject({ status }) + expect(body).not.toContain("token") + } + ) }) diff --git a/apps/herbatika/src/app/api/url-registry/invalidate/route.test.ts b/apps/herbatika/src/app/api/url-registry/invalidate/route.test.ts index d484079812..1215efafc9 100644 --- a/apps/herbatika/src/app/api/url-registry/invalidate/route.test.ts +++ b/apps/herbatika/src/app/api/url-registry/invalidate/route.test.ts @@ -74,16 +74,19 @@ describe("URL registry invalidation route wiring", () => { it.each([ ["0", "1"], ["1", "0"], - ])("stays hidden unless both gates are enabled (%s/%s)", async (registry, invalidation) => { - vi.stubEnv("URL_REGISTRY_ENABLED", registry) - vi.stubEnv("URL_REGISTRY_INVALIDATION_ENABLED", invalidation) - const { POST } = await import("./route") + ])( + "stays hidden unless both gates are enabled (%s/%s)", + async (registry, invalidation) => { + vi.stubEnv("URL_REGISTRY_ENABLED", registry) + vi.stubEnv("URL_REGISTRY_INVALIDATION_ENABLED", invalidation) + const { POST } = await import("./route") - const response = await POST(request()) + const response = await POST(request()) - expect(response.status).toBe(404) - expect(mocks.consume).not.toHaveBeenCalled() - }) + expect(response.status).toBe(404) + expect(mocks.consume).not.toHaveBeenCalled() + } + ) it("rejects an unknown Host before consuming", async () => { const { POST } = await import("./route") diff --git a/apps/herbatika/src/assets/category-images/lipo-vitaminy.svg b/apps/herbatika/src/assets/category-images/lipo-vitaminy.svg index 447e414cd1..8e6c52237b 100644 --- a/apps/herbatika/src/assets/category-images/lipo-vitaminy.svg +++ b/apps/herbatika/src/assets/category-images/lipo-vitaminy.svg @@ -1,62 +1,62 @@ - + - + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns="http://www.w3.org/2000/svg" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + width="140" + height="100" + viewBox="0 0 140 100" + id="svg2" + version="1.1" + inkscape:version="0.91 r13725" + sodipodi:docname="default-image.svg" +> + - + id="base" + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1.0" + inkscape:pageopacity="0.0" + inkscape:pageshadow="2" + inkscape:zoom="2.744" + inkscape:cx="70.41252" + inkscape:cy="-3.0695191" + inkscape:document-units="px" + inkscape:current-layer="layer1" + showgrid="false" + units="px" + fit-margin-top="0" + fit-margin-left="0" + fit-margin-right="0" + fit-margin-bottom="0" + /> + - + image/svg+xml - + + inkscape:label="Layer 1" + inkscape:groupmode="layer" + id="layer1" + transform="translate(-429.00128,-192.17599)" + > + style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:0.8;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#e6e6e6;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:42.82723999;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;marker:none;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" + d="m 474.26337,213.92119 0.005,5.20792 -5.29239,0 -0.0171,1.78959 0,43.82695 59.61076,0 0,-45.60101 -37.79494,-0.0149 -0.0856,-5.19223 -16.42522,-0.0149 z m 2.25511,2.29783 11.90983,0 0,3.0274 -11.90983,0 0,-3.0274 z m -4.01201,6.98699 52.5147,0 0,37.99153 -52.5147,0 0,-37.99153 z m 26.53542,6.47537 c -6.80103,0 -12.34832,5.54728 -12.34832,12.34832 0,6.80102 5.54729,12.34833 12.34832,12.34833 6.80103,0 12.34832,-5.54731 12.34832,-12.34833 0,-6.80104 -5.54729,-12.34832 -12.34832,-12.34832 z m 0,3.16623 c 5.08989,0 9.18207,4.0922 9.18207,9.18209 0,5.0899 -4.09218,9.18207 -9.18207,9.18207 -5.08989,0 -9.18224,-4.09217 -9.18224,-9.18207 0,-5.08989 4.09235,-9.18209 9.18224,-9.18209 z" + id="rect3347" + inkscape:connector-curvature="0" + /> diff --git a/apps/herbatika/src/components/about/about-page.data.test.ts b/apps/herbatika/src/components/about/about-page.data.test.ts index f7e95175d0..975dab5095 100644 --- a/apps/herbatika/src/components/about/about-page.data.test.ts +++ b/apps/herbatika/src/components/about/about-page.data.test.ts @@ -98,25 +98,30 @@ describe("localized About page data", () => { it.each([ ["cs-CZ", "O našem týmu", "Důležité milníky naší historie"], ["hu-HU", "Csapatunkról", "Történetünk fontos mérföldkövei"], - ] as const)("provides complete %s structural parity", (locale, expectedTitle, expectedMilestonesTitle) => { - const slovak = getAboutPageData("sk-SK") - const localized = getAboutPageData(locale) + ] as const)( + "provides complete %s structural parity", + (locale, expectedTitle, expectedMilestonesTitle) => { + const slovak = getAboutPageData("sk-SK") + const localized = getAboutPageData(locale) - expect(localized).not.toBeNull() - expect(localized?.hero.title).toBe(expectedTitle) - expect(localized?.milestonesTitle).toBe(expectedMilestonesTitle) - expect( - localized?.sections.map(({ paragraphs }) => paragraphs.length) - ).toEqual(slovak?.sections.map(({ paragraphs }) => paragraphs.length)) - expect(localized?.sections.map(({ image }) => Boolean(image))).toEqual( - slovak?.sections.map(({ image }) => Boolean(image)) - ) - expect(localized?.milestones.map(({ year }) => year)).toEqual( - slovak?.milestones.map(({ year }) => year) - ) - expect(localized?.principles).toHaveLength(slovak?.principles.length ?? 0) - expect(visibleStrings(localized as AboutPageData).every(Boolean)).toBe(true) - }) + expect(localized).not.toBeNull() + expect(localized?.hero.title).toBe(expectedTitle) + expect(localized?.milestonesTitle).toBe(expectedMilestonesTitle) + expect( + localized?.sections.map(({ paragraphs }) => paragraphs.length) + ).toEqual(slovak?.sections.map(({ paragraphs }) => paragraphs.length)) + expect(localized?.sections.map(({ image }) => Boolean(image))).toEqual( + slovak?.sections.map(({ image }) => Boolean(image)) + ) + expect(localized?.milestones.map(({ year }) => year)).toEqual( + slovak?.milestones.map(({ year }) => year) + ) + expect(localized?.principles).toHaveLength(slovak?.principles.length ?? 0) + expect(visibleStrings(localized as AboutPageData).every(Boolean)).toBe( + true + ) + } + ) it.each([ [ @@ -129,22 +134,25 @@ describe("localized About page data", () => { "Kapcsolat a magyar piachoz", "Herbatica ügyfélszolgálat Magyarország számára", ], - ] as const)("keeps %s contact authority market-neutral and free of foreign operator data", (locale, operatorTitle, marketCanary) => { - const localized = getAboutPageData(locale) - const contact = localized?.contact - const contactCopy = [ - contact?.title, - contact?.operatorTitle, - ...(contact?.paragraphs.flatMap(paragraphStrings) ?? []), - ...(contact?.companyDetails ?? []), - ].join("\n") + ] as const)( + "keeps %s contact authority market-neutral and free of foreign operator data", + (locale, operatorTitle, marketCanary) => { + const localized = getAboutPageData(locale) + const contact = localized?.contact + const contactCopy = [ + contact?.title, + contact?.operatorTitle, + ...(contact?.paragraphs.flatMap(paragraphStrings) ?? []), + ...(contact?.companyDetails ?? []), + ].join("\n") - expect(contact?.operatorTitle).toBe(operatorTitle) - expect(contactCopy).toContain(marketCanary) - expect(JSON.stringify(contact)).not.toMatch( - FOREIGN_CONTACT_AUTHORITY_CANARY - ) - }) + expect(contact?.operatorTitle).toBe(operatorTitle) + expect(contactCopy).toContain(marketCanary) + expect(JSON.stringify(contact)).not.toMatch( + FOREIGN_CONTACT_AUTHORITY_CANARY + ) + } + ) it("has approved content for every supported storefront locale", () => { for (const locale of ["sk-SK", "cs-CZ", "hu-HU", "ro-RO"] as const) { diff --git a/apps/herbatika/src/components/about/about-page.test.tsx b/apps/herbatika/src/components/about/about-page.test.tsx index 7d350f4c03..462cf4f34c 100644 --- a/apps/herbatika/src/components/about/about-page.test.tsx +++ b/apps/herbatika/src/components/about/about-page.test.tsx @@ -60,16 +60,19 @@ describe("AboutPage locale selection", () => { it.each([ ["cs-CZ", "O našem týmu", "Kontakt pro český trh"], ["hu-HU", "Csapatunkról", "Kapcsolat a magyar piachoz"], - ] as const)("renders approved %s page copy", (locale, title, contactTitle) => { - const markup = renderToStaticMarkup( - - ) + ] as const)( + "renders approved %s page copy", + (locale, title, contactTitle) => { + const markup = renderToStaticMarkup( + + ) - expect(markup).toContain(title) - expect(markup).toContain(contactTitle) - expect(markup).not.toContain("salut@herbatica.ro") - expect(markup).not.toContain("Prevádzkovateľ internetového obchodu") - }) + expect(markup).toContain(title) + expect(markup).toContain(contactTitle) + expect(markup).not.toContain("salut@herbatica.ro") + expect(markup).not.toContain("Prevádzkovateľ internetového obchodu") + } + ) it("renders no content for an unsupported locale", () => { expect( diff --git a/apps/herbatika/src/components/account/account-deactivation-localization.test.tsx b/apps/herbatika/src/components/account/account-deactivation-localization.test.tsx index fa360ce9b2..f7099a5466 100644 --- a/apps/herbatika/src/components/account/account-deactivation-localization.test.tsx +++ b/apps/herbatika/src/components/account/account-deactivation-localization.test.tsx @@ -157,33 +157,37 @@ const ACCOUNT_CASES = [ ] as const describe("account deactivation localization", () => { - it.each( - ACCOUNT_CASES - )("renders the exact $locale request, modal, and confirmation copy", ({ - confirmationAction, - confirmationTitle, - dialogTitle, - foreignCanaries, - locale, - requestAction, - requestTitle, - }) => { - const requestHtml = renderLocalized(locale, ) - const confirmationHtml = renderLocalized( + it.each(ACCOUNT_CASES)( + "renders the exact $locale request, modal, and confirmation copy", + ({ + confirmationAction, + confirmationTitle, + dialogTitle, + foreignCanaries, locale, - - ) - const html = `${requestHtml}${confirmationHtml}` + requestAction, + requestTitle, + }) => { + const requestHtml = renderLocalized( + locale, + + ) + const confirmationHtml = renderLocalized( + locale, + + ) + const html = `${requestHtml}${confirmationHtml}` - expect(requestHtml).toContain(requestTitle) - expect(requestHtml).toContain(dialogTitle) - expect(requestHtml).toContain(requestAction) - expect(confirmationHtml).toContain(confirmationTitle) - expect(confirmationHtml).toContain(confirmationAction) - for (const foreignCanary of foreignCanaries) { - expect(html).not.toContain(foreignCanary) + expect(requestHtml).toContain(requestTitle) + expect(requestHtml).toContain(dialogTitle) + expect(requestHtml).toContain(requestAction) + expect(confirmationHtml).toContain(confirmationTitle) + expect(confirmationHtml).toContain(confirmationAction) + for (const foreignCanary of foreignCanaries) { + expect(html).not.toContain(foreignCanary) + } } - }) + ) it("keeps every reachable source wired to storefront text without market copy", () => { for (const source of componentSources) { @@ -192,26 +196,27 @@ describe("account deactivation localization", () => { } }) - it.each( - ACCOUNT_CASES - )("publishes exact $locale status and error messages without foreign canaries", ({ - failed, - foreignCanaries, - invalidTokenCanary, - locale, - sentStatusCanary, - successCanary, - }) => { - const deactivation = messagesForLocale(locale).auth.deactivation + it.each(ACCOUNT_CASES)( + "publishes exact $locale status and error messages without foreign canaries", + ({ + failed, + foreignCanaries, + invalidTokenCanary, + locale, + sentStatusCanary, + successCanary, + }) => { + const deactivation = messagesForLocale(locale).auth.deactivation - expect(deactivation.request.failed).toBe(failed) - expect(deactivation.request.sent_status).toContain(sentStatusCanary) - expect(deactivation.confirmation.invalid_token).toContain( - invalidTokenCanary - ) - expect(deactivation.confirmation.success).toContain(successCanary) - for (const foreignCanary of foreignCanaries) { - expect(JSON.stringify(deactivation)).not.toContain(foreignCanary) + expect(deactivation.request.failed).toBe(failed) + expect(deactivation.request.sent_status).toContain(sentStatusCanary) + expect(deactivation.confirmation.invalid_token).toContain( + invalidTokenCanary + ) + expect(deactivation.confirmation.success).toContain(successCanary) + for (const foreignCanary of foreignCanaries) { + expect(JSON.stringify(deactivation)).not.toContain(foreignCanary) + } } - }) + ) }) diff --git a/apps/herbatika/src/components/checkout/checkout-address.utils.test.ts b/apps/herbatika/src/components/checkout/checkout-address.utils.test.ts index f63b24c1d4..ee094a9bb5 100644 --- a/apps/herbatika/src/components/checkout/checkout-address.utils.test.ts +++ b/apps/herbatika/src/components/checkout/checkout-address.utils.test.ts @@ -24,10 +24,10 @@ describe("resolveHasStoredAddress", () => { expect(resolveHasStoredAddress(createCart("0905 123 456"), "sk")).toBe(true) }) - it.each([ - undefined, - "+42155555555555", - ])("rejects a stored address with an invalid contact phone: %s", (phone) => { - expect(resolveHasStoredAddress(createCart(phone), "sk")).toBe(false) - }) + it.each([undefined, "+42155555555555"])( + "rejects a stored address with an invalid contact phone: %s", + (phone) => { + expect(resolveHasStoredAddress(createCart(phone), "sk")).toBe(false) + } + ) }) diff --git a/apps/herbatika/src/components/checkout/checkout-payment-compatibility.test.ts b/apps/herbatika/src/components/checkout/checkout-payment-compatibility.test.ts index 08b0e20cf8..eaf99f8161 100644 --- a/apps/herbatika/src/components/checkout/checkout-payment-compatibility.test.ts +++ b/apps/herbatika/src/components/checkout/checkout-payment-compatibility.test.ts @@ -34,29 +34,29 @@ const roDemoCarrierShippingOption = { } describe("checkout payment compatibility", () => { - it.each([ - "home_delivery_cod", - "parcelshop_cod", - ])("keeps only cash on delivery for GLS %s", (code) => { - expect( - filterPaymentProvidersForShipping({ - paymentProviders: providers, - shippingOption: { data: { code, supports_cod: true } }, - }) - ).toEqual([{ id: CASH_ON_DELIVERY_PAYMENT_PROVIDER_ID }]) - }) + it.each(["home_delivery_cod", "parcelshop_cod"])( + "keeps only cash on delivery for GLS %s", + (code) => { + expect( + filterPaymentProvidersForShipping({ + paymentProviders: providers, + shippingOption: { data: { code, supports_cod: true } }, + }) + ).toEqual([{ id: CASH_ON_DELIVERY_PAYMENT_PROVIDER_ID }]) + } + ) - it.each([ - "home_delivery", - "parcelshop", - ])("removes cash on delivery for GLS %s", (code) => { - expect( - filterPaymentProvidersForShipping({ - paymentProviders: providers, - shippingOption: { data: { code, supports_cod: false } }, - }) - ).toEqual([{ id: "pp_paykit_gopay" }]) - }) + it.each(["home_delivery", "parcelshop"])( + "removes cash on delivery for GLS %s", + (code) => { + expect( + filterPaymentProvidersForShipping({ + paymentProviders: providers, + shippingOption: { data: { code, supports_cod: false } }, + }) + ).toEqual([{ id: "pp_paykit_gopay" }]) + } + ) it("offers on-site and online payment for a location pickup option", () => { expect( @@ -112,39 +112,42 @@ describe("checkout payment compatibility", () => { ["Cargus", "ro-demo-cargus"], ["Packeta address", "ro-demo-packeta-address"], ["Packeta pickup", "ro-demo-packeta-pickup"], - ])("gates the generated %s carrier on the exact RO demo marker", (_, code) => { - const shippingOption = { - ...roDemoCarrierShippingOption, - data: { code, ro_demo_checkout: roDemoCheckoutMarker }, - } - expect( - isPaymentProviderCompatibleWithShipping({ - paymentProviderId: ON_SITE_PAYMENT_PROVIDER_ID, - shippingOption, - }) - ).toBe(true) - - const rejectedMarkers = [ - undefined, - { ...roDemoCheckoutMarker, market: "sk" }, - { ...roDemoCheckoutMarker, label: "Plată demo" }, - { ...roDemoCheckoutMarker, binding_sha256: "not-a-valid-hash" }, - ] - for (const marker of rejectedMarkers) { + ])( + "gates the generated %s carrier on the exact RO demo marker", + (_, code) => { + const shippingOption = { + ...roDemoCarrierShippingOption, + data: { code, ro_demo_checkout: roDemoCheckoutMarker }, + } expect( isPaymentProviderCompatibleWithShipping({ paymentProviderId: ON_SITE_PAYMENT_PROVIDER_ID, - shippingOption: { - ...shippingOption, - data: { - code, - ...(marker ? { ro_demo_checkout: marker } : {}), - }, - }, + shippingOption, }) - ).toBe(false) + ).toBe(true) + + const rejectedMarkers = [ + undefined, + { ...roDemoCheckoutMarker, market: "sk" }, + { ...roDemoCheckoutMarker, label: "Plată demo" }, + { ...roDemoCheckoutMarker, binding_sha256: "not-a-valid-hash" }, + ] + for (const marker of rejectedMarkers) { + expect( + isPaymentProviderCompatibleWithShipping({ + paymentProviderId: ON_SITE_PAYMENT_PROVIDER_ID, + shippingOption: { + ...shippingOption, + data: { + code, + ...(marker ? { ro_demo_checkout: marker } : {}), + }, + }, + }) + ).toBe(false) + } } - }) + ) it("keeps COD disabled even when an RO demo carrier option claims COD support", () => { expect( diff --git a/apps/herbatika/src/components/checkout/sections/checkout-complete-section.market.test.tsx b/apps/herbatika/src/components/checkout/sections/checkout-complete-section.market.test.tsx index 70cf408a3c..bf30d95a63 100644 --- a/apps/herbatika/src/components/checkout/sections/checkout-complete-section.market.test.tsx +++ b/apps/herbatika/src/components/checkout/sections/checkout-complete-section.market.test.tsx @@ -119,16 +119,19 @@ describe("CheckoutCompleteSection external review consent", () => { ["cz", "cs-CZ", false], ["hu", "hu-HU", false], ["ro", "ro-RO", false], - ] as const)("renders approved optional purposes for %s", (code, locale, supportsHeureka) => { - testContext.market = { code, locale } + ] as const)( + "renders approved optional purposes for %s", + (code, locale, supportsHeureka) => { + testContext.market = { code, locale } - const markup = renderSection() + const markup = renderSection() - expect(markup).toContain("checkout.review_marketing_consent") - expect(markup).toContain("checkout.review_legal_confirmation") - expect(markup).toContain('data-required="true"') - expect(markup.includes("checkout.review_heureka_consent")).toBe( - supportsHeureka - ) - }) + expect(markup).toContain("checkout.review_marketing_consent") + expect(markup).toContain("checkout.review_legal_confirmation") + expect(markup).toContain('data-required="true"') + expect(markup.includes("checkout.review_heureka_consent")).toBe( + supportsHeureka + ) + } + ) }) diff --git a/apps/herbatika/src/components/claims/claims-localization.test.tsx b/apps/herbatika/src/components/claims/claims-localization.test.tsx index a9c6a4f6b4..a5858473fc 100644 --- a/apps/herbatika/src/components/claims/claims-localization.test.tsx +++ b/apps/herbatika/src/components/claims/claims-localization.test.tsx @@ -127,15 +127,16 @@ const SLOVAK_UI_COPY = /Reklamácie|Vrátiť tovar|Odoslať|Späť|Objednávka č\.|Vyberte produkty|Počet kusov|Požiadavku sa|Ako to funguje|Čo potrebujete|Popíšte, prosím/ describe("claims localization", () => { - it.each( - LOCALES - )("renders the %s claims flow from storefront text", (locale) => { - const html = renderClaims(locale) + it.each(LOCALES)( + "renders the %s claims flow from storefront text", + (locale) => { + const html = renderClaims(locale) - for (const copy of expectedCopy[locale]) { - expect(html).toContain(copy) + for (const copy of expectedCopy[locale]) { + expect(html).toContain(copy) + } } - }) + ) it("keeps every reachable claims component free of hard-coded Slovak UI", () => { for (const source of translatedComponentSources) { diff --git a/apps/herbatika/src/components/claims/turnstile-widget.test.tsx b/apps/herbatika/src/components/claims/turnstile-widget.test.tsx index fcf60a5c74..dcd59acb42 100644 --- a/apps/herbatika/src/components/claims/turnstile-widget.test.tsx +++ b/apps/herbatika/src/components/claims/turnstile-widget.test.tsx @@ -40,27 +40,27 @@ describe("TurnstileWidget localization", () => { vi.resetModules() }) - it.each( - MARKET_EXPECTATIONS - )("uses the $locale catalog copy when Turnstile is enabled without a site key", async ({ - copy, - locale, - }) => { - vi.stubEnv("NEXT_PUBLIC_CLOUDFLARE_TURNSTILE_ENABLED", "1") - vi.stubEnv("NEXT_PUBLIC_CLOUDFLARE_TURNSTILE_SITE_KEY", "") - vi.resetModules() - const { TurnstileWidget } = await import("./turnstile-widget") + it.each(MARKET_EXPECTATIONS)( + "uses the $locale catalog copy when Turnstile is enabled without a site key", + async ({ copy, locale }) => { + vi.stubEnv("NEXT_PUBLIC_CLOUDFLARE_TURNSTILE_ENABLED", "1") + vi.stubEnv("NEXT_PUBLIC_CLOUDFLARE_TURNSTILE_SITE_KEY", "") + vi.resetModules() + const { TurnstileWidget } = await import("./turnstile-widget") - const html = renderToStaticMarkup( - - - - ) + const html = renderToStaticMarkup( + + + + ) - expect(html).toContain(copy) - expect(html).not.toContain("Overenie proti robotom nie je nakonfigurované.") - }) + expect(html).toContain(copy) + expect(html).not.toContain( + "Overenie proti robotom nie je nakonfigurované." + ) + } + ) }) diff --git a/apps/herbatika/src/components/contact-phone-localization.test.tsx b/apps/herbatika/src/components/contact-phone-localization.test.tsx index 78ba742713..e36ad514d2 100644 --- a/apps/herbatika/src/components/contact-phone-localization.test.tsx +++ b/apps/herbatika/src/components/contact-phone-localization.test.tsx @@ -115,14 +115,17 @@ describe("localized contact phone", () => { ["cs-CZ", unavailableByLocale["cs-CZ"]], ["hu-HU", unavailableByLocale["hu-HU"]], ["ro-RO", unavailableByLocale["ro-RO"]], - ] as const)("hides all contact actions for %s without reviewed authority", (locale, unavailable) => { - const html = renderCheckoutHeader(locale) + ] as const)( + "hides all contact actions for %s without reviewed authority", + (locale, unavailable) => { + const html = renderCheckoutHeader(locale) - expect(html).toContain(unavailable) - expect(html).not.toContain('href="tel:') - expect(html).not.toContain("+421 2/321 123 45") - expect(html).not.toContain("+40 (31) 2295431") - }) + expect(html).toContain(unavailable) + expect(html).not.toContain('href="tel:') + expect(html).not.toContain("+421 2/321 123 45") + expect(html).not.toContain("+40 (31) 2295431") + } + ) it.each([ ["checkout header", checkoutHeaderSource], diff --git a/apps/herbatika/src/components/faq/faq-page.data.test.ts b/apps/herbatika/src/components/faq/faq-page.data.test.ts index e9a40e4a79..a6e76214a8 100644 --- a/apps/herbatika/src/components/faq/faq-page.data.test.ts +++ b/apps/herbatika/src/components/faq/faq-page.data.test.ts @@ -87,45 +87,53 @@ describe("localized FAQ data", () => { it.each([ ["cs-CZ", "Často kladené otázky", "CZK"], ["hu-HU", "Gyakran ismételt kérdések", "HUF"], - ] as const)("provides full %s item and answer-block parity", (locale, expectedTitle, currency) => { - const slovak = getFaqPageData("sk-SK") - const localized = getFaqPageData(locale) + ] as const)( + "provides full %s item and answer-block parity", + (locale, expectedTitle, currency) => { + const slovak = getFaqPageData("sk-SK") + const localized = getFaqPageData(locale) - expect(localized).not.toBeNull() - expect(localized?.title).toBe(expectedTitle) - expect(localized?.items.map((item) => item.id)).toEqual( - slovak?.items.map((item) => item.id) - ) - expect( - localized?.items.map((item) => item.answer.map(({ type }) => type)) - ).toEqual(slovak?.items.map((item) => item.answer.map(({ type }) => type))) - expect( - localized?.items.every( - (item) => - item.question.trim().length > 0 && - item.answer.every((block) => - block.type === "list" || block.type === "links" - ? block.items.length > 0 - : block.text.trim().length > 0 - ) + expect(localized).not.toBeNull() + expect(localized?.title).toBe(expectedTitle) + expect(localized?.items.map((item) => item.id)).toEqual( + slovak?.items.map((item) => item.id) ) - ).toBe(true) - expect(visibleStrings(localized?.items ?? []).join("\n")).toContain( - currency - ) - }) + expect( + localized?.items.map((item) => item.answer.map(({ type }) => type)) + ).toEqual( + slovak?.items.map((item) => item.answer.map(({ type }) => type)) + ) + expect( + localized?.items.every( + (item) => + item.question.trim().length > 0 && + item.answer.every((block) => + block.type === "list" || block.type === "links" + ? block.items.length > 0 + : block.text.trim().length > 0 + ) + ) + ).toBe(true) + expect(visibleStrings(localized?.items ?? []).join("\n")).toContain( + currency + ) + } + ) it.each([ ["cs-CZ", HUF_CANARY], ["hu-HU", CZK_CANARY], - ] as const)("keeps %s FAQ free of foreign contacts, operator claims, and currency leakage", (locale, otherMarketCurrency) => { - const localized = getFaqPageData(locale) - const serialized = JSON.stringify(localized) + ] as const)( + "keeps %s FAQ free of foreign contacts, operator claims, and currency leakage", + (locale, otherMarketCurrency) => { + const localized = getFaqPageData(locale) + const serialized = JSON.stringify(localized) - expect(serialized).not.toMatch(FOREIGN_CONTACT_AUTHORITY_CANARY) - expect(serialized).not.toMatch(otherMarketCurrency) - expect(serialized).toContain('"page":"contact"') - }) + expect(serialized).not.toMatch(FOREIGN_CONTACT_AUTHORITY_CANARY) + expect(serialized).not.toMatch(otherMarketCurrency) + expect(serialized).toContain('"page":"contact"') + } + ) it("has approved FAQ content for every supported storefront locale", () => { for (const locale of ["sk-SK", "cs-CZ", "hu-HU", "ro-RO"] as const) { diff --git a/apps/herbatika/src/components/faq/faq-page.test.tsx b/apps/herbatika/src/components/faq/faq-page.test.tsx index d1b37a2727..f5976a710c 100644 --- a/apps/herbatika/src/components/faq/faq-page.test.tsx +++ b/apps/herbatika/src/components/faq/faq-page.test.tsx @@ -54,16 +54,21 @@ describe("FaqPage locale selection", () => { "V jakém stavu je vaše objednávka?", ], ["hu", "hu-HU", "Gyakran ismételt kérdések", "Hol tart a rendelése?"], - ])("renders approved %s market FAQ copy", (code, locale, title, firstQuestion) => { - testContext.market = { code, locale } + ])( + "renders approved %s market FAQ copy", + (code, locale, title, firstQuestion) => { + testContext.market = { code, locale } - const markup = renderToStaticMarkup() + const markup = renderToStaticMarkup() - expect(markup).toContain(title) - expect(markup).toContain(firstQuestion) - expect(markup).toContain("faq.item_count:10") - expect(markup).not.toContain("În ce stadiu se află comanda dumneavoastră?") - }) + expect(markup).toContain(title) + expect(markup).toContain(firstQuestion) + expect(markup).toContain("faq.item_count:10") + expect(markup).not.toContain( + "În ce stadiu se află comanda dumneavoastră?" + ) + } + ) it("renders no FAQ content for an unsupported locale", () => { testContext.market = { code: "gb", locale: "en-GB" } diff --git a/apps/herbatika/src/components/header/herbatika-desktop-submenu.test.tsx b/apps/herbatika/src/components/header/herbatika-desktop-submenu.test.tsx index 9c61befb4c..647d440146 100644 --- a/apps/herbatika/src/components/header/herbatika-desktop-submenu.test.tsx +++ b/apps/herbatika/src/components/header/herbatika-desktop-submenu.test.tsx @@ -71,9 +71,12 @@ describe("Herbatika desktop submenu ordering", () => { ["cs-CZ", "Șalvie", "Sare"], ["hu-HU", "Șalvie", "Sare"], ["ro-RO", "Sare", "Șalvie"], - ] as const)("orders desktop categories with the exact %s collation", (locale, first, second) => { - const html = renderLabels(locale) + ] as const)( + "orders desktop categories with the exact %s collation", + (locale, first, second) => { + const html = renderLabels(locale) - expect(html.indexOf(first)).toBeLessThan(html.indexOf(second)) - }) + expect(html.indexOf(first)).toBeLessThan(html.indexOf(second)) + } + ) }) diff --git a/apps/herbatika/src/components/header/use-herbatika-header-submenu.test.tsx b/apps/herbatika/src/components/header/use-herbatika-header-submenu.test.tsx index 93a2a680f7..9fe51129aa 100644 --- a/apps/herbatika/src/components/header/use-herbatika-header-submenu.test.tsx +++ b/apps/herbatika/src/components/header/use-herbatika-header-submenu.test.tsx @@ -83,7 +83,10 @@ describe("Herbatika header category ordering", () => { ["cz", "cs-CZ", ["Șalvie", "Sare"]], ["hu", "hu-HU", ["Șalvie", "Sare"]], ["ro", "ro-RO", ["Sare", "Șalvie"]], - ] as const)("orders %s categories with the exact %s collation", (market, locale, expected) => { - expect(labelsForLocale(market, locale)).toEqual(expected) - }) + ] as const)( + "orders %s categories with the exact %s collation", + (market, locale, expected) => { + expect(labelsForLocale(market, locale)).toEqual(expected) + } + ) }) diff --git a/apps/herbatika/src/components/herbatika-footer.test.ts b/apps/herbatika/src/components/herbatika-footer.test.ts index 7e3b34d451..37d33fbc83 100644 --- a/apps/herbatika/src/components/herbatika-footer.test.ts +++ b/apps/herbatika/src/components/herbatika-footer.test.ts @@ -83,21 +83,19 @@ describe("HerbatikaFooter market links", () => { }, ] as const - it.each([ - "sk", - "cz", - "hu", - "ro", - ] as const)("marks only %s active and preserves every configured market alternate", (activeMarket) => { - expect( - resolveFooterMarketLinks(activeMarket, configuredAlternates) - ).toEqual( - expectedLinks.map((link) => ({ - active: link.market === activeMarket, - ...link, - })) - ) - }) + it.each(["sk", "cz", "hu", "ro"] as const)( + "marks only %s active and preserves every configured market alternate", + (activeMarket) => { + expect( + resolveFooterMarketLinks(activeMarket, configuredAlternates) + ).toEqual( + expectedLinks.map((link) => ({ + active: link.market === activeMarket, + ...link, + })) + ) + } + ) it("omits unavailable or unsafe markets instead of guessing a domain", () => { expect( @@ -134,13 +132,12 @@ describe("HerbatikaFooter social links", () => { ]) }) - it.each([ - "cz", - "hu", - "ro", - ] as const)("hides every social action without %s market authority", (market) => { - expect(resolveFooterSocialLinks(market)).toEqual([]) - }) + it.each(["cz", "hu", "ro"] as const)( + "hides every social action without %s market authority", + (market) => { + expect(resolveFooterSocialLinks(market)).toEqual([]) + } + ) it("renders only the exact reviewed market profiles", () => { expect( diff --git a/apps/herbatika/src/components/homepage/homepage.hero-readiness.test.ts b/apps/herbatika/src/components/homepage/homepage.hero-readiness.test.ts index 85d77b5281..ee1dd4ae4c 100644 --- a/apps/herbatika/src/components/homepage/homepage.hero-readiness.test.ts +++ b/apps/herbatika/src/components/homepage/homepage.hero-readiness.test.ts @@ -168,28 +168,27 @@ const requestContext = (market: "cz" | "hu" | "ro" = "cz") => { } describe("homepage hero readiness", () => { - it.each([ - "cz", - "hu", - "ro", - ] as const)("renders the approved bundled hero source for %s without CMS content", async (market) => { - const request = requestContext(market) + it.each(["cz", "hu", "ro"] as const)( + "renders the approved bundled hero source for %s without CMS content", + async (market) => { + const request = requestContext(market) - const result = await getServerSideProps(request.context) + const result = await getServerSideProps(request.context) - expect(mocks.readReviewedHomepageHeroBanners).not.toHaveBeenCalled() - expect(mocks.hydrateCmsHeroBannerTargets).toHaveBeenCalled() - expect(result).toMatchObject({ - props: { - page: { - kind: "found", - value: { publicationApproved: true }, + expect(mocks.readReviewedHomepageHeroBanners).not.toHaveBeenCalled() + expect(mocks.hydrateCmsHeroBannerTargets).toHaveBeenCalled() + expect(result).toMatchObject({ + props: { + page: { + kind: "found", + value: { publicationApproved: true }, + }, + seo: { robots: "index, follow" }, }, - seo: { robots: "index, follow" }, - }, - }) - expect(request.context.res.statusCode).toBe(200) - }) + }) + expect(request.context.res.statusCode).toBe(200) + } + ) it("returns 503 when commerce region authority is missing", async () => { mocks.prefetchHomePageStorefrontData.mockResolvedValueOnce({ diff --git a/apps/herbatika/src/components/homepage/homepage.hero.data.test.ts b/apps/herbatika/src/components/homepage/homepage.hero.data.test.ts index b0ef6b31d3..af1acca2f1 100644 --- a/apps/herbatika/src/components/homepage/homepage.hero.data.test.ts +++ b/apps/herbatika/src/components/homepage/homepage.hero.data.test.ts @@ -21,43 +21,46 @@ describe("resolveHomepageHeroBanners", () => { ["cz", CZ_HERO_BANNERS, "Objevte sortiment Herbatica"], ["hu", HU_HERO_BANNERS, "Fedezze fel a Herbatica kínálatát"], ["ro", RO_HERO_BANNERS, "Descoperă gama Herbatica"], - ] as const)("uses a complete safe %s fallback when CMS is empty", (market, expected, headline) => { - const result = resolveHomepageHeroBanners([], market) - const copy = result - .flatMap(({ badge, imageAlt, subtitle, title }) => [ - badge, - imageAlt, - subtitle, - title, - ]) - .filter(Boolean) - .join(" ") + ] as const)( + "uses a complete safe %s fallback when CMS is empty", + (market, expected, headline) => { + const result = resolveHomepageHeroBanners([], market) + const copy = result + .flatMap(({ badge, imageAlt, subtitle, title }) => [ + badge, + imageAlt, + subtitle, + title, + ]) + .filter(Boolean) + .join(" ") - expect(result).toBe(expected) - expect(result).toHaveLength(8) - expect( - result.every( - ({ ctaLabel, ctaTarget, imageAlt, title }) => - ctaLabel && ctaTarget && imageAlt && title - ) - ).toBe(true) - expect(copy).toContain(headline) - expect(copy).not.toMatch(SLOVAK_CANARY) - expect(copy).not.toMatch(UNSAFE_MARKETING_CLAIM_CANARY) - for (const banner of result) { - expect(banner.ctaTarget?.kind).toBe("static") - if (banner.ctaTarget?.kind !== "static") { - throw new Error("Expected a route-registry CTA") - } + expect(result).toBe(expected) + expect(result).toHaveLength(8) expect( - parsePublicPath({ - market, - pathname: banner.ctaTarget.href, - rawQuery: "", - }).kind - ).toBe("found") + result.every( + ({ ctaLabel, ctaTarget, imageAlt, title }) => + ctaLabel && ctaTarget && imageAlt && title + ) + ).toBe(true) + expect(copy).toContain(headline) + expect(copy).not.toMatch(SLOVAK_CANARY) + expect(copy).not.toMatch(UNSAFE_MARKETING_CLAIM_CANARY) + for (const banner of result) { + expect(banner.ctaTarget?.kind).toBe("static") + if (banner.ctaTarget?.kind !== "static") { + throw new Error("Expected a route-registry CTA") + } + expect( + parsePublicPath({ + market, + pathname: banner.ctaTarget.href, + rawQuery: "", + }).kind + ).toBe("found") + } } - }) + ) it("preserves the existing Slovak fallback", () => { expect(resolveHomepageHeroBanners(undefined, "sk")).toBe(HERO_BANNERS) @@ -78,18 +81,16 @@ describe("resolveHomepageHeroBanners", () => { }) describe("resolveHomepageHeroSource", () => { - it.each([ - "sk", - "cz", - "hu", - "ro", - ] as const)("approves the bundled localized source for %s when CMS is empty", (market) => { - expect(resolveHomepageHeroSource([], market)).toEqual({ - kind: "found", - publicationApproved: true, - value: HERO_BANNERS_BY_MARKET[market], - }) - }) + it.each(["sk", "cz", "hu", "ro"] as const)( + "approves the bundled localized source for %s when CMS is empty", + (market) => { + expect(resolveHomepageHeroSource([], market)).toEqual({ + kind: "found", + publicationApproved: true, + value: HERO_BANNERS_BY_MARKET[market], + }) + } + ) it("prefers CMS banners over the bundled source", () => { const cms: HeroBannerItem[] = [{ id: "cms", imageSrc: "/cms.avif" }] diff --git a/apps/herbatika/src/components/product-detail/sections/product-detail-tabs.market.test.tsx b/apps/herbatika/src/components/product-detail/sections/product-detail-tabs.market.test.tsx index 7781517f05..e3fa7bc72e 100644 --- a/apps/herbatika/src/components/product-detail/sections/product-detail-tabs.market.test.tsx +++ b/apps/herbatika/src/components/product-detail/sections/product-detail-tabs.market.test.tsx @@ -60,16 +60,14 @@ const renderTabs = () => ) describe("ProductDetailTabs section structure", () => { - it.each([ - "sk", - "cz", - "hu", - "ro", - ])("renders the reviews tab for %s", (market) => { - testContext.market = { code: market } + it.each(["sk", "cz", "hu", "ro"])( + "renders the reviews tab for %s", + (market) => { + testContext.market = { code: market } - expect(renderTabs()).toContain("reviews.tab_label") - }) + expect(renderTabs()).toContain("reviews.tab_label") + } + ) it("drops the reviews tab when the product id is unknown", () => { testContext.market = { code: "sk" } diff --git a/apps/herbatika/src/components/search/public-search-suggestions.test.ts b/apps/herbatika/src/components/search/public-search-suggestions.test.ts index ec6b915db5..19e7df700f 100644 --- a/apps/herbatika/src/components/search/public-search-suggestions.test.ts +++ b/apps/herbatika/src/components/search/public-search-suggestions.test.ts @@ -18,14 +18,12 @@ const suggestion = (href: string): SearchAutocompleteSuggestion => ({ }) describe("public search suggestion boundary", () => { - it.each([ - "/p/handle", - "/c/handle", - "/znacka/title-slug", - "/search?q=x", - ])("rejects legacy producer href %s", (href) => { - expect(isCanonicalPublicSuggestion(suggestion(href))).toBe(false) - }) + it.each(["/p/handle", "/c/handle", "/znacka/title-slug", "/search?q=x"])( + "rejects legacy producer href %s", + (href) => { + expect(isCanonicalPublicSuggestion(suggestion(href))).toBe(false) + } + ) it.each([ "https://example.test/product", @@ -35,12 +33,12 @@ describe("public search suggestion boundary", () => { expect(isCanonicalPublicSuggestion(suggestion(href))).toBe(false) }) - it.each([ - "/produkt/public-slug", - "/produkt/public-slug?variant=variant-1", - ])("accepts a canonical public href %s", (href) => { - expect(isCanonicalPublicSuggestion(suggestion(href))).toBe(true) - }) + it.each(["/produkt/public-slug", "/produkt/public-slug?variant=variant-1"])( + "accepts a canonical public href %s", + (href) => { + expect(isCanonicalPublicSuggestion(suggestion(href))).toBe(true) + } + ) it("projects every suggestion kind from its stable source ID", () => { const response: SearchAutocompleteResponse = { diff --git a/apps/herbatika/src/components/search/search-entity-results.test.tsx b/apps/herbatika/src/components/search/search-entity-results.test.tsx index 4cbad2f043..6d7890c914 100644 --- a/apps/herbatika/src/components/search/search-entity-results.test.tsx +++ b/apps/herbatika/src/components/search/search-entity-results.test.tsx @@ -112,55 +112,47 @@ const SEARCH_CASES = [ ] as const describe("search entity result localization", () => { - it.each( - SEARCH_CASES - )("renders every $locale public entity label without cross-market copy", ({ - foreignCanaries, - locale, - related, - sections, - }) => { - const html = renderToStaticMarkup( - - - - ) + it.each(SEARCH_CASES)( + "renders every $locale public entity label without cross-market copy", + ({ foreignCanaries, locale, related, sections }) => { + const html = renderToStaticMarkup( + + + + ) - expect(html).toContain(related) - expect(html).toContain(sections.categories) - expect(html).toContain(sections.brands) - expect(html).toContain(sections.content) - for (const foreignCanary of foreignCanaries) { - expect(html).not.toContain(foreignCanary) + expect(html).toContain(related) + expect(html).toContain(sections.categories) + expect(html).toContain(sections.brands) + expect(html).toContain(sections.content) + for (const foreignCanary of foreignCanaries) { + expect(html).not.toContain(foreignCanary) + } } - }) + ) it("keeps the reachable component source free of hardcoded market labels", () => { expect(componentSource).toContain('useTranslations("search")') expect(componentSource).not.toMatch(LOCALIZED_ENTITY_LABELS) }) - it.each( - SEARCH_CASES - )("publishes the exact $locale search catalog without foreign canaries", ({ - foreignCanaries, - locale, - related, - sections, - }) => { - const search = messagesForLocale(locale).search + it.each(SEARCH_CASES)( + "publishes the exact $locale search catalog without foreign canaries", + ({ foreignCanaries, locale, related, sections }) => { + const search = messagesForLocale(locale).search - expect(search.results.related).toBe(related) - expect(search.autocomplete.sections).toEqual(sections) - for (const foreignCanary of foreignCanaries) { - expect(JSON.stringify(search)).not.toContain(foreignCanary) + expect(search.results.related).toBe(related) + expect(search.autocomplete.sections).toEqual(sections) + for (const foreignCanary of foreignCanaries) { + expect(JSON.stringify(search)).not.toContain(foreignCanary) + } } - }) + ) }) diff --git a/apps/herbatika/src/lib/forms/validators/address.test.ts b/apps/herbatika/src/lib/forms/validators/address.test.ts index 56cae28906..552be542bd 100644 --- a/apps/herbatika/src/lib/forms/validators/address.test.ts +++ b/apps/herbatika/src/lib/forms/validators/address.test.ts @@ -53,14 +53,14 @@ describe("postal-code validation", () => { { countryCode: "sk", postalCode: "123 45" }, { countryCode: "hu", postalCode: "0123" }, { countryCode: "ro", postalCode: "012345" }, - ])("accepts the required digit count for $countryCode", ({ - countryCode, - postalCode, - }) => { - const validators = createAddressFieldValidators(messages, countryCode) + ])( + "accepts the required digit count for $countryCode", + ({ countryCode, postalCode }) => { + const validators = createAddressFieldValidators(messages, countryCode) - expect(validators.postalCode(postalCode)).toBeUndefined() - }) + expect(validators.postalCode(postalCode)).toBeUndefined() + } + ) it.each<{ countryCode: HerbatikaCountryCode @@ -74,14 +74,14 @@ describe("postal-code validation", () => { { countryCode: "hu", postalCode: "12345" }, { countryCode: "ro", postalCode: "12345" }, { countryCode: "ro", postalCode: "1234567" }, - ])("rejects the wrong digit count for $countryCode: $postalCode", ({ - countryCode, - postalCode, - }) => { - const validators = createAddressFieldValidators(messages, countryCode) + ])( + "rejects the wrong digit count for $countryCode: $postalCode", + ({ countryCode, postalCode }) => { + const validators = createAddressFieldValidators(messages, countryCode) - expect(validators.postalCode(postalCode)).toBe(messages.postalCodeInvalid) - }) + expect(validators.postalCode(postalCode)).toBe(messages.postalCodeInvalid) + } + ) it("preserves required-value and invalid-character errors", () => { const validators = createAddressFieldValidators(messages, "cz") diff --git a/apps/herbatika/src/lib/market/market-runtime.test.ts b/apps/herbatika/src/lib/market/market-runtime.test.ts index 818790e179..66493ed674 100644 --- a/apps/herbatika/src/lib/market/market-runtime.test.ts +++ b/apps/herbatika/src/lib/market/market-runtime.test.ts @@ -102,13 +102,14 @@ describe("createMarketRuntime", () => { expect(resolveMarketRuntimeByHost(runtime, "unknown.example")).toBeNull() }) - it.each( - HOST_MATRIX - )("resolves the exact accepted host %s to only market %s", (host, market) => { - const runtime = createMarketRuntime(COMPLETE_ENVIRONMENT) + it.each(HOST_MATRIX)( + "resolves the exact accepted host %s to only market %s", + (host, market) => { + const runtime = createMarketRuntime(COMPLETE_ENVIRONMENT) - expect(resolveMarketRuntimeByHost(runtime, host)?.market).toBe(market) - }) + expect(resolveMarketRuntimeByHost(runtime, host)?.market).toBe(market) + } + ) it.each([ ["HERBATICA.CZ:443", "cz"], @@ -192,26 +193,29 @@ describe("createMarketRuntime", () => { ["salesChannelId", "MARKET_SALES_CHANNEL_CZ", "sc_sk"], ["publishableApiKey", "MARKET_PUBLISHABLE_KEY_CZ", "pk_sk"], ["publishableApiKeyId", "MARKET_PUBLISHABLE_KEY_ID_CZ", "pkid_sk"], - ])("rejects a cross-market duplicate %s", (field, environmentName, duplicateValue) => { - expect(() => - createMarketRuntime({ - ...COMPLETE_ENVIRONMENT, - [environmentName]: duplicateValue, - }) - ).toThrow(`${field} is assigned to both sk and cz`) - }) + ])( + "rejects a cross-market duplicate %s", + (field, environmentName, duplicateValue) => { + expect(() => + createMarketRuntime({ + ...COMPLETE_ENVIRONMENT, + [environmentName]: duplicateValue, + }) + ).toThrow(`${field} is assigned to both sk and cz`) + } + ) - it.each([ - "sc_sk,sc_other", - "sc_sk sc_other", - ])("requires exactly one salesChannelId instead of %j", (salesChannelId) => { - expect(() => - createMarketRuntime({ - ...COMPLETE_ENVIRONMENT, - MARKET_SALES_CHANNEL_SK: salesChannelId, - }) - ).toThrow("MARKET_SALES_CHANNEL_SK must contain exactly one value") - }) + it.each(["sc_sk,sc_other", "sc_sk sc_other"])( + "requires exactly one salesChannelId instead of %j", + (salesChannelId) => { + expect(() => + createMarketRuntime({ + ...COMPLETE_ENVIRONMENT, + MARKET_SALES_CHANNEL_SK: salesChannelId, + }) + ).toThrow("MARKET_SALES_CHANNEL_SK must contain exactly one value") + } + ) it("never accepts NEXT_PUBLIC publishable-key authority", () => { expect(() => @@ -241,12 +245,15 @@ describe("createMarketRuntime", () => { ["https://herbatica.sk", "invalid host https://herbatica.sk"], ["herbatica.sk:443", "invalid host herbatica.sk:443"], ["herbatica.sk,herbatica.sk", "duplicate host herbatica.sk"], - ])("rejects an invalid accepted-host manifest %j", (acceptedHosts, message) => { - expect(() => - createMarketRuntime({ - ...COMPLETE_ENVIRONMENT, - MARKET_ACCEPTED_HOSTS_SK: acceptedHosts, - }) - ).toThrow(message) - }) + ])( + "rejects an invalid accepted-host manifest %j", + (acceptedHosts, message) => { + expect(() => + createMarketRuntime({ + ...COMPLETE_ENVIRONMENT, + MARKET_ACCEPTED_HOSTS_SK: acceptedHosts, + }) + ).toThrow(message) + } + ) }) diff --git a/apps/herbatika/src/lib/routing/pages/document-language.test.ts b/apps/herbatika/src/lib/routing/pages/document-language.test.ts index e02d2bd833..d2dd4c0189 100644 --- a/apps/herbatika/src/lib/routing/pages/document-language.test.ts +++ b/apps/herbatika/src/lib/routing/pages/document-language.test.ts @@ -11,12 +11,10 @@ describe("resolvePagesDocumentHtmlLang", () => { expect(resolvePagesDocumentHtmlLang(market)).toBe(expected) }) - it.each([ - undefined, - "", - "en", - ["ro"], - ] as const)("falls back to the valid default language for an invalid header %#", (header) => { - expect(resolvePagesDocumentHtmlLang(header)).toBe("sk-SK") - }) + it.each([undefined, "", "en", ["ro"]] as const)( + "falls back to the valid default language for an invalid header %#", + (header) => { + expect(resolvePagesDocumentHtmlLang(header)).toBe("sk-SK") + } + ) }) diff --git a/apps/herbatika/src/lib/routing/pages/localized-page-error.test.tsx b/apps/herbatika/src/lib/routing/pages/localized-page-error.test.tsx index 5efdf51c4a..dfb1097675 100644 --- a/apps/herbatika/src/lib/routing/pages/localized-page-error.test.tsx +++ b/apps/herbatika/src/lib/routing/pages/localized-page-error.test.tsx @@ -79,38 +79,35 @@ const ERROR_CASES = [ ] as const describe("localized Pages error shells", () => { - it.each( - ERROR_CASES - )("renders every $locale storefront-text surface without raw or foreign errors", ({ - catalog, - foreignCatalogCanaries, - locale, - }) => { - const pageErrors = messagesForLocale(locale).navigation.page_errors - let renderedSurfaces = "" + it.each(ERROR_CASES)( + "renders every $locale storefront-text surface without raw or foreign errors", + ({ catalog, foreignCatalogCanaries, locale }) => { + const pageErrors = messagesForLocale(locale).navigation.page_errors + let renderedSurfaces = "" - expect(pageErrors.catalog).toBe(catalog) - for (const surface of LOCALIZED_ERROR_SURFACES) { - const html = renderToStaticMarkup( - - - - ) + expect(pageErrors.catalog).toBe(catalog) + for (const surface of LOCALIZED_ERROR_SURFACES) { + const html = renderToStaticMarkup( + + + + ) - expect(html, surface).toContain('data-status="503"') - expect(html, surface).toContain('role="alert"') - expect(html, surface).toContain(pageErrors[surface]) - expect(html, surface).not.toContain("Error:") - expect(html, surface).not.toContain("page_errors.") - renderedSurfaces += html - } - for (const foreignCanary of foreignCatalogCanaries) { - expect(renderedSurfaces).not.toContain(foreignCanary) + expect(html, surface).toContain('data-status="503"') + expect(html, surface).toContain('role="alert"') + expect(html, surface).toContain(pageErrors[surface]) + expect(html, surface).not.toContain("Error:") + expect(html, surface).not.toContain("page_errors.") + renderedSurfaces += html + } + for (const foreignCanary of foreignCatalogCanaries) { + expect(renderedSurfaces).not.toContain(foreignCanary) + } } - }) + ) it("renders market-complete standalone 404 and global error copy", () => { const notFoundHtml = renderToStaticMarkup( diff --git a/apps/herbatika/src/lib/routing/pages/product-page.test.ts b/apps/herbatika/src/lib/routing/pages/product-page.test.ts index 7057c9e7f0..cbec342a6c 100644 --- a/apps/herbatika/src/lib/routing/pages/product-page.test.ts +++ b/apps/herbatika/src/lib/routing/pages/product-page.test.ts @@ -181,11 +181,14 @@ describe("resolveProductPageRequest", () => { { kind: "invalid-response", causeCode: "MIGRATION_DRIFT" }, { kind: "unavailable" }, ], - ] as const)("maps registry infrastructure outcome %# to an unavailable page", async (registryResult, expected) => { - await expect( - resolveProductPageRequest(input, dependencies(registryResult)) - ).resolves.toEqual(expected) - }) + ] as const)( + "maps registry infrastructure outcome %# to an unavailable page", + async (registryResult, expected) => { + await expect( + resolveProductPageRequest(input, dependencies(registryResult)) + ).resolves.toEqual(expected) + } + ) it("maps an unexpected adapter failure to unavailable", async () => { const deps = dependencies() diff --git a/apps/herbatika/src/lib/routing/pages/product-route.test.ts b/apps/herbatika/src/lib/routing/pages/product-route.test.ts index 20e7202de9..4e3f6dc18f 100644 --- a/apps/herbatika/src/lib/routing/pages/product-route.test.ts +++ b/apps/herbatika/src/lib/routing/pages/product-route.test.ts @@ -309,17 +309,20 @@ describe("resolveProductRoute", () => { it.each([ [{ kind: "unavailable", retryAfterSeconds: 12 }, 12], [{ kind: "invalid-response", causeCode: "bad-row" }, undefined], - ] as const)("maps registry source failure %o to 503", async (registryResult, retryAfterSeconds) => { - const result = await resolve(registryResult, { - kind: "found", - value: sourceProduct(), - }) + ] as const)( + "maps registry source failure %o to 503", + async (registryResult, retryAfterSeconds) => { + const result = await resolve(registryResult, { + kind: "found", + value: sourceProduct(), + }) - expect(result).toEqual({ - kind: "unavailable", - ...(retryAfterSeconds === undefined ? {} : { retryAfterSeconds }), - }) - }) + expect(result).toEqual({ + kind: "unavailable", + ...(retryAfterSeconds === undefined ? {} : { retryAfterSeconds }), + }) + } + ) it.each([ [{ kind: "missing" }, { kind: "not-found" }], @@ -331,14 +334,17 @@ describe("resolveProductRoute", () => { { kind: "invalid-response", causeCode: "bad-product" }, { kind: "unavailable" }, ], - ] as const)("maps Medusa source result %o", async (sourceResult, expected) => { - const result = await resolve( - { kind: "found", value: currentResolution() }, - sourceResult - ) - - expect(result).toEqual(expected) - }) + ] as const)( + "maps Medusa source result %o", + async (sourceResult, expected) => { + const result = await resolve( + { kind: "found", value: currentResolution() }, + sourceResult + ) + + expect(result).toEqual(expected) + } + ) it("fails closed when URLR identity and Medusa payload disagree", async () => { const wrongIdentity = await resolve( @@ -370,30 +376,33 @@ describe("resolveProductRoute", () => { it.each([ ["cross-market", route("prod-1", { market: "cz" })], ["inactive", route("prod-1", { status: "retired" })], - ] as const)("fails closed for a %s current URLR projection", async (_label, invalidRoute) => { - const currentSlug = slug("current-product", invalidRoute.id, "current") - const readProductById = createSourceReader({ - kind: "found", - value: sourceProduct(), - }) - - const result = await resolve( - { + ] as const)( + "fails closed for a %s current URLR projection", + async (_label, invalidRoute) => { + const currentSlug = slug("current-product", invalidRoute.id, "current") + const readProductById = createSourceReader({ kind: "found", - value: { - disposition: "current", - route: invalidRoute, - matchedSlug: currentSlug, - currentSlug, + value: sourceProduct(), + }) + + const result = await resolve( + { + kind: "found", + value: { + disposition: "current", + route: invalidRoute, + matchedSlug: currentSlug, + currentSlug, + }, }, - }, - { kind: "found", value: sourceProduct() }, - { readProductById } - ) + { kind: "found", value: sourceProduct() }, + { readProductById } + ) - expect(result).toEqual({ kind: "unavailable" }) - expect(readProductById).not.toHaveBeenCalled() - }) + expect(result).toEqual({ kind: "unavailable" }) + expect(readProductById).not.toHaveBeenCalled() + } + ) it("maps a malformed variant payload to 503 instead of throwing", async () => { const malformedProduct = { diff --git a/apps/herbatika/src/lib/routing/pages/ssr-outcome.test.ts b/apps/herbatika/src/lib/routing/pages/ssr-outcome.test.ts index c50e8855aa..43de01d305 100644 --- a/apps/herbatika/src/lib/routing/pages/ssr-outcome.test.ts +++ b/apps/herbatika/src/lib/routing/pages/ssr-outcome.test.ts @@ -73,18 +73,21 @@ describe("applySsrOutcome", () => { it.each([ ["bad-request", 400], ["gone", 410], - ] as const)("renders a hard %s error before the page flushes", (kind, status) => { - const { headers, response } = createResponse() + ] as const)( + "renders a hard %s error before the page flushes", + (kind, status) => { + const { headers, response } = createResponse() - expect(applySsrOutcome(response, { kind })).toEqual({ - props: { page: { kind: "error", status } }, - }) - expect(response.statusCode).toBe(status) - expect(headers.get("cache-control")).toBe( - "private, no-store, max-age=0, must-revalidate" - ) - expect(headers.get("x-robots-tag")).toBe("noindex, nofollow") - }) + expect(applySsrOutcome(response, { kind })).toEqual({ + props: { page: { kind: "error", status } }, + }) + expect(response.statusCode).toBe(status) + expect(headers.get("cache-control")).toBe( + "private, no-store, max-age=0, must-revalidate" + ) + expect(headers.get("x-robots-tag")).toBe("noindex, nofollow") + } + ) it("renders 503 with a bounded default Retry-After value", () => { const { headers, response } = createResponse() @@ -122,14 +125,17 @@ describe("applySsrOutcome", () => { [{ kind: "bad-request" }], [{ kind: "gone" }], [{ kind: "unavailable" }], - ] as const)("keys the %o response on Host for any shared cache", (outcome) => { - const { headers, response } = createResponse() - - applySsrOutcome(response, outcome) - - expect(headers.get("vary")).toBe("Host") - expect(headers.get("cache-control")).toBe( - "private, no-store, max-age=0, must-revalidate" - ) - }) + ] as const)( + "keys the %o response on Host for any shared cache", + (outcome) => { + const { headers, response } = createResponse() + + applySsrOutcome(response, outcome) + + expect(headers.get("vary")).toBe("Host") + expect(headers.get("cache-control")).toBe( + "private, no-store, max-age=0, must-revalidate" + ) + } + ) }) diff --git a/apps/herbatika/src/lib/routing/pages/static-faq-page.test.ts b/apps/herbatika/src/lib/routing/pages/static-faq-page.test.ts index 4046637059..8e5ff4991d 100644 --- a/apps/herbatika/src/lib/routing/pages/static-faq-page.test.ts +++ b/apps/herbatika/src/lib/routing/pages/static-faq-page.test.ts @@ -164,39 +164,43 @@ describe("localized FAQ route", () => { [market, "about"], [market, "faq"], ]) - )("returns a noindex 503 for %s %s when G1 evidence is absent", async (market, pageKey) => { - const { readCmsStaticPageWithDemoFallback } = await import( - "@/lib/storefront/cms" - ) - const { loadStaticRoutePublicationDecision } = await import( - "@/lib/url/segment-registry-publication.server" - ) - vi.mocked(loadStaticRoutePublicationDecision).mockResolvedValueOnce({ - kind: "rejected", - reason: "artifact-unavailable", - }) - const request = requestContext(pageKey, market) + )( + "returns a noindex 503 for %s %s when G1 evidence is absent", + async (market, pageKey) => { + const { readCmsStaticPageWithDemoFallback } = await import( + "@/lib/storefront/cms" + ) + const { loadStaticRoutePublicationDecision } = await import( + "@/lib/url/segment-registry-publication.server" + ) + vi.mocked(loadStaticRoutePublicationDecision).mockResolvedValueOnce({ + kind: "rejected", + reason: "artifact-unavailable", + }) + const request = requestContext(pageKey, market) - const result = await getServerSideProps(request.context) + const result = await getServerSideProps(request.context) - expect(result).toMatchObject({ - props: { - page: { kind: "error", status: 503 }, - seo: { robots: "noindex, nofollow" }, - }, - }) - expect( - (result as { props: { seo: { canonical?: string } } }).props.seo.canonical - ).toBeUndefined() - expect(request.context.res.statusCode).toBe(503) - expect(request.headers.get("x-robots-tag")).toBe("noindex, nofollow") - expect(request.headers.get("retry-after")).toBe("30") - expect(request.headers.get("cache-control")).toBe( - "private, no-store, max-age=0, must-revalidate" - ) - expect(readCmsStaticPageWithDemoFallback).not.toHaveBeenCalled() - expect(mocks.readRequiredPublicEntitySlugs).not.toHaveBeenCalled() - }) + expect(result).toMatchObject({ + props: { + page: { kind: "error", status: 503 }, + seo: { robots: "noindex, nofollow" }, + }, + }) + expect( + (result as { props: { seo: { canonical?: string } } }).props.seo + .canonical + ).toBeUndefined() + expect(request.context.res.statusCode).toBe(503) + expect(request.headers.get("x-robots-tag")).toBe("noindex, nofollow") + expect(request.headers.get("retry-after")).toBe("30") + expect(request.headers.get("cache-control")).toBe( + "private, no-store, max-age=0, must-revalidate" + ) + expect(readCmsStaticPageWithDemoFallback).not.toHaveBeenCalled() + expect(mocks.readRequiredPublicEntitySlugs).not.toHaveBeenCalled() + } + ) it("keeps a real CMS page noindex when the market taxonomy excludes it from G1", async () => { const { readCmsStaticPageWithDemoFallback } = await import( diff --git a/apps/herbatika/src/lib/routing/pages/static-page.test.ts b/apps/herbatika/src/lib/routing/pages/static-page.test.ts index 14d9212fde..3ea7e169ac 100644 --- a/apps/herbatika/src/lib/routing/pages/static-page.test.ts +++ b/apps/herbatika/src/lib/routing/pages/static-page.test.ts @@ -165,29 +165,29 @@ describe("root-static CMS page source", () => { expect(readCmsStaticPageWithDemoFallback).not.toHaveBeenCalled() }) - it.each([ - "about", - "faq", - ] as const)("fails closed before reading %s CMS content without G1 approval", async (pageKey) => { - const { readCmsStaticPageWithDemoFallback } = await import( - "@/lib/storefront/cms" - ) - const { loadStaticRoutePublicationDecision } = await import( - "@/lib/url/segment-registry-publication.server" - ) - vi.mocked(loadStaticRoutePublicationDecision).mockResolvedValueOnce({ - kind: "rejected", - reason: "artifact-unavailable", - }) - const { getServerSideProps } = await import( - "@/pages/~sf/[market]/static/[pageKey]" - ) + it.each(["about", "faq"] as const)( + "fails closed before reading %s CMS content without G1 approval", + async (pageKey) => { + const { readCmsStaticPageWithDemoFallback } = await import( + "@/lib/storefront/cms" + ) + const { loadStaticRoutePublicationDecision } = await import( + "@/lib/url/segment-registry-publication.server" + ) + vi.mocked(loadStaticRoutePublicationDecision).mockResolvedValueOnce({ + kind: "rejected", + reason: "artifact-unavailable", + }) + const { getServerSideProps } = await import( + "@/pages/~sf/[market]/static/[pageKey]" + ) - await expect( - getServerSideProps({ params: { pageKey } } as never) - ).resolves.toEqual({ kind: "unavailable", retryAfterSeconds: 30 }) - expect(readCmsStaticPageWithDemoFallback).not.toHaveBeenCalled() - }) + await expect( + getServerSideProps({ params: { pageKey } } as never) + ).resolves.toEqual({ kind: "unavailable", retryAfterSeconds: 30 }) + expect(readCmsStaticPageWithDemoFallback).not.toHaveBeenCalled() + } + ) it("keeps a real CMS source noindex when taxonomy does not require G1", async () => { const { resolveStaticPublicPage } = await import( diff --git a/apps/herbatika/src/lib/routing/private-flows/medusa-private-flow-reader.test.ts b/apps/herbatika/src/lib/routing/private-flows/medusa-private-flow-reader.test.ts index a8a973428a..135ae01358 100644 --- a/apps/herbatika/src/lib/routing/private-flows/medusa-private-flow-reader.test.ts +++ b/apps/herbatika/src/lib/routing/private-flows/medusa-private-flow-reader.test.ts @@ -61,16 +61,17 @@ describe("Medusa private-flow reader", () => { ) }) - it.each([ - 401, 403, - ])("treats a %s customer response as unauthenticated", async (status) => { - const fetch = vi.fn().mockResolvedValue(response(status)) - const { reader } = createReader(fetch) + it.each([401, 403])( + "treats a %s customer response as unauthenticated", + async (status) => { + const fetch = vi.fn().mockResolvedValue(response(status)) + const { reader } = createReader(fetch) - await expect(reader.readSession("sk", "expired")).resolves.toEqual({ - kind: "unauthenticated", - }) - }) + await expect(reader.readSession("sk", "expired")).resolves.toEqual({ + kind: "unauthenticated", + }) + } + ) it("maps backend failures to retryable unavailability", async () => { const failingResponse = vi.fn().mockResolvedValue(response(503)) diff --git a/apps/herbatika/src/lib/routing/private-flows/medusa-transactional-flow-reader.test.ts b/apps/herbatika/src/lib/routing/private-flows/medusa-transactional-flow-reader.test.ts index e10d40f1f1..9d77d24ef7 100644 --- a/apps/herbatika/src/lib/routing/private-flows/medusa-transactional-flow-reader.test.ts +++ b/apps/herbatika/src/lib/routing/private-flows/medusa-transactional-flow-reader.test.ts @@ -190,16 +190,19 @@ describe("Medusa transactional-flow reader", () => { provider_id: "pp_paykit_stripe", status: "unknown", }, - ])("collapses an incoherent payment-result projection to missing", async (payload) => { - const fetch = vi.fn().mockResolvedValue(response(200, payload)) + ])( + "collapses an incoherent payment-result projection to missing", + async (payload) => { + const fetch = vi.fn().mockResolvedValue(response(200, payload)) - await expect( - createReader(fetch).reader.readPaymentResult("sk", { - cartSessionToken: "Signed.Cart.Session", - resultToken: "Opaque.Result.Bearer", - }) - ).resolves.toEqual({ kind: "missing" }) - }) + await expect( + createReader(fetch).reader.readPaymentResult("sk", { + cartSessionToken: "Signed.Cart.Session", + resultToken: "Opaque.Result.Bearer", + }) + ).resolves.toEqual({ kind: "missing" }) + } + ) it("accepts only a coherent checkout projection", async () => { const fetch = vi.fn().mockResolvedValue( @@ -274,14 +277,15 @@ describe("Medusa transactional-flow reader", () => { ) }) - it.each([ - 400, 401, 403, 404, 409, 410, - ])("collapses unusable secret status %s to missing", async (status) => { - const fetch = vi.fn().mockResolvedValue(response(status)) - await expect( - createReader(fetch).reader.readReviewInvitation("sk", "secret") - ).resolves.toEqual({ kind: "missing" }) - }) + it.each([400, 401, 403, 404, 409, 410])( + "collapses unusable secret status %s to missing", + async (status) => { + const fetch = vi.fn().mockResolvedValue(response(status)) + await expect( + createReader(fetch).reader.readReviewInvitation("sk", "secret") + ).resolves.toEqual({ kind: "missing" }) + } + ) it("maps backend failure and invalid JSON to unavailability", async () => { const backendFailure = vi.fn().mockResolvedValue(response(503)) diff --git a/apps/herbatika/src/lib/routing/public-page-shell.test.ts b/apps/herbatika/src/lib/routing/public-page-shell.test.ts index b8b9bee56f..8c04bf222a 100644 --- a/apps/herbatika/src/lib/routing/public-page-shell.test.ts +++ b/apps/herbatika/src/lib/routing/public-page-shell.test.ts @@ -173,49 +173,52 @@ describe("public storefront shell URL projections", () => { it.each([ { causeCode: "MARKET_DISABLED", kind: "invalid-response" as const }, { kind: "unavailable" as const, retryAfterSeconds: 19 }, - ])("omits a non-current static alternate whose source is $kind", async (alternateFailure) => { - const response = { - setHeader: vi.fn(), - statusCode: 200, - } - const context = { - params: { market: "sk" }, - req: { - headers: { - "x-sf-canonical-origin": "https://herbatica.sk", - "x-sf-market": "sk", - "x-sf-public-path": "/", - "x-sf-route-key": "home", + ])( + "omits a non-current static alternate whose source is $kind", + async (alternateFailure) => { + const response = { + setHeader: vi.fn(), + statusCode: 200, + } + const context = { + params: { market: "sk" }, + req: { + headers: { + "x-sf-canonical-origin": "https://herbatica.sk", + "x-sf-market": "sk", + "x-sf-public-path": "/", + "x-sf-route-key": "home", + }, + url: "/", }, - url: "/", - }, - res: response, - } as never - const loadSource = vi.fn(async (market: "sk" | "cz" | "hu" | "ro") => - market === "sk" - ? ({ kind: "found", value: { title: "Home" } } as const) - : alternateFailure - ) - - const result = await resolveStaticPublicPage(context, { - expectedRouteKey: "home", - loadSource, - path: { kind: "home" }, - queryKind: "homepage", - }) - - expect(result).toMatchObject({ - props: { - page: { kind: "found", value: { title: "Home" } }, - seo: { - alternates: { "sk-SK": "https://herbatica.sk/" }, + res: response, + } as never + const loadSource = vi.fn(async (market: "sk" | "cz" | "hu" | "ro") => + market === "sk" + ? ({ kind: "found", value: { title: "Home" } } as const) + : alternateFailure + ) + + const result = await resolveStaticPublicPage(context, { + expectedRouteKey: "home", + loadSource, + path: { kind: "home" }, + queryKind: "homepage", + }) + + expect(result).toMatchObject({ + props: { + page: { kind: "found", value: { title: "Home" } }, + seo: { + alternates: { "sk-SK": "https://herbatica.sk/" }, + }, }, - }, - }) - expect(loadSource).toHaveBeenCalledTimes(2) - expect(loadSource).toHaveBeenNthCalledWith(1, "sk") - expect(loadSource).toHaveBeenNthCalledWith(2, "cz") - }) + }) + expect(loadSource).toHaveBeenCalledTimes(2) + expect(loadSource).toHaveBeenNthCalledWith(1, "sk") + expect(loadSource).toHaveBeenNthCalledWith(2, "cz") + } + ) it("omits a rejected non-current static alternate source", async () => { const response = { @@ -300,37 +303,40 @@ describe("public storefront shell URL projections", () => { it.each([ { causeCode: "MALFORMED_HOME", kind: "invalid-response" as const }, { kind: "unavailable" as const, retryAfterSeconds: 19 }, - ])("keeps the current static source $kind failure strict", async (sourceFailure) => { - const response = { - setHeader: vi.fn(), - statusCode: 200, - } - const context = { - params: { market: "sk" }, - req: { - headers: { - "x-sf-canonical-origin": "https://herbatica.sk", - "x-sf-market": "sk", - "x-sf-public-path": "/", - "x-sf-route-key": "home", + ])( + "keeps the current static source $kind failure strict", + async (sourceFailure) => { + const response = { + setHeader: vi.fn(), + statusCode: 200, + } + const context = { + params: { market: "sk" }, + req: { + headers: { + "x-sf-canonical-origin": "https://herbatica.sk", + "x-sf-market": "sk", + "x-sf-public-path": "/", + "x-sf-route-key": "home", + }, + url: "/", }, - url: "/", - }, - res: response, - } as never - - const result = await resolveStaticPublicPage(context, { - expectedRouteKey: "home", - loadSource: vi.fn(async () => sourceFailure), - path: { kind: "home" }, - queryKind: "homepage", - }) - - expect(result).toMatchObject({ - props: { page: { kind: "error", status: 503 } }, - }) - expect(response.statusCode).toBe(503) - }) + res: response, + } as never + + const result = await resolveStaticPublicPage(context, { + expectedRouteKey: "home", + loadSource: vi.fn(async () => sourceFailure), + path: { kind: "home" }, + queryKind: "homepage", + }) + + expect(result).toMatchObject({ + props: { page: { kind: "error", status: 503 } }, + }) + expect(response.statusCode).toBe(503) + } + ) it("keeps a rejected current static source strict", async () => { const response = { diff --git a/apps/herbatika/src/lib/routing/public-page.test.ts b/apps/herbatika/src/lib/routing/public-page.test.ts index d3ee006804..e0768856d1 100644 --- a/apps/herbatika/src/lib/routing/public-page.test.ts +++ b/apps/herbatika/src/lib/routing/public-page.test.ts @@ -283,51 +283,51 @@ describe("resolveEntityPublicPage authoritative source ordering", () => { }) }) - it.each( - redirectCases - )("returns 404 instead of redirecting a missing source for $label", async ({ - expectedSourceId, - request, - resolution, - }) => { - mocks.resolveRegistryRoute.mockResolvedValue({ - kind: "found", - value: resolution(), - }) - const loadSource = vi.fn(async () => ({ kind: "missing" as const })) - - const result = await resolve(request(), loadSource) - - expect(result).toEqual({ notFound: true }) - expect(loadSource).toHaveBeenCalledWith({ - market: "sk", - publicSlug: - expectedSourceId === "category-successor" - ? "successor-category" - : "current-category", - sourceId: expectedSourceId, - sourceVersion: "1", - }) - }) + it.each(redirectCases)( + "returns 404 instead of redirecting a missing source for $label", + async ({ expectedSourceId, request, resolution }) => { + mocks.resolveRegistryRoute.mockResolvedValue({ + kind: "found", + value: resolution(), + }) + const loadSource = vi.fn(async () => ({ kind: "missing" as const })) + + const result = await resolve(request(), loadSource) + + expect(result).toEqual({ notFound: true }) + expect(loadSource).toHaveBeenCalledWith({ + market: "sk", + publicSlug: + expectedSourceId === "category-successor" + ? "successor-category" + : "current-category", + sourceId: expectedSourceId, + sourceVersion: "1", + }) + } + ) it.each([ { kind: "unavailable" as const, retryAfterSeconds: 17 }, { causeCode: "malformed-source", kind: "invalid-response" as const }, - ])("returns 503 before an alias redirect for $kind", async (sourceFailure) => { - mocks.resolveRegistryRoute.mockResolvedValue({ - kind: "found", - value: aliasResolution(), - }) - const loadSource = vi.fn(async () => sourceFailure) - const requestContext = context({ slug: "old-category" }) - - const result = await resolve(requestContext, loadSource) - - expect(result).toMatchObject({ - props: { page: { kind: "error", status: 503 } }, - }) - expect(requestContext.res.statusCode).toBe(503) - }) + ])( + "returns 503 before an alias redirect for $kind", + async (sourceFailure) => { + mocks.resolveRegistryRoute.mockResolvedValue({ + kind: "found", + value: aliasResolution(), + }) + const loadSource = vi.fn(async () => sourceFailure) + const requestContext = context({ slug: "old-category" }) + + const result = await resolve(requestContext, loadSource) + + expect(result).toMatchObject({ + props: { page: { kind: "error", status: 503 } }, + }) + expect(requestContext.res.statusCode).toBe(503) + } + ) it("keeps a rejected current entity source strict", async () => { mocks.resolveRegistryRoute.mockResolvedValue({ @@ -438,44 +438,47 @@ describe("resolveEntityPublicPage authoritative source ordering", () => { it.each([ { kind: "unavailable" as const, retryAfterSeconds: 23 }, { causeCode: "malformed-alternate", kind: "invalid-response" as const }, - ])("omits an equivalent category whose source is $kind", async (alternateFailure) => { - const resolution = currentResolution() - const czRoute = route("category-cz", { market: "cz" }) - mocks.resolveRegistryRoute.mockResolvedValue({ - kind: "found", - value: resolution, - }) - mocks.findActiveEquivalents.mockResolvedValue({ - kind: "found", - value: [ - { - projectionType: "entity", - route: czRoute, - currentSlug: slug("ceska-kategorie", czRoute), - }, - ], - }) - const loadSource = vi.fn(async ({ market }: { market: string }) => - market === "sk" - ? { kind: "found" as const, value: { title: "Category" } } - : alternateFailure - ) - const requestContext = context() + ])( + "omits an equivalent category whose source is $kind", + async (alternateFailure) => { + const resolution = currentResolution() + const czRoute = route("category-cz", { market: "cz" }) + mocks.resolveRegistryRoute.mockResolvedValue({ + kind: "found", + value: resolution, + }) + mocks.findActiveEquivalents.mockResolvedValue({ + kind: "found", + value: [ + { + projectionType: "entity", + route: czRoute, + currentSlug: slug("ceska-kategorie", czRoute), + }, + ], + }) + const loadSource = vi.fn(async ({ market }: { market: string }) => + market === "sk" + ? { kind: "found" as const, value: { title: "Category" } } + : alternateFailure + ) + const requestContext = context() - const result = await resolve(requestContext, loadSource) + const result = await resolve(requestContext, loadSource) - expect(result).toMatchObject({ - props: { - page: { kind: "found", value: { title: "Category" } }, - seo: { alternates: { "sk-SK": expect.any(String) } }, - }, - }) - expect( - (result as { props: { seo: { alternates: object } } }).props.seo - .alternates - ).not.toHaveProperty("cs-CZ") - expect(requestContext.res.statusCode).toBe(200) - }) + expect(result).toMatchObject({ + props: { + page: { kind: "found", value: { title: "Category" } }, + seo: { alternates: { "sk-SK": expect.any(String) } }, + }, + }) + expect( + (result as { props: { seo: { alternates: object } } }).props.seo + .alternates + ).not.toHaveProperty("cs-CZ") + expect(requestContext.res.statusCode).toBe(200) + } + ) it("omits a rejected equivalent category source", async () => { const resolution = currentResolution() @@ -516,50 +519,53 @@ describe("resolveEntityPublicPage authoritative source ordering", () => { it.each([ { kind: "unavailable" as const, retryAfterSeconds: 23 }, { causeCode: "malformed-alternate", kind: "invalid-response" as const }, - ])("omits an equivalent product whose source is $kind", async (alternateFailure) => { - const skRoute = route("product-sk", { - equivalenceKey: "product:shared", - kind: "product", - sourceType: "product", - }) - const czRoute = route("product-cz", { - equivalenceKey: "product:shared", - kind: "product", - market: "cz", - sourceType: "product", - }) - mocks.findActiveEquivalents.mockResolvedValue({ - kind: "found", - value: [ + ])( + "omits an equivalent product whose source is $kind", + async (alternateFailure) => { + const skRoute = route("product-sk", { + equivalenceKey: "product:shared", + kind: "product", + sourceType: "product", + }) + const czRoute = route("product-cz", { + equivalenceKey: "product:shared", + kind: "product", + market: "cz", + sourceType: "product", + }) + mocks.findActiveEquivalents.mockResolvedValue({ + kind: "found", + value: [ + { + projectionType: "entity", + route: czRoute, + currentSlug: slug("cesky-produkt", czRoute), + }, + ], + }) + const loadSource = vi.fn(async () => alternateFailure) + + const result = await loadEntityAlternates( { projectionType: "entity", - route: czRoute, - currentSlug: slug("cesky-produkt", czRoute), + route: skRoute, + currentSlug: slug("slovensky-produkt", skRoute), }, - ], - }) - const loadSource = vi.fn(async () => alternateFailure) - - const result = await loadEntityAlternates( - { - projectionType: "entity", - route: skRoute, - currentSlug: slug("slovensky-produkt", skRoute), - }, - loadSource - ) + loadSource + ) - expect(result).toEqual({ - "sk-SK": "https://herbatica.sk/produkty/slovensky-produkt", - }) - expect(loadSource).toHaveBeenCalledOnce() - expect(loadSource).toHaveBeenCalledWith({ - market: "cz", - publicSlug: "cesky-produkt", - sourceId: "product-cz", - sourceVersion: "2026-08-21T10:00:00.000Z", - }) - }) + expect(result).toEqual({ + "sk-SK": "https://herbatica.sk/produkty/slovensky-produkt", + }) + expect(loadSource).toHaveBeenCalledOnce() + expect(loadSource).toHaveBeenCalledWith({ + market: "cz", + publicSlug: "cesky-produkt", + sourceId: "product-cz", + sourceVersion: "2026-08-21T10:00:00.000Z", + }) + } + ) it("omits a product alternate whose current sourceVersion has no exact URLR audit", async () => { const skRoute = route("product-sk", { @@ -619,73 +625,77 @@ describe("resolveEntityPublicPage authoritative source ordering", () => { throw new Error("Malformed collection projection") }, }, - ])("omits a found collection alternate when it $label", async ({ - predicate, - }) => { - const skRoute = route("collection-sk", { - equivalenceKey: "collection:shared", - kind: "collection", - sourceType: "collection", - }) - const czRoute = route("collection-cz", { - equivalenceKey: "collection:shared", - kind: "collection", - market: "cz", - sourceType: "collection", - }) - mocks.findActiveEquivalents.mockResolvedValue({ - kind: "found", - value: [ + ])( + "omits a found collection alternate when it $label", + async ({ predicate }) => { + const skRoute = route("collection-sk", { + equivalenceKey: "collection:shared", + kind: "collection", + sourceType: "collection", + }) + const czRoute = route("collection-cz", { + equivalenceKey: "collection:shared", + kind: "collection", + market: "cz", + sourceType: "collection", + }) + mocks.findActiveEquivalents.mockResolvedValue({ + kind: "found", + value: [ + { + projectionType: "entity", + route: czRoute, + currentSlug: slug("ceska-kolekce", czRoute), + }, + ], + }) + + const result = await loadEntityAlternates( { projectionType: "entity", - route: czRoute, - currentSlug: slug("ceska-kolekce", czRoute), + route: skRoute, + currentSlug: slug("slovenska-kolekcia", skRoute), }, - ], - }) - - const result = await loadEntityAlternates( - { - projectionType: "entity", - route: skRoute, - currentSlug: slug("slovenska-kolekcia", skRoute), - }, - vi.fn(() => - Promise.resolve({ - kind: "found" as const, - value: { catalog: { count: 0 } }, - }) - ), - predicate - ) + vi.fn(() => + Promise.resolve({ + kind: "found" as const, + value: { catalog: { count: 0 } }, + }) + ), + predicate + ) - expect(result).toEqual({ - "sk-SK": expect.stringContaining("slovenska-kolekcia"), - }) - }) + expect(result).toEqual({ + "sk-SK": expect.stringContaining("slovenska-kolekcia"), + }) + } + ) it.each([ { kind: "unavailable" as const, retryAfterSeconds: 23 }, { causeCode: "malformed-equivalents", kind: "invalid-response" as const }, - ])("keeps self when equivalent route discovery is $kind", async (equivalentFailure) => { - const currentRoute = route("category-current") - mocks.findActiveEquivalents.mockResolvedValue(equivalentFailure) - const loadSource = vi.fn() - - const result = await loadEntityAlternates( - { - projectionType: "entity", - route: currentRoute, - currentSlug: slug("current-category", currentRoute), - }, - loadSource - ) + ])( + "keeps self when equivalent route discovery is $kind", + async (equivalentFailure) => { + const currentRoute = route("category-current") + mocks.findActiveEquivalents.mockResolvedValue(equivalentFailure) + const loadSource = vi.fn() + + const result = await loadEntityAlternates( + { + projectionType: "entity", + route: currentRoute, + currentSlug: slug("current-category", currentRoute), + }, + loadSource + ) - expect(result).toEqual({ - "sk-SK": "https://herbatica.sk/kategorie/current-category", - }) - expect(loadSource).not.toHaveBeenCalled() - }) + expect(result).toEqual({ + "sk-SK": "https://herbatica.sk/kategorie/current-category", + }) + expect(loadSource).not.toHaveBeenCalled() + } + ) it("keeps self when equivalent route discovery rejects", async () => { const currentRoute = route("category-current") diff --git a/apps/herbatika/src/lib/routing/public-proxy.test.ts b/apps/herbatika/src/lib/routing/public-proxy.test.ts index 0cb45813de..1e772b740e 100644 --- a/apps/herbatika/src/lib/routing/public-proxy.test.ts +++ b/apps/herbatika/src/lib/routing/public-proxy.test.ts @@ -109,13 +109,16 @@ describe("full public URL proxy", () => { "/~sf/sk/reviews/product/Token-AbC", "reviews.product", ], - ])("rewrites %s to its semantic Pages target", (pathname, internal, routeKey) => { - expect(resolve(pathname)).toMatchObject({ - kind: "rewrite", - pathname: internal, - routeKey, - }) - }) + ])( + "rewrites %s to its semantic Pages target", + (pathname, internal, routeKey) => { + expect(resolve(pathname)).toMatchObject({ + kind: "rewrite", + pathname: internal, + routeKey, + }) + } + ) it("selects localized namespaces from the verified host market", () => { expect( @@ -131,16 +134,17 @@ describe("full public URL proxy", () => { }) }) - it.each( - HOST_MATRIX - )("binds accepted host %s to %s and its canonical origin", (host, market, canonicalizationRequired, canonicalOrigin) => { - expect(resolve("/", { host })).toMatchObject({ - canonicalOrigin, - canonicalizationRequired, - kind: "rewrite", - market, - }) - }) + it.each(HOST_MATRIX)( + "binds accepted host %s to %s and its canonical origin", + (host, market, canonicalizationRequired, canonicalOrigin) => { + expect(resolve("/", { host })).toMatchObject({ + canonicalOrigin, + canonicalizationRequired, + kind: "rewrite", + market, + }) + } + ) it.each([ ["/contact", "contact"], @@ -180,14 +184,17 @@ describe("full public URL proxy", () => { ["herbatica.cz", "cz"], ["herbatica.ro", "ro"], ["herbatica.sk", "sk"], - ] as const)("binds the shared /dropshipping segment to %s", (host, market) => { - expect(resolve("/dropshipping", { host })).toMatchObject({ - kind: "rewrite", - market, - pathname: `/~sf/${market}/static/dropshipping`, - routeKey: "static.dropshipping", - }) - }) + ] as const)( + "binds the shared /dropshipping segment to %s", + (host, market) => { + expect(resolve("/dropshipping", { host })).toMatchObject({ + kind: "rewrite", + market, + pathname: `/~sf/${market}/static/dropshipping`, + routeKey: "static.dropshipping", + }) + } + ) it("does not publish /dropshipping on HU", () => { expect(resolve("/dropshipping", { host: "herbatica.hu" })).toEqual({ @@ -285,12 +292,15 @@ describe("full public URL proxy", () => { ["/akce/jarni", "herbatica.cz", "/~sf/cz/campaign/jarni"], ["/akciok", "herbatica.hu", "/~sf/hu/campaigns"], ["/promotii/vara", "herbatica.ro", "/~sf/ro/campaign/vara"], - ])("publishes the localized campaign route %s", (pathname, host, internal) => { - expect(resolve(pathname, { host })).toMatchObject({ - kind: "rewrite", - pathname: internal, - }) - }) + ])( + "publishes the localized campaign route %s", + (pathname, host, internal) => { + expect(resolve(pathname, { host })).toMatchObject({ + kind: "rewrite", + pathname: internal, + }) + } + ) it.each([ ["/akce", "herbatica.sk"], @@ -368,13 +378,16 @@ describe("full public URL proxy", () => { "/feeds/products.xml", "/favicon.ico", "/.well-known/security.txt", - ])("passes verified-host system route %s to its route handler", (pathname) => { - expect(resolve(pathname)).toEqual({ kind: "next" }) - expect(resolve(pathname, { host: "unknown.example" })).toEqual({ - kind: "respond", - status: 421, - }) - }) + ])( + "passes verified-host system route %s to its route handler", + (pathname) => { + expect(resolve(pathname)).toEqual({ kind: "next" }) + expect(resolve(pathname, { host: "unknown.example" })).toEqual({ + kind: "respond", + status: 421, + }) + } + ) it("preserves canonical /o-nas for SK/CZ and rejects it for HU/RO", () => { expect(resolve("/o-nas")).toMatchObject({ diff --git a/apps/herbatika/src/lib/seo/product.test.ts b/apps/herbatika/src/lib/seo/product.test.ts index cda391315b..84154e1130 100644 --- a/apps/herbatika/src/lib/seo/product.test.ts +++ b/apps/herbatika/src/lib/seo/product.test.ts @@ -106,29 +106,32 @@ describe("buildProductSeo", () => { ["cs-CZ", "CZK", "https://herbatica.cz/produkty/bylinny-caj"], ["hu-HU", "HUF", "https://herbatica.hu/termekek/gyogytea"], ["ro-RO", "RON", "https://herbatica.ro/produse/ceai-de-plante"], - ] as const)("emits %s Product JSON-LD in %s", (locale, currency, canonicalUrl) => { - const marketProduct = { - ...product, - variants: [ - { - ...product.variants[0], - calculated_price: { - calculated_amount: 125, - currency_code: currency.toLowerCase(), + ] as const)( + "emits %s Product JSON-LD in %s", + (locale, currency, canonicalUrl) => { + const marketProduct = { + ...product, + variants: [ + { + ...product.variants[0], + calculated_price: { + calculated_amount: 125, + currency_code: currency.toLowerCase(), + }, }, - }, - ], - } as unknown as ProductRouteMedusaProduct + ], + } as unknown as ProductRouteMedusaProduct - const jsonLd = buildProductSeo({ - canonicalUrl, - locale, - product: marketProduct, - }).jsonLd + const jsonLd = buildProductSeo({ + canonicalUrl, + locale, + product: marketProduct, + }).jsonLd - expect(jsonLd.inLanguage).toBe(locale) - expect(jsonLd.offers?.priceCurrency).toBe(currency) - }) + expect(jsonLd.inLanguage).toBe(locale) + expect(jsonLd.offers?.priceCurrency).toBe(currency) + } + ) it("omits an invalid GTIN instead of publishing unverified identifiers", () => { const invalidGtinProduct = { diff --git a/apps/herbatika/src/lib/seo/sitemap-source-validation.test.ts b/apps/herbatika/src/lib/seo/sitemap-source-validation.test.ts index 80f4e02e21..2bb4d80d81 100644 --- a/apps/herbatika/src/lib/seo/sitemap-source-validation.test.ts +++ b/apps/herbatika/src/lib/seo/sitemap-source-validation.test.ts @@ -181,52 +181,55 @@ describe("sitemap source validation", () => { ["category", "product_category"], ["brand", "brand"], ["collection", "product_collection"], - ] as const)("requires the exact %s translation proof", async (kind, reference) => { - const valid = assignment( - `${kind}_1`, - `slug-${kind}-1`, - { - translation: { - localeCode: "cs-CZ", - reference, - translationId: `translation_${kind}_1`, + ] as const)( + "requires the exact %s translation proof", + async (kind, reference) => { + const valid = assignment( + `${kind}_1`, + `slug-${kind}-1`, + { + translation: { + localeCode: "cs-CZ", + reference, + translationId: `translation_${kind}_1`, + }, }, - }, - kind - ) - await expect( - validateCatalogSitemapSources( - { binding, kind, sources: [source(`${kind}_1`)] }, - catalogDependencies([valid], kind) + kind ) - ).resolves.toEqual({ - kind: "found", - value: [{ routeId: `route_${kind}_1` }], - }) - - for (const translation of [ - { - localeCode: "sk-SK", - reference, - translationId: `translation_${kind}_1`, - }, - { - localeCode: "cs-CZ", - reference: "product", - translationId: `translation_${kind}_1`, - }, - ]) { await expect( validateCatalogSitemapSources( { binding, kind, sources: [source(`${kind}_1`)] }, - catalogDependencies([{ ...valid, translation }], kind) + catalogDependencies([valid], kind) ) ).resolves.toEqual({ - causeCode: "INVALID_SITEMAP_ASSIGNMENT_BATCH_RESPONSE", - kind: "invalid-response", + kind: "found", + value: [{ routeId: `route_${kind}_1` }], }) + + for (const translation of [ + { + localeCode: "sk-SK", + reference, + translationId: `translation_${kind}_1`, + }, + { + localeCode: "cs-CZ", + reference: "product", + translationId: `translation_${kind}_1`, + }, + ]) { + await expect( + validateCatalogSitemapSources( + { binding, kind, sources: [source(`${kind}_1`)] }, + catalogDependencies([{ ...valid, translation }], kind) + ) + ).resolves.toEqual({ + causeCode: "INVALID_SITEMAP_ASSIGNMENT_BATCH_RESPONSE", + kind: "invalid-response", + }) + } } - }) + ) it("rejects catalog source-version drift before or after the request", async () => { const candidate = source("cat_1", "slug-cat-1", "7") diff --git a/apps/herbatika/src/lib/seo/system-runtime.server.test.ts b/apps/herbatika/src/lib/seo/system-runtime.server.test.ts index c15076689b..6bf3b51541 100644 --- a/apps/herbatika/src/lib/seo/system-runtime.server.test.ts +++ b/apps/herbatika/src/lib/seo/system-runtime.server.test.ts @@ -289,55 +289,54 @@ describe("system sitemap source wiring", () => { expect(mocks.fetch).not.toHaveBeenCalled() }) - it.each([ - "category", - "brand", - "collection", - ] as const)("uses the bounded %s assignment endpoint", async (kind) => { - mocks.fetch.mockResolvedValue({ - assignments: [assignment("source_1", "public-slug", kind)], - entityKind: kind, - marketCode: "cz", - schemaVersion: 1, - }) - - await expect( - systemSitemapDependencies.validateEntitySources({ - kind, - market: "cz", - sources: [ - { - publicSlug: "public-slug", - routeId: "route_1", - sourceId: "source_1", - sourceVersion: "7", - }, - ], + it.each(["category", "brand", "collection"] as const)( + "uses the bounded %s assignment endpoint", + async (kind) => { + mocks.fetch.mockResolvedValue({ + assignments: [assignment("source_1", "public-slug", kind)], + entityKind: kind, + marketCode: "cz", + schemaVersion: 1, }) - ).resolves.toEqual({ - kind: "found", - value: [{ routeId: "route_1" }], - }) - expect(mocks.fetch).toHaveBeenCalledWith( - "/store/url-registry/catalog/sources", - { - body: { - candidates: [ + + await expect( + systemSitemapDependencies.validateEntitySources({ + kind, + market: "cz", + sources: [ { - entityId: "source_1", publicSlug: "public-slug", + routeId: "route_1", + sourceId: "source_1", sourceVersion: "7", }, ], - entityKind: kind, - market: "cz", - schemaVersion: 1, - }, - method: "POST", - signal: expect.any(AbortSignal), - } - ) - }) + }) + ).resolves.toEqual({ + kind: "found", + value: [{ routeId: "route_1" }], + }) + expect(mocks.fetch).toHaveBeenCalledWith( + "/store/url-registry/catalog/sources", + { + body: { + candidates: [ + { + entityId: "source_1", + publicSlug: "public-slug", + sourceVersion: "7", + }, + ], + entityKind: kind, + market: "cz", + schemaVersion: 1, + }, + method: "POST", + signal: expect.any(AbortSignal), + } + ) + } + ) it("resolves the current catalog source version from the matching URLR audit", async () => { const target = projection("category_1", 2) @@ -462,56 +461,56 @@ describe("system sitemap source wiring", () => { ) }) - it.each([ - "about", - "faq", - ] as const)("validates the %s static root page against its live CMS-rendered source, not code-owned data", async (staticRouteKey) => { - const page = { - content: `Reviewed ${staticRouteKey} content`, - id: 91, - publishedDate: "2026-08-21T10:00:00.000Z", - title: staticRouteKey, - } - mocks.readStaticPage.mockResolvedValue({ kind: "found", value: page }) + it.each(["about", "faq"] as const)( + "validates the %s static root page against its live CMS-rendered source, not code-owned data", + async (staticRouteKey) => { + const page = { + content: `Reviewed ${staticRouteKey} content`, + id: 91, + publishedDate: "2026-08-21T10:00:00.000Z", + title: staticRouteKey, + } + mocks.readStaticPage.mockResolvedValue({ kind: "found", value: page }) - await expect( - systemSitemapDependencies.validateStaticSources({ - market: "cz", - sources: [{ routeId: `route_${staticRouteKey}`, staticRouteKey }], - }) - ).resolves.toEqual({ - kind: "found", - value: [ - { - routeId: `route_${staticRouteKey}`, - updatedAt: "2026-08-21T10:00:00.000Z", - }, - ], - }) - expect(mocks.readStaticPage).toHaveBeenCalledWith(staticRouteKey, "cs-CZ") - expect(mocks.assertReviewedStaticSource).toHaveBeenCalledWith( - expect.objectContaining({ - market: "cz", - pageKey: staticRouteKey, - renderedSource: page, + await expect( + systemSitemapDependencies.validateStaticSources({ + market: "cz", + sources: [{ routeId: `route_${staticRouteKey}`, staticRouteKey }], + }) + ).resolves.toEqual({ + kind: "found", + value: [ + { + routeId: `route_${staticRouteKey}`, + updatedAt: "2026-08-21T10:00:00.000Z", + }, + ], }) - ) - }) + expect(mocks.readStaticPage).toHaveBeenCalledWith(staticRouteKey, "cs-CZ") + expect(mocks.assertReviewedStaticSource).toHaveBeenCalledWith( + expect.objectContaining({ + market: "cz", + pageKey: staticRouteKey, + renderedSource: page, + }) + ) + } + ) - it.each([ - "about", - "faq", - ] as const)("excludes %s from the sitemap when the live CMS source is unavailable, even if code-owned fallback data exists", async (staticRouteKey) => { - mocks.readStaticPage.mockResolvedValue({ kind: "missing" }) + it.each(["about", "faq"] as const)( + "excludes %s from the sitemap when the live CMS source is unavailable, even if code-owned fallback data exists", + async (staticRouteKey) => { + mocks.readStaticPage.mockResolvedValue({ kind: "missing" }) - await expect( - systemSitemapDependencies.validateStaticSources({ - market: "cz", - sources: [{ routeId: `route_${staticRouteKey}`, staticRouteKey }], - }) - ).resolves.toEqual({ kind: "found", value: [] }) - expect(mocks.assertReviewedStaticSource).not.toHaveBeenCalled() - }) + await expect( + systemSitemapDependencies.validateStaticSources({ + market: "cz", + sources: [{ routeId: `route_${staticRouteKey}`, staticRouteKey }], + }) + ).resolves.toEqual({ kind: "found", value: [] }) + expect(mocks.assertReviewedStaticSource).not.toHaveBeenCalled() + } + ) it("normalizes only production root static taxonomy keys", async () => { const page = { diff --git a/apps/herbatika/src/lib/storefront/catalog-publication-proof.test.ts b/apps/herbatika/src/lib/storefront/catalog-publication-proof.test.ts index 4f87a2a729..6b690f2795 100644 --- a/apps/herbatika/src/lib/storefront/catalog-publication-proof.test.ts +++ b/apps/herbatika/src/lib/storefront/catalog-publication-proof.test.ts @@ -139,16 +139,19 @@ describe("readCatalogPublicationProof", () => { kind: "invalid-response", }, ], - ])("maps HTTP %s without accepting an unproven entity", async (status, expected) => { - await expect( - readCatalogPublicationProof(request, { - resolveMarket: vi.fn(() => binding), - retrieveAssignments: vi - .fn() - .mockRejectedValue( - Object.assign(new Error("request failed"), { status }) - ), - }) - ).resolves.toEqual(expected) - }) + ])( + "maps HTTP %s without accepting an unproven entity", + async (status, expected) => { + await expect( + readCatalogPublicationProof(request, { + resolveMarket: vi.fn(() => binding), + retrieveAssignments: vi + .fn() + .mockRejectedValue( + Object.assign(new Error("request failed"), { status }) + ), + }) + ).resolves.toEqual(expected) + } + ) }) diff --git a/apps/herbatika/src/lib/storefront/checkout-consent.test.ts b/apps/herbatika/src/lib/storefront/checkout-consent.test.ts index db66fc911e..f82ea45638 100644 --- a/apps/herbatika/src/lib/storefront/checkout-consent.test.ts +++ b/apps/herbatika/src/lib/storefront/checkout-consent.test.ts @@ -15,20 +15,18 @@ import { isOptionalCheckoutProviderAllowed } from "./checkout-consent-provider-g const NOW = new Date("2026-08-21T12:00:00.000Z") describe("checkout consent", () => { - it.each([ - "sk", - "cz", - "hu", - "ro", - ] as const)("defaults every purpose to denied for %s", (market) => { - expect(createDeniedCheckoutConsent(market, NOW)).toEqual({ - market, - policyVersion: CHECKOUT_CONSENT_POLICY_VERSION, - purposes: { heureka: false, marketing: false }, - timestamp: NOW.toISOString(), - version: CHECKOUT_CONSENT_VERSION, - }) - }) + it.each(["sk", "cz", "hu", "ro"] as const)( + "defaults every purpose to denied for %s", + (market) => { + expect(createDeniedCheckoutConsent(market, NOW)).toEqual({ + market, + policyVersion: CHECKOUT_CONSENT_POLICY_VERSION, + purposes: { heureka: false, marketing: false }, + timestamp: NOW.toISOString(), + version: CHECKOUT_CONSENT_VERSION, + }) + } + ) it("keeps purpose decisions independent", () => { expect( @@ -47,19 +45,18 @@ describe("checkout consent", () => { ).toEqual({ heureka: true, marketing: false }) }) - it.each([ - "cz", - "hu", - "ro", - ] as const)("rejects Heureka consent for unapproved market %s", (market) => { - expect( - createCheckoutConsentSnapshot({ - market, - now: NOW, - purposes: { heureka: true, marketing: false }, - }) - ).toBeNull() - }) + it.each(["cz", "hu", "ro"] as const)( + "rejects Heureka consent for unapproved market %s", + (market) => { + expect( + createCheckoutConsentSnapshot({ + market, + now: NOW, + purposes: { heureka: true, marketing: false }, + }) + ).toBeNull() + } + ) it("fails closed for policy, market, timestamp, shape, or Heureka mismatch", () => { const valid = createDeniedCheckoutConsent("sk", NOW) diff --git a/apps/herbatika/src/lib/storefront/cms-footer-navigation.test.ts b/apps/herbatika/src/lib/storefront/cms-footer-navigation.test.ts index e35248a018..16e6ef4d67 100644 --- a/apps/herbatika/src/lib/storefront/cms-footer-navigation.test.ts +++ b/apps/herbatika/src/lib/storefront/cms-footer-navigation.test.ts @@ -15,31 +15,36 @@ describe("CMS footer navigation fallback", () => { ["cs-CZ", ["/blog", "/o-nas", "/caste-dotazy"]], ["hu-HU", ["/blog", "/rolunk", "/gyakori-kerdesek"]], ["ro-RO", ["/blog", "/despre-noi", "/intrebari-frecvente"]], - ] as const)("uses route-derived %s navigation when Payload is empty", async (locale, expectedHrefs) => { - const { fetchCmsJsonOrThrow } = await import("./cms-client") - vi.mocked(fetchCmsJsonOrThrow).mockResolvedValue({ - footerNavigation: { columns: [] }, - }) - const { fetchCmsFooterNavigation } = await import("./cms-footer-navigation") + ] as const)( + "uses route-derived %s navigation when Payload is empty", + async (locale, expectedHrefs) => { + const { fetchCmsJsonOrThrow } = await import("./cms-client") + vi.mocked(fetchCmsJsonOrThrow).mockResolvedValue({ + footerNavigation: { columns: [] }, + }) + const { fetchCmsFooterNavigation } = await import( + "./cms-footer-navigation" + ) - const navigation = await fetchCmsFooterNavigation(locale) + const navigation = await fetchCmsFooterNavigation(locale) - expect(navigation.columns.map((column) => column.slot)).toEqual([ - "information", - ]) - expect( - navigation.columns.flatMap((column) => - column.items.map((item) => item.href) - ) - ).toEqual(expectedHrefs) - expect( - navigation.columns.flatMap((column) => - column.items.map((item) => item.slot) - ) - // The catalog currently ships without brand records, so the fallback - // footer intentionally omits the brands link. - ).toEqual(["blog", "about", "faq"]) - }) + expect(navigation.columns.map((column) => column.slot)).toEqual([ + "information", + ]) + expect( + navigation.columns.flatMap((column) => + column.items.map((item) => item.href) + ) + ).toEqual(expectedHrefs) + expect( + navigation.columns.flatMap((column) => + column.items.map((item) => item.slot) + ) + // The catalog currently ships without brand records, so the fallback + // footer intentionally omits the brands link. + ).toEqual(["blog", "about", "faq"]) + } + ) it("preserves an approved non-empty Payload navigation", async () => { const approved = { diff --git a/apps/herbatika/src/lib/storefront/collections-route-source.test.ts b/apps/herbatika/src/lib/storefront/collections-route-source.test.ts index a8850bae5a..a0482d601b 100644 --- a/apps/herbatika/src/lib/storefront/collections-route-source.test.ts +++ b/apps/herbatika/src/lib/storefront/collections-route-source.test.ts @@ -167,20 +167,21 @@ describe("readCollectionRouteSource", () => { }) }) - it.each([ - 408, 425, 429, 500, 503, - ])("maps retryable HTTP %s to unavailable", async (status) => { - const deps = dependencies({ - retrieveAssignment: vi.fn().mockRejectedValue(statusError(status)), - }) - - await expect( - readCollectionRouteSource( - { collectionId: "pcol_1", market: "sk", queryState }, - deps - ) - ).resolves.toEqual({ kind: "unavailable" }) - }) + it.each([408, 425, 429, 500, 503])( + "maps retryable HTTP %s to unavailable", + async (status) => { + const deps = dependencies({ + retrieveAssignment: vi.fn().mockRejectedValue(statusError(status)), + }) + + await expect( + readCollectionRouteSource( + { collectionId: "pcol_1", market: "sk", queryState }, + deps + ) + ).resolves.toEqual({ kind: "unavailable" }) + } + ) it("maps an absent assignment to a missing source", async () => { const deps = dependencies({ diff --git a/apps/herbatika/src/lib/storefront/free-shipping.test.ts b/apps/herbatika/src/lib/storefront/free-shipping.test.ts index 3d00c36958..c7aeed2a98 100644 --- a/apps/herbatika/src/lib/storefront/free-shipping.test.ts +++ b/apps/herbatika/src/lib/storefront/free-shipping.test.ts @@ -11,9 +11,12 @@ describe("resolveFreeShippingThresholdAmount", () => { ["ron", 249], ["EUR", 49], ["eur", 49], - ] as const)("resolves the %s commerce threshold", (currencyCode, expected) => { - expect(resolveFreeShippingThresholdAmount(currencyCode)).toBe(expected) - }) + ] as const)( + "resolves the %s commerce threshold", + (currencyCode, expected) => { + expect(resolveFreeShippingThresholdAmount(currencyCode)).toBe(expected) + } + ) it("does not guess a threshold for an invalid or unknown currency", () => { expect(resolveFreeShippingThresholdAmount("lei")).toBeNull() diff --git a/apps/herbatika/src/lib/storefront/market-context.test.ts b/apps/herbatika/src/lib/storefront/market-context.test.ts index eb42657cae..ef04b03841 100644 --- a/apps/herbatika/src/lib/storefront/market-context.test.ts +++ b/apps/herbatika/src/lib/storefront/market-context.test.ts @@ -34,18 +34,19 @@ const HOST_MATRIX = [ ] as const describe("resolveMarketContext", () => { - it.each( - HOST_MATRIX - )("resolves accepted host %s as the %s market", (host, code, countryCode, locale) => { - expect( - resolveMarketContext({ environment: ROUTING_ENVIRONMENT, host }) - ).toMatchObject({ - code, - countryCode, - domain: `herbatica.${code}`, - locale, - }) - }) + it.each(HOST_MATRIX)( + "resolves accepted host %s as the %s market", + (host, code, countryCode, locale) => { + expect( + resolveMarketContext({ environment: ROUTING_ENVIRONMENT, host }) + ).toMatchObject({ + code, + countryCode, + domain: `herbatica.${code}`, + locale, + }) + } + ) it("prefers the public Host header over a proxy forwarded host", () => { expect( diff --git a/apps/herbatika/src/lib/storefront/market-region-authority.test.ts b/apps/herbatika/src/lib/storefront/market-region-authority.test.ts index e8a44e2b8c..1dd6cfe93f 100644 --- a/apps/herbatika/src/lib/storefront/market-region-authority.test.ts +++ b/apps/herbatika/src/lib/storefront/market-region-authority.test.ts @@ -36,43 +36,51 @@ const region = ( }) as HttpTypes.StoreRegion describe("resolveBoundRegion", () => { - it.each( - MARKET_CASES - )("binds $market to its configured region, country, and $currencyCode currency", (marketCase) => { - expect( - resolveBoundRegion(binding(marketCase), [ - region("reg_other", [marketCase.countryCode], marketCase.currencyCode), - region( - `reg_${marketCase.market}`, - [marketCase.countryCode], - marketCase.currencyCode.toLowerCase() - ), - ]) - ).toEqual({ - country_code: marketCase.countryCode.toLowerCase(), - currency_code: marketCase.currencyCode, - region_id: `reg_${marketCase.market}`, - }) - }) + it.each(MARKET_CASES)( + "binds $market to its configured region, country, and $currencyCode currency", + (marketCase) => { + expect( + resolveBoundRegion(binding(marketCase), [ + region( + "reg_other", + [marketCase.countryCode], + marketCase.currencyCode + ), + region( + `reg_${marketCase.market}`, + [marketCase.countryCode], + marketCase.currencyCode.toLowerCase() + ), + ]) + ).toEqual({ + country_code: marketCase.countryCode.toLowerCase(), + currency_code: marketCase.currencyCode, + region_id: `reg_${marketCase.market}`, + }) + } + ) it.each([ { ...MARKET_CASES[0], wrongCurrencyCode: "CZK" }, { ...MARKET_CASES[1], wrongCurrencyCode: "HUF" }, { ...MARKET_CASES[2], wrongCurrencyCode: "RON" }, { ...MARKET_CASES[3], wrongCurrencyCode: "EUR" }, - ])("rejects $market when its configured region uses $wrongCurrencyCode instead of $currencyCode", (marketCase) => { - expect(() => - resolveBoundRegion(binding(marketCase), [ - region( - `reg_${marketCase.market}`, - [marketCase.countryCode], - marketCase.wrongCurrencyCode - ), - ]) - ).toThrow( - `Configured region currency does not match market ${marketCase.market}: expected ${marketCase.currencyCode}` - ) - }) + ])( + "rejects $market when its configured region uses $wrongCurrencyCode instead of $currencyCode", + (marketCase) => { + expect(() => + resolveBoundRegion(binding(marketCase), [ + region( + `reg_${marketCase.market}`, + [marketCase.countryCode], + marketCase.wrongCurrencyCode + ), + ]) + ).toThrow( + `Configured region currency does not match market ${marketCase.market}: expected ${marketCase.currencyCode}` + ) + } + ) it("does not fall back to another region containing the same country", () => { const czBinding = binding(MARKET_CASES[1]) diff --git a/apps/herbatika/src/lib/storefront/operator-contact-authority.server.test.ts b/apps/herbatika/src/lib/storefront/operator-contact-authority.server.test.ts index fe34367173..a4325d41cd 100644 --- a/apps/herbatika/src/lib/storefront/operator-contact-authority.server.test.ts +++ b/apps/herbatika/src/lib/storefront/operator-contact-authority.server.test.ts @@ -292,18 +292,19 @@ describe("applyOperatorContactAuthority", () => { }) }) - it.each([ - "cz", - "hu", - "ro", - ] as const)("falls back to %s storefront messages without reviewed authority", (market) => { - expect(applyOperatorContactAuthority(market, baseMessages)).toMatchObject({ - "navigation.contact.authority_status": "available", - "navigation.contact.authority_source": "sk-existing", - "navigation.contact.email_href": "mailto:ahoj@herbatica.sk", - "navigation.contact.phone_href": "tel:+421232112345", - }) - }) + it.each(["cz", "hu", "ro"] as const)( + "falls back to %s storefront messages without reviewed authority", + (market) => { + expect(applyOperatorContactAuthority(market, baseMessages)).toMatchObject( + { + "navigation.contact.authority_status": "available", + "navigation.contact.authority_source": "sk-existing", + "navigation.contact.email_href": "mailto:ahoj@herbatica.sk", + "navigation.contact.phone_href": "tel:+421232112345", + } + ) + } + ) it("accepts a payload only when manifest, artifact, and both approvals match", () => { expect( diff --git a/apps/herbatika/src/lib/storefront/order-payment-qr.test.ts b/apps/herbatika/src/lib/storefront/order-payment-qr.test.ts index c00268ebcb..5e32bb0d1d 100644 --- a/apps/herbatika/src/lib/storefront/order-payment-qr.test.ts +++ b/apps/herbatika/src/lib/storefront/order-payment-qr.test.ts @@ -64,41 +64,39 @@ describe("order payment QR private client", () => { expect(hasOrderPaymentQrAuthority(input)).toBe(expected) }) - it.each([ - "EUR", - "CZK", - "HUF", - "RON", - ] as const)("maps a QR response only for the exact %s market currency", async (currencyCode) => { - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue( - Response.json({ - qr_payment: { - amount: 123.45, - currency_code: currencyCode.toLowerCase(), - iban: "CZ6508000000192000145399", - order_display_id: "42", - order_id: "order_Case", - provider_id: "pp_qr_manual_default", - qr_svg: "", - spayd: `SPD*1.0*ACC:CZ6508000000192000145399*AM:123.45*CC:${currencyCode}*X-VS:42`, - }, - status: "ready", - }) + it.each(["EUR", "CZK", "HUF", "RON"] as const)( + "maps a QR response only for the exact %s market currency", + async (currencyCode) => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + Response.json({ + qr_payment: { + amount: 123.45, + currency_code: currencyCode.toLowerCase(), + iban: "CZ6508000000192000145399", + order_display_id: "42", + order_id: "order_Case", + provider_id: "pp_qr_manual_default", + qr_svg: "", + spayd: `SPD*1.0*ACC:CZ6508000000192000145399*AM:123.45*CC:${currencyCode}*X-VS:42`, + }, + status: "ready", + }) + ) ) - ) - await expect( - fetchOrderPaymentQr({ - expectedCurrencyCode: currencyCode, - orderId: "order_Case", + await expect( + fetchOrderPaymentQr({ + expectedCurrencyCode: currencyCode, + orderId: "order_Case", + }) + ).resolves.toMatchObject({ + qrPayment: { currencyCode }, + status: "ready", }) - ).resolves.toMatchObject({ - qrPayment: { currencyCode }, - status: "ready", - }) - }) + } + ) it.each([ ["missing response currency", null, "CC:CZK"], @@ -106,30 +104,33 @@ describe("order payment QR private client", () => { ["foreign response currency", "EUR", "CC:CZK"], ["foreign SPAYD currency", "CZK", "CC:EUR"], ["ambiguous SPAYD currency", "CZK", "CC:CZK*CC:CZK"], - ] as const)("fails closed for %s", async (_name, responseCurrencyCode, spaydCurrencyField) => { - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue( - Response.json({ - qr_payment: { - amount: 123.45, - currency_code: responseCurrencyCode, - iban: "CZ6508000000192000145399", - order_id: "order_Case", - provider_id: "pp_qr_manual_default", - qr_svg: "", - spayd: `SPD*1.0*ACC:CZ6508000000192000145399*AM:123.45*${spaydCurrencyField}*X-VS:42`, - }, - status: "ready", - }) + ] as const)( + "fails closed for %s", + async (_name, responseCurrencyCode, spaydCurrencyField) => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + Response.json({ + qr_payment: { + amount: 123.45, + currency_code: responseCurrencyCode, + iban: "CZ6508000000192000145399", + order_id: "order_Case", + provider_id: "pp_qr_manual_default", + qr_svg: "", + spayd: `SPD*1.0*ACC:CZ6508000000192000145399*AM:123.45*${spaydCurrencyField}*X-VS:42`, + }, + status: "ready", + }) + ) ) - ) - await expect( - fetchOrderPaymentQr({ - expectedCurrencyCode: "CZK", - orderId: "order_Case", - }) - ).resolves.toEqual({ qrPayment: null, status: "unavailable" }) - }) + await expect( + fetchOrderPaymentQr({ + expectedCurrencyCode: "CZK", + orderId: "order_Case", + }) + ).resolves.toEqual({ qrPayment: null, status: "unavailable" }) + } + ) }) diff --git a/apps/herbatika/src/lib/storefront/price-format.test.ts b/apps/herbatika/src/lib/storefront/price-format.test.ts index 15d2a971a4..5a39711836 100644 --- a/apps/herbatika/src/lib/storefront/price-format.test.ts +++ b/apps/herbatika/src/lib/storefront/price-format.test.ts @@ -9,9 +9,12 @@ describe("formatCurrencyAmount", () => { ["CZK", "1 234,50 Kč"], ["HUF", "1234,50 Ft"], ["RON", "1.234,50 lei"], - ])("renders the %s market symbol, never the ISO code", (currency, expected) => { - expect(normalize(formatCurrencyAmount(1234.5, currency))).toBe(expected) - }) + ])( + "renders the %s market symbol, never the ISO code", + (currency, expected) => { + expect(normalize(formatCurrencyAmount(1234.5, currency))).toBe(expected) + } + ) it("renders Romanian amounts as lei rather than the RON ISO code", () => { const formatted = normalize(formatCurrencyAmount(249, "RON")) @@ -40,16 +43,19 @@ describe("formatWholeCurrencyAmount", () => { ["CZK", "1 190 Kč"], ["HUF", "17 900 Ft"], ["RON", "249 lei"], - ])("renders the %s free-shipping threshold without decimals", (currency, expected) => { - const amounts: Record = { - EUR: 49, - CZK: 1190, - HUF: 17_900, - RON: 249, + ])( + "renders the %s free-shipping threshold without decimals", + (currency, expected) => { + const amounts: Record = { + EUR: 49, + CZK: 1190, + HUF: 17_900, + RON: 249, + } + + expect( + normalize(formatWholeCurrencyAmount(amounts[currency], currency)) + ).toBe(expected) } - - expect( - normalize(formatWholeCurrencyAmount(amounts[currency], currency)) - ).toBe(expected) - }) + ) }) diff --git a/apps/herbatika/src/lib/storefront/product-attributes.test.ts b/apps/herbatika/src/lib/storefront/product-attributes.test.ts index 0787f2fdfb..00088dde6e 100644 --- a/apps/herbatika/src/lib/storefront/product-attributes.test.ts +++ b/apps/herbatika/src/lib/storefront/product-attributes.test.ts @@ -79,17 +79,20 @@ describe("Product Attribute Warranty", () => { ["2 roky", "2 ani"], ["24 mesiacov", "24 de luni"], ["1 mesiac", "1 lună"], - ] as const)("localizes the exact duration %s by market", (label, romanian) => { - const attribute = { - ...warrantyAttribute, - option: { ...warrantyAttribute.option, label }, - } + ] as const)( + "localizes the exact duration %s by market", + (label, romanian) => { + const attribute = { + ...warrantyAttribute, + option: { ...warrantyAttribute.option, label }, + } - expect(resolveProductWarranty([attribute], "sk-SK")).toBe(label) - expect(resolveProductWarranty([attribute], "cs-CZ")).toBe(label) - expect(resolveProductWarranty([attribute], "hu-HU")).toBe(label) - expect(resolveProductWarranty([attribute], "ro-RO")).toBe(romanian) - }) + expect(resolveProductWarranty([attribute], "sk-SK")).toBe(label) + expect(resolveProductWarranty([attribute], "cs-CZ")).toBe(label) + expect(resolveProductWarranty([attribute], "hu-HU")).toBe(label) + expect(resolveProductWarranty([attribute], "ro-RO")).toBe(romanian) + } + ) it("leaves an unrecognized warranty value unchanged", () => { const attribute = { @@ -202,55 +205,55 @@ describe("Product Attribute Warranty", () => { warranty: "Garanție", }, ], - ] as const)("renders the exact %s warranty presentation without cross-market labels", (locale, { - forbiddenLabels, - input, - other, - output, - warranty, - }) => { - const localizedWarranty = resolveProductWarranty( - [ - { - ...warrantyAttribute, - option: { ...warrantyAttribute.option, label: input }, - }, - ], - locale - ) - const [section] = mergeWarrantyIntoProductContentSections( - [], - localizedWarranty, - other, - warranty - ) + ] as const)( + "renders the exact %s warranty presentation without cross-market labels", + (locale, { forbiddenLabels, input, other, output, warranty }) => { + const localizedWarranty = resolveProductWarranty( + [ + { + ...warrantyAttribute, + option: { ...warrantyAttribute.option, label: input }, + }, + ], + locale + ) + const [section] = mergeWarrantyIntoProductContentSections( + [], + localizedWarranty, + other, + warranty + ) - expect(section).toEqual({ - html: `

${warranty}: ${output}

`, - key: "other", - title: other, - }) - for (const forbiddenLabel of forbiddenLabels) { - expect(JSON.stringify(section)).not.toContain(forbiddenLabel) - } - if (locale === "ro-RO") { - expect(section?.html).not.toMatch(SLOVAK_WARRANTY_PATTERN) + expect(section).toEqual({ + html: `

${warranty}: ${output}

`, + key: "other", + title: other, + }) + for (const forbiddenLabel of forbiddenLabels) { + expect(JSON.stringify(section)).not.toContain(forbiddenLabel) + } + if (locale === "ro-RO") { + expect(section?.html).not.toMatch(SLOVAK_WARRANTY_PATTERN) + } + expect(productAttributesSource).not.toContain(`${warranty}:`) } - expect(productAttributesSource).not.toContain(`${warranty}:`) - }) + ) it.each([ ["sk-SK", "Záruka", ["Garancia", "Garanție"]], ["cs-CZ", "Záruka", ["Garancia", "Garanție"]], ["hu-HU", "Garancia", ["Záruka", "Garanție"]], ["ro-RO", "Garanție", ["Záruka", "Garancia"]], - ] as const)("publishes the exact %s warranty catalog label", (locale, expected, forbiddenLabels) => { - const warranty = - messagesForLocale(locale).catalog.product_detail.sections.warranty + ] as const)( + "publishes the exact %s warranty catalog label", + (locale, expected, forbiddenLabels) => { + const warranty = + messagesForLocale(locale).catalog.product_detail.sections.warranty - expect(warranty).toBe(expected) - for (const forbiddenLabel of forbiddenLabels) { - expect(warranty).not.toBe(forbiddenLabel) + expect(warranty).toBe(expected) + for (const forbiddenLabel of forbiddenLabels) { + expect(warranty).not.toBe(forbiddenLabel) + } } - }) + ) }) diff --git a/apps/herbatika/src/lib/storefront/product-location-availability-medusa-reader.test.ts b/apps/herbatika/src/lib/storefront/product-location-availability-medusa-reader.test.ts index f2999623e6..b99d888090 100644 --- a/apps/herbatika/src/lib/storefront/product-location-availability-medusa-reader.test.ts +++ b/apps/herbatika/src/lib/storefront/product-location-availability-medusa-reader.test.ts @@ -76,25 +76,28 @@ describe("createProductLocationAvailabilityMedusaReader", () => { [401, { kind: "unavailable" }], [403, { kind: "unavailable" }], [500, { kind: "unavailable" }], - ] as const)("maps Medusa status %s without exposing its body", async (status, expected) => { - const read = createProductLocationAvailabilityMedusaReader({ - baseUrl: "http://medusa.internal:9000", - createClient: () => ({ - fetch: () => - Promise.reject( - Object.assign(new Error("secret upstream detail"), { status }) - ), - }), - }) - - await expect( - read({ - binding: BINDING, - productId: "prod_cz_1", - signal: new AbortController().signal, + ] as const)( + "maps Medusa status %s without exposing its body", + async (status, expected) => { + const read = createProductLocationAvailabilityMedusaReader({ + baseUrl: "http://medusa.internal:9000", + createClient: () => ({ + fetch: () => + Promise.reject( + Object.assign(new Error("secret upstream detail"), { status }) + ), + }), }) - ).resolves.toEqual(expected) - }) + + await expect( + read({ + binding: BINDING, + productId: "prod_cz_1", + signal: new AbortController().signal, + }) + ).resolves.toEqual(expected) + } + ) it("does not send customer authorization and projects extra source fields", async () => { const fetch = vi.fn().mockResolvedValue({ diff --git a/apps/herbatika/src/lib/storefront/product-page-context.test.ts b/apps/herbatika/src/lib/storefront/product-page-context.test.ts index 59cb708a3d..63e31903d6 100644 --- a/apps/herbatika/src/lib/storefront/product-page-context.test.ts +++ b/apps/herbatika/src/lib/storefront/product-page-context.test.ts @@ -56,44 +56,45 @@ const createDependencies = ( } describe("readProductPageContext", () => { - it.each( - Object.entries(marketDetails) - )("builds the trusted %s provider context without a market fallback", async (market, details) => { - const typedMarket = market as keyof typeof marketDetails - const dependencies = createDependencies(typedMarket) - const result = await readProductPageContext( - { - market: typedMarket, - product: createProduct(details.currencyCode.toLowerCase()), - }, - dependencies - ) - - expect(result).toEqual({ - kind: "found", - value: { - locale: details.locale, - marketContext: expect.objectContaining({ code: typedMarket }), - messages: { - catalog: { product_detail: { retry: "Retry" } }, - navigation: { breadcrumbs: { home: "Home" } }, + it.each(Object.entries(marketDetails))( + "builds the trusted %s provider context without a market fallback", + async (market, details) => { + const typedMarket = market as keyof typeof marketDetails + const dependencies = createDependencies(typedMarket) + const result = await readProductPageContext( + { + market: typedMarket, + product: createProduct(details.currencyCode.toLowerCase()), }, - region: { - country_code: details.countryCode.toLowerCase(), - currency_code: details.currencyCode, - region_id: `reg_${market}`, - salesChannelId: `sc_${market}`, + dependencies + ) + + expect(result).toEqual({ + kind: "found", + value: { + locale: details.locale, + marketContext: expect.objectContaining({ code: typedMarket }), + messages: { + catalog: { product_detail: { retry: "Retry" } }, + navigation: { breadcrumbs: { home: "Home" } }, + }, + region: { + country_code: details.countryCode.toLowerCase(), + currency_code: details.currencyCode, + region_id: `reg_${market}`, + salesChannelId: `sc_${market}`, + }, }, - }, - }) - expect(dependencies.loadMessages).toHaveBeenCalledWith( - expect.objectContaining({ - binding: expect.objectContaining({ market: typedMarket }), - locale: details.locale, - market: typedMarket, }) - ) - }) + expect(dependencies.loadMessages).toHaveBeenCalledWith( + expect.objectContaining({ + binding: expect.objectContaining({ market: typedMarket }), + locale: details.locale, + market: typedMarket, + }) + ) + } + ) it("derives currency from the explicitly selected non-first variant", async () => { const result = await readProductPageContext( diff --git a/apps/herbatika/src/lib/storefront/product-query-config.test.ts b/apps/herbatika/src/lib/storefront/product-query-config.test.ts index d8f7b5dd2a..c7844cb039 100644 --- a/apps/herbatika/src/lib/storefront/product-query-config.test.ts +++ b/apps/herbatika/src/lib/storefront/product-query-config.test.ts @@ -40,12 +40,15 @@ describe("product query fields", () => { ) }) - it.each( - Object.entries(productFieldSets) - )("%s requests metadata as an atomic JSON field", (_name, fields) => { - const selectors = fields.split(",") - - expect(selectors).toContain("+metadata") - expect(selectors.filter((field) => field.includes("metadata."))).toEqual([]) - }) + it.each(Object.entries(productFieldSets))( + "%s requests metadata as an atomic JSON field", + (_name, fields) => { + const selectors = fields.split(",") + + expect(selectors).toContain("+metadata") + expect(selectors.filter((field) => field.includes("metadata."))).toEqual( + [] + ) + } + ) }) diff --git a/apps/herbatika/src/lib/storefront/product-route-source.test.ts b/apps/herbatika/src/lib/storefront/product-route-source.test.ts index 8163e266a8..a64aaa77c0 100644 --- a/apps/herbatika/src/lib/storefront/product-route-source.test.ts +++ b/apps/herbatika/src/lib/storefront/product-route-source.test.ts @@ -82,34 +82,36 @@ describe("readProductRouteSource", () => { expect(retrieveProduct).not.toHaveBeenCalled() }) - it.each([ - 404, 410, - ])("maps definitive Medusa HTTP %s according to the source contract", async (status) => { - const result = await readProductRouteSource( - request, - dependencies(vi.fn().mockRejectedValue(statusError(status))) - ) - - expect(result).toEqual( - status === 404 - ? { kind: "missing" } - : { - kind: "invalid-response", - causeCode: "MEDUSA_REJECTED_REQUEST", - } - ) - }) + it.each([404, 410])( + "maps definitive Medusa HTTP %s according to the source contract", + async (status) => { + const result = await readProductRouteSource( + request, + dependencies(vi.fn().mockRejectedValue(statusError(status))) + ) - it.each([ - 408, 425, 429, 500, 503, - ])("maps retryable Medusa HTTP %s to unavailable", async (status) => { - const result = await readProductRouteSource( - request, - dependencies(vi.fn().mockRejectedValue(statusError(status))) - ) + expect(result).toEqual( + status === 404 + ? { kind: "missing" } + : { + kind: "invalid-response", + causeCode: "MEDUSA_REJECTED_REQUEST", + } + ) + } + ) + + it.each([408, 425, 429, 500, 503])( + "maps retryable Medusa HTTP %s to unavailable", + async (status) => { + const result = await readProductRouteSource( + request, + dependencies(vi.fn().mockRejectedValue(statusError(status))) + ) - expect(result).toEqual({ kind: "unavailable" }) - }) + expect(result).toEqual({ kind: "unavailable" }) + } + ) it("maps a transport failure to unavailable", async () => { const result = await readProductRouteSource( @@ -177,17 +179,20 @@ describe("readProductRouteSource", () => { }, }, { product: { id: "prod_1", variants: [null] } }, - ])("maps malformed Medusa payload %o to invalid-response", async (payload) => { - const result = await readProductRouteSource( - request, - dependencies(vi.fn().mockResolvedValue(payload)) - ) + ])( + "maps malformed Medusa payload %o to invalid-response", + async (payload) => { + const result = await readProductRouteSource( + request, + dependencies(vi.fn().mockResolvedValue(payload)) + ) - expect(result).toEqual({ - kind: "invalid-response", - causeCode: "INVALID_MEDUSA_PRODUCT_RESPONSE", - }) - }) + expect(result).toEqual({ + kind: "invalid-response", + causeCode: "INVALID_MEDUSA_PRODUCT_RESPONSE", + }) + } + ) it("maps a mismatched product identity to invalid-response", async () => { const result = await readProductRouteSource( @@ -238,35 +243,33 @@ describe("readProductRouteSourceByHandle", () => { variants: [{ id: "variant_1", sku: "SKU-1" }], } - it.each([ - "sk", - "cz", - "hu", - "ro", - ] as const)("resolves a product by handle without any publication proof for %s", async (market) => { - const marketBinding = marketBindings[market] - const retrieveProducts = vi - .fn() - .mockResolvedValue({ products: [handleProduct] }) - - const result = await readProductRouteSourceByHandle( - { market, publicSlug: "vitamin-c" }, - { resolveMarket: vi.fn(() => marketBinding), retrieveProducts } - ) + it.each(["sk", "cz", "hu", "ro"] as const)( + "resolves a product by handle without any publication proof for %s", + async (market) => { + const marketBinding = marketBindings[market] + const retrieveProducts = vi + .fn() + .mockResolvedValue({ products: [handleProduct] }) + + const result = await readProductRouteSourceByHandle( + { market, publicSlug: "vitamin-c" }, + { resolveMarket: vi.fn(() => marketBinding), retrieveProducts } + ) - expect(result).toEqual({ kind: "found", value: handleProduct }) - expect(retrieveProducts).toHaveBeenCalledWith({ - binding: marketBinding, - query: { - country_code: marketBinding.countryCode.toLowerCase(), - fields: PRODUCT_DETAIL_FIELDS, - handle: "vitamin-c", - limit: 1, - locale: marketBinding.locale, - region_id: marketBinding.regionId, - }, - }) - }) + expect(result).toEqual({ kind: "found", value: handleProduct }) + expect(retrieveProducts).toHaveBeenCalledWith({ + binding: marketBinding, + query: { + country_code: marketBinding.countryCode.toLowerCase(), + fields: PRODUCT_DETAIL_FIELDS, + handle: "vitamin-c", + limit: 1, + locale: marketBinding.locale, + region_id: marketBinding.regionId, + }, + }) + } + ) it("returns missing when no product carries the handle", async () => { const result = await readProductRouteSourceByHandle( diff --git a/apps/herbatika/src/lib/storefront/ro-demo-static-pages.test.ts b/apps/herbatika/src/lib/storefront/ro-demo-static-pages.test.ts index 771bf57905..339e2a6f47 100644 --- a/apps/herbatika/src/lib/storefront/ro-demo-static-pages.test.ts +++ b/apps/herbatika/src/lib/storefront/ro-demo-static-pages.test.ts @@ -24,28 +24,30 @@ const RO_STATIC_ROUTE_MATRIX = [ ] as const satisfies readonly (readonly [StaticRootPageKey, string])[] describe("Romanian demo static route matrix", () => { - it.each( - RO_STATIC_ROUTE_MATRIX - )("builds and parses %s at %s", (page, pathname) => { - expect(buildPath({ kind: "static", page }, "ro")).toBe(pathname) - expect( - parsePublicPath({ market: "ro", pathname, rawQuery: "" }) - ).toMatchObject({ - kind: "found", - target: { kind: "static", page }, - }) - }) + it.each(RO_STATIC_ROUTE_MATRIX)( + "builds and parses %s at %s", + (page, pathname) => { + expect(buildPath({ kind: "static", page }, "ro")).toBe(pathname) + expect( + parsePublicPath({ market: "ro", pathname, rawQuery: "" }) + ).toMatchObject({ + kind: "found", + target: { kind: "static", page }, + }) + } + ) - it.each( - RO_STATIC_ROUTE_MATRIX - )("provides explicit unreviewed Romanian demo content for %s", (page) => { - const fallback = getRoDemoStaticPage(page, "ro-RO") + it.each(RO_STATIC_ROUTE_MATRIX)( + "provides explicit unreviewed Romanian demo content for %s", + (page) => { + const fallback = getRoDemoStaticPage(page, "ro-RO") - expect(fallback).not.toBeNull() - expect(fallback?.id).toBe(`${RO_DEMO_APPROVAL_MARKER}:ro:${page}`) - expect(fallback?.content).toContain("Conținut demonstrativ neaprobat") - expect(fallback && isRoDemoStaticPage(fallback)).toBe(true) - }) + expect(fallback).not.toBeNull() + expect(fallback?.id).toBe(`${RO_DEMO_APPROVAL_MARKER}:ro:${page}`) + expect(fallback?.content).toContain("Conținut demonstrativ neaprobat") + expect(fallback && isRoDemoStaticPage(fallback)).toBe(true) + } + ) it("never supplies Romanian demo content to another locale", () => { expect(getRoDemoStaticPage("terms", "sk-SK")).toBeNull() diff --git a/apps/herbatika/src/lib/storefront/volume-discounts-medusa-reader.test.ts b/apps/herbatika/src/lib/storefront/volume-discounts-medusa-reader.test.ts index 504ec16c90..e2cd8868dd 100644 --- a/apps/herbatika/src/lib/storefront/volume-discounts-medusa-reader.test.ts +++ b/apps/herbatika/src/lib/storefront/volume-discounts-medusa-reader.test.ts @@ -75,26 +75,29 @@ describe("createVolumeDiscountMedusaReader", () => { [404, { kind: "missing" }], [429, { kind: "rate-limited" }], [500, { kind: "unavailable" }], - ] as const)("maps Medusa status %s without exposing its body", async (status, expected) => { - const read = createVolumeDiscountMedusaReader({ - baseUrl: "http://medusa.internal:9000", - createClient: () => ({ - fetch: () => - Promise.reject( - Object.assign(new Error("secret upstream detail"), { status }) - ), - }), - }) - - await expect( - read({ - authToken: null, - binding: BINDING, - signal: new AbortController().signal, - variantId: "variant_cz_1", + ] as const)( + "maps Medusa status %s without exposing its body", + async (status, expected) => { + const read = createVolumeDiscountMedusaReader({ + baseUrl: "http://medusa.internal:9000", + createClient: () => ({ + fetch: () => + Promise.reject( + Object.assign(new Error("secret upstream detail"), { status }) + ), + }), }) - ).resolves.toEqual(expected) - }) + + await expect( + read({ + authToken: null, + binding: BINDING, + signal: new AbortController().signal, + variantId: "variant_cz_1", + }) + ).resolves.toEqual(expected) + } + ) it("omits Authorization for a guest and aborts when the timeout fires", async () => { const timeoutAbort = new AbortController() diff --git a/apps/herbatika/src/lib/url-registry/http/command-auth.test.ts b/apps/herbatika/src/lib/url-registry/http/command-auth.test.ts index 173de74976..c8575b4e93 100644 --- a/apps/herbatika/src/lib/url-registry/http/command-auth.test.ts +++ b/apps/herbatika/src/lib/url-registry/http/command-auth.test.ts @@ -38,18 +38,15 @@ describe("verifyUrlRegistryCommandAuthorization", () => { ) }) - it.each([ - undefined, - "", - "short", - ` ${TOKEN}`, - `${TOKEN} `, - ])("fails closed for a malformed configured token: %s", (configuredToken) => { - expect( - verifyUrlRegistryCommandAuthorization( - `Bearer ${configuredToken ?? TOKEN}`, - configuredToken - ) - ).toBe("misconfigured") - }) + it.each([undefined, "", "short", ` ${TOKEN}`, `${TOKEN} `])( + "fails closed for a malformed configured token: %s", + (configuredToken) => { + expect( + verifyUrlRegistryCommandAuthorization( + `Bearer ${configuredToken ?? TOKEN}`, + configuredToken + ) + ).toBe("misconfigured") + } + ) }) diff --git a/apps/herbatika/src/lib/url-registry/http/product-lifecycle-endpoint.test.ts b/apps/herbatika/src/lib/url-registry/http/product-lifecycle-endpoint.test.ts index 3762d63135..5dd30dc602 100644 --- a/apps/herbatika/src/lib/url-registry/http/product-lifecycle-endpoint.test.ts +++ b/apps/herbatika/src/lib/url-registry/http/product-lifecycle-endpoint.test.ts @@ -189,30 +189,33 @@ describe("handleProductLifecycleRequest", () => { ["noop-source-present", false, "applied"], ["requires-publication", false, "applied"], ["retired", true, "already-applied"], - ] as const)("acknowledges %s with replay=%s", async (action, replayed, outcome) => { - const consume = vi.fn().mockResolvedValue({ - kind: "acknowledged", - action: action as ProductLifecycleReceiptAction, - replayed, - }) - const response = await handleProductLifecycleRequest( - request(), - dependencies(consume) - ) + ] as const)( + "acknowledges %s with replay=%s", + async (action, replayed, outcome) => { + const consume = vi.fn().mockResolvedValue({ + kind: "acknowledged", + action: action as ProductLifecycleReceiptAction, + replayed, + }) + const response = await handleProductLifecycleRequest( + request(), + dependencies(consume) + ) - expect(response.status).toBe(200) - expect(await response.json()).toEqual({ - schemaVersion: 1, - outcome, - action, - replayed, - outboxEventId: "urlroe_01", - marketCode: "sk", - streamSequence: 1, - }) - expect(consume).toHaveBeenCalledWith(delivery()) - expect(privateHeaders(response)).toEqual(PRIVATE_HEADERS) - }) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + schemaVersion: 1, + outcome, + action, + replayed, + outboxEventId: "urlroe_01", + marketCode: "sk", + streamSequence: 1, + }) + expect(consume).toHaveBeenCalledWith(delivery()) + expect(privateHeaders(response)).toEqual(PRIVATE_HEADERS) + } + ) it.each([ ["source-unavailable", 5], @@ -220,62 +223,69 @@ describe("handleProductLifecycleRequest", () => { ["route-unavailable", 5], ["route-invalid-response", 5], ["source-event-gap", 17], - ] as const)("maps retryable %s to a bounded 503", async (code, retryAfter) => { - const consume = vi.fn().mockResolvedValue({ - kind: "retry", - action: null, - cause: code, - ...(retryAfter === 5 ? {} : { retryAfterSeconds: retryAfter }), - }) - const response = await handleProductLifecycleRequest( - request(), - dependencies(consume) - ) + ] as const)( + "maps retryable %s to a bounded 503", + async (code, retryAfter) => { + const consume = vi.fn().mockResolvedValue({ + kind: "retry", + action: null, + cause: code, + ...(retryAfter === 5 ? {} : { retryAfterSeconds: retryAfter }), + }) + const response = await handleProductLifecycleRequest( + request(), + dependencies(consume) + ) - expect(response.status).toBe(503) - expect(response.headers.get("retry-after")).toBe(String(retryAfter)) - expect(await response.json()).toEqual({ error: code }) - expect(privateHeaders(response)).toEqual(PRIVATE_HEADERS) - }) + expect(response.status).toBe(503) + expect(response.headers.get("retry-after")).toBe(String(retryAfter)) + expect(await response.json()).toEqual({ error: code }) + expect(privateHeaders(response)).toEqual(PRIVATE_HEADERS) + } + ) - it.each([ - "live-source-has-terminal-route", - ] as const)("maps permanent %s to 409", async (cause) => { - const consume = vi - .fn() - .mockResolvedValue({ kind: "conflict", action: null, cause }) - const response = await handleProductLifecycleRequest( - request(), - dependencies(consume) - ) + it.each(["live-source-has-terminal-route"] as const)( + "maps permanent %s to 409", + async (cause) => { + const consume = vi + .fn() + .mockResolvedValue({ kind: "conflict", action: null, cause }) + const response = await handleProductLifecycleRequest( + request(), + dependencies(consume) + ) - expect(response.status).toBe(409) - expect(await response.json()).toEqual({ error: cause }) - expect(privateHeaders(response)).toEqual(PRIVATE_HEADERS) - }) + expect(response.status).toBe(409) + expect(await response.json()).toEqual({ error: cause }) + expect(privateHeaders(response)).toEqual(PRIVATE_HEADERS) + } + ) it.each([ ["SEQUENCE_GAP", 503, "source-event-gap"], ["DELIVERY_DRIFT", 409, "source-event-conflict"], ["STALE_DELIVERY", 409, "source-event-conflict"], - ] as const)("redacts and maps consumer ordering error %s", async (code, status, publicCode) => { - const consume = vi.fn().mockRejectedValue( - Object.assign(new Error("private ordering details"), { - code, - name: "ProductLifecycleConsumerError", - }) - ) - const response = await handleProductLifecycleRequest( - request(), - dependencies(consume) - ) + ] as const)( + "redacts and maps consumer ordering error %s", + async (code, status, publicCode) => { + const consume = vi.fn().mockRejectedValue( + Object.assign(new Error("private ordering details"), { + code, + name: "ProductLifecycleConsumerError", + }) + ) + const response = await handleProductLifecycleRequest( + request(), + dependencies(consume) + ) - expect(response.status).toBe(status) - expect(await response.json()).toEqual({ error: publicCode }) - expect(response.headers.get("retry-after")).toBe( - status === 503 ? "5" : null - ) - }) + expect(response.status).toBe(status) + expect(await response.json()).toEqual({ error: publicCode }) + expect(response.headers.get("retry-after")).toBe( + status === 503 ? "5" : null + ) + } + ) it("redacts unexpected consumer failures", async () => { const consume = vi diff --git a/apps/herbatika/src/lib/url-registry/postgres/product-lifecycle-consumer.test.ts b/apps/herbatika/src/lib/url-registry/postgres/product-lifecycle-consumer.test.ts index 405248b7c5..1000a8c12c 100644 --- a/apps/herbatika/src/lib/url-registry/postgres/product-lifecycle-consumer.test.ts +++ b/apps/herbatika/src/lib/url-registry/postgres/product-lifecycle-consumer.test.ts @@ -108,30 +108,36 @@ describe("classifyProductLifecycleStream", () => { it.each([ [null, 2], [1, 3], - ] as const)("rejects sequence %s -> %s gaps", (cursorLastSequence, streamSequence) => { - expect(() => - classifyProductLifecycleStream(delivery(streamSequence), fingerprint, { - cursorLastSequence, - eventReceipt: null, - sequenceReceipt: null, - }) - ).toThrowError( - expect.objectContaining({ - code: "SEQUENCE_GAP", - }) - ) - }) + ] as const)( + "rejects sequence %s -> %s gaps", + (cursorLastSequence, streamSequence) => { + expect(() => + classifyProductLifecycleStream(delivery(streamSequence), fingerprint, { + cursorLastSequence, + eventReceipt: null, + sequenceReceipt: null, + }) + ).toThrowError( + expect.objectContaining({ + code: "SEQUENCE_GAP", + }) + ) + } + ) it.each([ [null, 1], [1, 2], - ] as const)("accepts the next contiguous stream position", (cursorLastSequence, streamSequence) => { - expect( - classifyProductLifecycleStream(delivery(streamSequence), fingerprint, { - cursorLastSequence, - eventReceipt: null, - sequenceReceipt: null, - }) - ).toEqual({ kind: "next" }) - }) + ] as const)( + "accepts the next contiguous stream position", + (cursorLastSequence, streamSequence) => { + expect( + classifyProductLifecycleStream(delivery(streamSequence), fingerprint, { + cursorLastSequence, + eventReceipt: null, + sequenceReceipt: null, + }) + ).toEqual({ kind: "next" }) + } + ) }) diff --git a/apps/herbatika/src/lib/url-registry/postgres/transaction.test.ts b/apps/herbatika/src/lib/url-registry/postgres/transaction.test.ts index 3c6202b9e1..d280fa8114 100644 --- a/apps/herbatika/src/lib/url-registry/postgres/transaction.test.ts +++ b/apps/herbatika/src/lib/url-registry/postgres/transaction.test.ts @@ -96,35 +96,38 @@ describe("Postgres URL registry transaction boundary", () => { Object.assign(new Error("socket reset"), { code: "ECONNRESET" }), Object.assign(new Error("broken pipe"), { code: "EPIPE" }), Object.assign(new Error("socket timeout"), { code: "ETIMEDOUT" }), - ])("retries and destroys the lease for ambiguous transport failure", async (failure) => { - const first = client() - const second = client() - const pool: SqlPool = { - connect: vi - .fn<() => Promise>() - .mockResolvedValueOnce(first) - .mockResolvedValueOnce(second), - query: vi.fn(async () => result()), - } - let attempt = 0 - - await expect( - executeRetriableTransaction( - pool, - () => { - attempt += 1 - if (attempt === 1) { - throw failure - } - return Promise.resolve("replayed") - }, - { random: () => 0, sleep: vi.fn(() => Promise.resolve()) } - ) - ).resolves.toBe("replayed") + ])( + "retries and destroys the lease for ambiguous transport failure", + async (failure) => { + const first = client() + const second = client() + const pool: SqlPool = { + connect: vi + .fn<() => Promise>() + .mockResolvedValueOnce(first) + .mockResolvedValueOnce(second), + query: vi.fn(async () => result()), + } + let attempt = 0 + + await expect( + executeRetriableTransaction( + pool, + () => { + attempt += 1 + if (attempt === 1) { + throw failure + } + return Promise.resolve("replayed") + }, + { random: () => 0, sleep: vi.fn(() => Promise.resolve()) } + ) + ).resolves.toBe("replayed") - expect(first.release).toHaveBeenCalledWith(failure) - expect(second.release).toHaveBeenCalledWith(undefined) - }) + expect(first.release).toHaveBeenCalledWith(failure) + expect(second.release).toHaveBeenCalledWith(undefined) + } + ) it("retries an ambiguous COMMIT read timeout through a fresh lease", async () => { const failure = new Error("Query read timeout") diff --git a/apps/herbatika/src/lib/url-registry/runtime/config.test.ts b/apps/herbatika/src/lib/url-registry/runtime/config.test.ts index 3a2a9a87f0..00a91a20e8 100644 --- a/apps/herbatika/src/lib/url-registry/runtime/config.test.ts +++ b/apps/herbatika/src/lib/url-registry/runtime/config.test.ts @@ -2,30 +2,26 @@ import { describe, expect, it } from "vitest" import { parseUrlRegistryRuntimeConfig } from "./config" describe("parseUrlRegistryRuntimeConfig", () => { - it.each([ - undefined, - "0", - ])("keeps URLR disabled for the default gate value %s", (enabled) => { - expect( - parseUrlRegistryRuntimeConfig({ - URL_REGISTRY_DATABASE_URL: "not parsed while disabled", - URL_REGISTRY_ENABLED: enabled, - }) - ).toEqual({ enabled: false }) - }) + it.each([undefined, "0"])( + "keeps URLR disabled for the default gate value %s", + (enabled) => { + expect( + parseUrlRegistryRuntimeConfig({ + URL_REGISTRY_DATABASE_URL: "not parsed while disabled", + URL_REGISTRY_ENABLED: enabled, + }) + ).toEqual({ enabled: false }) + } + ) - it.each([ - "", - "true", - "false", - " 1", - "1 ", - "yes", - ])("rejects the non-exact gate value %j", (enabled) => { - expect(() => - parseUrlRegistryRuntimeConfig({ URL_REGISTRY_ENABLED: enabled }) - ).toThrow("URL_REGISTRY_ENABLED must be exactly 0 or 1") - }) + it.each(["", "true", "false", " 1", "1 ", "yes"])( + "rejects the non-exact gate value %j", + (enabled) => { + expect(() => + parseUrlRegistryRuntimeConfig({ URL_REGISTRY_ENABLED: enabled }) + ).toThrow("URL_REGISTRY_ENABLED must be exactly 0 or 1") + } + ) it("requires the dedicated private database URL when enabled", () => { expect(() => @@ -38,17 +34,17 @@ describe("parseUrlRegistryRuntimeConfig", () => { ).toThrow("URL_REGISTRY_DATABASE_URL is required") }) - it.each([ - "postgres://urlr:secret@db/urlr", - "postgresql://db/urlr", - ])("accepts a private PostgreSQL URL", (databaseUrl) => { - expect( - parseUrlRegistryRuntimeConfig({ - URL_REGISTRY_DATABASE_URL: databaseUrl, - URL_REGISTRY_ENABLED: "1", - }) - ).toEqual({ databaseUrl, enabled: true }) - }) + it.each(["postgres://urlr:secret@db/urlr", "postgresql://db/urlr"])( + "accepts a private PostgreSQL URL", + (databaseUrl) => { + expect( + parseUrlRegistryRuntimeConfig({ + URL_REGISTRY_DATABASE_URL: databaseUrl, + URL_REGISTRY_ENABLED: "1", + }) + ).toEqual({ databaseUrl, enabled: true }) + } + ) it.each([ " postgres://urlr:secret@db/urlr", diff --git a/apps/herbatika/src/lib/url-registry/runtime/invalidation-delivery-client.test.ts b/apps/herbatika/src/lib/url-registry/runtime/invalidation-delivery-client.test.ts index cf61961703..15ad44f39a 100644 --- a/apps/herbatika/src/lib/url-registry/runtime/invalidation-delivery-client.test.ts +++ b/apps/herbatika/src/lib/url-registry/runtime/invalidation-delivery-client.test.ts @@ -47,35 +47,37 @@ describe("URL registry invalidation delivery client", () => { }) }) - it.each([ - 408, 425, 429, 500, 502, 503, 504, 599, - ])("retries HTTP %i with a bounded retry hint", async (status) => { - const response = new Response(null, { - headers: { "retry-after": "7" }, - status, - }) - await expect( - deliverInvalidationOutboxEvent(event(), config, { - fetchImpl: vi.fn(async () => response) as unknown as typeof fetch, + it.each([408, 425, 429, 500, 502, 503, 504, 599])( + "retries HTTP %i with a bounded retry hint", + async (status) => { + const response = new Response(null, { + headers: { "retry-after": "7" }, + status, }) - ).resolves.toEqual({ - errorCode: `http-${status}`, - kind: "retry", - retryAfterMs: 7000, - }) - }) - - it.each([ - 400, 401, 404, 409, 421, - ])("permanently fails non-retryable HTTP %i", async (status) => { - await expect( - deliverInvalidationOutboxEvent(event(), config, { - fetchImpl: vi.fn( - async () => new Response(null, { status }) - ) as unknown as typeof fetch, + await expect( + deliverInvalidationOutboxEvent(event(), config, { + fetchImpl: vi.fn(async () => response) as unknown as typeof fetch, + }) + ).resolves.toEqual({ + errorCode: `http-${status}`, + kind: "retry", + retryAfterMs: 7000, }) - ).resolves.toEqual({ errorCode: `http-${status}`, kind: "failed" }) - }) + } + ) + + it.each([400, 401, 404, 409, 421])( + "permanently fails non-retryable HTTP %i", + async (status) => { + await expect( + deliverInvalidationOutboxEvent(event(), config, { + fetchImpl: vi.fn( + async () => new Response(null, { status }) + ) as unknown as typeof fetch, + }) + ).resolves.toEqual({ errorCode: `http-${status}`, kind: "failed" }) + } + ) it("caps Retry-After hints from both seconds and HTTP dates", async () => { const responses = [ diff --git a/apps/herbatika/src/lib/url/account-addresses-settings-route.test.ts b/apps/herbatika/src/lib/url/account-addresses-settings-route.test.ts index 10f79b6248..7e3cbd0284 100644 --- a/apps/herbatika/src/lib/url/account-addresses-settings-route.test.ts +++ b/apps/herbatika/src/lib/url/account-addresses-settings-route.test.ts @@ -10,11 +10,12 @@ const SETTINGS_ROUTES = [ ] as const satisfies readonly (readonly [Market, string])[] describe("saved-address account settings routes", () => { - it.each( - SETTINGS_ROUTES - )("keeps address management on the approved %s settings route", (market, expectedPath) => { - expect(buildPath({ kind: "account", section: "settings" }, market)).toBe( - expectedPath - ) - }) + it.each(SETTINGS_ROUTES)( + "keeps address management on the approved %s settings route", + (market, expectedPath) => { + expect(buildPath({ kind: "account", section: "settings" }, market)).toBe( + expectedPath + ) + } + ) }) diff --git a/apps/herbatika/src/lib/url/product-path.test.ts b/apps/herbatika/src/lib/url/product-path.test.ts index d58034d6e9..d5b84eb03e 100644 --- a/apps/herbatika/src/lib/url/product-path.test.ts +++ b/apps/herbatika/src/lib/url/product-path.test.ts @@ -19,10 +19,13 @@ describe("product URL builder", () => { "https://herbatica.hu/termekek/bylinny-caj", ], ["ro", "/produse/bylinny-caj", "https://herbatica.ro/produse/bylinny-caj"], - ] as const)("binds the %s market to its exact path and canonical origin", (market, expectedPath, expectedUrl) => { - expect(buildProductPath(market, "bylinny-caj")).toBe(expectedPath) - expect(buildProductAbsoluteUrl(market, "bylinny-caj")).toBe(expectedUrl) - }) + ] as const)( + "binds the %s market to its exact path and canonical origin", + (market, expectedPath, expectedUrl) => { + expect(buildProductPath(market, "bylinny-caj")).toBe(expectedPath) + expect(buildProductAbsoluteUrl(market, "bylinny-caj")).toBe(expectedUrl) + } + ) it("appends an already-normalized raw query without changing case", () => { expect( diff --git a/apps/herbatika/src/lib/url/public-route-api.test.ts b/apps/herbatika/src/lib/url/public-route-api.test.ts index d1262ded4b..8ececa6770 100644 --- a/apps/herbatika/src/lib/url/public-route-api.test.ts +++ b/apps/herbatika/src/lib/url/public-route-api.test.ts @@ -84,16 +84,19 @@ describe("parsePublicPath", () => { "/velemenyek/termek/Token-AbC", { kind: "review", token: "Token-AbC" }, ], - ] as const)("parses %s %s to one semantic target", (market, pathname, target) => { - const parsed = parsePublicPath({ market, pathname }) - expect(parsed).toMatchObject({ - canonicalization: { required: false }, - kind: "found", - market, - navigation: "document", - target, - }) - }) + ] as const)( + "parses %s %s to one semantic target", + (market, pathname, target) => { + const parsed = parsePublicPath({ market, pathname }) + expect(parsed).toMatchObject({ + canonicalization: { required: false }, + kind: "found", + market, + navigation: "document", + target, + }) + } + ) it("composes path and query repairs into one canonicalization destination", () => { expect( @@ -221,15 +224,14 @@ describe("parsePublicPath", () => { }) }) - it.each([ - "q=%", - "q=%0G", - "q=ok%00bad", - ])("rejects malformed or control-bearing query %s", (rawQuery) => { - expect( - parsePublicPath({ market: "sk", pathname: "/vyhladavanie", rawQuery }) - ).toMatchObject({ kind: "not-found", reason: "malformed-path" }) - }) + it.each(["q=%", "q=%0G", "q=ok%00bad"])( + "rejects malformed or control-bearing query %s", + (rawQuery) => { + expect( + parsePublicPath({ market: "sk", pathname: "/vyhladavanie", rawQuery }) + ).toMatchObject({ kind: "not-found", reason: "malformed-path" }) + } + ) it.each([ "/~sf/sk/home", @@ -243,15 +245,15 @@ describe("parsePublicPath", () => { }) }) - it.each([ - "/p/legacy", - "/informacie", - ])("omits disabled or invalid public route %s", (pathname) => { - expect(parsePublicPath({ market: "sk", pathname })).toMatchObject({ - kind: "not-found", - reason: "route-not-found", - }) - }) + it.each(["/p/legacy", "/informacie"])( + "omits disabled or invalid public route %s", + (pathname) => { + expect(parsePublicPath({ market: "sk", pathname })).toMatchObject({ + kind: "not-found", + reason: "route-not-found", + }) + } + ) }) describe("resolvePublicRoute", () => { diff --git a/apps/herbatika/src/lib/url/public-seo.test.ts b/apps/herbatika/src/lib/url/public-seo.test.ts index 8141a0092b..2234203b3b 100644 --- a/apps/herbatika/src/lib/url/public-seo.test.ts +++ b/apps/herbatika/src/lib/url/public-seo.test.ts @@ -67,15 +67,14 @@ describe("public SEO classification", () => { }) }) - it.each([ - "account-lists", - "search", - "account-orders", - ] as const)("never indexes %s", (routeKind) => { - expect( - classifySeo({ canonicalRawQuery: "", routeKind, values: {} }) - ).toMatchObject({ canonicalRawQuery: null, indexable: false }) - }) + it.each(["account-lists", "search", "account-orders"] as const)( + "never indexes %s", + (routeKind) => { + expect( + classifySeo({ canonicalRawQuery: "", routeKind, values: {} }) + ).toMatchObject({ canonicalRawQuery: null, indexable: false }) + } + ) it("emits matching canonical JSON-LD only for a public SEO identity", () => { expect( diff --git a/apps/herbatika/src/lib/url/public-url.test.ts b/apps/herbatika/src/lib/url/public-url.test.ts index 3efa13e767..3f680a43e6 100644 --- a/apps/herbatika/src/lib/url/public-url.test.ts +++ b/apps/herbatika/src/lib/url/public-url.test.ts @@ -12,11 +12,14 @@ describe("public URL API", () => { ["cz", "/produkty/ashwagandha"], ["hu", "/termekek/ashwagandha"], ["ro", "/produse/ashwagandha"], - ] as const)("builds the localized product path for %s", (market, expected) => { - expect(buildPath({ kind: "product", slug: "ashwagandha" }, market)).toBe( - expected - ) - }) + ] as const)( + "builds the localized product path for %s", + (market, expected) => { + expect(buildPath({ kind: "product", slug: "ashwagandha" }, market)).toBe( + expected + ) + } + ) it("builds entity indexes, root-static pages, and private flows", () => { expect(buildPath({ kind: "article" }, "sk")).toBe("/blog") @@ -38,9 +41,12 @@ describe("public URL API", () => { ["cz", "dropshipping", "/dropshipping"], ["cz", "privateLabel", "/private-label"], ["cz", "wholesale", "/velkoobchod"], - ] as const)("builds the customer-authoritative %s %s static path", (market, page, expected) => { - expect(buildPath({ kind: "static", page }, market)).toBe(expected) - }) + ] as const)( + "builds the customer-authoritative %s %s static path", + (market, page, expected) => { + expect(buildPath({ kind: "static", page }, market)).toBe(expected) + } + ) it("preserves opaque checkout confirmation identifiers", () => { expect( diff --git a/apps/herbatika/src/lib/url/query-normalizer.test.ts b/apps/herbatika/src/lib/url/query-normalizer.test.ts index f6dd3f909d..226068e0f2 100644 --- a/apps/herbatika/src/lib/url/query-normalizer.test.ts +++ b/apps/herbatika/src/lib/url/query-normalizer.test.ts @@ -144,14 +144,15 @@ describe("normalizeQuery route scopes", () => { ) }) - it.each( - forbiddenQueryKeyCases - )("rejects known key %s on the wrong route scope %s", (routeKind, key) => { - const result = requireNotFound(routeKind, `${key}=value&unknown=strip-me`) + it.each(forbiddenQueryKeyCases)( + "rejects known key %s on the wrong route scope %s", + (routeKind, key) => { + const result = requireNotFound(routeKind, `${key}=value&unknown=strip-me`) - expect(result.reason).toBe("known-key-not-allowed") - expect(result.key).toBe(key) - }) + expect(result.reason).toBe("known-key-not-allowed") + expect(result.key).toBe(key) + } + ) }) describe("normalizeQuery canonical output", () => { @@ -182,19 +183,20 @@ describe("normalizeQuery canonical output", () => { expect(result.redirectRawQuery).toBe(result.canonicalRawQuery) }) - it.each( - canonicalSerializationCases - )("uses WHATWG serialization for %s", (rawQuery, canonicalRawQuery) => { - const result = normalizeQuery({ rawQuery, routeKind: "search" }) + it.each(canonicalSerializationCases)( + "uses WHATWG serialization for %s", + (rawQuery, canonicalRawQuery) => { + const result = normalizeQuery({ rawQuery, routeKind: "search" }) - expect(result.kind).toBe( - rawQuery === canonicalRawQuery ? "accept" : "redirect" - ) - if (result.kind === "not-found") { - throw new Error("Expected a valid WHATWG serialization fixture") + expect(result.kind).toBe( + rawQuery === canonicalRawQuery ? "accept" : "redirect" + ) + if (result.kind === "not-found") { + throw new Error("Expected a valid WHATWG serialization fixture") + } + expect(result.canonicalRawQuery).toBe(canonicalRawQuery) } - expect(result.canonicalRawQuery).toBe(canonicalRawQuery) - }) + ) it("keeps an already canonical query without requesting a redirect", () => { const result = requireAccepted( diff --git a/apps/herbatika/src/lib/url/slug.test.ts b/apps/herbatika/src/lib/url/slug.test.ts index 3413b7b046..a5c2af2da0 100644 --- a/apps/herbatika/src/lib/url/slug.test.ts +++ b/apps/herbatika/src/lib/url/slug.test.ts @@ -20,11 +20,12 @@ const localeCases = [ ] as const describe("createPublishedSlug", () => { - it.each( - localeCases - )("uses the frozen %s transliteration", (locale, input, expected) => { - expect(createPublishedSlug(input, { locale })).toBe(expected) - }) + it.each(localeCases)( + "uses the frozen %s transliteration", + (locale, input, expected) => { + expect(createPublishedSlug(input, { locale })).toBe(expected) + } + ) it("applies NFKC before locale-aware lowercasing", () => { expect(createPublishedSlug(" PŘÍLIŠ ", { locale: "cs-CZ" })).toBe( @@ -110,16 +111,16 @@ describe("validatePublishedSlug", () => { expect(validatePublishedSlug(maximumLengthSlug)).toBe(maximumLengthSlug) }) - it.each([ - "Upper-Case", - "under_score", - ])("rejects the unsafe value %s", (value) => { - expect(() => validatePublishedSlug(value)).toThrowError( - expect.objectContaining>({ - reason: "invalid-characters", - }) - ) - }) + it.each(["Upper-Case", "under_score"])( + "rejects the unsafe value %s", + (value) => { + expect(() => validatePublishedSlug(value)).toThrowError( + expect.objectContaining>({ + reason: "invalid-characters", + }) + ) + } + ) it("rejects an overlong value instead of truncating it", () => { const overlong = "a".repeat(MAX_PUBLISHED_SLUG_LENGTH + 1) diff --git a/apps/herbatika/src/proxy.test.ts b/apps/herbatika/src/proxy.test.ts index f7fed0a862..fe087542a7 100644 --- a/apps/herbatika/src/proxy.test.ts +++ b/apps/herbatika/src/proxy.test.ts @@ -101,31 +101,33 @@ describe("public proxy adapter", () => { ) }) - it.each( - HOST_MATRIX - )("leaves public routing disabled for accepted host %s when the production flag is 0", (host) => { - process.env.URL_ARCHITECTURE_ENABLED = "0" + it.each(HOST_MATRIX)( + "leaves public routing disabled for accepted host %s when the production flag is 0", + (host) => { + process.env.URL_ARCHITECTURE_ENABLED = "0" - const response = proxy(request("/", host)) + const response = proxy(request("/", host)) - expect(response.status).toBe(200) - expect(response.headers.get("x-middleware-next")).toBe("1") - expect(response.headers.has("x-middleware-rewrite")).toBe(false) - }) + expect(response.status).toBe(200) + expect(response.headers.get("x-middleware-next")).toBe("1") + expect(response.headers.has("x-middleware-rewrite")).toBe(false) + } + ) - it.each( - HOST_MATRIX - )("rewrites accepted host %s when the production flag is 1", (host, market) => { - process.env.URL_ARCHITECTURE_ENABLED = "1" + it.each(HOST_MATRIX)( + "rewrites accepted host %s when the production flag is 1", + (host, market) => { + process.env.URL_ARCHITECTURE_ENABLED = "1" - const response = proxy(request("/", host)) + const response = proxy(request("/", host)) - expect(response.status).toBe(200) - expect(response.headers.has("x-middleware-rewrite")).toBe(true) - expect(response.headers.get("x-middleware-request-x-sf-market")).toBe( - market - ) - }) + expect(response.status).toBe(200) + expect(response.headers.has("x-middleware-rewrite")).toBe(true) + expect(response.headers.get("x-middleware-request-x-sf-market")).toBe( + market + ) + } + ) it("still rejects an unknown Host when production routing is enabled", () => { process.env.URL_ARCHITECTURE_ENABLED = "1" @@ -166,19 +168,20 @@ describe("public proxy adapter", () => { ) }) - it.each( - HOST_MATRIX - )("recognizes accepted host %s as %s with the canonical origin", (host, market, canonicalOrigin) => { - const response = proxy(request("/", host)) - - expect(response.status).toBe(200) - expect(response.headers.get("x-middleware-request-x-sf-market")).toBe( - market - ) - expect( - response.headers.get("x-middleware-request-x-sf-canonical-origin") - ).toBe(canonicalOrigin) - }) + it.each(HOST_MATRIX)( + "recognizes accepted host %s as %s with the canonical origin", + (host, market, canonicalOrigin) => { + const response = proxy(request("/", host)) + + expect(response.status).toBe(200) + expect(response.headers.get("x-middleware-request-x-sf-market")).toBe( + market + ) + expect( + response.headers.get("x-middleware-request-x-sf-canonical-origin") + ).toBe(canonicalOrigin) + } + ) it("permanently redirects a legacy official category path and keeps the query", () => { const response = proxy( @@ -283,22 +286,20 @@ describe("public proxy adapter", () => { expect(proxy(request(pathname)).status).toBe(404) }) - it.each([ - "/p", - "/p/legacy", - "/c", - "/c/legacy", - ])("delegates an unknown legacy route to URL Registry: %s", (pathname) => { - const response = proxy(request(pathname)) - - expect(response.status).toBe(200) - expect(response.headers.get("x-middleware-rewrite")).toBe( - `https://herbatica.sk/~sf/sk/url-registry${pathname}` - ) - expect(response.headers.get("x-middleware-request-x-sf-route-key")).toBe( - "url-registry.resolve" - ) - }) + it.each(["/p", "/p/legacy", "/c", "/c/legacy"])( + "delegates an unknown legacy route to URL Registry: %s", + (pathname) => { + const response = proxy(request(pathname)) + + expect(response.status).toBe(200) + expect(response.headers.get("x-middleware-rewrite")).toBe( + `https://herbatica.sk/~sf/sk/url-registry${pathname}` + ) + expect(response.headers.get("x-middleware-request-x-sf-route-key")).toBe( + "url-registry.resolve" + ) + } + ) it("passes a verified-host system route through and rejects an unknown host", () => { expect(proxy(request("/robots.txt")).headers.get("x-middleware-next")).toBe( @@ -310,16 +311,19 @@ describe("public proxy adapter", () => { it.each([ ["herbatica.hu", "hu"], ["herbatica.ro", "ro"], - ])("delegates the legacy about path on %s to URL Registry", (host, market) => { - const response = proxy(request("/o-nas", host)) - - expect(response.status).toBe(200) - expect(response.headers.get("x-middleware-rewrite")).toBe( - `https://${host}/~sf/${market}/url-registry/o-nas` - ) - expect(response.headers.get("x-middleware-request-x-sf-route-key")).toBe( - "url-registry.resolve" - ) - expect(response.headers.has("location")).toBe(false) - }) + ])( + "delegates the legacy about path on %s to URL Registry", + (host, market) => { + const response = proxy(request("/o-nas", host)) + + expect(response.status).toBe(200) + expect(response.headers.get("x-middleware-rewrite")).toBe( + `https://${host}/~sf/${market}/url-registry/o-nas` + ) + expect(response.headers.get("x-middleware-request-x-sf-route-key")).toBe( + "url-registry.resolve" + ) + expect(response.headers.has("location")).toBe(false) + } + ) }) diff --git a/apps/herbatika/tests/market-readiness/convergence-collector.test.ts b/apps/herbatika/tests/market-readiness/convergence-collector.test.ts index 7d6167c516..7ad472d307 100644 --- a/apps/herbatika/tests/market-readiness/convergence-collector.test.ts +++ b/apps/herbatika/tests/market-readiness/convergence-collector.test.ts @@ -37,15 +37,14 @@ describe("four-market convergence collector", () => { expect(artifacts.staticTaxonomy.markets.ro.expectedCount).toBeGreaterThan(0) }) - it.each([ - "pending", - "processing", - "failed", - ])("rejects an outbox event in %s state", (status) => { - const rows = fourMarketRowsFixture() - const events = [{ ...rows.events[0], status }, ...rows.events.slice(1)] - expect(() => collect({ ...rows, events })).toThrow("is not converged") - }) + it.each(["pending", "processing", "failed"])( + "rejects an outbox event in %s state", + (status) => { + const rows = fourMarketRowsFixture() + const events = [{ ...rows.events[0], status }, ...rows.events.slice(1)] + expect(() => collect({ ...rows, events })).toThrow("is not converged") + } + ) it.each([ [ diff --git a/apps/herbatika/tests/market-readiness/convergence-db.test.ts b/apps/herbatika/tests/market-readiness/convergence-db.test.ts index c02cf9d836..12204fd533 100644 --- a/apps/herbatika/tests/market-readiness/convergence-db.test.ts +++ b/apps/herbatika/tests/market-readiness/convergence-db.test.ts @@ -107,20 +107,23 @@ describe("four-market convergence DB reader", () => { }, ], ], - ])("rejects a migration ledger that is %s the build", async (_label, ledger) => { - const reader = createFourMarketConvergenceReader( - { - medusaDatabaseUrl: "postgres://medusa@db.internal/commerce", - urlRegistryDatabaseUrl: "postgres://urlr@urlr.internal/registry", - }, - { - poolFactory: (connectionString) => - connectionString.includes("urlr.internal") - ? fakePool("urlr", [], ledger) - : fakePool("medusa", []), - } - ) - await expect(reader.read()).rejects.toThrow(MIGRATION_ERROR) - await reader.close() - }) + ])( + "rejects a migration ledger that is %s the build", + async (_label, ledger) => { + const reader = createFourMarketConvergenceReader( + { + medusaDatabaseUrl: "postgres://medusa@db.internal/commerce", + urlRegistryDatabaseUrl: "postgres://urlr@urlr.internal/registry", + }, + { + poolFactory: (connectionString) => + connectionString.includes("urlr.internal") + ? fakePool("urlr", [], ledger) + : fakePool("medusa", []), + } + ) + await expect(reader.read()).rejects.toThrow(MIGRATION_ERROR) + await reader.close() + } + ) }) diff --git a/apps/herbatika/tests/market-readiness/four-market-checkout-legal.acceptance.test.tsx b/apps/herbatika/tests/market-readiness/four-market-checkout-legal.acceptance.test.tsx index 5d972ad11e..8bd8a7a05d 100644 --- a/apps/herbatika/tests/market-readiness/four-market-checkout-legal.acceptance.test.tsx +++ b/apps/herbatika/tests/market-readiness/four-market-checkout-legal.acceptance.test.tsx @@ -131,21 +131,22 @@ const renderSection = (canCompleteOrder: boolean, currencyCode: string) => ) describe("four-market checkout mandatory legal acceptance", () => { - it.each( - FOUR_MARKET_CHECKOUT_FIXTURES - )("$market keeps completion gated and links its exact terms and privacy paths", (fixture) => { - testContext.market = { code: fixture.market, locale: fixture.locale } + it.each(FOUR_MARKET_CHECKOUT_FIXTURES)( + "$market keeps completion gated and links its exact terms and privacy paths", + (fixture) => { + testContext.market = { code: fixture.market, locale: fixture.locale } - const blockedMarkup = renderSection(false, fixture.currencyCode) - expect(blockedMarkup).toContain("checkout.review_legal_confirmation") - expect(blockedMarkup).toContain(`href="${fixture.termsPath}"`) - expect(blockedMarkup).toContain(`href="${fixture.privacyPath}"`) - expect(blockedMarkup).toContain('data-disabled="true"') - expect(blockedMarkup).toContain('data-checked="false"') - expect(blockedMarkup).toContain('data-required="true"') + const blockedMarkup = renderSection(false, fixture.currencyCode) + expect(blockedMarkup).toContain("checkout.review_legal_confirmation") + expect(blockedMarkup).toContain(`href="${fixture.termsPath}"`) + expect(blockedMarkup).toContain(`href="${fixture.privacyPath}"`) + expect(blockedMarkup).toContain('data-disabled="true"') + expect(blockedMarkup).toContain('data-checked="false"') + expect(blockedMarkup).toContain('data-required="true"') - const allowedMarkup = renderSection(true, fixture.currencyCode) - expect(allowedMarkup).toContain('data-disabled="false"') - expect(allowedMarkup).toContain('data-checked="true"') - }) + const allowedMarkup = renderSection(true, fixture.currencyCode) + expect(allowedMarkup).toContain('data-disabled="false"') + expect(allowedMarkup).toContain('data-checked="true"') + } + ) }) diff --git a/apps/herbatika/tests/market-readiness/four-market-checkout.acceptance.test.ts b/apps/herbatika/tests/market-readiness/four-market-checkout.acceptance.test.ts index 2973cf417a..165694abe5 100644 --- a/apps/herbatika/tests/market-readiness/four-market-checkout.acceptance.test.ts +++ b/apps/herbatika/tests/market-readiness/four-market-checkout.acceptance.test.ts @@ -52,128 +52,136 @@ describe("four-market checkout acceptance matrix", () => { vi.unstubAllGlobals() }) - it.each( - FOUR_MARKET_CHECKOUT_FIXTURES - )("$market binds Host to PK, sales channel, region, currency, and isolated checkout reads", async (fixture) => { - const audit = createFourMarketCheckoutAudit() - const binding = resolveMarketRuntimeByHost(runtime, fixture.host) - expect(binding).toMatchObject({ - countryCode: fixture.countryCode, - locale: fixture.locale, - market: fixture.market, - publishableApiKey: fixture.publishableKey, - publishableApiKeyId: fixture.publishableKeyId, - regionId: fixture.regionId, - salesChannelId: fixture.salesChannelId, - }) - expect(getHerbatikaMarketContext(fixture.market).currencyCode).toBe( - fixture.currencyCode - ) - - const authority = createMarketSdkAuthority({ - baseUrl: "https://medusa.mock.invalid", - createSdk: ({ baseUrl, publishableKey }) => { - expect(baseUrl).toBe("https://medusa.mock.invalid") - expect(publishableKey).toBe(fixture.publishableKey) - return createFourMarketMockSdk(fixture, audit) - }, - runtime, - }) - const entry = authority(fixture.market) - const cartService = createMedusaCartService(entry.sdk as never) - const checkoutService = createMedusaCheckoutService(entry.sdk as never) - - expect( - await cartService.createCart({ - region_id: fixture.regionId, + it.each(FOUR_MARKET_CHECKOUT_FIXTURES)( + "$market binds Host to PK, sales channel, region, currency, and isolated checkout reads", + async (fixture) => { + const audit = createFourMarketCheckoutAudit() + const binding = resolveMarketRuntimeByHost(runtime, fixture.host) + expect(binding).toMatchObject({ + countryCode: fixture.countryCode, + locale: fixture.locale, + market: fixture.market, + publishableApiKey: fixture.publishableKey, + publishableApiKeyId: fixture.publishableKeyId, + regionId: fixture.regionId, + salesChannelId: fixture.salesChannelId, }) - ).toMatchObject(checkoutCartFor(fixture)) - expect(audit.cartCreateRequests).toEqual([{ region_id: fixture.regionId }]) - expect( - await cartService.retrieveCart(`cart_${fixture.market}`) - ).toMatchObject(checkoutCartFor(fixture)) - const foreignMarket = FOUR_MARKET_CHECKOUT_FIXTURES.find( - (candidate) => candidate.market !== fixture.market - ) - expect( - await cartService.retrieveCart(`cart_${foreignMarket?.market}`) - ).toBeNull() + expect(getHerbatikaMarketContext(fixture.market).currencyCode).toBe( + fixture.currencyCode + ) - const shippingOptions = await checkoutService.listShippingOptions( - `cart_${fixture.market}` - ) - expect(shippingOptions).toHaveLength(1) - expect(shippingOptions[0]?.id).toBe(`so_${fixture.market}`) - expect(audit.shippingRequests).toEqual([ - { cart_id: `cart_${fixture.market}` }, - ]) - const providers = await checkoutService.listPaymentProviders( - fixture.regionId - ) - expect(audit.providerRequests).toEqual([{ region_id: fixture.regionId }]) - expect( - filterPaymentProvidersForShipping({ - paymentProviders: providers, - shippingOption: shippingOptions[0], - }).map((provider) => provider.id) - ).toEqual([fixture.market === "ro" ? "pp_system_default" : "pp_gopay_card"]) + const authority = createMarketSdkAuthority({ + baseUrl: "https://medusa.mock.invalid", + createSdk: ({ baseUrl, publishableKey }) => { + expect(baseUrl).toBe("https://medusa.mock.invalid") + expect(publishableKey).toBe(fixture.publishableKey) + return createFourMarketMockSdk(fixture, audit) + }, + runtime, + }) + const entry = authority(fixture.market) + const cartService = createMedusaCartService(entry.sdk as never) + const checkoutService = createMedusaCheckoutService(entry.sdk as never) - await checkoutService.completeCart?.(`cart_${fixture.market}`) - expect(audit.completionRequests).toEqual([`cart_${fixture.market}`]) - expect(audit.orderWrites).toEqual([]) - expect(audit.paymentWrites).toEqual([]) - }) + expect( + await cartService.createCart({ + region_id: fixture.regionId, + }) + ).toMatchObject(checkoutCartFor(fixture)) + expect(audit.cartCreateRequests).toEqual([ + { region_id: fixture.regionId }, + ]) + expect( + await cartService.retrieveCart(`cart_${fixture.market}`) + ).toMatchObject(checkoutCartFor(fixture)) + const foreignMarket = FOUR_MARKET_CHECKOUT_FIXTURES.find( + (candidate) => candidate.market !== fixture.market + ) + expect( + await cartService.retrieveCart(`cart_${foreignMarket?.market}`) + ).toBeNull() - it.each( - FOUR_MARKET_CHECKOUT_FIXTURES - )("$market returns QR only after guest ownership and exact Host binding", async (fixture) => { - const binding = runtime.bindings[fixture.market] - resolveBinding.mockImplementation((host) => - host === fixture.host ? binding : null - ) - expect(hasOrderPaymentQrAuthority({ isAuthenticated: false })).toBe(false) + const shippingOptions = await checkoutService.listShippingOptions( + `cart_${fixture.market}` + ) + expect(shippingOptions).toHaveLength(1) + expect(shippingOptions[0]?.id).toBe(`so_${fixture.market}`) + expect(audit.shippingRequests).toEqual([ + { cart_id: `cart_${fixture.market}` }, + ]) + const providers = await checkoutService.listPaymentProviders( + fixture.regionId + ) + expect(audit.providerRequests).toEqual([{ region_id: fixture.regionId }]) + expect( + filterPaymentProvidersForShipping({ + paymentProviders: providers, + shippingOption: shippingOptions[0], + }).map((provider) => provider.id) + ).toEqual([ + fixture.market === "ro" ? "pp_system_default" : "pp_gopay_card", + ]) - const noAuthorityFetch = vi.fn() - vi.stubGlobal("fetch", noAuthorityFetch) - const denied = await callQrRoute(fixture, {}) - expect(denied.status).toBe(404) - expect(denied.headers.get("cache-control")).toBe( - "private, no-store, max-age=0" - ) - expect(noAuthorityFetch).not.toHaveBeenCalled() + await checkoutService.completeCart?.(`cart_${fixture.market}`) + expect(audit.completionRequests).toEqual([`cart_${fixture.market}`]) + expect(audit.orderWrites).toEqual([]) + expect(audit.paymentWrites).toEqual([]) + } + ) - const orderToken = `signed.guest.${fixture.market}` - expect( - hasOrderPaymentQrAuthority({ isAuthenticated: false, orderToken }) - ).toBe(true) - const upstreamFetch = vi - .fn() - .mockResolvedValueOnce( - Response.json({ order: { id: `order_${fixture.market}` } }) + it.each(FOUR_MARKET_CHECKOUT_FIXTURES)( + "$market returns QR only after guest ownership and exact Host binding", + async (fixture) => { + const binding = runtime.bindings[fixture.market] + resolveBinding.mockImplementation((host) => + host === fixture.host ? binding : null ) - .mockResolvedValueOnce( - Response.json({ - order: { - currency_code: fixture.currencyCode, - id: `order_${fixture.market}`, - payment_collections: [], - region_id: fixture.regionId, - sales_channel_id: fixture.salesChannelId, - }, - }) + expect(hasOrderPaymentQrAuthority({ isAuthenticated: false })).toBe(false) + + const noAuthorityFetch = vi.fn() + vi.stubGlobal("fetch", noAuthorityFetch) + const denied = await callQrRoute(fixture, {}) + expect(denied.status).toBe(404) + expect(denied.headers.get("cache-control")).toBe( + "private, no-store, max-age=0" ) - vi.stubGlobal("fetch", upstreamFetch) - const allowed = await callQrRoute(fixture, { order_token: orderToken }) - expect(allowed.status).toBe(200) - expect(await allowed.json()).toEqual({ - qr_payment: null, - status: "not_applicable", - }) - expect(upstreamFetch).toHaveBeenCalledTimes(2) - for (const [, init] of upstreamFetch.mock.calls) { + expect(noAuthorityFetch).not.toHaveBeenCalled() + + const orderToken = `signed.guest.${fixture.market}` expect( - new Headers((init as RequestInit).headers).get("x-publishable-api-key") - ).toBe(fixture.publishableKey) + hasOrderPaymentQrAuthority({ isAuthenticated: false, orderToken }) + ).toBe(true) + const upstreamFetch = vi + .fn() + .mockResolvedValueOnce( + Response.json({ order: { id: `order_${fixture.market}` } }) + ) + .mockResolvedValueOnce( + Response.json({ + order: { + currency_code: fixture.currencyCode, + id: `order_${fixture.market}`, + payment_collections: [], + region_id: fixture.regionId, + sales_channel_id: fixture.salesChannelId, + }, + }) + ) + vi.stubGlobal("fetch", upstreamFetch) + const allowed = await callQrRoute(fixture, { order_token: orderToken }) + expect(allowed.status).toBe(200) + expect(await allowed.json()).toEqual({ + qr_payment: null, + status: "not_applicable", + }) + expect(upstreamFetch).toHaveBeenCalledTimes(2) + for (const [, init] of upstreamFetch.mock.calls) { + expect( + new Headers((init as RequestInit).headers).get( + "x-publishable-api-key" + ) + ).toBe(fixture.publishableKey) + } } - }) + ) }) diff --git a/apps/herbatika/tests/market-readiness/static-taxonomy-convergence.test.ts b/apps/herbatika/tests/market-readiness/static-taxonomy-convergence.test.ts index f1fd0bf319..a9ceda5244 100644 --- a/apps/herbatika/tests/market-readiness/static-taxonomy-convergence.test.ts +++ b/apps/herbatika/tests/market-readiness/static-taxonomy-convergence.test.ts @@ -182,26 +182,25 @@ describe("four-market static taxonomy convergence", () => { ).toThrow("is not converged") }) - it.each([ - "conflictCount", - "failedCount", - "pendingCount", - ] as const)("rejects non-zero %s", (field) => { - const health = zeroHealth() - health[3] = { ...health[3], [field]: 1 } - expect(() => - buildFourMarketStaticTaxonomyConvergence({ - environmentId: "zane-production", - generatedAt: "2026-08-21T08:05:00.000Z", - health, - manifest: manifestFixture(), - migrationLedgerSha256: MIGRATION_SHA256, - observedProjections: expectedStaticTaxonomyProjections(), - releaseId: "release-2026-08-21", - segmentRegistryByMarket, - }) - ).toThrow("ro is not converged") - }) + it.each(["conflictCount", "failedCount", "pendingCount"] as const)( + "rejects non-zero %s", + (field) => { + const health = zeroHealth() + health[3] = { ...health[3], [field]: 1 } + expect(() => + buildFourMarketStaticTaxonomyConvergence({ + environmentId: "zane-production", + generatedAt: "2026-08-21T08:05:00.000Z", + health, + manifest: manifestFixture(), + migrationLedgerSha256: MIGRATION_SHA256, + observedProjections: expectedStaticTaxonomyProjections(), + releaseId: "release-2026-08-21", + segmentRegistryByMarket, + }) + ).toThrow("ro is not converged") + } + ) it("rejects aggregate, binding, build taxonomy, and schema tampering", () => { const { manifest, proof } = buildFixture() diff --git a/apps/herbatika/tests/ro-localization/population-manifest-source-client.test.ts b/apps/herbatika/tests/ro-localization/population-manifest-source-client.test.ts index 4c3f1fee2e..5bc58a7be0 100644 --- a/apps/herbatika/tests/ro-localization/population-manifest-source-client.test.ts +++ b/apps/herbatika/tests/ro-localization/population-manifest-source-client.test.ts @@ -86,17 +86,20 @@ describe("population source export client", () => { "https://operator:secret@population.internal", "https://population.internal/export?token=embedded", "https://population.internal/export#fragment", - ])("rejects unsafe base URL %s before sending the bearer token", async (baseUrl) => { - const fetchImpl = vi.fn() - await expect( - fetchPopulationSourceExport("ro", "product", { - baseUrl, - fetchImpl, - token: "private-token", - }) - ).rejects.toThrow("population source baseUrl") - expect(fetchImpl).not.toHaveBeenCalled() - }) + ])( + "rejects unsafe base URL %s before sending the bearer token", + async (baseUrl) => { + const fetchImpl = vi.fn() + await expect( + fetchPopulationSourceExport("ro", "product", { + baseUrl, + fetchImpl, + token: "private-token", + }) + ).rejects.toThrow("population source baseUrl") + expect(fetchImpl).not.toHaveBeenCalled() + } + ) it("rejects kind-specific source fields and binding drift", () => { expect(() => diff --git a/apps/herbatika/tests/url-registry/postgres.product-lifecycle.integration.ts b/apps/herbatika/tests/url-registry/postgres.product-lifecycle.integration.ts index 65e780bc74..2301f8c317 100644 --- a/apps/herbatika/tests/url-registry/postgres.product-lifecycle.integration.ts +++ b/apps/herbatika/tests/url-registry/postgres.product-lifecycle.integration.ts @@ -226,61 +226,60 @@ const persistedStream = async (sourceId: string) => { } describe.sequential("PostgreSQL 18.1 product lifecycle consumer", () => { - it.each([ - "category", - "brand", - "collection", - ] as const)("publishes and forward-changes a %s slug while preserving the old alias", async (entityKind) => { - const sourceId = context.nextNamespace(`catalog-${entityKind}`) - const readCatalog = vi.fn(async () => ({ - kind: "found" as const, - value: { id: sourceId }, - })) - const consumer = createPostgresProductLifecycleConsumer(context.sqlPool, { - readCatalog, - readProduct: readSource({ kind: "missing" }), - }) - const originalSlug = `${sourceId}-old` - const renamedSlug = `${sourceId}-nou` + it.each(["category", "brand", "collection"] as const)( + "publishes and forward-changes a %s slug while preserving the old alias", + async (entityKind) => { + const sourceId = context.nextNamespace(`catalog-${entityKind}`) + const readCatalog = vi.fn(async () => ({ + kind: "found" as const, + value: { id: sourceId }, + })) + const consumer = createPostgresProductLifecycleConsumer(context.sqlPool, { + readCatalog, + readProduct: readSource({ kind: "missing" }), + }) + const originalSlug = `${sourceId}-old` + const renamedSlug = `${sourceId}-nou` - await expect( - consumer.consume( - catalogDelivery({ - entityKind, - publicSlug: originalSlug, - sequence: 1, - sourceId, - }) - ) - ).resolves.toMatchObject({ action: "published", kind: "acknowledged" }) - await expect( - consumer.consume( - catalogDelivery({ - entityKind, - publicSlug: renamedSlug, - sequence: 2, - sourceId, + await expect( + consumer.consume( + catalogDelivery({ + entityKind, + publicSlug: originalSlug, + sequence: 1, + sourceId, + }) + ) + ).resolves.toMatchObject({ action: "published", kind: "acknowledged" }) + await expect( + consumer.consume( + catalogDelivery({ + entityKind, + publicSlug: renamedSlug, + sequence: 2, + sourceId, + }) + ) + ).resolves.toMatchObject({ + action: "slug-changed", + kind: "acknowledged", + }) + await expect( + context.registry.resolve({ + kind: entityKind, + market: "ro", + normalizedSlug: originalSlug, }) - ) - ).resolves.toMatchObject({ - action: "slug-changed", - kind: "acknowledged", - }) - await expect( - context.registry.resolve({ - kind: entityKind, - market: "ro", - normalizedSlug: originalSlug, + ).resolves.toMatchObject({ + kind: "found", + value: { + currentSlug: { normalizedSlug: renamedSlug }, + disposition: "alias", + route: { kind: entityKind, sourceId }, + }, }) - ).resolves.toMatchObject({ - kind: "found", - value: { - currentSlug: { normalizedSlug: renamedSlug }, - disposition: "alias", - route: { kind: entityKind, sourceId }, - }, - }) - }) + } + ) it("keeps a stale queued catalog slug non-mutating before applying the current slug", async () => { const sourceId = context.nextNamespace("catalog-backlog") @@ -411,67 +410,66 @@ describe.sequential("PostgreSQL 18.1 product lifecycle consumer", () => { expect(stream.rows).toEqual([{ last_sequence: 5, receipt_count: 5 }]) }) - it.each([ - "category", - "brand", - "collection", - ] as const)("keeps an active %s route when its assignment becomes draft", async (entityKind) => { - const sourceId = context.nextNamespace(`catalog-draft-${entityKind}`) - const source = vi.fn(async () => ({ - kind: "found" as const, - value: { id: sourceId }, - })) - const consumer = createPostgresProductLifecycleConsumer(context.sqlPool, { - readCatalog: source, - readProduct: readSource({ kind: "missing" }), - }) - const publicSlug = `${sourceId}-public` + it.each(["category", "brand", "collection"] as const)( + "keeps an active %s route when its assignment becomes draft", + async (entityKind) => { + const sourceId = context.nextNamespace(`catalog-draft-${entityKind}`) + const source = vi.fn(async () => ({ + kind: "found" as const, + value: { id: sourceId }, + })) + const consumer = createPostgresProductLifecycleConsumer(context.sqlPool, { + readCatalog: source, + readProduct: readSource({ kind: "missing" }), + }) + const publicSlug = `${sourceId}-public` - await expect( - consumer.consume( - catalogDelivery({ - entityKind, - publicSlug, - sequence: 1, + await expect( + consumer.consume( + catalogDelivery({ + entityKind, + publicSlug, + sequence: 1, + sourceId, + }) + ) + ).resolves.toMatchObject({ action: "published", kind: "acknowledged" }) + const draft = catalogDelivery({ + entityKind, + publicSlug, + sequence: 2, + sourceId, + }) + await expect( + consumer.consume({ + ...draft, + payload: { + ...draft.payload, + assignment: { + ...draft.payload.assignment, + publicationStatus: "draft", + }, + }, + }) + ).resolves.toMatchObject({ action: "unpublished", kind: "acknowledged" }) + + await expect( + context.registry.findEntityRoute({ + market: "ro", sourceId, + sourceSystem: "medusa", + sourceType: entityKind, }) - ) - ).resolves.toMatchObject({ action: "published", kind: "acknowledged" }) - const draft = catalogDelivery({ - entityKind, - publicSlug, - sequence: 2, - sourceId, - }) - await expect( - consumer.consume({ - ...draft, - payload: { - ...draft.payload, - assignment: { - ...draft.payload.assignment, - publicationStatus: "draft", - }, + ).resolves.toMatchObject({ + kind: "found", + value: { + currentSlug: { normalizedSlug: publicSlug }, + route: { status: "active", version: 1 }, }, }) - ).resolves.toMatchObject({ action: "unpublished", kind: "acknowledged" }) - - await expect( - context.registry.findEntityRoute({ - market: "ro", - sourceId, - sourceSystem: "medusa", - sourceType: entityKind, - }) - ).resolves.toMatchObject({ - kind: "found", - value: { - currentSlug: { normalizedSlug: publicSlug }, - route: { status: "active", version: 1 }, - }, - }) - expect(source).toHaveBeenCalledTimes(1) - }) + expect(source).toHaveBeenCalledTimes(1) + } + ) it("keeps identical category slugs isolated between SK and RO", async () => { const sourceId = context.nextNamespace("catalog-market-isolation") @@ -737,17 +735,20 @@ describe.sequential("PostgreSQL 18.1 product lifecycle consumer", () => { "invalid", { kind: "invalid-response", causeCode: "INVALID_MEDUSA_RESPONSE" }, ], - ] as const)("does not advance for an %s source read", async (_label, sourceResult) => { - const sourceId = context.nextNamespace("lifecycle-source-retry") - const consumer = createPostgresProductLifecycleConsumer(context.sqlPool, { - readProduct: readSource(sourceResult), - }) + ] as const)( + "does not advance for an %s source read", + async (_label, sourceResult) => { + const sourceId = context.nextNamespace("lifecycle-source-retry") + const consumer = createPostgresProductLifecycleConsumer(context.sqlPool, { + readProduct: readSource(sourceResult), + }) - await expect( - consumer.consume(delivery(sourceId, 1)) - ).resolves.toMatchObject({ kind: "retry" }) - await expect(persistedStream(sourceId)).resolves.toBeNull() - }) + await expect( + consumer.consume(delivery(sourceId, 1)) + ).resolves.toMatchObject({ kind: "retry" }) + await expect(persistedStream(sourceId)).resolves.toBeNull() + } + ) it("does not advance a live-source conflict on a terminal route", async () => { const sourceId = context.nextNamespace("lifecycle-terminal-live") diff --git a/apps/medusa-be/e2e-tests/http/promotions-custom-rules.spec.ts b/apps/medusa-be/e2e-tests/http/promotions-custom-rules.spec.ts index 1785bf5386..983a86ccc4 100644 --- a/apps/medusa-be/e2e-tests/http/promotions-custom-rules.spec.ts +++ b/apps/medusa-be/e2e-tests/http/promotions-custom-rules.spec.ts @@ -186,61 +186,64 @@ describe("Custom promotion rules HTTP E2E", () => { unmatchedQuantity: 1, values: () => ["2"], }, - ])("applies $name target rules only to matching cart items", async ({ - attribute, - matchingAmount, - matchingQuantity, - operator, - unmatchedAmount, - unmatchedQuantity, - values, - }) => { - const context = await createTestContext(backendUrl) - const matchingProduct = await createProduct( - context.admin, - context.salesChannelId, - { - amount: matchingAmount, - title: `Matching Rule Product ${suffix()}`, - } - ) - const unmatchedProduct = await createProduct( - context.admin, - context.salesChannelId, - { - amount: unmatchedAmount, - title: `Unmatched Rule Product ${suffix()}`, - } - ) - const promotion = await createPromotion(context.admin, { - targetRules: [ + ])( + "applies $name target rules only to matching cart items", + async ({ + attribute, + matchingAmount, + matchingQuantity, + operator, + unmatchedAmount, + unmatchedQuantity, + values, + }) => { + const context = await createTestContext(backendUrl) + const matchingProduct = await createProduct( + context.admin, + context.salesChannelId, { - attribute, - operator, - values: values(matchingProduct.variants[0].id), - }, - ], - }) - - const cart = await createCartAndApplyPromotion( - context, - [ - { - quantity: matchingQuantity, - variantId: matchingProduct.variants[0].id, - }, + amount: matchingAmount, + title: `Matching Rule Product ${suffix()}`, + } + ) + const unmatchedProduct = await createProduct( + context.admin, + context.salesChannelId, { - quantity: unmatchedQuantity, - variantId: unmatchedProduct.variants[0].id, - }, - ], - promotion.code - ) + amount: unmatchedAmount, + title: `Unmatched Rule Product ${suffix()}`, + } + ) + const promotion = await createPromotion(context.admin, { + targetRules: [ + { + attribute, + operator, + values: values(matchingProduct.variants[0].id), + }, + ], + }) - expectAdjusted(getItem(cart, matchingProduct.variants[0].id), promotion) - expectUnadjusted(getItem(cart, unmatchedProduct.variants[0].id)) - expectCartDiscounted(cart) - }) + const cart = await createCartAndApplyPromotion( + context, + [ + { + quantity: matchingQuantity, + variantId: matchingProduct.variants[0].id, + }, + { + quantity: unmatchedQuantity, + variantId: unmatchedProduct.variants[0].id, + }, + ], + promotion.code + ) + + expectAdjusted(getItem(cart, matchingProduct.variants[0].id), promotion) + expectUnadjusted(getItem(cart, unmatchedProduct.variants[0].id)) + expectCartDiscounted(cart) + } + ) it("applies cart item total rules only when the cart reaches the threshold", async () => { const context = await createTestContext(backendUrl) diff --git a/apps/medusa-be/src/api/admin/promotions/__tests__/utils.unit.spec.ts b/apps/medusa-be/src/api/admin/promotions/__tests__/utils.unit.spec.ts index 3c55b27637..e9247cef17 100644 --- a/apps/medusa-be/src/api/admin/promotions/__tests__/utils.unit.spec.ts +++ b/apps/medusa-be/src/api/admin/promotions/__tests__/utils.unit.spec.ts @@ -183,18 +183,14 @@ describe("validateRuleType", () => { }) describe("invalid rule types", () => { - it.each([ - "invalid", - "RULES", - "", - "rule", - "target", - "buy", - ])('throws for invalid rule type "%s"', (invalidType) => { - expect(() => validateRuleType(invalidType)).toThrow( - `Invalid param rule_type (${invalidType})` - ) - }) + it.each(["invalid", "RULES", "", "rule", "target", "buy"])( + 'throws for invalid rule type "%s"', + (invalidType) => { + expect(() => validateRuleType(invalidType)).toThrow( + `Invalid param rule_type (${invalidType})` + ) + } + ) }) it("narrows type after assertion", () => { diff --git a/apps/medusa-be/src/api/store/__tests__/review-market-scope.unit.spec.ts b/apps/medusa-be/src/api/store/__tests__/review-market-scope.unit.spec.ts index 5da33aa4e5..350f819b30 100644 --- a/apps/medusa-be/src/api/store/__tests__/review-market-scope.unit.spec.ts +++ b/apps/medusa-be/src/api/store/__tests__/review-market-scope.unit.spec.ts @@ -80,17 +80,16 @@ describe("review market scope", () => { expect(listStorefrontUrlAssignments).toHaveBeenCalledTimes(4) }) - it.each([ - "cz", - "hu", - "ro", - ] as const)("rejects a catalog channel assigned to %s", async (market) => { - const { request } = catalogRequest({ market }) - - await expect(hasExactSlovakReviewScope(request as never)).resolves.toBe( - false - ) - }) + it.each(["cz", "hu", "ro"] as const)( + "rejects a catalog channel assigned to %s", + async (market) => { + const { request } = catalogRequest({ market }) + + await expect(hasExactSlovakReviewScope(request as never)).resolves.toBe( + false + ) + } + ) it("fails closed for missing, ambiguous, or unavailable channel scope", async () => { for (const salesChannelIds of [null, [], ["sc_sk", "sc_ro"]]) { diff --git a/apps/medusa-be/src/api/store/catalog/products/__tests__/degraded-price-sort.unit.spec.ts b/apps/medusa-be/src/api/store/catalog/products/__tests__/degraded-price-sort.unit.spec.ts index 3975908720..4e901ddb5d 100644 --- a/apps/medusa-be/src/api/store/catalog/products/__tests__/degraded-price-sort.unit.spec.ts +++ b/apps/medusa-be/src/api/store/catalog/products/__tests__/degraded-price-sort.unit.spec.ts @@ -117,40 +117,44 @@ describe.each(MARKETS)("$key degraded catalog price sorting", (market) => { mocks.loadSearchProfiles.mockResolvedValue(MARKETS.map(profile)) }) - it.each([ - "price-asc", - "price-desc", - ] as const)("fails closed for %s instead of returning a wrongly ordered page", async (sort) => { - const { graph, request, response, search, warn } = createRoute(market, sort) + it.each(["price-asc", "price-desc"] as const)( + "fails closed for %s instead of returning a wrongly ordered page", + async (sort) => { + const { graph, request, response, search, warn } = createRoute( + market, + sort + ) - await GET(request as never, response as never) + await GET(request as never, response as never) - expect(search).toHaveBeenCalledWith( - `product_${market.key}`, - "", - expect.objectContaining({ - filter: expect.stringContaining( - `facet_sales_channel_ids = "${market.salesChannelId}"` - ), - paginationOptions: { limit: 20, offset: 0 }, - additionalOptions: expect.objectContaining({ - sort: [`facet_price:${sort === "price-asc" ? "asc" : "desc"}`], - }), + expect(search).toHaveBeenCalledWith( + `product_${market.key}`, + "", + expect.objectContaining({ + filter: expect.stringContaining( + `facet_sales_channel_ids = "${market.salesChannelId}"` + ), + paginationOptions: { limit: 20, offset: 0 }, + additionalOptions: expect.objectContaining({ + sort: [`facet_price:${sort === "price-asc" ? "asc" : "desc"}`], + }), + }) + ) + expect(response.status).toHaveBeenCalledWith(503) + expect(response.json).toHaveBeenCalledWith({ + code: "CATALOG_PRICE_SORT_UNAVAILABLE_DEGRADED", + message: + "Price sorting is unavailable while catalog search is degraded", + search: { + degraded: true, + exactIdentifierMatch: false, + profile: market.key, + }, }) - ) - expect(response.status).toHaveBeenCalledWith(503) - expect(response.json).toHaveBeenCalledWith({ - code: "CATALOG_PRICE_SORT_UNAVAILABLE_DEGRADED", - message: "Price sorting is unavailable while catalog search is degraded", - search: { - degraded: true, - exactIdentifierMatch: false, - profile: market.key, - }, - }) - expect(graph).not.toHaveBeenCalled() - expect(warn).toHaveBeenCalledWith( - expect.stringContaining("price-sorted degraded fallback is unavailable") - ) - }) + expect(graph).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("price-sorted degraded fallback is unavailable") + ) + } + ) }) diff --git a/apps/medusa-be/src/api/store/catalog/products/__tests__/price-pagination.unit.spec.ts b/apps/medusa-be/src/api/store/catalog/products/__tests__/price-pagination.unit.spec.ts index 6e4124c98a..1aa197d53d 100644 --- a/apps/medusa-be/src/api/store/catalog/products/__tests__/price-pagination.unit.spec.ts +++ b/apps/medusa-be/src/api/store/catalog/products/__tests__/price-pagination.unit.spec.ts @@ -46,133 +46,137 @@ const planFor = ( requestedSort, }) -describe.each( - MARKET_PRICE_SCOPES -)("$market catalog price pagination", (market) => { - it("uses native market-currency facet sorting for the first ascending and descending pages", () => { - expect(planFor(market, "price-asc")).toMatchObject({ - exhaustiveCandidateSearch: false, - indexedProfilePriceSort: true, - meiliSort: ["facet_price:asc"], - paginationOptions: { limit: PAGE_SIZE, offset: 0 }, - }) - expect(planFor(market, "price-desc")).toMatchObject({ - exhaustiveCandidateSearch: false, - indexedProfilePriceSort: true, - meiliSort: ["facet_price:desc"], - paginationOptions: { limit: PAGE_SIZE, offset: 0 }, +describe.each(MARKET_PRICE_SCOPES)( + "$market catalog price pagination", + (market) => { + it("uses native market-currency facet sorting for the first ascending and descending pages", () => { + expect(planFor(market, "price-asc")).toMatchObject({ + exhaustiveCandidateSearch: false, + indexedProfilePriceSort: true, + meiliSort: ["facet_price:asc"], + paginationOptions: { limit: PAGE_SIZE, offset: 0 }, + }) + expect(planFor(market, "price-desc")).toMatchObject({ + exhaustiveCandidateSearch: false, + indexedProfilePriceSort: true, + meiliSort: ["facet_price:desc"], + paginationOptions: { limit: PAGE_SIZE, offset: 0 }, + }) }) - }) - - it("returns the exact final ascending and descending pages beyond 1000 hits", () => { - const ascendingPage = ascendingMatches.slice(LAST_PAGE_OFFSET) - const descendingPage = [...ascendingMatches] - .reverse() - .slice(LAST_PAGE_OFFSET) - expect( - planFor(market, "price-asc", { offset: LAST_PAGE_OFFSET }) - ).toMatchObject({ - meiliSort: ["facet_price:asc"], - paginationOptions: { limit: PAGE_SIZE, offset: LAST_PAGE_OFFSET }, - }) - expect( - selectProductMatchesForHydration({ - cleanedQuery: "", - limit: PAGE_SIZE, - matchingProducts: ascendingPage, - offset: LAST_PAGE_OFFSET, - prePaginated: true, - priceSortDirection: 1, - }) - ).toEqual([{ productId: "prod_2001" }, { productId: "prod_2002" }]) + it("returns the exact final ascending and descending pages beyond 1000 hits", () => { + const ascendingPage = ascendingMatches.slice(LAST_PAGE_OFFSET) + const descendingPage = [...ascendingMatches] + .reverse() + .slice(LAST_PAGE_OFFSET) - expect( - planFor(market, "price-desc", { offset: LAST_PAGE_OFFSET }) - ).toMatchObject({ - meiliSort: ["facet_price:desc"], - paginationOptions: { limit: PAGE_SIZE, offset: LAST_PAGE_OFFSET }, - }) - expect( - selectProductMatchesForHydration({ - cleanedQuery: "", - limit: PAGE_SIZE, - matchingProducts: descendingPage, - offset: LAST_PAGE_OFFSET, - prePaginated: true, - priceSortDirection: -1, + expect( + planFor(market, "price-asc", { offset: LAST_PAGE_OFFSET }) + ).toMatchObject({ + meiliSort: ["facet_price:asc"], + paginationOptions: { limit: PAGE_SIZE, offset: LAST_PAGE_OFFSET }, }) - ).toEqual([{ productId: "prod_2" }, { productId: "prod_1" }]) - }) - - it("keeps query plus ascending and descending price sorting paginated in Meili", () => { - for (const requestedSort of ["price-asc", "price-desc"] as const) { expect( - planFor(market, requestedSort, { - cleanedQuery: "vitamin", + selectProductMatchesForHydration({ + cleanedQuery: "", + limit: PAGE_SIZE, + matchingProducts: ascendingPage, offset: LAST_PAGE_OFFSET, + prePaginated: true, + priceSortDirection: 1, }) + ).toEqual([{ productId: "prod_2001" }, { productId: "prod_2002" }]) + + expect( + planFor(market, "price-desc", { offset: LAST_PAGE_OFFSET }) ).toMatchObject({ - exhaustiveCandidateSearch: false, - indexedProfilePriceSort: true, - meiliSort: [ - `facet_price:${requestedSort === "price-asc" ? "asc" : "desc"}`, - ], + meiliSort: ["facet_price:desc"], paginationOptions: { limit: PAGE_SIZE, offset: LAST_PAGE_OFFSET }, }) - } - }) -}) - -describe("catalog price pagination currency safety", () => { - it.each( - MARKET_PRICE_SCOPES - )("fails closed when $market lacks verified currency scope", (market) => { - const plan = planFor(market, "price-asc", { - pricingContextCurrencyCode: " ", - requestedCurrencyCode: " ", + expect( + selectProductMatchesForHydration({ + cleanedQuery: "", + limit: PAGE_SIZE, + matchingProducts: descendingPage, + offset: LAST_PAGE_OFFSET, + prePaginated: true, + priceSortDirection: -1, + }) + ).toEqual([{ productId: "prod_2" }, { productId: "prod_1" }]) }) - expect(plan).toMatchObject({ - exhaustiveCandidateSearch: true, - indexedProfilePriceSort: false, - meiliSort: undefined, - paginationOptions: { limit: 1000, offset: 0 }, + it("keeps query plus ascending and descending price sorting paginated in Meili", () => { + for (const requestedSort of ["price-asc", "price-desc"] as const) { + expect( + planFor(market, requestedSort, { + cleanedQuery: "vitamin", + offset: LAST_PAGE_OFFSET, + }) + ).toMatchObject({ + exhaustiveCandidateSearch: false, + indexedProfilePriceSort: true, + meiliSort: [ + `facet_price:${requestedSort === "price-asc" ? "asc" : "desc"}`, + ], + paginationOptions: { limit: PAGE_SIZE, offset: LAST_PAGE_OFFSET }, + }) + } }) - }) + } +) - it.each( - MARKET_PRICE_SCOPES - )("fails closed when $market has mixed pricing-context and request currencies", (market) => { - const competingCurrency = market.currency === "eur" ? "ron" : "eur" - const plan = planFor(market, "price-desc", { - pricingContextCurrencyCode: market.currency, - requestedCurrencyCode: competingCurrency, - }) +describe("catalog price pagination currency safety", () => { + it.each(MARKET_PRICE_SCOPES)( + "fails closed when $market lacks verified currency scope", + (market) => { + const plan = planFor(market, "price-asc", { + pricingContextCurrencyCode: " ", + requestedCurrencyCode: " ", + }) - expect(plan).toMatchObject({ - exhaustiveCandidateSearch: true, - indexedProfilePriceSort: false, - meiliSort: undefined, - paginationOptions: { limit: 1000, offset: 0 }, - }) - }) + expect(plan).toMatchObject({ + exhaustiveCandidateSearch: true, + indexedProfilePriceSort: false, + meiliSort: undefined, + paginationOptions: { limit: 1000, offset: 0 }, + }) + } + ) + + it.each(MARKET_PRICE_SCOPES)( + "fails closed when $market has mixed pricing-context and request currencies", + (market) => { + const competingCurrency = market.currency === "eur" ? "ron" : "eur" + const plan = planFor(market, "price-desc", { + pricingContextCurrencyCode: market.currency, + requestedCurrencyCode: competingCurrency, + }) - it.each( - MARKET_PRICE_SCOPES - )("fails closed when $market uses another market currency", (market) => { - const competingCurrency = market.currency === "eur" ? "czk" : "eur" - const plan = planFor(market, "price-asc", { - pricingContextCurrencyCode: competingCurrency, - requestedCurrencyCode: competingCurrency, - }) + expect(plan).toMatchObject({ + exhaustiveCandidateSearch: true, + indexedProfilePriceSort: false, + meiliSort: undefined, + paginationOptions: { limit: 1000, offset: 0 }, + }) + } + ) + + it.each(MARKET_PRICE_SCOPES)( + "fails closed when $market uses another market currency", + (market) => { + const competingCurrency = market.currency === "eur" ? "czk" : "eur" + const plan = planFor(market, "price-asc", { + pricingContextCurrencyCode: competingCurrency, + requestedCurrencyCode: competingCurrency, + }) - expect(plan).toMatchObject({ - exhaustiveCandidateSearch: true, - indexedProfilePriceSort: false, - meiliSort: undefined, - }) - }) + expect(plan).toMatchObject({ + exhaustiveCandidateSearch: true, + indexedProfilePriceSort: false, + meiliSort: undefined, + }) + } + ) it("fails closed for an unsupported profile locale", () => { expect( diff --git a/apps/medusa-be/src/api/store/customers/deactivate/confirm/__tests__/route.unit.spec.ts b/apps/medusa-be/src/api/store/customers/deactivate/confirm/__tests__/route.unit.spec.ts index dd5b930350..1526f40fa0 100644 --- a/apps/medusa-be/src/api/store/customers/deactivate/confirm/__tests__/route.unit.spec.ts +++ b/apps/medusa-be/src/api/store/customers/deactivate/confirm/__tests__/route.unit.spec.ts @@ -102,25 +102,23 @@ describe("POST /store/customers/deactivate/confirm", () => { expect(res.setHeader).toHaveBeenNthCalledWith(2, "Pragma", "no-cache") }) - it.each([ - undefined, - [], - ["sc_ro", "sc_cz"], - [null, ""], - ])("rejects missing or ambiguous current market scope: %o", async (salesChannelIds) => { - const res = response() - - await expect( - POST(request(salesChannelIds) as never, res as never) - ).rejects.toThrow("Resource was not found.") - - expect(workflows.verify).not.toHaveBeenCalled() - expect(workflows.deactivate).not.toHaveBeenCalled() - expect(res.setHeader).toHaveBeenNthCalledWith( - 1, - "Cache-Control", - "private, no-store" - ) - expect(res.setHeader).toHaveBeenNthCalledWith(2, "Pragma", "no-cache") - }) + it.each([undefined, [], ["sc_ro", "sc_cz"], [null, ""]])( + "rejects missing or ambiguous current market scope: %o", + async (salesChannelIds) => { + const res = response() + + await expect( + POST(request(salesChannelIds) as never, res as never) + ).rejects.toThrow("Resource was not found.") + + expect(workflows.verify).not.toHaveBeenCalled() + expect(workflows.deactivate).not.toHaveBeenCalled() + expect(res.setHeader).toHaveBeenNthCalledWith( + 1, + "Cache-Control", + "private, no-store" + ) + expect(res.setHeader).toHaveBeenNthCalledWith(2, "Pragma", "no-cache") + } + ) }) diff --git a/apps/medusa-be/src/api/store/customers/me/deactivate/__tests__/route.unit.spec.ts b/apps/medusa-be/src/api/store/customers/me/deactivate/__tests__/route.unit.spec.ts index a6e225207d..85d7cc1955 100644 --- a/apps/medusa-be/src/api/store/customers/me/deactivate/__tests__/route.unit.spec.ts +++ b/apps/medusa-be/src/api/store/customers/me/deactivate/__tests__/route.unit.spec.ts @@ -65,30 +65,28 @@ describe("POST /store/customers/me/deactivate", () => { expect(result.setHeader).toHaveBeenNthCalledWith(2, "Pragma", "no-cache") }) - it.each([ - undefined, - [], - ["sc_cz", "sc_hu"], - [null, ""], - ])("rejects missing or ambiguous publishable-key scope: %o", async (salesChannelIds) => { - const result = response() + it.each([undefined, [], ["sc_cz", "sc_hu"], [null, ""]])( + "rejects missing or ambiguous publishable-key scope: %o", + async (salesChannelIds) => { + const result = response() - await expect( - POST( - { - auth_context: { actor_id: "cus_1" }, - publishable_key_context: { sales_channel_ids: salesChannelIds }, - scope: {}, - } as never, - result as never + await expect( + POST( + { + auth_context: { actor_id: "cus_1" }, + publishable_key_context: { sales_channel_ids: salesChannelIds }, + scope: {}, + } as never, + result as never + ) + ).rejects.toThrow("Resource was not found.") + expect(workflow.request).not.toHaveBeenCalled() + expect(result.setHeader).toHaveBeenNthCalledWith( + 1, + "Cache-Control", + "private, no-store" ) - ).rejects.toThrow("Resource was not found.") - expect(workflow.request).not.toHaveBeenCalled() - expect(result.setHeader).toHaveBeenNthCalledWith( - 1, - "Cache-Control", - "private, no-store" - ) - expect(result.setHeader).toHaveBeenNthCalledWith(2, "Pragma", "no-cache") - }) + expect(result.setHeader).toHaveBeenNthCalledWith(2, "Pragma", "no-cache") + } + ) }) diff --git a/apps/medusa-be/src/api/store/customers/route.ts b/apps/medusa-be/src/api/store/customers/route.ts index 7ce4313477..2f3dd3acfb 100644 --- a/apps/medusa-be/src/api/store/customers/route.ts +++ b/apps/medusa-be/src/api/store/customers/route.ts @@ -1,4 +1,3 @@ -import { createCustomerAccountWorkflow } from "@medusajs/core-flows" import type { AuthenticatedMedusaRequest, MedusaResponse, @@ -8,6 +7,7 @@ import { ContainerRegistrationKeys, MedusaError, } from "@medusajs/framework/utils" +import { createCustomerAccountWorkflow } from "@medusajs/medusa/core-flows" import { normalizeEmail } from "../../../utils/email" import { reactivateCustomerAccountWorkflow } from "../../../workflows/customer/workflows/reactivate-customer-account" import { findInactiveCustomerWithEmail, refetchCustomer } from "./helpers" diff --git a/apps/medusa-be/src/api/store/search/autocomplete/__tests__/route.unit.spec.ts b/apps/medusa-be/src/api/store/search/autocomplete/__tests__/route.unit.spec.ts index db5880f42b..27d678ad51 100644 --- a/apps/medusa-be/src/api/store/search/autocomplete/__tests__/route.unit.spec.ts +++ b/apps/medusa-be/src/api/store/search/autocomplete/__tests__/route.unit.spec.ts @@ -210,45 +210,47 @@ describe("GET /store/search/autocomplete", () => { expect(mocks.wrapProductsWithTaxPrices).toHaveBeenCalledOnce() }) - it.each( - MARKETS - )("rejects a competing currency for the $key storefront", async (current) => { - const competing = MARKETS[(MARKETS.indexOf(current) + 1) % MARKETS.length] - const { graph, request, response, search } = createRoute({ - current, - pricingCurrency: competing.currency, - queryCurrency: competing.currency, - }) + it.each(MARKETS)( + "rejects a competing currency for the $key storefront", + async (current) => { + const competing = MARKETS[(MARKETS.indexOf(current) + 1) % MARKETS.length] + const { graph, request, response, search } = createRoute({ + current, + pricingCurrency: competing.currency, + queryCurrency: competing.currency, + }) - await GET(request as never, response as never) + await GET(request as never, response as never) - expect(response.status).toHaveBeenCalledWith(400) - expect(response.json).toHaveBeenCalledWith({ - message: "Search currency does not match the storefront market", - }) - expect(search).not.toHaveBeenCalled() - expect(graph).not.toHaveBeenCalled() - expect(mocks.wrapProductsWithTaxPrices).not.toHaveBeenCalled() - }) + expect(response.status).toHaveBeenCalledWith(400) + expect(response.json).toHaveBeenCalledWith({ + message: "Search currency does not match the storefront market", + }) + expect(search).not.toHaveBeenCalled() + expect(graph).not.toHaveBeenCalled() + expect(mocks.wrapProductsWithTaxPrices).not.toHaveBeenCalled() + } + ) - it.each( - MARKETS - )("rejects a rotating cross-market profile spoof from $key", async (current) => { - const competing = MARKETS[(MARKETS.indexOf(current) + 1) % MARKETS.length] - const { graph, request, response, search } = createRoute({ - current, - requestedProfile: competing.key, - }) + it.each(MARKETS)( + "rejects a rotating cross-market profile spoof from $key", + async (current) => { + const competing = MARKETS[(MARKETS.indexOf(current) + 1) % MARKETS.length] + const { graph, request, response, search } = createRoute({ + current, + requestedProfile: competing.key, + }) - await GET(request as never, response as never) + await GET(request as never, response as never) - expect(response.status).toHaveBeenCalledWith(400) - expect(response.json).toHaveBeenCalledWith({ - message: `Search profile ${competing.key} is not available for this storefront`, - }) - expect(search).not.toHaveBeenCalled() - expect(graph).not.toHaveBeenCalled() - }) + expect(response.status).toHaveBeenCalledWith(400) + expect(response.json).toHaveBeenCalledWith({ + message: `Search profile ${competing.key} is not available for this storefront`, + }) + expect(search).not.toHaveBeenCalled() + expect(graph).not.toHaveBeenCalled() + } + ) it("rejects a same-channel profile whose locale disagrees with the trusted storefront locale", async () => { const mismatchedProfile = { diff --git a/apps/medusa-be/src/api/store/url-registry/catalog/sources/__tests__/route.unit.spec.ts b/apps/medusa-be/src/api/store/url-registry/catalog/sources/__tests__/route.unit.spec.ts index 11d0f7b8c1..1217b01e73 100644 --- a/apps/medusa-be/src/api/store/url-registry/catalog/sources/__tests__/route.unit.spec.ts +++ b/apps/medusa-be/src/api/store/url-registry/catalog/sources/__tests__/route.unit.spec.ts @@ -41,53 +41,56 @@ describe("catalog source batch route", () => { ["category", "pcat_1", "doplnky", "product_category"], ["brand", "brand_1", "herbatika", "brand"], ["collection", "pcol_1", "zimna-kolekcia", "product_collection"], - ] as const)("returns the exact successful %s response", async (entityKind, entityId, publicSlug, translationReference) => { - const candidate = { entityId, publicSlug, sourceVersion: "1" } - const assignment = { - entityId, - id: entityId, - marketCode: "sk", - publicationStatus: "published", - publicSlug, - salesChannelId: "sc_sk", - schemaVersion: 1, - sourceVersion: "1", - translation: { - localeCode: "sk-SK", - reference: translationReference, - translationId: `trans_${entityId}`, - }, + ] as const)( + "returns the exact successful %s response", + async (entityKind, entityId, publicSlug, translationReference) => { + const candidate = { entityId, publicSlug, sourceVersion: "1" } + const assignment = { + entityId, + id: entityId, + marketCode: "sk", + publicationStatus: "published", + publicSlug, + salesChannelId: "sc_sk", + schemaVersion: 1, + sourceVersion: "1", + translation: { + localeCode: "sk-SK", + reference: translationReference, + translationId: `trans_${entityId}`, + }, + } + readPublishedStorefrontAssignmentSources.mockResolvedValueOnce({ + assignments: [assignment], + kind: "found", + }) + const request = makeRequest({ + candidates: [candidate], + entityKind, + market: "sk", + schemaVersion: 1, + }) + const { json, response, status } = makeResponse() + + await POST(request, response) + + expect(readPublishedStorefrontAssignmentSources).toHaveBeenCalledOnce() + expect(readPublishedStorefrontAssignmentSources).toHaveBeenCalledWith( + request, + entityKind, + "sk", + [candidate] + ) + expect(status).not.toHaveBeenCalled() + expect(json).toHaveBeenCalledOnce() + expect(json).toHaveBeenCalledWith({ + assignments: [assignment], + entityKind, + marketCode: "sk", + schemaVersion: 1, + }) } - readPublishedStorefrontAssignmentSources.mockResolvedValueOnce({ - assignments: [assignment], - kind: "found", - }) - const request = makeRequest({ - candidates: [candidate], - entityKind, - market: "sk", - schemaVersion: 1, - }) - const { json, response, status } = makeResponse() - - await POST(request, response) - - expect(readPublishedStorefrontAssignmentSources).toHaveBeenCalledOnce() - expect(readPublishedStorefrontAssignmentSources).toHaveBeenCalledWith( - request, - entityKind, - "sk", - [candidate] - ) - expect(status).not.toHaveBeenCalled() - expect(json).toHaveBeenCalledOnce() - expect(json).toHaveBeenCalledWith({ - assignments: [assignment], - entityKind, - marketCode: "sk", - schemaVersion: 1, - }) - }) + ) it.each([ ["an extra top-level field", { extra: true }], diff --git a/apps/medusa-be/src/api/store/url-registry/collections/__tests__/utils.unit.spec.ts b/apps/medusa-be/src/api/store/url-registry/collections/__tests__/utils.unit.spec.ts index d4a29fe8bc..e1909ff998 100644 --- a/apps/medusa-be/src/api/store/url-registry/collections/__tests__/utils.unit.spec.ts +++ b/apps/medusa-be/src/api/store/url-registry/collections/__tests__/utils.unit.spec.ts @@ -158,23 +158,26 @@ describe("Store collection assignment reads", () => { it.each([ ["category", "pcat_1"], ["brand", "brand_1"], - ] as const)("uses the owning %s source adapter", async (entityKind, entityId) => { - const context = request({ - records: [ - assignment({ - entity_id: entityId, - entity_kind: entityKind, - public_slug: `${entityKind}-slug`, - }), - ], - }) - await expect( - readPublishedStorefrontAssignment(context.value, entityKind, entityId) - ).resolves.toMatchObject({ - kind: "found", - assignment: { entityId }, - }) - }) + ] as const)( + "uses the owning %s source adapter", + async (entityKind, entityId) => { + const context = request({ + records: [ + assignment({ + entity_id: entityId, + entity_kind: entityKind, + public_slug: `${entityKind}-slug`, + }), + ], + }) + await expect( + readPublishedStorefrontAssignment(context.value, entityKind, entityId) + ).resolves.toMatchObject({ + kind: "found", + assignment: { entityId }, + }) + } + ) it("returns missing for absent, draft, wrong-channel, or deleted collections", async () => { await expect( @@ -283,69 +286,72 @@ describe("Store collection assignment reads", () => { ["category", "pcat_1", "category-slug", "product_category"], ["brand", "brand_1", "brand-slug", "brand"], ["collection", "pcol_1", "zimna-kolekcia", "product_collection"], - ] as const)("reads only exact %s sitemap candidates with one bounded assignment query", async (entityKind, entityId, publicSlug, translationReference) => { - const context = request({ - records: [ - assignment({ - entity_id: entityId, - entity_kind: entityKind, - public_slug: publicSlug, - }), - ], - }) - await expect( - readPublishedStorefrontAssignmentSources( - context.value, - entityKind, - "sk", - [ - { entityId, publicSlug, sourceVersion: "1" }, + ] as const)( + "reads only exact %s sitemap candidates with one bounded assignment query", + async (entityKind, entityId, publicSlug, translationReference) => { + const context = request({ + records: [ + assignment({ + entity_id: entityId, + entity_kind: entityKind, + public_slug: publicSlug, + }), + ], + }) + await expect( + readPublishedStorefrontAssignmentSources( + context.value, + entityKind, + "sk", + [ + { entityId, publicSlug, sourceVersion: "1" }, + { + entityId: "pcol_stale", + publicSlug: "new-slug", + sourceVersion: "2", + }, + ] + ) + ).resolves.toEqual({ + assignments: [ { - entityId: "pcol_stale", - publicSlug: "new-slug", - sourceVersion: "2", + entityId, + id: entityId, + marketCode: "sk", + publicationStatus: "published", + publicSlug, + salesChannelId: "sc_sk", + schemaVersion: 1, + sourceVersion: "1", + translation: { + localeCode: "sk-SK", + reference: translationReference, + translationId: "trans_1", + }, }, - ] - ) - ).resolves.toEqual({ - assignments: [ + ], + kind: "found", + }) + expect( + context.assignmentService.listStorefrontUrlAssignments + ).toHaveBeenCalledTimes(1) + expect( + context.assignmentService.listStorefrontUrlAssignments + ).toHaveBeenCalledWith( { - entityId, - id: entityId, - marketCode: "sk", - publicationStatus: "published", - publicSlug, - salesChannelId: "sc_sk", - schemaVersion: 1, - sourceVersion: "1", - translation: { - localeCode: "sk-SK", - reference: translationReference, - translationId: "trans_1", - }, + entity_id: [entityId, "pcol_stale"], + entity_kind: entityKind, + market_code: "sk", + publication_status: "published", + sales_channel_id: "sc_sk", }, - ], - kind: "found", - }) - expect( - context.assignmentService.listStorefrontUrlAssignments - ).toHaveBeenCalledTimes(1) - expect( - context.assignmentService.listStorefrontUrlAssignments - ).toHaveBeenCalledWith( - { - entity_id: [entityId, "pcol_stale"], - entity_kind: entityKind, - market_code: "sk", - publication_status: "published", - sales_channel_id: "sc_sk", - }, - { - order: { entity_id: "ASC" }, - take: 3, - } - ) - }) + { + order: { entity_id: "ASC" }, + take: 3, + } + ) + } + ) it("omits a superseded same-slug source version", async () => { const context = request({ records: [assignment()] }) diff --git a/apps/medusa-be/src/modules/meilisearch/__tests__/content-documents.unit.spec.ts b/apps/medusa-be/src/modules/meilisearch/__tests__/content-documents.unit.spec.ts index 06b2f5daf1..2400347aa1 100644 --- a/apps/medusa-be/src/modules/meilisearch/__tests__/content-documents.unit.spec.ts +++ b/apps/medusa-be/src/modules/meilisearch/__tests__/content-documents.unit.spec.ts @@ -75,11 +75,10 @@ describe("content search documents", () => { ).toBeUndefined() }) - it.each([ - "/blog/article", - "/informace/kontakt", - "/", - ])("accepts a public path %s", (href) => { - expect(readCanonicalPublicHref(href)).toBe(href) - }) + it.each(["/blog/article", "/informace/kontakt", "/"])( + "accepts a public path %s", + (href) => { + expect(readCanonicalPublicHref(href)).toBe(href) + } + ) }) diff --git a/apps/medusa-be/src/modules/meilisearch/__tests__/product-facets.unit.spec.ts b/apps/medusa-be/src/modules/meilisearch/__tests__/product-facets.unit.spec.ts index 630d81f249..5c175dfeed 100644 --- a/apps/medusa-be/src/modules/meilisearch/__tests__/product-facets.unit.spec.ts +++ b/apps/medusa-be/src/modules/meilisearch/__tests__/product-facets.unit.spec.ts @@ -7,15 +7,18 @@ describe("product price facets", () => { ["czk", 242], ["huf", 3651], ["ron", 52.52], - ])("preserves Medusa v2 major-unit amounts for %s", (currencyCode, amount) => { - expect( - buildProductFacetDocument({ - variants: [ - { - prices: [{ amount, currency_code: currencyCode }], - }, - ], - }).facet_price - ).toBe(amount) - }) + ])( + "preserves Medusa v2 major-unit amounts for %s", + (currencyCode, amount) => { + expect( + buildProductFacetDocument({ + variants: [ + { + prices: [{ amount, currency_code: currencyCode }], + }, + ], + }).facet_price + ).toBe(amount) + } + ) }) diff --git a/apps/medusa-be/src/modules/meilisearch/__tests__/profile-currency.unit.spec.ts b/apps/medusa-be/src/modules/meilisearch/__tests__/profile-currency.unit.spec.ts index 4558b8a730..ccdc5dbd5b 100644 --- a/apps/medusa-be/src/modules/meilisearch/__tests__/profile-currency.unit.spec.ts +++ b/apps/medusa-be/src/modules/meilisearch/__tests__/profile-currency.unit.spec.ts @@ -7,14 +7,17 @@ describe("Meilisearch profile facet-price currency", () => { ["cs_CZ", "czk", "czk"], ["hu-HU", "HUF", "huf"], ["ro-RO", "ron", "ron"], - ])("verifies %s against its exact %s contract", (locale, currency, expected) => { - expect( - resolveVerifiedFacetPriceCurrency(locale, { - pricingContextCurrencyCode: currency, - requestedCurrencyCode: currency, - }) - ).toBe(expected) - }) + ])( + "verifies %s against its exact %s contract", + (locale, currency, expected) => { + expect( + resolveVerifiedFacetPriceCurrency(locale, { + pricingContextCurrencyCode: currency, + requestedCurrencyCode: currency, + }) + ).toBe(expected) + } + ) it.each([ ["missing currency proof", "sk-SK", undefined, undefined], @@ -22,12 +25,15 @@ describe("Meilisearch profile facet-price currency", () => { ["wrong profile currency", "cs-CZ", "eur", "eur"], ["mixed request scope", "hu-HU", "huf", "eur"], ["blank competing scope", "ro-RO", "ron", " "], - ])("fails closed for %s", (_label, locale, pricingContextCurrencyCode, requestedCurrencyCode) => { - expect( - resolveVerifiedFacetPriceCurrency(locale, { - pricingContextCurrencyCode, - requestedCurrencyCode, - }) - ).toBeUndefined() - }) + ])( + "fails closed for %s", + (_label, locale, pricingContextCurrencyCode, requestedCurrencyCode) => { + expect( + resolveVerifiedFacetPriceCurrency(locale, { + pricingContextCurrencyCode, + requestedCurrencyCode, + }) + ).toBeUndefined() + } + ) }) diff --git a/apps/medusa-be/src/modules/meilisearch/__tests__/profiles.unit.spec.ts b/apps/medusa-be/src/modules/meilisearch/__tests__/profiles.unit.spec.ts index f29aebd029..f54aa15dd7 100644 --- a/apps/medusa-be/src/modules/meilisearch/__tests__/profiles.unit.spec.ts +++ b/apps/medusa-be/src/modules/meilisearch/__tests__/profiles.unit.spec.ts @@ -34,50 +34,55 @@ const rotate = (values: readonly T[], offset: number): T[] => [ ] describe("Meilisearch search profile isolation", () => { - it.each( - MARKET_PROFILES - )("rejects a %s sales channel that is also assigned to the next market", (...market) => { - const index = MARKET_PROFILES.findIndex(([key]) => key === market[0]) - const profiles = MARKET_PROFILES.map(createProfile) - const nextProfile = profiles[(index + 1) % profiles.length] as SearchProfile + it.each(MARKET_PROFILES)( + "rejects a %s sales channel that is also assigned to the next market", + (...market) => { + const index = MARKET_PROFILES.findIndex(([key]) => key === market[0]) + const profiles = MARKET_PROFILES.map(createProfile) + const nextProfile = profiles[ + (index + 1) % profiles.length + ] as SearchProfile - nextProfile.salesChannelIds = [market[2]] + nextProfile.salesChannelIds = [market[2]] - expect(() => validateSearchProfileSet(profiles)).toThrow( - SearchProfileConfigurationError - ) - expect(() => validateSearchProfileSet(profiles)).toThrow( - `Meilisearch sales channel ${market[2]} is assigned to multiple search profiles` - ) - }) + expect(() => validateSearchProfileSet(profiles)).toThrow( + SearchProfileConfigurationError + ) + expect(() => validateSearchProfileSet(profiles)).toThrow( + `Meilisearch sales channel ${market[2]} is assigned to multiple search profiles` + ) + } + ) - it.each([ - 0, 1, 2, 3, - ])("fails closed for ambiguous four-market channel scope rotation %i", (offset) => { - const profiles = MARKET_PROFILES.map(createProfile) - const salesChannelIds = rotate( - MARKET_PROFILES.map(([, , salesChannelId]) => salesChannelId), - offset - ) + it.each([0, 1, 2, 3])( + "fails closed for ambiguous four-market channel scope rotation %i", + (offset) => { + const profiles = MARKET_PROFILES.map(createProfile) + const salesChannelIds = rotate( + MARKET_PROFILES.map(([, , salesChannelId]) => salesChannelId), + offset + ) - expect(() => resolveSearchProfile({ salesChannelIds }, profiles)).toThrow( - SearchProfileResolutionError - ) - expect(() => resolveSearchProfile({ salesChannelIds }, profiles)).toThrow( - "Multiple Meilisearch profiles are assigned to this storefront Sales Channel and language: cz, hu, ro, sk" - ) - }) + expect(() => resolveSearchProfile({ salesChannelIds }, profiles)).toThrow( + SearchProfileResolutionError + ) + expect(() => resolveSearchProfile({ salesChannelIds }, profiles)).toThrow( + "Multiple Meilisearch profiles are assigned to this storefront Sales Channel and language: cz, hu, ro, sk" + ) + } + ) - it.each( - MARKET_PROFILES - )("still resolves the exact %s market when one profile remains", (...market) => { - const profiles = MARKET_PROFILES.map(createProfile) + it.each(MARKET_PROFILES)( + "still resolves the exact %s market when one profile remains", + (...market) => { + const profiles = MARKET_PROFILES.map(createProfile) - expect( - resolveSearchProfile( - { locale: market[1], salesChannelIds: [market[2]] }, - profiles - ).key - ).toBe(market[0]) - }) + expect( + resolveSearchProfile( + { locale: market[1], salesChannelIds: [market[2]] }, + profiles + ).key + ).toBe(market[0]) + } + ) }) diff --git a/apps/medusa-be/src/modules/meilisearch/__tests__/synchronize-localization.unit.spec.ts b/apps/medusa-be/src/modules/meilisearch/__tests__/synchronize-localization.unit.spec.ts index 1995f33bce..a07a0bcbf3 100644 --- a/apps/medusa-be/src/modules/meilisearch/__tests__/synchronize-localization.unit.spec.ts +++ b/apps/medusa-be/src/modules/meilisearch/__tests__/synchronize-localization.unit.spec.ts @@ -1750,87 +1750,93 @@ describe("Meilisearch catalog localization", () => { ["cz", "cs-CZ", "cz", "czk"], ["hu", "hu-HU", "hu", "huf"], ["ro", "ro-RO", "ro", "ron"], - ] as const)("requires explicit %s publication and commerce authority", async (market, locale, country, currencyCode) => { - const query = { - graph: vi.fn().mockResolvedValue({ data: [] }), - } as unknown as Query - const profile = { - ...roProfile(`herbatika-${market}`), - locale, - salesChannelIds: [`sc_${market}`], - } satisfies SearchProfile - const indexState = inMemoryIndexClient() - const region = { - countries: [{ iso_2: country }], - currency_code: currencyCode, - id: `reg_${market}`, - metadata: { - market_code: market, - sales_channel_id: `sc_${market}`, - }, - } - - await expect( - syncProfile({ - client: indexState.client, - container: syncContainer( - query, - vi.fn().mockResolvedValue([]), - vi.fn().mockResolvedValue([]), - [region] - ) as never, - logger: { info: vi.fn(), warn: vi.fn() } as never, - mode: "normal", - profile, - }) - ).resolves.toEqual({ deleted: 0, indexed: 0 }) + ] as const)( + "requires explicit %s publication and commerce authority", + async (market, locale, country, currencyCode) => { + const query = { + graph: vi.fn().mockResolvedValue({ data: [] }), + } as unknown as Query + const profile = { + ...roProfile(`herbatika-${market}`), + locale, + salesChannelIds: [`sc_${market}`], + } satisfies SearchProfile + const indexState = inMemoryIndexClient() + const region = { + countries: [{ iso_2: country }], + currency_code: currencyCode, + id: `reg_${market}`, + metadata: { + market_code: market, + sales_channel_id: `sc_${market}`, + }, + } - expect(indexState.swapIndexPairs).toHaveBeenCalledTimes(1) - }) + await expect( + syncProfile({ + client: indexState.client, + container: syncContainer( + query, + vi.fn().mockResolvedValue([]), + vi.fn().mockResolvedValue([]), + [region] + ) as never, + logger: { info: vi.fn(), warn: vi.fn() } as never, + mode: "normal", + profile, + }) + ).resolves.toEqual({ deleted: 0, indexed: 0 }) + + expect(indexState.swapIndexPairs).toHaveBeenCalledTimes(1) + } + ) it.each([ ["sk", "sk-SK", "sk", "czk"], ["cz", "cs-CZ", "cz", "eur"], ["hu", "hu-HU", "hu", "ron"], ["ro", "ro-RO", "ro", "huf"], - ] as const)("rejects a %s profile whose region violates its exact currency contract", async (market, locale, country, wrongCurrencyCode) => { - const query = { - graph: vi.fn().mockRejectedValue(new Error("catalog must not be read")), - } as unknown as Query - const profile = { - ...roProfile(`herbatika-${market}-wrong-currency`), - locale, - salesChannelIds: [`sc_${market}`], - } satisfies SearchProfile - const indexState = inMemoryIndexClient() - const region = { - countries: [{ iso_2: country }], - currency_code: wrongCurrencyCode, - id: `reg_${market}`, - metadata: { - market_code: market, - sales_channel_id: `sc_${market}`, - }, - } - - await expect( - syncProfile({ - client: indexState.client, - container: syncContainer( - query, - vi.fn().mockResolvedValue([]), - vi.fn().mockResolvedValue([]), - [region] - ) as never, - logger: { info: vi.fn(), warn: vi.fn() } as never, - mode: "normal", - profile, - }) - ).rejects.toThrow("cannot prove exact region, currency") + ] as const)( + "rejects a %s profile whose region violates its exact currency contract", + async (market, locale, country, wrongCurrencyCode) => { + const query = { + graph: vi.fn().mockRejectedValue(new Error("catalog must not be read")), + } as unknown as Query + const profile = { + ...roProfile(`herbatika-${market}-wrong-currency`), + locale, + salesChannelIds: [`sc_${market}`], + } satisfies SearchProfile + const indexState = inMemoryIndexClient() + const region = { + countries: [{ iso_2: country }], + currency_code: wrongCurrencyCode, + id: `reg_${market}`, + metadata: { + market_code: market, + sales_channel_id: `sc_${market}`, + }, + } - expect(indexState.swapIndexPairs).not.toHaveBeenCalled() - expect(query.graph).not.toHaveBeenCalled() - }) + await expect( + syncProfile({ + client: indexState.client, + container: syncContainer( + query, + vi.fn().mockResolvedValue([]), + vi.fn().mockResolvedValue([]), + [region] + ) as never, + logger: { info: vi.fn(), warn: vi.fn() } as never, + mode: "normal", + profile, + }) + ).rejects.toThrow("cannot prove exact region, currency") + + expect(indexState.swapIndexPairs).not.toHaveBeenCalled() + expect(query.graph).not.toHaveBeenCalled() + } + ) it("rejects an implicit Slovak profile without exact market scope", async () => { const query = { diff --git a/apps/medusa-be/src/modules/payload/schemas.ts b/apps/medusa-be/src/modules/payload/schemas.ts index c1a874a66a..efa06dff05 100644 --- a/apps/medusa-be/src/modules/payload/schemas.ts +++ b/apps/medusa-be/src/modules/payload/schemas.ts @@ -429,38 +429,38 @@ const CmsCategoryListOptionsSchema = z.object({ }) export { + ArticleCategoriesWithArticlesSchema, CMS_FOOTER_COLUMN_SLOTS, CMS_FOOTER_ITEM_SLOTS, - CmsVisibilitySchema, - CmsStatusSchema, - CmsSeoSchema, - CmsProductReferenceSchema, - CmsProductCarouselBlockSchema, - CmsLexicalNodeSchema, - CmsLexicalContentSchema, - CmsMediaSchema, - CmsArticleCategoryReferenceSchema, CmsArticleAuthorSchema, - CmsRelatedArticleSchema, - CmsPageSchema, - CmsPageCategorySchema, - CmsArticleSchema, + CmsArticleCategoryReferenceSchema, CmsArticleCategorySchema, - CmsHeroButtonTargetSchema, - CmsHeroCarouselSchema, + CmsArticleSchema, + CmsArticlesBulkResultSchema, + CmsCategoryListOptionsSchema, CmsFooterColumnSlotSchema, CmsFooterItemSlotSchema, - CmsFooterPageReferenceSchema, - CmsFooterNavigationItemSchema, CmsFooterNavigationGlobalSchema, - CmsStoreFooterNavigationItemSchema, - CmsStoreFooterNavigationSchema, - CmsPagesBulkResultSchema, - CmsArticlesBulkResultSchema, + CmsFooterNavigationItemSchema, + CmsFooterPageReferenceSchema, + CmsHeroButtonTargetSchema, + CmsHeroCarouselSchema, CmsHeroCarouselsBulkResultSchema, - PageCategoriesWithPagesSchema, - ArticleCategoriesWithArticlesSchema, + CmsLexicalContentSchema, + CmsLexicalNodeSchema, CmsListOptionsSchema, - CmsCategoryListOptionsSchema, + CmsMediaSchema, + CmsPageCategorySchema, + CmsPageSchema, + CmsPagesBulkResultSchema, + CmsProductCarouselBlockSchema, + CmsProductReferenceSchema, + CmsRelatedArticleSchema, + CmsSeoSchema, + CmsStatusSchema, + CmsStoreFooterNavigationItemSchema, + CmsStoreFooterNavigationSchema, + CmsVisibilitySchema, createPayloadBulkResultSchema, + PageCategoriesWithPagesSchema, } diff --git a/apps/medusa-be/src/modules/product-list/__tests__/normalizers.unit.spec.ts b/apps/medusa-be/src/modules/product-list/__tests__/normalizers.unit.spec.ts index 73f8edaafc..c9b6a0da71 100644 --- a/apps/medusa-be/src/modules/product-list/__tests__/normalizers.unit.spec.ts +++ b/apps/medusa-be/src/modules/product-list/__tests__/normalizers.unit.spec.ts @@ -25,12 +25,12 @@ describe("product-list normalizers", () => { expect(normalizeProductListAccessType(undefined)).toBe("private") }) - it.each([ - "private", - "public", - ] as const)("accepts %s access", (accessType) => { - expect(normalizeProductListAccessType(accessType)).toBe(accessType) - }) + it.each(["private", "public"] as const)( + "accepts %s access", + (accessType) => { + expect(normalizeProductListAccessType(accessType)).toBe(accessType) + } + ) it("rejects unsupported access values", () => { expect( diff --git a/apps/medusa-be/src/modules/resend-config/__tests__/service.unit.spec.ts b/apps/medusa-be/src/modules/resend-config/__tests__/service.unit.spec.ts index d76603d384..1850f2f409 100644 --- a/apps/medusa-be/src/modules/resend-config/__tests__/service.unit.spec.ts +++ b/apps/medusa-be/src/modules/resend-config/__tests__/service.unit.spec.ts @@ -211,13 +211,16 @@ describe("ResendConfigModuleService", () => { }, "SK From Email and Reply-To must use herbatica.sk", ], - ])("refuses enablement when %s", async (_case, configuredMarkets, message) => { - const service = createEnabledConfigurationService(configuredMarkets) + ])( + "refuses enablement when %s", + async (_case, configuredMarkets, message) => { + const service = createEnabledConfigurationService(configuredMarkets) - await expect(service.updateConfig({ is_enabled: true })).rejects.toThrow( - message - ) - }) + await expect(service.updateConfig({ is_enabled: true })).rejects.toThrow( + message + ) + } + ) it("keeps a legacy global-only configuration disabled", async () => { const service = createEnabledConfigurationService({}) diff --git a/apps/medusa-be/src/modules/resend/__tests__/service.unit.spec.ts b/apps/medusa-be/src/modules/resend/__tests__/service.unit.spec.ts index 484e7980b0..61f8c7daa7 100644 --- a/apps/medusa-be/src/modules/resend/__tests__/service.unit.spec.ts +++ b/apps/medusa-be/src/modules/resend/__tests__/service.unit.spec.ts @@ -189,33 +189,36 @@ describe("ResendNotificationProviderService", () => { ["cs-CZ", "herbatica.cz", "cz"], ["hu-HU", "herbatica.hu", "hu"], ["ro-RO", "herbatica.ro", "ro"], - ])("routes %s and %s to the exact %s sender and template", async (locale, storefrontDomain, market) => { - const fetchMock = vi - .fn() - .mockResolvedValue(jsonResponse({ id: `email_${market}` })) - vi.stubGlobal("fetch", fetchMock) - - await createProvider().provider.send({ - ...notification, - data: { - locale, - reset_url: "https://shop.example/reset", - storefront_domain: storefrontDomain, - }, - }) + ])( + "routes %s and %s to the exact %s sender and template", + async (locale, storefrontDomain, market) => { + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse({ id: `email_${market}` })) + vi.stubGlobal("fetch", fetchMock) + + await createProvider().provider.send({ + ...notification, + data: { + locale, + reset_url: "https://shop.example/reset", + storefront_domain: storefrontDomain, + }, + }) - const [, request] = fetchMock.mock.calls[0] ?? [] - const body = JSON.parse(String(request?.body)) as { - from: string - reply_to: string - template: { id: string } + const [, request] = fetchMock.mock.calls[0] ?? [] + const body = JSON.parse(String(request?.body)) as { + from: string + reply_to: string + template: { id: string } + } + expect(body).toMatchObject({ + from: `Herbatica `, + reply_to: `support@herbatica.${market}`, + template: { id: `account-setup-template-id-${market}` }, + }) } - expect(body).toMatchObject({ - from: `Herbatica `, - reply_to: `support@herbatica.${market}`, - template: { id: `account-setup-template-id-${market}` }, - }) - }) + ) it.each([ [{ locale: "sk-SK" }, "missing domain"], @@ -270,56 +273,56 @@ describe("ResendNotificationProviderService", () => { resendEmailMarkets.flatMap((market) => resendEmailTemplateKeys.map((template) => ({ market, template })) ) - )("routes $template through the exact $market critical-template configuration", async ({ - market, - template, - }) => { - const fetchMock = vi - .fn() - .mockResolvedValue(jsonResponse({ id: `email_${market}_${template}` })) - vi.stubGlobal("fetch", fetchMock) - - await createProvider({ - ...enabledConfig, - market_configurations: criticalTemplateConfiguration, - }).provider.send({ - channel: "email", - data: criticalTemplateData(market, template), - template, - to: "customer@example.test", - }) + )( + "routes $template through the exact $market critical-template configuration", + async ({ market, template }) => { + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse({ id: `email_${market}_${template}` })) + vi.stubGlobal("fetch", fetchMock) + + await createProvider({ + ...enabledConfig, + market_configurations: criticalTemplateConfiguration, + }).provider.send({ + channel: "email", + data: criticalTemplateData(market, template), + template, + to: "customer@example.test", + }) - const [, request] = fetchMock.mock.calls[0] ?? [] - const body = JSON.parse(String(request?.body)) as { - from: string - reply_to: string - template: { id: string } + const [, request] = fetchMock.mock.calls[0] ?? [] + const body = JSON.parse(String(request?.body)) as { + from: string + reply_to: string + template: { id: string } + } + const domain = resendEmailMarketBindings[market].senderDomain + expect(body).toMatchObject({ + from: `Herbatica `, + reply_to: `support@${domain}`, + template: { id: `tmpl_${market}_${template}` }, + }) } - const domain = resendEmailMarketBindings[market].senderDomain - expect(body).toMatchObject({ - from: `Herbatica `, - reply_to: `support@${domain}`, - template: { id: `tmpl_${market}_${template}` }, - }) - }) - - it.each([ - "market_code", - "country_code", - ] as const)("rejects a %s cross-wired to the resolved locale and domain tuple", async (field) => { - const { getRuntimeConfig, provider } = createProvider() + ) - await expect( - provider.send({ - ...notification, - data: { - ...notification.data, - [field]: "cz", - }, - }) - ).rejects.toThrow("market context is cross-wired") - expect(getRuntimeConfig).not.toHaveBeenCalled() - }) + it.each(["market_code", "country_code"] as const)( + "rejects a %s cross-wired to the resolved locale and domain tuple", + async (field) => { + const { getRuntimeConfig, provider } = createProvider() + + await expect( + provider.send({ + ...notification, + data: { + ...notification.data, + [field]: "cz", + }, + }) + ).rejects.toThrow("market context is cross-wired") + expect(getRuntimeConfig).not.toHaveBeenCalled() + } + ) it("rejects a legacy global-only runtime configuration", async () => { await expect( diff --git a/apps/medusa-be/src/modules/resend/__tests__/templates.unit.spec.ts b/apps/medusa-be/src/modules/resend/__tests__/templates.unit.spec.ts index 930098d8e9..92ae8bde84 100644 --- a/apps/medusa-be/src/modules/resend/__tests__/templates.unit.spec.ts +++ b/apps/medusa-be/src/modules/resend/__tests__/templates.unit.spec.ts @@ -66,11 +66,12 @@ const subjectCases = Object.entries(expectedSubjects).flatMap( ) describe("Resend template subjects", () => { - it.each( - subjectCases - )("maps %s and %s to its localized subject", (template, locale, subject) => { - expect(getResendTemplateSubject(template, locale)).toBe(subject) - }) + it.each(subjectCases)( + "maps %s and %s to its localized subject", + (template, locale, subject) => { + expect(getResendTemplateSubject(template, locale)).toBe(subject) + } + ) it("requires an explicit supported locale", () => { expect( diff --git a/apps/medusa-be/src/modules/storefront-url-assignment/__tests__/contracts.unit.spec.ts b/apps/medusa-be/src/modules/storefront-url-assignment/__tests__/contracts.unit.spec.ts index c7904ac972..9c7ef1750f 100644 --- a/apps/medusa-be/src/modules/storefront-url-assignment/__tests__/contracts.unit.spec.ts +++ b/apps/medusa-be/src/modules/storefront-url-assignment/__tests__/contracts.unit.spec.ts @@ -53,20 +53,19 @@ describe("collection URL assignment contract", () => { ).toBe(false) }) - it.each([ - "Zimna-kolekcia", - "zimná-kolekcia", - "", - ])("rejects a non-canonical slug: %s", (publicSlug) => { - expect( - AdminUpsertCollectionUrlAssignmentSchema.safeParse({ - marketCode: "sk", - salesChannelId: "sc_sk", - publicSlug, - publicationStatus: "draft", - }).success - ).toBe(false) - }) + it.each(["Zimna-kolekcia", "zimná-kolekcia", ""])( + "rejects a non-canonical slug: %s", + (publicSlug) => { + expect( + AdminUpsertCollectionUrlAssignmentSchema.safeParse({ + marketCode: "sk", + salesChannelId: "sc_sk", + publicSlug, + publicationStatus: "draft", + }).success + ).toBe(false) + } + ) it("accepts a customer-authoritative slug with consecutive hyphens", () => { expect( diff --git a/apps/medusa-be/src/modules/url-registry-outbox/__tests__/service.spec.ts b/apps/medusa-be/src/modules/url-registry-outbox/__tests__/service.spec.ts index 6f59bbe65a..9067820691 100644 --- a/apps/medusa-be/src/modules/url-registry-outbox/__tests__/service.spec.ts +++ b/apps/medusa-be/src/modules/url-registry-outbox/__tests__/service.spec.ts @@ -184,30 +184,29 @@ moduleIntegrationTestRunner({ }) describe("enqueueCatalogLifecycleEvent", () => { - it.each([ - "category", - "brand", - "collection", - ] as const)("preserves the %s stream identity and replays exactly", async (entityKind) => { - const event = catalogInput(`catalog:${entityKind}`, entityKind) - const first = await service.enqueueCatalogLifecycleEvent(event) - const replay = await service.enqueueCatalogLifecycleEvent(event) - - expect(first.events).toEqual([ - expect.objectContaining({ marketCode: "ro", streamSequence: 1 }), - ]) - expect(replay.events[0]).toEqual( - expect.objectContaining({ replayed: true, streamSequence: 1 }) - ) - const [stored] = await service.listUrlRegistryOutboxEvents({ - event_id: event.eventId, - }) - expect(stored).toMatchObject({ - entity_id: `${entityKind}_1`, - entity_kind: entityKind, - market_code: "ro", - }) - }) + it.each(["category", "brand", "collection"] as const)( + "preserves the %s stream identity and replays exactly", + async (entityKind) => { + const event = catalogInput(`catalog:${entityKind}`, entityKind) + const first = await service.enqueueCatalogLifecycleEvent(event) + const replay = await service.enqueueCatalogLifecycleEvent(event) + + expect(first.events).toEqual([ + expect.objectContaining({ marketCode: "ro", streamSequence: 1 }), + ]) + expect(replay.events[0]).toEqual( + expect.objectContaining({ replayed: true, streamSequence: 1 }) + ) + const [stored] = await service.listUrlRegistryOutboxEvents({ + event_id: event.eventId, + }) + expect(stored).toMatchObject({ + entity_id: `${entityKind}_1`, + entity_kind: entityKind, + market_code: "ro", + }) + } + ) it("isolates the same entity and slug between SK and RO streams", async () => { const ro = await service.enqueueCatalogLifecycleEvent( diff --git a/apps/medusa-be/src/scripts/herbatica-pterostilben-150-import.ts b/apps/medusa-be/src/scripts/herbatica-pterostilben-150-import.ts index 92ad93bfd9..7c51c69718 100644 --- a/apps/medusa-be/src/scripts/herbatica-pterostilben-150-import.ts +++ b/apps/medusa-be/src/scripts/herbatica-pterostilben-150-import.ts @@ -1,8 +1,4 @@ import { createHash } from "node:crypto" -import { - createTranslationsWorkflow, - updateTranslationsWorkflow, -} from "@medusajs/core-flows" import type { CreateTranslationDTO, ExecArgs, @@ -22,8 +18,10 @@ import { ProductStatus, } from "@medusajs/framework/utils" import { + createTranslationsWorkflow, updateProductsWorkflow, updateProductVariantsWorkflow, + updateTranslationsWorkflow, } from "@medusajs/medusa/core-flows" import { PRODUCT_CONTENT_MODULE } from "../modules/product-content" import type ProductContentModuleService from "../modules/product-content/service" diff --git a/apps/medusa-be/src/scripts/herbatica-pterostilben-import.ts b/apps/medusa-be/src/scripts/herbatica-pterostilben-import.ts index 9e9e791f6d..c0f88ba6c7 100644 --- a/apps/medusa-be/src/scripts/herbatica-pterostilben-import.ts +++ b/apps/medusa-be/src/scripts/herbatica-pterostilben-import.ts @@ -1,8 +1,4 @@ import { createHash } from "node:crypto" -import { - createTranslationsWorkflow, - updateTranslationsWorkflow, -} from "@medusajs/core-flows" import type { CreateTranslationDTO, ExecArgs, @@ -22,8 +18,10 @@ import { ProductStatus, } from "@medusajs/framework/utils" import { + createTranslationsWorkflow, updateProductsWorkflow, updateProductVariantsWorkflow, + updateTranslationsWorkflow, } from "@medusajs/medusa/core-flows" import { PRODUCT_CONTENT_MODULE } from "../modules/product-content" import type ProductContentModuleService from "../modules/product-content/service" diff --git a/apps/medusa-be/src/scripts/herbatica-supplemental-import.ts b/apps/medusa-be/src/scripts/herbatica-supplemental-import.ts index a7c4e411de..dcf5cb8fca 100644 --- a/apps/medusa-be/src/scripts/herbatica-supplemental-import.ts +++ b/apps/medusa-be/src/scripts/herbatica-supplemental-import.ts @@ -1,10 +1,6 @@ import { createHash } from "node:crypto" import { readFile } from "node:fs/promises" import { resolve } from "node:path" -import { - createTranslationsWorkflow, - updateTranslationsWorkflow, -} from "@medusajs/core-flows" import type { CreateTranslationDTO, ExecArgs, @@ -20,8 +16,10 @@ import type { } from "@medusajs/framework/types" import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils" import { + createTranslationsWorkflow, updateProductCategoriesWorkflow, updateProductsWorkflow, + updateTranslationsWorkflow, } from "@medusajs/medusa/core-flows" import { PRODUCT_CONTENT_MODULE } from "../modules/product-content" import type ProductContentModuleService from "../modules/product-content/service" diff --git a/apps/medusa-be/src/scripts/measurement-unit-translations.ts b/apps/medusa-be/src/scripts/measurement-unit-translations.ts index 264cd5e06c..0b39bf0cbf 100644 --- a/apps/medusa-be/src/scripts/measurement-unit-translations.ts +++ b/apps/medusa-be/src/scripts/measurement-unit-translations.ts @@ -1,4 +1,3 @@ -import { createTranslationsWorkflow } from "@medusajs/core-flows" import type { CreateTranslationDTO, ITranslationModuleService, @@ -6,6 +5,7 @@ import type { TranslationDTO, } from "@medusajs/framework/types" import { Modules } from "@medusajs/framework/utils" +import { createTranslationsWorkflow } from "@medusajs/medusa/core-flows" import { isPieceMeasurementSourceUnit } from "../utils/measurement-unit-source" import { getMeasurementUnitService } from "../utils/measurement-units" diff --git a/apps/medusa-be/src/scripts/ro-catalog-import/runtime.ts b/apps/medusa-be/src/scripts/ro-catalog-import/runtime.ts index 3217bdc4df..6fdc601820 100644 --- a/apps/medusa-be/src/scripts/ro-catalog-import/runtime.ts +++ b/apps/medusa-be/src/scripts/ro-catalog-import/runtime.ts @@ -1,7 +1,3 @@ -import { - createTranslationsWorkflow, - updateTranslationsWorkflow, -} from "@medusajs/core-flows" import type { SqlEntityManager } from "@medusajs/framework/mikro-orm/knex" import type { Context, @@ -11,7 +7,11 @@ import type { UpdateTranslationDTO, } from "@medusajs/framework/types" import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils" -import { updateProductsWorkflow } from "@medusajs/medusa/core-flows" +import { + createTranslationsWorkflow, + updateProductsWorkflow, + updateTranslationsWorkflow, +} from "@medusajs/medusa/core-flows" import { MARKET_VARIANT_AUTHORITY_MODULE } from "../../modules/market-variant-authority" import type MarketVariantAuthorityModuleService from "../../modules/market-variant-authority/service" import { STOREFRONT_URL_ASSIGNMENT_MODULE } from "../../modules/storefront-url-assignment" diff --git a/apps/medusa-be/src/workflows/__tests__/generate-market-bound-reset-password-token.unit.spec.ts b/apps/medusa-be/src/workflows/__tests__/generate-market-bound-reset-password-token.unit.spec.ts index 306613e580..2e56ce1a80 100644 --- a/apps/medusa-be/src/workflows/__tests__/generate-market-bound-reset-password-token.unit.spec.ts +++ b/apps/medusa-be/src/workflows/__tests__/generate-market-bound-reset-password-token.unit.spec.ts @@ -8,25 +8,28 @@ describe("market-bound password-reset token generation", () => { ["cz", "sc_cz"], ["hu", "sc_hu"], ["ro", "sc_ro"], - ])("signs the exact %s sales channel into the reset JWT", (_market, salesChannelId) => { - const token = createMarketBoundPasswordResetJwt({ - actorType: "customer", - entityId: "global-customer@example.com", - jti: `jti_${salesChannelId}`, - marketCode: _market as "sk" | "cz" | "hu" | "ro", - provider: "emailpass", - salesChannelId, - secret: "reset-test-secret", - }) + ])( + "signs the exact %s sales channel into the reset JWT", + (_market, salesChannelId) => { + const token = createMarketBoundPasswordResetJwt({ + actorType: "customer", + entityId: "global-customer@example.com", + jti: `jti_${salesChannelId}`, + marketCode: _market as "sk" | "cz" | "hu" | "ro", + provider: "emailpass", + salesChannelId, + secret: "reset-test-secret", + }) - expect(decodeJwt(token)).toMatchObject({ - actor_type: "customer", - entity_id: "global-customer@example.com", - jti: `jti_${salesChannelId}`, - market_code: _market, - provider: "emailpass", - purpose: "reset", - sales_channel_id: salesChannelId, - }) - }) + expect(decodeJwt(token)).toMatchObject({ + actor_type: "customer", + entity_id: "global-customer@example.com", + jti: `jti_${salesChannelId}`, + market_code: _market, + provider: "emailpass", + purpose: "reset", + sales_channel_id: salesChannelId, + }) + } + ) }) diff --git a/apps/medusa-be/src/workflows/__tests__/send-customer-registration-confirmation.unit.spec.ts b/apps/medusa-be/src/workflows/__tests__/send-customer-registration-confirmation.unit.spec.ts index ead34bb5a6..c1f74875e8 100644 --- a/apps/medusa-be/src/workflows/__tests__/send-customer-registration-confirmation.unit.spec.ts +++ b/apps/medusa-be/src/workflows/__tests__/send-customer-registration-confirmation.unit.spec.ts @@ -57,48 +57,51 @@ describe("customer registration confirmation workflow", () => { ["cz", "cs-CZ", "herbatica.cz"], ["hu", "hu-HU", "herbatica.hu"], ["ro", "ro-RO", "herbatica.ro"], - ])("adds the canonical %s market context to the notification", async (marketCode, locale, domain) => { - resolveCustomerNotificationMarketContext.mockResolvedValue({ - country_code: marketCode, - locale, - market_code: marketCode, - sales_channel_id: `sc_${marketCode}`, - store_name: "Herbatica", - storefront_base_url: `https://${domain}`, - storefront_domain: domain, - }) - await import("../send-customer-registration-confirmation") - const step = workflowSdkMock.steps.get( - "build-customer-registration-confirmation-notification" - ) + ])( + "adds the canonical %s market context to the notification", + async (marketCode, locale, domain) => { + resolveCustomerNotificationMarketContext.mockResolvedValue({ + country_code: marketCode, + locale, + market_code: marketCode, + sales_channel_id: `sc_${marketCode}`, + store_name: "Herbatica", + storefront_base_url: `https://${domain}`, + storefront_domain: domain, + }) + await import("../send-customer-registration-confirmation") + const step = workflowSdkMock.steps.get( + "build-customer-registration-confirmation-notification" + ) - expect(step).toBeDefined() + expect(step).toBeDefined() - const result = (await step?.( - { - customer_id: "cus_123", - customer_name: "Test Customer", - email: "customer@example.test", - }, - { container: { resolve: vi.fn() } } - )) as { output: Notification[] } + const result = (await step?.( + { + customer_id: "cus_123", + customer_name: "Test Customer", + email: "customer@example.test", + }, + { container: { resolve: vi.fn() } } + )) as { output: Notification[] } - expect(resolveCustomerNotificationMarketContext).toHaveBeenCalledWith( - expect.anything(), - { customerId: "cus_123", email: "customer@example.test" } - ) - expect(result.output).toEqual([ - expect.objectContaining({ - data: expect.objectContaining({ - locale, - market_code: marketCode, - storefront_base_url: `https://${domain}`, + expect(resolveCustomerNotificationMarketContext).toHaveBeenCalledWith( + expect.anything(), + { customerId: "cus_123", email: "customer@example.test" } + ) + expect(result.output).toEqual([ + expect.objectContaining({ + data: expect.objectContaining({ + locale, + market_code: marketCode, + storefront_base_url: `https://${domain}`, + }), + template: "customer-registration-confirmation", + to: "customer@example.test", }), - template: "customer-registration-confirmation", - to: "customer@example.test", - }), - ]) - }) + ]) + } + ) it("fails closed when the customer market is ambiguous", async () => { resolveCustomerNotificationMarketContext.mockRejectedValue( diff --git a/apps/medusa-be/src/workflows/__tests__/send-order-payment-reminder.unit.spec.ts b/apps/medusa-be/src/workflows/__tests__/send-order-payment-reminder.unit.spec.ts index af80231ea5..9c7469892b 100644 --- a/apps/medusa-be/src/workflows/__tests__/send-order-payment-reminder.unit.spec.ts +++ b/apps/medusa-be/src/workflows/__tests__/send-order-payment-reminder.unit.spec.ts @@ -259,47 +259,48 @@ describe("send order payment reminder workflow", () => { domain: "herbatica.ro", locale: "ro-RO", }, - ])("formats the $countryCode reminder for its canonical market", async ({ - countryCode, - currencyCode, - domain, - locale, - }) => { - await import("../send-order-payment-reminder") - resolveNotificationMarketContext.mockResolvedValue({ - country_code: countryCode, - locale, - market_code: countryCode, - sales_channel_id: `sc_${countryCode}`, - store_name: "Herbatica", - storefront_base_url: `https://${domain}`, - storefront_domain: domain, - }) + ])( + "formats the $countryCode reminder for its canonical market", + async ({ countryCode, currencyCode, domain, locale }) => { + await import("../send-order-payment-reminder") + resolveNotificationMarketContext.mockResolvedValue({ + country_code: countryCode, + locale, + market_code: countryCode, + sales_channel_id: `sc_${countryCode}`, + store_name: "Herbatica", + storefront_base_url: `https://${domain}`, + storefront_domain: domain, + }) - const { container } = createPaymentReminderNotificationContext({ - currency_code: currencyCode, - sales_channel_id: `sc_${countryCode}`, - shipping_address: { country_code: countryCode }, - summary: { current_order_total: 1234.5 }, - total: 9999, - }) - const step = workflowSdkMock.steps.get( - "build-order-payment-reminder-notification" - ) - const result = (await step?.({ order_id: "order_123" }, { container })) as { - output: Notification[] - } + const { container } = createPaymentReminderNotificationContext({ + currency_code: currencyCode, + sales_channel_id: `sc_${countryCode}`, + shipping_address: { country_code: countryCode }, + summary: { current_order_total: 1234.5 }, + total: 9999, + }) + const step = workflowSdkMock.steps.get( + "build-order-payment-reminder-notification" + ) + const result = (await step?.( + { order_id: "order_123" }, + { container } + )) as { + output: Notification[] + } - expect(result.output[0]?.data).toMatchObject({ - locale, - payment_url: "https://payments.example.test/retry/123", - storefront_base_url: `https://${domain}`, - total: new Intl.NumberFormat(locale, { - currency: currencyCode.toUpperCase(), - style: "currency", - }).format(1234.5), - }) - }) + expect(result.output[0]?.data).toMatchObject({ + locale, + payment_url: "https://payments.example.test/retry/123", + storefront_base_url: `https://${domain}`, + total: new Intl.NumberFormat(locale, { + currency: currencyCode.toUpperCase(), + style: "currency", + }).format(1234.5), + }) + } + ) it.each([ { @@ -335,23 +336,26 @@ describe("send order payment reminder workflow", () => { total: null, }, }, - ])("uses fetched order total precedence before input fallback %#", async ({ - expectedTotal, - order, - }) => { - await import("../send-order-payment-reminder") - - const step = workflowSdkMock.steps.get( - "build-order-payment-reminder-notification" - ) - expect(step).toBeDefined() - - const { container } = createPaymentReminderNotificationContext(order) + ])( + "uses fetched order total precedence before input fallback %#", + async ({ expectedTotal, order }) => { + await import("../send-order-payment-reminder") + + const step = workflowSdkMock.steps.get( + "build-order-payment-reminder-notification" + ) + expect(step).toBeDefined() + + const { container } = createPaymentReminderNotificationContext(order) + + const result = (await step?.( + { order_id: "order_123" }, + { container } + )) as { + output: Notification[] + } - const result = (await step?.({ order_id: "order_123" }, { container })) as { - output: Notification[] + expect(result.output[0]?.data?.total).toBe(expectedTotal) } - - expect(result.output[0]?.data?.total).toBe(expectedTotal) - }) + ) }) diff --git a/apps/medusa-be/src/workflows/claim-case/steps/__tests__/create-claim.unit.spec.ts b/apps/medusa-be/src/workflows/claim-case/steps/__tests__/create-claim.unit.spec.ts index e26c2e6a9c..9bfb08f287 100644 --- a/apps/medusa-be/src/workflows/claim-case/steps/__tests__/create-claim.unit.spec.ts +++ b/apps/medusa-be/src/workflows/claim-case/steps/__tests__/create-claim.unit.spec.ts @@ -69,46 +69,49 @@ describe("create claim step", () => { ["cz", "cs-CZ", "herbatica.cz"], ["hu", "hu-HU", "herbatica.hu"], ["ro", "ro-RO", "herbatica.ro"], - ])("adds the canonical %s market context to a manual claim confirmation", async (marketCode, locale, domain) => { - resolveNotificationMarketContext.mockResolvedValue({ - country_code: marketCode, - locale, - market_code: marketCode, - sales_channel_id: `sc_${marketCode}`, - store_name: "Herbatica", - storefront_base_url: `https://${domain}`, - storefront_domain: domain, - }) - await import("../create-claim") - const step = workflowSdkMock.steps.get("create-claim") - const { container } = createContext() - - expect(step).toBeDefined() - - const result = (await step?.( - { - email: "customer@example.test", - items: [{ quantity: 1, title: "Herbal tea" }], - sales_channel_id: `sc_${marketCode}`, - type: "complaint", - }, - { container } - )) as { output: { notification_input: Notification[] } } - - expect(resolveNotificationMarketContext).toHaveBeenCalledWith(container, { - salesChannelId: `sc_${marketCode}`, - }) - expect(result.output.notification_input[0]).toMatchObject({ - data: { + ])( + "adds the canonical %s market context to a manual claim confirmation", + async (marketCode, locale, domain) => { + resolveNotificationMarketContext.mockResolvedValue({ + country_code: marketCode, locale, market_code: marketCode, + sales_channel_id: `sc_${marketCode}`, + store_name: "Herbatica", storefront_base_url: `https://${domain}`, - case_type: "complaint", - }, - template: "claim-confirmation", - to: "customer@example.test", - }) - }) + storefront_domain: domain, + }) + await import("../create-claim") + const step = workflowSdkMock.steps.get("create-claim") + const { container } = createContext() + + expect(step).toBeDefined() + + const result = (await step?.( + { + email: "customer@example.test", + items: [{ quantity: 1, title: "Herbal tea" }], + sales_channel_id: `sc_${marketCode}`, + type: "complaint", + }, + { container } + )) as { output: { notification_input: Notification[] } } + + expect(resolveNotificationMarketContext).toHaveBeenCalledWith(container, { + salesChannelId: `sc_${marketCode}`, + }) + expect(result.output.notification_input[0]).toMatchObject({ + data: { + locale, + market_code: marketCode, + storefront_base_url: `https://${domain}`, + case_type: "complaint", + }, + template: "claim-confirmation", + to: "customer@example.test", + }) + } + ) it("fails before creating a claim when the customer market is ambiguous", async () => { resolveNotificationMarketContext.mockRejectedValue( diff --git a/apps/medusa-be/src/workflows/claim-case/steps/__tests__/request-claim-access.unit.spec.ts b/apps/medusa-be/src/workflows/claim-case/steps/__tests__/request-claim-access.unit.spec.ts index b50b0afd20..bd9abe39c8 100644 --- a/apps/medusa-be/src/workflows/claim-case/steps/__tests__/request-claim-access.unit.spec.ts +++ b/apps/medusa-be/src/workflows/claim-case/steps/__tests__/request-claim-access.unit.spec.ts @@ -85,65 +85,68 @@ describe("request claim access step", () => { ["cz", "cs-CZ", "herbatica.cz"], ["hu", "hu-HU", "herbatica.hu"], ["ro", "ro-RO", "herbatica.ro"], - ])("adds the canonical %s market context to the access email", async (marketCode, locale, domain) => { - resolveNotificationMarketContext.mockResolvedValue({ - country_code: marketCode, - locale, - market_code: marketCode, - sales_channel_id: `sc_${marketCode}`, - store_name: "Herbatica", - storefront_base_url: `https://${domain}`, - storefront_domain: domain, - }) - await import("../request-claim-access") - const step = workflowSdkMock.steps.get("request-claim-access") - const { container, createClaimAccesses, graph } = createContext( - marketCode, - `sc_${marketCode}` - ) - - expect(step).toBeDefined() - - const result = (await step?.( - { - email: "customer@example.test", - order_number: "1001", + ])( + "adds the canonical %s market context to the access email", + async (marketCode, locale, domain) => { + resolveNotificationMarketContext.mockResolvedValue({ + country_code: marketCode, + locale, + market_code: marketCode, sales_channel_id: `sc_${marketCode}`, - }, - { container } - )) as { output: { notification_input: Notification[] } } + store_name: "Herbatica", + storefront_base_url: `https://${domain}`, + storefront_domain: domain, + }) + await import("../request-claim-access") + const step = workflowSdkMock.steps.get("request-claim-access") + const { container, createClaimAccesses, graph } = createContext( + marketCode, + `sc_${marketCode}` + ) + + expect(step).toBeDefined() - expect(resolveNotificationMarketContext).toHaveBeenCalledWith(container, { - countryCode: marketCode, - salesChannelId: `sc_${marketCode}`, - }) - expect(graph).toHaveBeenCalledWith( - expect.objectContaining({ - fields: expect.arrayContaining([ - "sales_channel_id", - "shipping_address.country_code", - "billing_address.country_code", - ]), - filters: { - display_id: "1001", + const result = (await step?.( + { + email: "customer@example.test", + order_number: "1001", sales_channel_id: `sc_${marketCode}`, }, + { container } + )) as { output: { notification_input: Notification[] } } + + expect(resolveNotificationMarketContext).toHaveBeenCalledWith(container, { + countryCode: marketCode, + salesChannelId: `sc_${marketCode}`, }) - ) - expect(createClaimAccesses).toHaveBeenCalledWith( - expect.objectContaining({ sales_channel_id: `sc_${marketCode}` }) - ) - expect(result.output.notification_input[0]).toMatchObject({ - data: { - locale, - market_code: marketCode, - storefront_base_url: `https://${domain}`, - order_display_id: "1001", - verification_code: "123456", - }, - template: "claim-access-code", - }) - }) + expect(graph).toHaveBeenCalledWith( + expect.objectContaining({ + fields: expect.arrayContaining([ + "sales_channel_id", + "shipping_address.country_code", + "billing_address.country_code", + ]), + filters: { + display_id: "1001", + sales_channel_id: `sc_${marketCode}`, + }, + }) + ) + expect(createClaimAccesses).toHaveBeenCalledWith( + expect.objectContaining({ sales_channel_id: `sc_${marketCode}` }) + ) + expect(result.output.notification_input[0]).toMatchObject({ + data: { + locale, + market_code: marketCode, + storefront_base_url: `https://${domain}`, + order_display_id: "1001", + verification_code: "123456", + }, + template: "claim-access-code", + }) + } + ) it("fails before creating an access challenge when the market is ambiguous", async () => { resolveNotificationMarketContext.mockRejectedValue( diff --git a/apps/medusa-be/src/workflows/customer/steps/__tests__/complete-customer-password-reset.unit.spec.ts b/apps/medusa-be/src/workflows/customer/steps/__tests__/complete-customer-password-reset.unit.spec.ts index d8c6d4638f..1807c652d7 100644 --- a/apps/medusa-be/src/workflows/customer/steps/__tests__/complete-customer-password-reset.unit.spec.ts +++ b/apps/medusa-be/src/workflows/customer/steps/__tests__/complete-customer-password-reset.unit.spec.ts @@ -95,28 +95,29 @@ describe("complete customer password reset step", () => { { expires_at: null, label: "null" }, { expires_at: "not-a-date", label: "malformed" }, { expires_at: new Date(Number.NaN), label: "non-finite" }, - ])("rejects a token with $label expires_at before any auth mutation", async ({ - expires_at: expiresAt, - }) => { - const token = validResetToken() - const tokenUnderTest = - expiresAt === undefined - ? { - entity_id: token.entity_id, - provider_identity: token.provider_identity, - } - : { ...token, expires_at: expiresAt } - const { consumePasswordResetToken, container, updateProvider } = - createResetContext(tokenUnderTest) - const step = workflowSdkMock.steps.get("complete-customer-password-reset") - - expect(step).toBeDefined() - await expect(step?.(resetInput, { container })).rejects.toThrow( - "Resource was not found." - ) - expect(updateProvider).not.toHaveBeenCalled() - expect(consumePasswordResetToken).not.toHaveBeenCalled() - }) + ])( + "rejects a token with $label expires_at before any auth mutation", + async ({ expires_at: expiresAt }) => { + const token = validResetToken() + const tokenUnderTest = + expiresAt === undefined + ? { + entity_id: token.entity_id, + provider_identity: token.provider_identity, + } + : { ...token, expires_at: expiresAt } + const { consumePasswordResetToken, container, updateProvider } = + createResetContext(tokenUnderTest) + const step = workflowSdkMock.steps.get("complete-customer-password-reset") + + expect(step).toBeDefined() + await expect(step?.(resetInput, { container })).rejects.toThrow( + "Resource was not found." + ) + expect(updateProvider).not.toHaveBeenCalled() + expect(consumePasswordResetToken).not.toHaveBeenCalled() + } + ) it("leaves the token retriable when the provider update fails", async () => { const firstAttempt = createResetContext(validResetToken()) diff --git a/apps/medusa-be/src/workflows/customer/workflows/reactivate-customer-account.ts b/apps/medusa-be/src/workflows/customer/workflows/reactivate-customer-account.ts index d44858ffcb..2e3bb95e56 100644 --- a/apps/medusa-be/src/workflows/customer/workflows/reactivate-customer-account.ts +++ b/apps/medusa-be/src/workflows/customer/workflows/reactivate-customer-account.ts @@ -1,12 +1,12 @@ -import { - setAuthAppMetadataStep, - updateCustomersWorkflow, -} from "@medusajs/core-flows" import { createWorkflow, transform, WorkflowResponse, } from "@medusajs/framework/workflows-sdk" +import { + setAuthAppMetadataStep, + updateCustomersWorkflow, +} from "@medusajs/medusa/core-flows" import { ensureReactivatedCustomerStep } from "../steps/ensure-reactivated-customer" import { markCustomerAccountActiveStep } from "../steps/mark-customer-account-active" import { diff --git a/apps/medusa-be/src/workflows/product-attribute/__tests__/assignment-mutations.unit.spec.ts b/apps/medusa-be/src/workflows/product-attribute/__tests__/assignment-mutations.unit.spec.ts index e0eccd14cc..d534d1fd73 100644 --- a/apps/medusa-be/src/workflows/product-attribute/__tests__/assignment-mutations.unit.spec.ts +++ b/apps/medusa-be/src/workflows/product-attribute/__tests__/assignment-mutations.unit.spec.ts @@ -168,36 +168,41 @@ describe("Product Attribute set/remove reconciliation", () => { it.each([ ["active first", false], ["deleted first", true], - ])("prefers the active assignment when history is returned %s", (_label, deletedFirst) => { - const active: ProductAttributeAssignmentRecord = { - definition_id: textDefinition.id, - deleted_at: null, - id: "pat_active", - product_id: "prod_1", - text_value: "current", - } - const deleted: ProductAttributeAssignmentRecord = { - definition_id: textDefinition.id, - deleted_at: new Date("2026-01-01"), - id: "pat_deleted", - product_id: "prod_1", - text_value: "old", - } - const operations = validateProductAttributeOperations({ - definitions: [textDefinition], - operations: [ - { - action: "remove", - definition_id: textDefinition.id, - }, - ], - options: [], - }) - const mutations = planProductAttributeAssignmentMutations({ - existingAssignments: deletedFirst ? [deleted, active] : [active, deleted], - operations, - }) + ])( + "prefers the active assignment when history is returned %s", + (_label, deletedFirst) => { + const active: ProductAttributeAssignmentRecord = { + definition_id: textDefinition.id, + deleted_at: null, + id: "pat_active", + product_id: "prod_1", + text_value: "current", + } + const deleted: ProductAttributeAssignmentRecord = { + definition_id: textDefinition.id, + deleted_at: new Date("2026-01-01"), + id: "pat_deleted", + product_id: "prod_1", + text_value: "old", + } + const operations = validateProductAttributeOperations({ + definitions: [textDefinition], + operations: [ + { + action: "remove", + definition_id: textDefinition.id, + }, + ], + options: [], + }) + const mutations = planProductAttributeAssignmentMutations({ + existingAssignments: deletedFirst + ? [deleted, active] + : [active, deleted], + operations, + }) - expect(mutations[0]?.existing).toBe(active) - }) + expect(mutations[0]?.existing).toBe(active) + } + ) }) diff --git a/apps/medusa-be/src/workflows/seed/__tests__/product-attribute-reconciliation.unit.spec.ts b/apps/medusa-be/src/workflows/seed/__tests__/product-attribute-reconciliation.unit.spec.ts index eda1e75076..b8ce8efe50 100644 --- a/apps/medusa-be/src/workflows/seed/__tests__/product-attribute-reconciliation.unit.spec.ts +++ b/apps/medusa-be/src/workflows/seed/__tests__/product-attribute-reconciliation.unit.spec.ts @@ -165,18 +165,21 @@ describe("Herbatica native visibility mapping", () => { ], ["cashDeskOnly", "published", [], false], ["hidden", "draft", [], false], - ])("maps %s to exact status and channel membership", (visibility, status, salesChannelNames, storefrontAccessible) => { - expect( - resolveHerbaticaProductVisibility({ - topOffer: { visible: true }, - visibility, + ])( + "maps %s to exact status and channel membership", + (visibility, status, salesChannelNames, storefrontAccessible) => { + expect( + resolveHerbaticaProductVisibility({ + topOffer: { visible: true }, + visibility, + }) + ).toEqual({ + salesChannelNames, + status, + storefrontAccessible, }) - ).toEqual({ - salesChannelNames, - status, - storefrontAccessible, - }) - }) + } + ) it("makes an offer marked invisible draft regardless of source visibility", () => { expect( diff --git a/apps/medusa-be/src/workflows/seed/__tests__/region-market-metadata.unit.spec.ts b/apps/medusa-be/src/workflows/seed/__tests__/region-market-metadata.unit.spec.ts index 5a08e8e2d2..5c8b35c70e 100644 --- a/apps/medusa-be/src/workflows/seed/__tests__/region-market-metadata.unit.spec.ts +++ b/apps/medusa-be/src/workflows/seed/__tests__/region-market-metadata.unit.spec.ts @@ -67,14 +67,17 @@ describe("region market metadata reconciliation", () => { { ...region, marketCode: "", salesChannelName: "" }, [{ id: "sc_ro", name: "Herbatica Storefront RO" }], ], - ] as const)("fails closed for a %s", (_case, configuredRegion, salesChannels) => { - expect(() => - resolveRegionSalesChannelBindings({ - regions: [configuredRegion], - salesChannels: [...salesChannels], - }) - ).toThrow() - }) + ] as const)( + "fails closed for a %s", + (_case, configuredRegion, salesChannels) => { + expect(() => + resolveRegionSalesChannelBindings({ + regions: [configuredRegion], + salesChannels: [...salesChannels], + }) + ).toThrow() + } + ) it("rejects duplicate market or Sales Channel bindings", () => { expect(() => diff --git a/apps/medusa-be/src/workflows/url-registry-outbox/__tests__/delivery-client.unit.spec.ts b/apps/medusa-be/src/workflows/url-registry-outbox/__tests__/delivery-client.unit.spec.ts index 611ad71f84..f540f02c89 100644 --- a/apps/medusa-be/src/workflows/url-registry-outbox/__tests__/delivery-client.unit.spec.ts +++ b/apps/medusa-be/src/workflows/url-registry-outbox/__tests__/delivery-client.unit.spec.ts @@ -211,24 +211,25 @@ describe("deliverUrlRegistryOutboxEvent", () => { expect(result).toEqual({ errorCode, kind: "fail" }) }) - it.each([ - 400, 409, - ])("keeps a catalog HTTP %i response retryable during consumer-first cutover", async (status) => { - const result = await deliverUrlRegistryOutboxEvent( - claim({ entityId: "pcat_1", entityKind: "category" }), - { - catalogEndpoint: CATALOG_ENDPOINT, - endpoint: ENDPOINT, - token: TOKEN, - }, - { fetchImpl: vi.fn(async () => new Response(null, { status })) } - ) + it.each([400, 409])( + "keeps a catalog HTTP %i response retryable during consumer-first cutover", + async (status) => { + const result = await deliverUrlRegistryOutboxEvent( + claim({ entityId: "pcat_1", entityKind: "category" }), + { + catalogEndpoint: CATALOG_ENDPOINT, + endpoint: ENDPOINT, + token: TOKEN, + }, + { fetchImpl: vi.fn(async () => new Response(null, { status })) } + ) - expect(result).toEqual({ - errorCode: `http-${status}`, - kind: "retry", - }) - }) + expect(result).toEqual({ + errorCode: `http-${status}`, + kind: "retry", + }) + } + ) it("keeps an oversized catalog response request terminal", async () => { const result = await deliverUrlRegistryOutboxEvent( @@ -244,20 +245,21 @@ describe("deliverUrlRegistryOutboxEvent", () => { expect(result).toEqual({ errorCode: "http-413", kind: "fail" }) }) - it.each([ - 401, 404, 408, 429, 500, 503, 302, - ])("classifies HTTP %i as retryable", async (status) => { - const result = await deliverUrlRegistryOutboxEvent( - claim(), - { endpoint: ENDPOINT, token: TOKEN }, - { fetchImpl: vi.fn(async () => new Response(null, { status })) } - ) - - expect(result).toMatchObject({ - errorCode: `http-${status}`, - kind: "retry", - }) - }) + it.each([401, 404, 408, 429, 500, 503, 302])( + "classifies HTTP %i as retryable", + async (status) => { + const result = await deliverUrlRegistryOutboxEvent( + claim(), + { endpoint: ENDPOINT, token: TOKEN }, + { fetchImpl: vi.fn(async () => new Response(null, { status })) } + ) + + expect(result).toMatchObject({ + errorCode: `http-${status}`, + kind: "retry", + }) + } + ) it("passes a bounded Retry-After hint to orchestration", async () => { const result = await deliverUrlRegistryOutboxEvent( diff --git a/apps/medusa-be/src/workflows/url-registry-outbox/__tests__/dispatcher-config.unit.spec.ts b/apps/medusa-be/src/workflows/url-registry-outbox/__tests__/dispatcher-config.unit.spec.ts index d62080868b..0beb00d3a0 100644 --- a/apps/medusa-be/src/workflows/url-registry-outbox/__tests__/dispatcher-config.unit.spec.ts +++ b/apps/medusa-be/src/workflows/url-registry-outbox/__tests__/dispatcher-config.unit.spec.ts @@ -9,19 +9,16 @@ import { const TOKEN = "urlr-lifecycle-token-with-at-least-32-characters" describe("parseUrlRegistryDispatcherConfig", () => { - it.each([ - undefined, - "", - "0", - "true", - " 1 ", - ])("stays disabled unless the shared gate is exactly 1: %s", (enabled) => { - expect( - parseUrlRegistryDispatcherConfig({ - URL_REGISTRY_PRODUCT_LIFECYCLE_ENABLED: enabled, - }) - ).toEqual({ enabled: false }) - }) + it.each([undefined, "", "0", "true", " 1 "])( + "stays disabled unless the shared gate is exactly 1: %s", + (enabled) => { + expect( + parseUrlRegistryDispatcherConfig({ + URL_REGISTRY_PRODUCT_LIFECYCLE_ENABLED: enabled, + }) + ).toEqual({ enabled: false }) + } + ) it("validates private dispatcher settings only after enablement", () => { expect( @@ -87,29 +84,26 @@ describe("parseUrlRegistryDispatcherConfig", () => { throw new Error("Expected invalid internal origin to be rejected") }) - it.each([ - undefined, - "", - "short", - ` ${TOKEN}`, - `${TOKEN} `, - ])("rejects a missing or malformed lifecycle token: %s", (token) => { - try { - parseUrlRegistryDispatcherConfig({ - URL_REGISTRY_HERBATIKA_INTERNAL_ORIGIN: "https://internal.test", - URL_REGISTRY_PRODUCT_LIFECYCLE_ENABLED: "1", - URL_REGISTRY_PRODUCT_LIFECYCLE_TOKEN: token, - }) - } catch (error) { - expect(error).toBeInstanceOf(MedusaError) - expect((error as MedusaError).type).toBe(MedusaError.Types.INVALID_DATA) - expect((error as Error).message).toContain( - "URL_REGISTRY_PRODUCT_LIFECYCLE_TOKEN" - ) - return + it.each([undefined, "", "short", ` ${TOKEN}`, `${TOKEN} `])( + "rejects a missing or malformed lifecycle token: %s", + (token) => { + try { + parseUrlRegistryDispatcherConfig({ + URL_REGISTRY_HERBATIKA_INTERNAL_ORIGIN: "https://internal.test", + URL_REGISTRY_PRODUCT_LIFECYCLE_ENABLED: "1", + URL_REGISTRY_PRODUCT_LIFECYCLE_TOKEN: token, + }) + } catch (error) { + expect(error).toBeInstanceOf(MedusaError) + expect((error as MedusaError).type).toBe(MedusaError.Types.INVALID_DATA) + expect((error as Error).message).toContain( + "URL_REGISTRY_PRODUCT_LIFECYCLE_TOKEN" + ) + return + } + throw new Error("Expected invalid lifecycle token to be rejected") } - throw new Error("Expected invalid lifecycle token to be rejected") - }) + ) }) describe("readUrlRegistryDispatchSchedule", () => { @@ -127,24 +121,22 @@ describe("readUrlRegistryDispatchSchedule", () => { ).toBe("*/5 * * * *") }) - it.each([ - "", - "* * * *", - "* * * * * *", - "* * * * *\nsecret", - ])("rejects an invalid schedule: %s", (schedule) => { - try { - readUrlRegistryDispatchSchedule({ - URL_REGISTRY_PRODUCT_LIFECYCLE_DISPATCH_SCHEDULE: schedule, - }) - } catch (error) { - expect(error).toBeInstanceOf(MedusaError) - expect((error as MedusaError).type).toBe(MedusaError.Types.INVALID_DATA) - expect((error as Error).message).toContain( - "URL_REGISTRY_PRODUCT_LIFECYCLE_DISPATCH_SCHEDULE" - ) - return + it.each(["", "* * * *", "* * * * * *", "* * * * *\nsecret"])( + "rejects an invalid schedule: %s", + (schedule) => { + try { + readUrlRegistryDispatchSchedule({ + URL_REGISTRY_PRODUCT_LIFECYCLE_DISPATCH_SCHEDULE: schedule, + }) + } catch (error) { + expect(error).toBeInstanceOf(MedusaError) + expect((error as MedusaError).type).toBe(MedusaError.Types.INVALID_DATA) + expect((error as Error).message).toContain( + "URL_REGISTRY_PRODUCT_LIFECYCLE_DISPATCH_SCHEDULE" + ) + return + } + throw new Error("Expected invalid dispatch schedule to be rejected") } - throw new Error("Expected invalid dispatch schedule to be rejected") - }) + ) }) diff --git a/apps/medusa-be/src/workflows/url-registry-outbox/__tests__/dispatcher.unit.spec.ts b/apps/medusa-be/src/workflows/url-registry-outbox/__tests__/dispatcher.unit.spec.ts index fd623ad9eb..a29658efda 100644 --- a/apps/medusa-be/src/workflows/url-registry-outbox/__tests__/dispatcher.unit.spec.ts +++ b/apps/medusa-be/src/workflows/url-registry-outbox/__tests__/dispatcher.unit.spec.ts @@ -63,11 +63,14 @@ describe("computeUrlRegistryRetryDelayMs", () => { [20, undefined, 3_600_000], [1, 120_000, 120_000], [1, 99_000_000, 3_600_000], - ])("bounds exponential attempt %i with Retry-After %s", (attemptCount, retryAfterMs, expected) => { - expect(computeUrlRegistryRetryDelayMs(attemptCount, retryAfterMs)).toBe( - expected - ) - }) + ])( + "bounds exponential attempt %i with Retry-After %s", + (attemptCount, retryAfterMs, expected) => { + expect(computeUrlRegistryRetryDelayMs(attemptCount, retryAfterMs)).toBe( + expected + ) + } + ) }) describe("dispatchUrlRegistryOutboxBatch", () => { diff --git a/apps/medusa-be/tests/unit/manufacturers-csv.unit.spec.ts b/apps/medusa-be/tests/unit/manufacturers-csv.unit.spec.ts index 579ffe1db1..6c0a390b25 100644 --- a/apps/medusa-be/tests/unit/manufacturers-csv.unit.spec.ts +++ b/apps/medusa-be/tests/unit/manufacturers-csv.unit.spec.ts @@ -94,32 +94,25 @@ describe("parseManufacturersCsv", () => { ).toHaveLength(1) }) - it.each([ - "1", - "true", - "TRUE", - "yes", - "on", - ])("accepts true boolean spelling %s", (value) => { - expect( - parseManufacturersCsv(`${HEADERS}\n${csvRow({ inList: value })}`)[0] - .inList - ).toBe(true) - }) - - it.each([ - "", - "0", - "false", - "FALSE", - "no", - "off", - ])("accepts false boolean spelling %s", (value) => { - expect( - parseManufacturersCsv(`${HEADERS}\n${csvRow({ inList: value })}`)[0] - .inList - ).toBe(false) - }) + it.each(["1", "true", "TRUE", "yes", "on"])( + "accepts true boolean spelling %s", + (value) => { + expect( + parseManufacturersCsv(`${HEADERS}\n${csvRow({ inList: value })}`)[0] + .inList + ).toBe(true) + } + ) + + it.each(["", "0", "false", "FALSE", "no", "off"])( + "accepts false boolean spelling %s", + (value) => { + expect( + parseManufacturersCsv(`${HEADERS}\n${csvRow({ inList: value })}`)[0] + .inList + ).toBe(false) + } + ) it("derives outside-EU only from a complete representative", () => { const row = parseManufacturersCsv( diff --git a/apps/medusa-be/tests/unit/notification-market-context.unit.spec.ts b/apps/medusa-be/tests/unit/notification-market-context.unit.spec.ts index 0ab8da22ab..c943c66eb5 100644 --- a/apps/medusa-be/tests/unit/notification-market-context.unit.spec.ts +++ b/apps/medusa-be/tests/unit/notification-market-context.unit.spec.ts @@ -107,27 +107,30 @@ describe("resolveNotificationMarketContext", () => { MarketCode, (typeof MARKET_FIXTURES)[MarketCode], ][] - )("resolves the configured %s market from its Sales Channel", async (marketCode, fixture) => { - const salesChannel = buildSalesChannel([marketCode]) - const { container } = buildQuery({ - regions: [buildRegion(marketCode)], - salesChannels: [salesChannel], - }) + )( + "resolves the configured %s market from its Sales Channel", + async (marketCode, fixture) => { + const salesChannel = buildSalesChannel([marketCode]) + const { container } = buildQuery({ + regions: [buildRegion(marketCode)], + salesChannels: [salesChannel], + }) - await expect( - resolveNotificationMarketContext(container, { - salesChannelId: salesChannel.id, + await expect( + resolveNotificationMarketContext(container, { + salesChannelId: salesChannel.id, + }) + ).resolves.toEqual({ + country_code: marketCode, + locale: fixture.locale, + market_code: marketCode, + sales_channel_id: salesChannel.id, + store_name: fixture.storeName, + storefront_base_url: `https://${fixture.storefrontDomain}`, + storefront_domain: fixture.storefrontDomain, }) - ).resolves.toEqual({ - country_code: marketCode, - locale: fixture.locale, - market_code: marketCode, - sales_channel_id: salesChannel.id, - store_name: fixture.storeName, - storefront_base_url: `https://${fixture.storefrontDomain}`, - storefront_domain: fixture.storefrontDomain, - }) - }) + } + ) it("uses a unique country mapping when no Sales Channel is supplied", async () => { const salesChannel = buildSalesChannel(["cz"]) @@ -218,31 +221,34 @@ describe("resolveNotificationMarketContext", () => { ["locale", "cs-CZ"], ["market_code", "cz"], ["storefront_domain", "herbatica.cz"], - ])("rejects a %s cross-wired to another canonical market", async (field, value) => { - const salesChannel = buildSalesChannel(["sk"]) - const marketConfiguration = salesChannel.metadata - ?.storefront_notification_markets as Record< - string, - Record - > - const slovakMarket = marketConfiguration.sk - if (!slovakMarket) { - throw new Error("Expected the Slovak market fixture") - } - slovakMarket[field] = value - const { container } = buildQuery({ - regions: [buildRegion("sk")], - salesChannels: [salesChannel], - }) - - await expect( - resolveNotificationMarketContext(container, { - salesChannelId: salesChannel.id, + ])( + "rejects a %s cross-wired to another canonical market", + async (field, value) => { + const salesChannel = buildSalesChannel(["sk"]) + const marketConfiguration = salesChannel.metadata + ?.storefront_notification_markets as Record< + string, + Record + > + const slovakMarket = marketConfiguration.sk + if (!slovakMarket) { + throw new Error("Expected the Slovak market fixture") + } + slovakMarket[field] = value + const { container } = buildQuery({ + regions: [buildRegion("sk")], + salesChannels: [salesChannel], }) - ).rejects.toThrow( - "Notification market configuration does not match its canonical authority." - ) - }) + + await expect( + resolveNotificationMarketContext(container, { + salesChannelId: salesChannel.id, + }) + ).rejects.toThrow( + "Notification market configuration does not match its canonical authority." + ) + } + ) it("rejects a canonical tuple published under another market key", async () => { const salesChannel = buildSalesChannel(["sk"]) diff --git a/apps/medusa-be/tests/unit/src/api/admin/measurement-units/validators.unit.spec.ts b/apps/medusa-be/tests/unit/src/api/admin/measurement-units/validators.unit.spec.ts index d317e67f70..2f46a3e2ae 100644 --- a/apps/medusa-be/tests/unit/src/api/admin/measurement-units/validators.unit.spec.ts +++ b/apps/medusa-be/tests/unit/src/api/admin/measurement-units/validators.unit.spec.ts @@ -22,21 +22,19 @@ describe("measurement unit request validation", () => { ).toBe(true) }) - it.each([ - "2", - true, - false, - null, - ])("rejects coerced create quantities such as %j", (baseQuantity) => { - expect( - AdminCreateMeasurementUnitSchema.safeParse({ - base_quantity: baseQuantity, - code: "kg", - name: "Kilogram", - symbol: "kg", - }).success - ).toBe(false) - }) + it.each(["2", true, false, null])( + "rejects coerced create quantities such as %j", + (baseQuantity) => { + expect( + AdminCreateMeasurementUnitSchema.safeParse({ + base_quantity: baseQuantity, + code: "kg", + name: "Kilogram", + symbol: "kg", + }).success + ).toBe(false) + } + ) it("rejects coerced quantities on update and variant assignment", () => { expect( diff --git a/apps/medusa-be/tests/unit/src/api/cloudflare-turnstile.unit.spec.ts b/apps/medusa-be/tests/unit/src/api/cloudflare-turnstile.unit.spec.ts index 39e533be99..4e689e3fe0 100644 --- a/apps/medusa-be/tests/unit/src/api/cloudflare-turnstile.unit.spec.ts +++ b/apps/medusa-be/tests/unit/src/api/cloudflare-turnstile.unit.spec.ts @@ -225,38 +225,38 @@ describe("verifyCloudflareTurnstile", () => { }) }) - it.each([ - undefined, - "login", - ])("rejects a valid Cloudflare response with action %s", async (action) => { - process.env.CLOUDFLARE_TURNSTILE_ENABLED = "true" - vi.stubGlobal( - "fetch", - vi.fn(async () => ({ - json: async () => ({ action, success: true }), - ok: true, - })) - ) - const req = createReq({ - body: { turnstileToken: "XXXX.DUMMY.TOKEN.XXXX" }, - }) - const res = createRes() - const next = vi.fn() + it.each([undefined, "login"])( + "rejects a valid Cloudflare response with action %s", + async (action) => { + process.env.CLOUDFLARE_TURNSTILE_ENABLED = "true" + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + json: async () => ({ action, success: true }), + ok: true, + })) + ) + const req = createReq({ + body: { turnstileToken: "XXXX.DUMMY.TOKEN.XXXX" }, + }) + const res = createRes() + const next = vi.fn() - await verifyCloudflareTurnstile({ expectedAction: "product_review" })( - req, - res, - next - ) + await verifyCloudflareTurnstile({ expectedAction: "product_review" })( + req, + res, + next + ) - expect(next).not.toHaveBeenCalled() - expect(res.status).toHaveBeenCalledWith(400) - expect(res.json).toHaveBeenCalledWith({ - code: "captcha_verification_failed", - message: "Captcha verification failed", - type: "invalid_data", - }) - }) + expect(next).not.toHaveBeenCalled() + expect(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith({ + code: "captcha_verification_failed", + message: "Captcha verification failed", + type: "invalid_data", + }) + } + ) it("returns service unavailable when Cloudflare verification cannot complete", async () => { process.env.CLOUDFLARE_TURNSTILE_ENABLED = "true" diff --git a/apps/medusa-be/tests/unit/src/api/store/claims/routes.unit.spec.ts b/apps/medusa-be/tests/unit/src/api/store/claims/routes.unit.spec.ts index e406b40801..308425dbe6 100644 --- a/apps/medusa-be/tests/unit/src/api/store/claims/routes.unit.spec.ts +++ b/apps/medusa-be/tests/unit/src/api/store/claims/routes.unit.spec.ts @@ -84,13 +84,16 @@ describe("claim Store routes", () => { type: "return", }, ], - ])("binds the %s workflow input to the exact publishable-key channel", async (_name, handler, run, body) => { - await handler(createRequest(body) as never, createResponse() as never) + ])( + "binds the %s workflow input to the exact publishable-key channel", + async (_name, handler, run, body) => { + await handler(createRequest(body) as never, createResponse() as never) - expect(run).toHaveBeenCalledWith({ - input: { ...body, sales_channel_id: "sc_cz" }, - }) - }) + expect(run).toHaveBeenCalledWith({ + input: { ...body, sales_channel_id: "sc_cz" }, + }) + } + ) it("rejects an ambiguous key scope before invoking a workflow", async () => { const req = { diff --git a/apps/medusa-be/tests/unit/src/api/store/cms/locales.unit.spec.ts b/apps/medusa-be/tests/unit/src/api/store/cms/locales.unit.spec.ts index 57eafca155..3dfe9473ea 100644 --- a/apps/medusa-be/tests/unit/src/api/store/cms/locales.unit.spec.ts +++ b/apps/medusa-be/tests/unit/src/api/store/cms/locales.unit.spec.ts @@ -10,14 +10,12 @@ describe("store CMS locale handling", () => { expect(StoreCmsLocaleQuerySchema.parse(undefined)).toBeUndefined() }) - it.each([ - "sk", - "cs", - "hu", - "ro", - ] as const)("resolves the supported %s locale from the request context", (locale) => { - expect(resolveStoreCmsLocale(locale)).toBe(locale) - }) + it.each(["sk", "cs", "hu", "ro"] as const)( + "resolves the supported %s locale from the request context", + (locale) => { + expect(resolveStoreCmsLocale(locale)).toBe(locale) + } + ) it("rejects a missing locale at the handler boundary", () => { expect(() => resolveStoreCmsLocale()).toThrowError(MedusaError) diff --git a/apps/medusa-be/tests/unit/src/api/store/product-lists/routes.unit.spec.ts b/apps/medusa-be/tests/unit/src/api/store/product-lists/routes.unit.spec.ts index 3e88db0941..5218c6d38d 100644 --- a/apps/medusa-be/tests/unit/src/api/store/product-lists/routes.unit.spec.ts +++ b/apps/medusa-be/tests/unit/src/api/store/product-lists/routes.unit.spec.ts @@ -442,39 +442,38 @@ describe("Store product-list routes", () => { metadata: { origin: "header" }, }, }, - ])("delegates $expectedType creation to the customer product-list workflow", async ({ - expectedType, - importPath, - validatedBody, - }) => { - const { POST } = await import(importPath) - const productList = createProductList({ - id: `plist_${expectedType}`, - type: expectedType, - }) - workflowMocks.createCustomerProductListRun.mockResolvedValue({ - result: { - created: expectedType === "custom", - product_list: productList, - }, - }) - const req = createMockRequest({ - actorId: "cus_1", - validatedBody, - }) - const res = createMockResponse() - - await POST(req, res) - - expect(res.status).toHaveBeenCalledWith(200) - expect(res.json).toHaveBeenCalledWith({ - created: expectedType === "custom", - product_list: expect.objectContaining({ + ])( + "delegates $expectedType creation to the customer product-list workflow", + async ({ expectedType, importPath, validatedBody }) => { + const { POST } = await import(importPath) + const productList = createProductList({ id: `plist_${expectedType}`, type: expectedType, - }), - }) - }) + }) + workflowMocks.createCustomerProductListRun.mockResolvedValue({ + result: { + created: expectedType === "custom", + product_list: productList, + }, + }) + const req = createMockRequest({ + actorId: "cus_1", + validatedBody, + }) + const res = createMockResponse() + + await POST(req, res) + + expect(res.status).toHaveBeenCalledWith(200) + expect(res.json).toHaveBeenCalledWith({ + created: expectedType === "custom", + product_list: expect.objectContaining({ + id: `plist_${expectedType}`, + type: expectedType, + }), + }) + } + ) }) describe("POST /store/product-lists/:id/items", () => { diff --git a/apps/medusa-be/tests/unit/src/api/store/storefront-market-sales-channel.unit.spec.ts b/apps/medusa-be/tests/unit/src/api/store/storefront-market-sales-channel.unit.spec.ts index deb59331ee..03af636ec6 100644 --- a/apps/medusa-be/tests/unit/src/api/store/storefront-market-sales-channel.unit.spec.ts +++ b/apps/medusa-be/tests/unit/src/api/store/storefront-market-sales-channel.unit.spec.ts @@ -102,24 +102,25 @@ describe("ordinary storefront market Sales Channel guard", () => { vi.clearAllMocks() }) - it.each( - MARKETS - )("accepts only the exact $market publishable-key binding and overwrites caller unions", async (market) => { - const harness = createHarness(market) - - await invokeGuard(harness) - - expect(harness.graph).toHaveBeenCalledExactlyOnceWith({ - entity: "sales_channel", - fields: ["id", "metadata"], - filters: { id: market.channelId }, - pagination: { take: 2 }, - }) - expect(harness.req.filterableFields.sales_channel_id).toEqual([ - market.channelId, - ]) - expect(harness.next).toHaveBeenCalledOnce() - }) + it.each(MARKETS)( + "accepts only the exact $market publishable-key binding and overwrites caller unions", + async (market) => { + const harness = createHarness(market) + + await invokeGuard(harness) + + expect(harness.graph).toHaveBeenCalledExactlyOnceWith({ + entity: "sales_channel", + fields: ["id", "metadata"], + filters: { id: market.channelId }, + pagination: { take: 2 }, + }) + expect(harness.req.filterableFields.sales_channel_id).toEqual([ + market.channelId, + ]) + expect(harness.next).toHaveBeenCalledOnce() + } + ) it.each([ ["missing", undefined], @@ -147,16 +148,16 @@ describe("ordinary storefront market Sales Channel guard", () => { expect(harness.next).not.toHaveBeenCalled() }) - it.each([ - undefined, - "", - ])("fails before any query when locale is %j", async (locale) => { - const harness = createHarness(MARKETS[0], { locale }) + it.each([undefined, ""])( + "fails before any query when locale is %j", + async (locale) => { + const harness = createHarness(MARKETS[0], { locale }) - await expect(invokeGuard(harness)).rejects.toThrow("locale is required") - expect(harness.graph).not.toHaveBeenCalled() - expect(harness.next).not.toHaveBeenCalled() - }) + await expect(invokeGuard(harness)).rejects.toThrow("locale is required") + expect(harness.graph).not.toHaveBeenCalled() + expect(harness.next).not.toHaveBeenCalled() + } + ) it("rejects a channel bound to another market before product queries", async () => { const wrongMarketChannel = { @@ -202,14 +203,17 @@ describe("ordinary storefront market Sales Channel guard", () => { it.each([ ["missing", []], ["ambiguous", [channelFor(MARKETS[0]), channelFor(MARKETS[0])]], - ])("rejects a %s Sales Channel authority result", async (_label, channels) => { - const harness = createHarness(MARKETS[0], { channels }) - - await expect(invokeGuard(harness)).rejects.toThrow( - "could not be resolved exactly" - ) - expect(harness.next).not.toHaveBeenCalled() - }) + ])( + "rejects a %s Sales Channel authority result", + async (_label, channels) => { + const harness = createHarness(MARKETS[0], { channels }) + + await expect(invokeGuard(harness)).rejects.toThrow( + "could not be resolved exactly" + ) + expect(harness.next).not.toHaveBeenCalled() + } + ) }) describe("ordinary storefront transport registration", () => { diff --git a/apps/medusa-be/tests/unit/src/modules/order-receipt/service.unit.spec.ts b/apps/medusa-be/tests/unit/src/modules/order-receipt/service.unit.spec.ts index 32c4b7148d..6c0a64eab8 100644 --- a/apps/medusa-be/tests/unit/src/modules/order-receipt/service.unit.spec.ts +++ b/apps/medusa-be/tests/unit/src/modules/order-receipt/service.unit.spec.ts @@ -633,21 +633,24 @@ describe("order receipt service", () => { [36, 2], [37, 3], [51, 3], - ])("paginates %i receipt items into %i PDF pages without dropping rows", async (itemCount, expectedPageCount) => { - const service = new OrderReceiptModuleService() - const order = buildPaginationOrder(itemCount) - - const attachment = await service.generateOrderReceiptAttachment( - order, - receiptContext - ) - const pdf = attachment.content.toString("utf8") - - expect(getPdfPageCount(pdf)).toBe(expectedPageCount) - for (const item of order.items) { - expect(pdf).toContain(item.title) + ])( + "paginates %i receipt items into %i PDF pages without dropping rows", + async (itemCount, expectedPageCount) => { + const service = new OrderReceiptModuleService() + const order = buildPaginationOrder(itemCount) + + const attachment = await service.generateOrderReceiptAttachment( + order, + receiptContext + ) + const pdf = attachment.content.toString("utf8") + + expect(getPdfPageCount(pdf)).toBe(expectedPageCount) + for (const item of order.items) { + expect(pdf).toContain(item.title) + } } - }) + ) it("wraps long customer text without overlapping the item table", async () => { const service = new OrderReceiptModuleService() diff --git a/apps/medusa-be/tests/unit/src/scripts/herbatica-option-vocabulary-translations.unit.spec.ts b/apps/medusa-be/tests/unit/src/scripts/herbatica-option-vocabulary-translations.unit.spec.ts index 161e8ae41e..0368f3a979 100644 --- a/apps/medusa-be/tests/unit/src/scripts/herbatica-option-vocabulary-translations.unit.spec.ts +++ b/apps/medusa-be/tests/unit/src/scripts/herbatica-option-vocabulary-translations.unit.spec.ts @@ -18,9 +18,12 @@ describe("translateVariantTitle", () => { ["30 kapsúl", "cs-CZ", "30 kapslí"], ["30 kapsúl", "hu-HU", "30 kapszula"], ["30 kapsúl", "ro-RO", "30 de capsule"], - ] as const)("localizes the unit count %s for %s", (source, locale, expected) => { - expect(translateVariantTitle(source, locale)).toBe(expected) - }) + ] as const)( + "localizes the unit count %s for %s", + (source, locale, expected) => { + expect(translateVariantTitle(source, locale)).toBe(expected) + } + ) it("keeps Romanian numerals under 20 without the linking word", () => { expect(translateVariantTitle("10 tabliet", "ro-RO")).toBe("10 comprimate") @@ -36,9 +39,12 @@ describe("translateVariantTitle", () => { ["Škorica", "hu-HU", "Fahéj"], ["Bez príchute", "ro-RO", "Fără aromă"], ["Zlatá blond 7.3", "ro-RO", "Blond auriu 7.3"], - ] as const)("localizes the option value %s for %s", (source, locale, expected) => { - expect(translateVariantTitle(source, locale)).toBe(expected) - }) + ] as const)( + "localizes the option value %s for %s", + (source, locale, expected) => { + expect(translateVariantTitle(source, locale)).toBe(expected) + } + ) it("localizes every segment of a composite title", () => { expect(translateVariantTitle("S / Black", "ro-RO")).toBe("S / Negru") @@ -202,17 +208,18 @@ const CZ_SK_SHIPPING_OPTIONS = [ ] describe("shipping option labels stay distinguishable per market", () => { - it.each( - SHIPPING_OPTION_TARGET_LOCALES - )("renders no duplicate and no English label across the CZ/SK shipping options in %s", (locale) => { - const labels = CZ_SK_SHIPPING_OPTIONS.map((name) => - translateShippingOptionName(name, locale) - ) + it.each(SHIPPING_OPTION_TARGET_LOCALES)( + "renders no duplicate and no English label across the CZ/SK shipping options in %s", + (locale) => { + const labels = CZ_SK_SHIPPING_OPTIONS.map((name) => + translateShippingOptionName(name, locale) + ) - expect(labels.every((label) => label !== null)).toBe(true) - expect(labels.some((label) => label?.includes("Shipping"))).toBe(false) - expect(new Set(labels).size).toBe(labels.length) - }) + expect(labels.every((label) => label !== null)).toBe(true) + expect(labels.some((label) => label?.includes("Shipping"))).toBe(false) + expect(new Set(labels).size).toBe(labels.length) + } + ) it("localizes the Slovak checkout too, because the stored names are English seed labels", () => { expect( diff --git a/apps/medusa-be/tests/unit/src/scripts/market-commerce-readiness-collector.unit.spec.ts b/apps/medusa-be/tests/unit/src/scripts/market-commerce-readiness-collector.unit.spec.ts index c48f9d8fdc..8651be7004 100644 --- a/apps/medusa-be/tests/unit/src/scripts/market-commerce-readiness-collector.unit.spec.ts +++ b/apps/medusa-be/tests/unit/src/scripts/market-commerce-readiness-collector.unit.spec.ts @@ -248,20 +248,23 @@ describe("four-market commerce live collector", () => { ["cz", "1299"], ["hu", "4999"], ["ro", "52.52"], - ] as const)("parses the %s approved major-unit amount as exact canonical decimal %s", (market, expectedAmount) => { - const bytes = serializeCanonicalCommerceArtifact({ - currencyCode: COMMERCE_MARKET_CONTRACTS[market].currencyCode, - kind: "market-approved-variant-prices", - market, - prices: [{ amount: PRICE_AMOUNTS[market], variantId: "variant_1" }], - schemaVersion: 2, - unavailableVariants: [], - }) + ] as const)( + "parses the %s approved major-unit amount as exact canonical decimal %s", + (market, expectedAmount) => { + const bytes = serializeCanonicalCommerceArtifact({ + currencyCode: COMMERCE_MARKET_CONTRACTS[market].currencyCode, + kind: "market-approved-variant-prices", + market, + prices: [{ amount: PRICE_AMOUNTS[market], variantId: "variant_1" }], + schemaVersion: 2, + unavailableVariants: [], + }) - expect( - parseMarketApprovedPricesArtifact(bytes, market).prices[0]?.amount - ).toBe(expectedAmount) - }) + expect( + parseMarketApprovedPricesArtifact(bytes, market).prices[0]?.amount + ).toBe(expectedAmount) + } + ) it("rejects approved amounts beyond the exact two-decimal contract", () => { const bytes = serializeCanonicalCommerceArtifact({ @@ -498,38 +501,40 @@ describe("four-market commerce live collector", () => { expected: "sk tax region differs from the reviewed identity scope", missingId: "taxreg_sk", }, - ] as const)("fails closed when $collection omits the reviewed identity", async ({ - collection, - expected, - missingId, - }) => { - const fixture = artifactFixture() - const artifacts = await readFourMarketReviewedArtifacts( - fixture.authority, - async (path) => fixture.files.get(path) ?? Promise.reject(new Error(path)) - ) - const state = - collection === "regions" - ? { - ...fixture.state, - regions: fixture.state.regions.filter(({ id }) => id !== missingId), - } - : { - ...fixture.state, - taxRegions: fixture.state.taxRegions.filter( - ({ id }) => id !== missingId - ), - } - - expect(() => - buildCollectedCommerceReadiness( + ] as const)( + "fails closed when $collection omits the reviewed identity", + async ({ collection, expected, missingId }) => { + const fixture = artifactFixture() + const artifacts = await readFourMarketReviewedArtifacts( fixture.authority, - artifacts, - state, - "2026-08-21T11:00:00.000Z" + async (path) => + fixture.files.get(path) ?? Promise.reject(new Error(path)) ) - ).toThrow(expected) - }) + const state = + collection === "regions" + ? { + ...fixture.state, + regions: fixture.state.regions.filter( + ({ id }) => id !== missingId + ), + } + : { + ...fixture.state, + taxRegions: fixture.state.taxRegions.filter( + ({ id }) => id !== missingId + ), + } + + expect(() => + buildCollectedCommerceReadiness( + fixture.authority, + artifacts, + state, + "2026-08-21T11:00:00.000Z" + ) + ).toThrow(expected) + } + ) it("does not enable a reviewed tax rate linked to another tax region", async () => { const fixture = artifactFixture() diff --git a/apps/medusa-be/tests/unit/src/scripts/market-meili-convergence-collector.unit.spec.ts b/apps/medusa-be/tests/unit/src/scripts/market-meili-convergence-collector.unit.spec.ts index 1822d4b68d..02a3ef9890 100644 --- a/apps/medusa-be/tests/unit/src/scripts/market-meili-convergence-collector.unit.spec.ts +++ b/apps/medusa-be/tests/unit/src/scripts/market-meili-convergence-collector.unit.spec.ts @@ -372,17 +372,20 @@ describe("four-market Meilisearch runtime collector", () => { }), "task, staging-index, or completion-marker residue", ], - ])("rejects %s instead of writing a false candidate", async (_label, client, error) => { - await expect( - collectFourMarketMeiliConvergenceCandidate({ - authorityReader: authorityReader(), - client: client(), - environmentId: "production-eu", - profiles: profiles(), - releaseId: "release-2026-08-21", - }) - ).rejects.toThrow(error) - }) + ])( + "rejects %s instead of writing a false candidate", + async (_label, client, error) => { + await expect( + collectFourMarketMeiliConvergenceCandidate({ + authorityReader: authorityReader(), + client: client(), + environmentId: "production-eu", + profiles: profiles(), + releaseId: "release-2026-08-21", + }) + ).rejects.toThrow(error) + } + ) it("rejects task state changes during collection", async () => { const client = clientFixture() diff --git a/apps/medusa-be/tests/unit/src/scripts/market-meili-convergence.unit.spec.ts b/apps/medusa-be/tests/unit/src/scripts/market-meili-convergence.unit.spec.ts index f04b13b545..80061f517b 100644 --- a/apps/medusa-be/tests/unit/src/scripts/market-meili-convergence.unit.spec.ts +++ b/apps/medusa-be/tests/unit/src/scripts/market-meili-convergence.unit.spec.ts @@ -295,25 +295,22 @@ describe("four-market Meilisearch convergence proof", () => { ) }) - it.each([ - "brand", - "category", - "content", - "product", - "variant", - ] as const)("rejects a %s authority/projected-ID mismatch", (kind) => { - const candidate = candidateFixture() - const target = - kind === "variant" - ? candidate.markets.cz.indexes.product.variantIds - : candidate.markets.cz.indexes[kind === "product" ? "product" : kind] - .entityIds - target.push(`unexpected_${kind}`) - target.sort() - expect(() => buildFourMarketMeiliConvergenceProof(candidate)).toThrow( - "projected IDs do not exactly match authority IDs" - ) - }) + it.each(["brand", "category", "content", "product", "variant"] as const)( + "rejects a %s authority/projected-ID mismatch", + (kind) => { + const candidate = candidateFixture() + const target = + kind === "variant" + ? candidate.markets.cz.indexes.product.variantIds + : candidate.markets.cz.indexes[kind === "product" ? "product" : kind] + .entityIds + target.push(`unexpected_${kind}`) + target.sort() + expect(() => buildFourMarketMeiliConvergenceProof(candidate)).toThrow( + "projected IDs do not exactly match authority IDs" + ) + } + ) it("rejects arbitrary same-cardinality document IDs", () => { const candidate = candidateFixture() diff --git a/apps/medusa-be/tests/unit/src/scripts/market-readiness-contract.unit.spec.ts b/apps/medusa-be/tests/unit/src/scripts/market-readiness-contract.unit.spec.ts index 1e482ed97a..d8ad4474b4 100644 --- a/apps/medusa-be/tests/unit/src/scripts/market-readiness-contract.unit.spec.ts +++ b/apps/medusa-be/tests/unit/src/scripts/market-readiness-contract.unit.spec.ts @@ -156,16 +156,14 @@ describe("four-market readiness canonical artifacts", () => { ) }) - it.each([ - '{"a":1}', - '{"a":1}\r\n', - '{"a":1}\n\n', - '{ "a": 1 }\n', - ])("rejects non-canonical or non-LF input: %j", (input) => { - expect(() => parseCanonicalJsonWithLf(input)).toThrow( - "Artifact must be canonical JSON with exactly one trailing LF" - ) - }) + it.each(['{"a":1}', '{"a":1}\r\n', '{"a":1}\n\n', '{ "a": 1 }\n'])( + "rejects non-canonical or non-LF input: %j", + (input) => { + expect(() => parseCanonicalJsonWithLf(input)).toThrow( + "Artifact must be canonical JSON with exactly one trailing LF" + ) + } + ) }) describe("immutable four-market release acceptance v2", () => { diff --git a/apps/medusa-be/tests/unit/src/scripts/market-readiness-notifications.unit.spec.ts b/apps/medusa-be/tests/unit/src/scripts/market-readiness-notifications.unit.spec.ts index 8b7821ee16..a1b7a63cb0 100644 --- a/apps/medusa-be/tests/unit/src/scripts/market-readiness-notifications.unit.spec.ts +++ b/apps/medusa-be/tests/unit/src/scripts/market-readiness-notifications.unit.spec.ts @@ -370,60 +370,62 @@ describe("four-market notification readiness collector", () => { ).rejects.toMatchObject({ code: "EEXIST" }) }) - it.each([ - "writeFile", - "sync", - ] as const)("leaves no final artifact after an injected %s failure and permits retry", async (failurePoint) => { - const report = await collectFourMarketNotificationReadiness({ - ...fourMarketInput(), - renderer: { - render: vi.fn(async ({ locale, template }) => ({ - html: "

safe

", - subject: `subject:${template}:${locale}`, - text: "safe", - })), - }, - subjectResolver: (template, locale) => `subject:${template}:${locale}`, - }) - const directory = await realpath( - await mkdtemp(join(tmpdir(), "notification-readiness-failure-test-")) - ) - temporaryDirectories.push(directory) - const outputPath = join(directory, "notifications.json") - const probePath = join(directory, "probe") - const probeHandle = await open(probePath, "wx", 0o600) - const handlePrototype = Object.getPrototypeOf(probeHandle) as Pick< - typeof probeHandle, - "sync" | "writeFile" - > - const failure = new Error(`injected ${failurePoint} failure`) - const failureSpy = - failurePoint === "writeFile" - ? vi.spyOn(handlePrototype, "writeFile").mockRejectedValueOnce(failure) - : vi.spyOn(handlePrototype, "sync").mockRejectedValueOnce(failure) - await probeHandle.close() - await unlink(probePath) - - try { + it.each(["writeFile", "sync"] as const)( + "leaves no final artifact after an injected %s failure and permits retry", + async (failurePoint) => { + const report = await collectFourMarketNotificationReadiness({ + ...fourMarketInput(), + renderer: { + render: vi.fn(async ({ locale, template }) => ({ + html: "

safe

", + subject: `subject:${template}:${locale}`, + text: "safe", + })), + }, + subjectResolver: (template, locale) => `subject:${template}:${locale}`, + }) + const directory = await realpath( + await mkdtemp(join(tmpdir(), "notification-readiness-failure-test-")) + ) + temporaryDirectories.push(directory) + const outputPath = join(directory, "notifications.json") + const probePath = join(directory, "probe") + const probeHandle = await open(probePath, "wx", 0o600) + const handlePrototype = Object.getPrototypeOf(probeHandle) as Pick< + typeof probeHandle, + "sync" | "writeFile" + > + const failure = new Error(`injected ${failurePoint} failure`) + const failureSpy = + failurePoint === "writeFile" + ? vi + .spyOn(handlePrototype, "writeFile") + .mockRejectedValueOnce(failure) + : vi.spyOn(handlePrototype, "sync").mockRejectedValueOnce(failure) + await probeHandle.close() + await unlink(probePath) + + try { + await expect( + writeNotificationReadinessArtifact(outputPath, report) + ).rejects.toBe(failure) + } finally { + failureSpy.mockRestore() + } + + await expect(readFile(outputPath, "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }) + expect(await readdir(directory)).toEqual([]) + await expect( writeNotificationReadinessArtifact(outputPath, report) - ).rejects.toBe(failure) - } finally { - failureSpy.mockRestore() + ).resolves.toMatchObject({ path: outputPath }) + expect( + parseNotificationReadinessArtifact(await readFile(outputPath, "utf8")) + ).toEqual(report) } - - await expect(readFile(outputPath, "utf8")).rejects.toMatchObject({ - code: "ENOENT", - }) - expect(await readdir(directory)).toEqual([]) - - await expect( - writeNotificationReadinessArtifact(outputPath, report) - ).resolves.toMatchObject({ path: outputPath }) - expect( - parseNotificationReadinessArtifact(await readFile(outputPath, "utf8")) - ).toEqual(report) - }) + ) it("rejects a symlinked output parent before creating an artifact", async () => { const report = await collectFourMarketNotificationReadiness({ @@ -457,36 +459,37 @@ describe("four-market notification readiness collector", () => { expect(await readdir(physicalParent)).toEqual([]) }) - it.each([ - 0o777, 0o770, - ])("rejects an output parent with mode %o", async (mode) => { - const report = await collectFourMarketNotificationReadiness({ - ...fourMarketInput(), - renderer: { - render: vi.fn(async ({ locale, template }) => ({ - html: "

safe

", - subject: `subject:${template}:${locale}`, - text: "safe", - })), - }, - subjectResolver: (template, locale) => `subject:${template}:${locale}`, - }) - const directory = await realpath( - await mkdtemp(join(tmpdir(), "notification-readiness-mode-test-")) - ) - temporaryDirectories.push(directory) - await chmod(directory, mode) + it.each([0o777, 0o770])( + "rejects an output parent with mode %o", + async (mode) => { + const report = await collectFourMarketNotificationReadiness({ + ...fourMarketInput(), + renderer: { + render: vi.fn(async ({ locale, template }) => ({ + html: "

safe

", + subject: `subject:${template}:${locale}`, + text: "safe", + })), + }, + subjectResolver: (template, locale) => `subject:${template}:${locale}`, + }) + const directory = await realpath( + await mkdtemp(join(tmpdir(), "notification-readiness-mode-test-")) + ) + temporaryDirectories.push(directory) + await chmod(directory, mode) - await expect( - writeNotificationReadinessArtifact( - join(directory, "notifications.json"), - report + await expect( + writeNotificationReadinessArtifact( + join(directory, "notifications.json"), + report + ) + ).rejects.toThrow( + "output parent must be a canonical process-owned private directory" ) - ).rejects.toThrow( - "output parent must be a canonical process-owned private directory" - ) - expect(await readdir(directory)).toEqual([]) - }) + expect(await readdir(directory)).toEqual([]) + } + ) it("rejects a parent identity swap after linking the artifact", async () => { const report = await collectFourMarketNotificationReadiness({ diff --git a/apps/medusa-be/tests/unit/src/subscribers/reset-password.unit.spec.ts b/apps/medusa-be/tests/unit/src/subscribers/reset-password.unit.spec.ts index e9717591a4..f84e6e7e66 100644 --- a/apps/medusa-be/tests/unit/src/subscribers/reset-password.unit.spec.ts +++ b/apps/medusa-be/tests/unit/src/subscribers/reset-password.unit.spec.ts @@ -62,27 +62,26 @@ describe("reset password subscriber", () => { }) }) - it.each([ - "user", - "admin", - "", - ])("ignores non-customer actor type %s", async (actorType) => { - const { default: resetPasswordHandler } = await import( - "../../../../src/subscribers/reset-password" - ) + it.each(["user", "admin", ""])( + "ignores non-customer actor type %s", + async (actorType) => { + const { default: resetPasswordHandler } = await import( + "../../../../src/subscribers/reset-password" + ) - await resetPasswordHandler({ - container: { resolve: vi.fn() }, - event: { - data: { - actor_type: actorType, - entity_id: "admin@example.test", - token: "token", + await resetPasswordHandler({ + container: { resolve: vi.fn() }, + event: { + data: { + actor_type: actorType, + entity_id: "admin@example.test", + token: "token", + }, }, - }, - } as never) + } as never) - expect(sendForgotPasswordWorkflow).not.toHaveBeenCalled() - expect(run).not.toHaveBeenCalled() - }) + expect(sendForgotPasswordWorkflow).not.toHaveBeenCalled() + expect(run).not.toHaveBeenCalled() + } + ) }) diff --git a/apps/medusa-be/tests/unit/src/utils/account-setup-url.unit.spec.ts b/apps/medusa-be/tests/unit/src/utils/account-setup-url.unit.spec.ts index fc81f556bb..fe5f0a9da9 100644 --- a/apps/medusa-be/tests/unit/src/utils/account-setup-url.unit.spec.ts +++ b/apps/medusa-be/tests/unit/src/utils/account-setup-url.unit.spec.ts @@ -7,18 +7,21 @@ describe("buildAccountSetupUrl", () => { ["cz", "herbatica.cz", "/ucet/obnova-hesla"], ["hu", "herbatica.hu", "/fiok/jelszo-visszaallitas"], ["ro", "herbatica.ro", "/cont/resetare-parola"], - ] as const)("builds the exact %s account-setup path on its market origin", (market, domain, path) => { - expect( - buildAccountSetupUrl( - "customer+market@example.test", - "Token/Exact+Case", - `https://${domain}/ignored-path`, - market + ] as const)( + "builds the exact %s account-setup path on its market origin", + (market, domain, path) => { + expect( + buildAccountSetupUrl( + "customer+market@example.test", + "Token/Exact+Case", + `https://${domain}/ignored-path`, + market + ) + ).toBe( + `https://${domain}${path}/Token%2FExact%2BCase?email=customer%2Bmarket%40example.test&flow=account-setup` ) - ).toBe( - `https://${domain}${path}/Token%2FExact%2BCase?email=customer%2Bmarket%40example.test&flow=account-setup` - ) - }) + } + ) it("ignores the retired arbitrary URL template", () => { process.env.ACCOUNT_SETUP_URL_TEMPLATE = diff --git a/apps/medusa-be/tests/unit/src/utils/catalog-translation.unit.spec.ts b/apps/medusa-be/tests/unit/src/utils/catalog-translation.unit.spec.ts index 42e1b76a2c..4a4b37fa13 100644 --- a/apps/medusa-be/tests/unit/src/utils/catalog-translation.unit.spec.ts +++ b/apps/medusa-be/tests/unit/src/utils/catalog-translation.unit.spec.ts @@ -111,32 +111,35 @@ describe("exact catalog Translation-record reads", () => { "ro-RO", { title: "Colecție" }, ], - ] as const)("requires the localized display field for %s translations", async (...[ - entityKind, - reference, - entityId, - market, - localeCode, - translations, - ]) => { - const { container } = context([ - translation({ - locale_code: localeCode, - reference, - reference_id: entityId, - translations, - }), - ]) + ] as const)( + "requires the localized display field for %s translations", + async (...[ + entityKind, + reference, + entityId, + market, + localeCode, + translations, + ]) => { + const { container } = context([ + translation({ + locale_code: localeCode, + reference, + reference_id: entityId, + translations, + }), + ]) - await expect( - readExactCatalogTranslation({ - container, - entityId, - entityKind, - market, - }) - ).resolves.toMatchObject({ kind: "found" }) - }) + await expect( + readExactCatalogTranslation({ + container, + entityId, + entityKind, + market, + }) + ).resolves.toMatchObject({ kind: "found" }) + } + ) it("returns missing instead of accepting a Store fallback value", async () => { const { container } = context([]) @@ -208,28 +211,31 @@ describe("exact catalog Translation-record reads", () => { ["category", "product_category", "pcat_1", { description: "Descriere" }], ["brand", "brand", "brand_1", { name: "Marcă" }], ["collection", "product_collection", "pcol_1", { name: "Colecție" }], - ] as const)("rejects a partial %s row without its required display field", async (entityKind, reference, entityId, translations) => { - const { container } = context([ - translation({ - locale_code: "ro-RO", - reference, - reference_id: entityId, - translations, - }), - ]) + ] as const)( + "rejects a partial %s row without its required display field", + async (entityKind, reference, entityId, translations) => { + const { container } = context([ + translation({ + locale_code: "ro-RO", + reference, + reference_id: entityId, + translations, + }), + ]) - await expect( - readExactCatalogTranslation({ - container, - entityId, - entityKind, - market: "ro", + await expect( + readExactCatalogTranslation({ + container, + entityId, + entityKind, + market: "ro", + }) + ).resolves.toEqual({ + causeCode: "INVALID_CATALOG_TRANSLATION_STATE", + kind: "invalid-response", }) - ).resolves.toEqual({ - causeCode: "INVALID_CATALOG_TRANSLATION_STATE", - kind: "invalid-response", - }) - }) + } + ) it("rejects ambiguous records instead of selecting one", async () => { const { container } = context([ diff --git a/apps/medusa-be/tests/unit/src/utils/customer-account-deactivation-url.unit.spec.ts b/apps/medusa-be/tests/unit/src/utils/customer-account-deactivation-url.unit.spec.ts index e9a8cc1f81..a72fa842fc 100644 --- a/apps/medusa-be/tests/unit/src/utils/customer-account-deactivation-url.unit.spec.ts +++ b/apps/medusa-be/tests/unit/src/utils/customer-account-deactivation-url.unit.spec.ts @@ -23,15 +23,18 @@ describe("buildCustomerAccountDeactivationUrl", () => { "https://herbatica.ro", "https://herbatica.ro/cont/dezactivare-cont?token=Token%2FExact%2BCase", ], - ] as const)("builds an encoded %s link on the canonical market origin", (market, storefrontBaseUrl, expectedUrl) => { - expect( - buildCustomerAccountDeactivationUrl( - "Token/Exact+Case", - storefrontBaseUrl, - market - ) - ).toBe(expectedUrl) - }) + ] as const)( + "builds an encoded %s link on the canonical market origin", + (market, storefrontBaseUrl, expectedUrl) => { + expect( + buildCustomerAccountDeactivationUrl( + "Token/Exact+Case", + storefrontBaseUrl, + market + ) + ).toBe(expectedUrl) + } + ) it("rejects an invalid storefront base URL", () => { expect(() => diff --git a/apps/medusa-be/tests/unit/src/utils/localized-category-content.unit.spec.ts b/apps/medusa-be/tests/unit/src/utils/localized-category-content.unit.spec.ts index 192b943447..5993bcc5b8 100644 --- a/apps/medusa-be/tests/unit/src/utils/localized-category-content.unit.spec.ts +++ b/apps/medusa-be/tests/unit/src/utils/localized-category-content.unit.spec.ts @@ -143,28 +143,31 @@ describe("localized category content", () => { top_description_html: null, }, ], - ])("maps Romanian %s to null without Slovak fallback", async (_label, translations) => { - const category: LocalizedCategoryContentDecoratable = { - description: "Slovenský popis", - id: "pcat_1", - metadata: { top_description_html: "

Slovenský text

" }, - } - const result = await decorateCategoriesWithLocalizedContent( - containerFor([translation({ translations })]), - [category], - "ro-RO" - ) + ])( + "maps Romanian %s to null without Slovak fallback", + async (_label, translations) => { + const category: LocalizedCategoryContentDecoratable = { + description: "Slovenský popis", + id: "pcat_1", + metadata: { top_description_html: "

Slovenský text

" }, + } + const result = await decorateCategoriesWithLocalizedContent( + containerFor([translation({ translations })]), + [category], + "ro-RO" + ) - expect(result).toEqual({ kind: "decorated" }) - expect(category.localized_content).toMatchObject({ - bottom_description_html: null, - meta_description: null, - meta_title: null, - top_description_html: null, - }) - expect(category.description).not.toBe("Slovenský popis") - expect(category.metadata).toEqual({}) - }) + expect(result).toEqual({ kind: "decorated" }) + expect(category.localized_content).toMatchObject({ + bottom_description_html: null, + meta_description: null, + meta_title: null, + top_description_html: null, + }) + expect(category.description).not.toBe("Slovenský popis") + expect(category.metadata).toEqual({}) + } + ) it("does not query translations for an empty page", async () => { const container = containerFor([]) diff --git a/apps/medusa-be/tests/unit/src/utils/order-payment-qr.unit.spec.ts b/apps/medusa-be/tests/unit/src/utils/order-payment-qr.unit.spec.ts index 82f0143185..925f1940a6 100644 --- a/apps/medusa-be/tests/unit/src/utils/order-payment-qr.unit.spec.ts +++ b/apps/medusa-be/tests/unit/src/utils/order-payment-qr.unit.spec.ts @@ -98,45 +98,39 @@ describe("order payment QR", () => { ).toContain("*AM:1109.91*") }) - it.each([ - "EUR", - "CZK", - "HUF", - "RON", - ])("preserves the exact %s order currency", (currencyCode) => { - expect( - orderPaymentQr.buildSpayd( - { - currency_code: currencyCode.toLowerCase(), - display_id: 23, - id: "order_23", - total: 578, - }, - "CZ9608000000005444195083" - ) - ).toContain(`*CC:${currencyCode}*`) - }) + it.each(["EUR", "CZK", "HUF", "RON"])( + "preserves the exact %s order currency", + (currencyCode) => { + expect( + orderPaymentQr.buildSpayd( + { + currency_code: currencyCode.toLowerCase(), + display_id: 23, + id: "order_23", + total: 578, + }, + "CZ9608000000005444195083" + ) + ).toContain(`*CC:${currencyCode}*`) + } + ) - it.each([ - undefined, - null, - "", - " ", - "EU", - "EURO", - ])("fails closed when order currency is missing or invalid: %s", (currencyCode) => { - expect( - orderPaymentQr.buildSpayd( - { - currency_code: currencyCode, - display_id: 23, - id: "order_23", - total: 578, - }, - "CZ9608000000005444195083" - ) - ).toBeNull() - }) + it.each([undefined, null, "", " ", "EU", "EURO"])( + "fails closed when order currency is missing or invalid: %s", + (currencyCode) => { + expect( + orderPaymentQr.buildSpayd( + { + currency_code: currencyCode, + display_id: 23, + id: "order_23", + total: 578, + }, + "CZ9608000000005444195083" + ) + ).toBeNull() + } + ) it("fails closed when payment-session currency is missing", () => { expect( diff --git a/apps/medusa-be/tests/unit/src/workflows/customer/prepare-customer-account-deactivation-request.unit.spec.ts b/apps/medusa-be/tests/unit/src/workflows/customer/prepare-customer-account-deactivation-request.unit.spec.ts index cde0059a36..4d3b3c21b0 100644 --- a/apps/medusa-be/tests/unit/src/workflows/customer/prepare-customer-account-deactivation-request.unit.spec.ts +++ b/apps/medusa-be/tests/unit/src/workflows/customer/prepare-customer-account-deactivation-request.unit.spec.ts @@ -121,54 +121,55 @@ describe("prepareCustomerAccountDeactivationRequestStep", () => { vi.unstubAllEnvs() }) - it.each( - marketFixtures - )("uses the requested $market_code market context for the confirmation link and token", async (marketContext) => { - resolveNotificationMarketContext.mockResolvedValue(marketContext) - const { prepareCustomerAccountDeactivationRequestStep } = await import( - "../../../../../src/workflows/customer/steps/prepare-customer-account-deactivation-request" - ) - const container = makeContainer() + it.each(marketFixtures)( + "uses the requested $market_code market context for the confirmation link and token", + async (marketContext) => { + resolveNotificationMarketContext.mockResolvedValue(marketContext) + const { prepareCustomerAccountDeactivationRequestStep } = await import( + "../../../../../src/workflows/customer/steps/prepare-customer-account-deactivation-request" + ) + const container = makeContainer() - const result = await ( - prepareCustomerAccountDeactivationRequestStep as PrepareStep - )( - { - customer_id: "cus_1", - sales_channel_id: marketContext.sales_channel_id, - }, - { container } - ) + const result = await ( + prepareCustomerAccountDeactivationRequestStep as PrepareStep + )( + { + customer_id: "cus_1", + sales_channel_id: marketContext.sales_channel_id, + }, + { container } + ) - expect(resolveNotificationMarketContext).toHaveBeenCalledWith(container, { - salesChannelId: marketContext.sales_channel_id, - }) - expect(generateJwtToken).toHaveBeenCalledWith( - { + expect(resolveNotificationMarketContext).toHaveBeenCalledWith(container, { + salesChannelId: marketContext.sales_channel_id, + }) + expect(generateJwtToken).toHaveBeenCalledWith( + { + customer_id: "cus_1", + email: "customer@example.test", + purpose: "customer-account-deactivation", + sales_channel_id: marketContext.sales_channel_id, + }, + { + expiresIn: "30m", + secret: "test-jwt-secret", + } + ) + expect(result.payload).toEqual({ + ...marketContext, + confirmation_url: [ + marketContext.storefront_base_url, + deactivationPathByMarket[ + marketContext.market_code as keyof typeof deactivationPathByMarket + ], + "?token=token%2Fwith%2Bsymbols", + ].join(""), customer_id: "cus_1", + customer_name: "Test Customer", email: "customer@example.test", - purpose: "customer-account-deactivation", - sales_channel_id: marketContext.sales_channel_id, - }, - { - expiresIn: "30m", - secret: "test-jwt-secret", - } - ) - expect(result.payload).toEqual({ - ...marketContext, - confirmation_url: [ - marketContext.storefront_base_url, - deactivationPathByMarket[ - marketContext.market_code as keyof typeof deactivationPathByMarket - ], - "?token=token%2Fwith%2Bsymbols", - ].join(""), - customer_id: "cus_1", - customer_name: "Test Customer", - email: "customer@example.test", - }) - }) + }) + } + ) it("rejects a market resolver result bound to another Sales Channel", async () => { resolveNotificationMarketContext.mockResolvedValue({ diff --git a/apps/medusa-be/tests/unit/src/workflows/send-forgot-password.unit.spec.ts b/apps/medusa-be/tests/unit/src/workflows/send-forgot-password.unit.spec.ts index 632a6822cd..46597b49db 100644 --- a/apps/medusa-be/tests/unit/src/workflows/send-forgot-password.unit.spec.ts +++ b/apps/medusa-be/tests/unit/src/workflows/send-forgot-password.unit.spec.ts @@ -85,52 +85,52 @@ describe("send forgot password notification", () => { vi.clearAllMocks() }) - it.each(MARKETS)("builds a canonical $countryCode reset URL", async ({ - countryCode, - domain, - locale, - resetPath, - }) => { - await import("../../../../src/workflows/send-forgot-password") - resolveNotificationMarketContext.mockResolvedValue({ - country_code: countryCode, - locale, - market_code: countryCode, - sales_channel_id: ["sc_", countryCode].join(""), - storefront_base_url: ["https://", domain].join(""), - storefront_domain: domain, - }) - - const step = workflowSdkMock.steps.get("build-forgot-password-notification") - const result = (await step?.( - { - email: " customer+reset@example.test ", - storefrontMarketCode: ` ${countryCode} `, - token: " token/value ", - }, - { container: { resolve: vi.fn() } } - )) as { output: Array<{ data: Record; to: string }> } - - expect(result.output[0]).toMatchObject({ - data: { + it.each(MARKETS)( + "builds a canonical $countryCode reset URL", + async ({ countryCode, domain, locale, resetPath }) => { + await import("../../../../src/workflows/send-forgot-password") + resolveNotificationMarketContext.mockResolvedValue({ country_code: countryCode, locale, - reset_url: [ - "https://", - domain, - resetPath, - "/token%2Fvalue?email=customer%2Breset%40example.test", - ].join(""), + market_code: countryCode, + sales_channel_id: ["sc_", countryCode].join(""), storefront_base_url: ["https://", domain].join(""), - }, - to: "customer+reset@example.test", - }) - expect(resolveNotificationMarketContext).toHaveBeenCalledWith( - expect.anything(), - { countryCode } - ) - expect(resolveCustomerNotificationMarketContext).not.toHaveBeenCalled() - }) + storefront_domain: domain, + }) + + const step = workflowSdkMock.steps.get( + "build-forgot-password-notification" + ) + const result = (await step?.( + { + email: " customer+reset@example.test ", + storefrontMarketCode: ` ${countryCode} `, + token: " token/value ", + }, + { container: { resolve: vi.fn() } } + )) as { output: Array<{ data: Record; to: string }> } + + expect(result.output[0]).toMatchObject({ + data: { + country_code: countryCode, + locale, + reset_url: [ + "https://", + domain, + resetPath, + "/token%2Fvalue?email=customer%2Breset%40example.test", + ].join(""), + storefront_base_url: ["https://", domain].join(""), + }, + to: "customer+reset@example.test", + }) + expect(resolveNotificationMarketContext).toHaveBeenCalledWith( + expect.anything(), + { countryCode } + ) + expect(resolveCustomerNotificationMarketContext).not.toHaveBeenCalled() + } + ) it("falls back to customer context when request metadata has no market", async () => { await import("../../../../src/workflows/send-forgot-password") diff --git a/apps/medusa-be/tests/unit/src/workflows/send-order-receipt.unit.spec.ts b/apps/medusa-be/tests/unit/src/workflows/send-order-receipt.unit.spec.ts index 824b607f3f..b366bb4827 100644 --- a/apps/medusa-be/tests/unit/src/workflows/send-order-receipt.unit.spec.ts +++ b/apps/medusa-be/tests/unit/src/workflows/send-order-receipt.unit.spec.ts @@ -109,93 +109,89 @@ describe("send order receipt workflow", () => { }) }) - it.each( - MARKETS - )("uses authoritative order data and $locale notification formatting", async ({ - countryCode, - currencyCode, - domain, - locale, - }) => { - await import("../../../../src/workflows/send-order-receipt") - resolveNotificationMarketContext.mockResolvedValue({ - country_code: countryCode, - locale, - market_code: countryCode, - sales_channel_id: ["sc_", countryCode].join(""), - store_name: "Herbatica", - storefront_base_url: ["https://", domain].join(""), - storefront_domain: domain, - }) + it.each(MARKETS)( + "uses authoritative order data and $locale notification formatting", + async ({ countryCode, currencyCode, domain, locale }) => { + await import("../../../../src/workflows/send-order-receipt") + resolveNotificationMarketContext.mockResolvedValue({ + country_code: countryCode, + locale, + market_code: countryCode, + sales_channel_id: ["sc_", countryCode].join(""), + store_name: "Herbatica", + storefront_base_url: ["https://", domain].join(""), + storefront_domain: domain, + }) - const order = { - currency_code: currencyCode, - customer: { first_name: "Fetched", last_name: "Customer" }, - customer_id: "cus_1", - display_id: 42, - email: "fetched@example.test", - id: "order_1", - sales_channel_id: ["sc_", countryCode].join(""), - shipping_address: { country_code: countryCode }, - summary: { current_order_total: 1234.5 }, - total: 9999, - } - const graph = vi.fn().mockResolvedValue({ data: [order] }) - const generateOrderReceiptAttachment = vi.fn().mockResolvedValue({ - content: Buffer.from("pdf"), - content_type: "application/pdf", - filename: "receipt.pdf", - }) - const container = { - resolve: vi.fn((key: string) => { - if (key === "query") { - return { graph } - } + const order = { + currency_code: currencyCode, + customer: { first_name: "Fetched", last_name: "Customer" }, + customer_id: "cus_1", + display_id: 42, + email: "fetched@example.test", + id: "order_1", + sales_channel_id: ["sc_", countryCode].join(""), + shipping_address: { country_code: countryCode }, + summary: { current_order_total: 1234.5 }, + total: 9999, + } + const graph = vi.fn().mockResolvedValue({ data: [order] }) + const generateOrderReceiptAttachment = vi.fn().mockResolvedValue({ + content: Buffer.from("pdf"), + content_type: "application/pdf", + filename: "receipt.pdf", + }) + const container = { + resolve: vi.fn((key: string) => { + if (key === "query") { + return { graph } + } - if (key === "logger") { - return { warn: vi.fn() } - } + if (key === "logger") { + return { warn: vi.fn() } + } - if (key === "order_receipt") { - return { generateOrderReceiptAttachment } - } - - throw new Error("Unexpected dependency") - }), - } - const step = workflowSdkMock.steps.get("build-order-receipt-notification") + if (key === "order_receipt") { + return { generateOrderReceiptAttachment } + } - const response = await step?.({ order_id: "order_1" }, { container }) - const notification = ( - response as { output: Record[] } | undefined - )?.output[0] - - expect(notification).toEqual( - expect.objectContaining({ - data: expect.objectContaining({ - customer_name: "Fetched Customer", - locale, - storefront_base_url: ["https://", domain].join(""), - total: new Intl.NumberFormat(locale, { - currency: currencyCode.toUpperCase(), - style: "currency", - }).format(1234.5), + throw new Error("Unexpected dependency") }), - idempotency_key: "order-receipt:order_1", - receiver_id: "cus_1", - resource_id: "order_1", - to: "fetched@example.test", + } + const step = workflowSdkMock.steps.get("build-order-receipt-notification") + + const response = await step?.({ order_id: "order_1" }, { container }) + const notification = ( + response as { output: Record[] } | undefined + )?.output[0] + + expect(notification).toEqual( + expect.objectContaining({ + data: expect.objectContaining({ + customer_name: "Fetched Customer", + locale, + storefront_base_url: ["https://", domain].join(""), + total: new Intl.NumberFormat(locale, { + currency: currencyCode.toUpperCase(), + style: "currency", + }).format(1234.5), + }), + idempotency_key: "order-receipt:order_1", + receiver_id: "cus_1", + resource_id: "order_1", + to: "fetched@example.test", + }) + ) + expect(resolveNotificationMarketContext).toHaveBeenCalledWith(container, { + countryCode, + salesChannelId: ["sc_", countryCode].join(""), }) - ) - expect(resolveNotificationMarketContext).toHaveBeenCalledWith(container, { - countryCode, - salesChannelId: ["sc_", countryCode].join(""), - }) - expect(generateOrderReceiptAttachment).toHaveBeenCalledWith(order, { - locale, - storeName: "Herbatica", - }) - }) + expect(generateOrderReceiptAttachment).toHaveBeenCalledWith(order, { + locale, + storeName: "Herbatica", + }) + } + ) it("skips a recipient-less order before attachment generation", async () => { await import("../../../../src/workflows/send-order-receipt") diff --git a/apps/medusa-be/tests/unit/src/workflows/send-product-review-request.unit.spec.ts b/apps/medusa-be/tests/unit/src/workflows/send-product-review-request.unit.spec.ts index 8fad2e4f5e..015b604ed8 100644 --- a/apps/medusa-be/tests/unit/src/workflows/send-product-review-request.unit.spec.ts +++ b/apps/medusa-be/tests/unit/src/workflows/send-product-review-request.unit.spec.ts @@ -195,56 +195,54 @@ describe("send product review request workflow", () => { vi.clearAllMocks() }) - it.each( - MARKETS - )("uses the $locale market URL and copy from the fetched order", async ({ - countryCode, - domain, - locale, - reviewPath, - }) => { - await import("../../../../src/workflows/send-product-review-request") - resolveNotificationMarketContext.mockResolvedValue({ - country_code: countryCode, - locale, - market_code: countryCode, - sales_channel_id: ["sc_", countryCode].join(""), - storefront_base_url: ["https://", domain].join(""), - storefront_domain: domain, - }) + it.each(MARKETS)( + "uses the $locale market URL and copy from the fetched order", + async ({ countryCode, domain, locale, reviewPath }) => { + await import("../../../../src/workflows/send-product-review-request") + resolveNotificationMarketContext.mockResolvedValue({ + country_code: countryCode, + locale, + market_code: countryCode, + sales_channel_id: ["sc_", countryCode].join(""), + storefront_base_url: ["https://", domain].join(""), + storefront_domain: domain, + }) - const { container, createReviewTokens } = createContext(countryCode) - const step = workflowSdkMock.steps.get( - "build-product-review-request-notification" - ) - const result = (await step?.( - { email: "stale@example.test", order_id: "order_1" }, - { container } - )) as { - output: Array<{ data: Record; to: string }> - } - const productReviews = result.output[0].data.product_reviews as Array<{ - review_url: string - title: string - }> + const { container, createReviewTokens } = createContext(countryCode) + const step = workflowSdkMock.steps.get( + "build-product-review-request-notification" + ) + const result = (await step?.( + { email: "stale@example.test", order_id: "order_1" }, + { container } + )) as { + output: Array<{ data: Record; to: string }> + } + const productReviews = result.output[0].data.product_reviews as Array<{ + review_url: string + title: string + }> - expect(result.output[0].to).toBe("fetched@example.test") - expect(result.output[0].data).toMatchObject({ - locale, - message: reviewUtilities.copy[locale].message, - storefront_base_url: ["https://", domain].join(""), - }) - expect(result.output[0].data.items).toContain( - reviewUtilities.copy[locale].action - ) - expect(productReviews).toEqual([ - expect.objectContaining({ - review_url: ["https://", domain, reviewPath, "token%2Fvalue"].join(""), - title: reviewUtilities.copy[locale].product, - }), - ]) - expect(createReviewTokens).not.toHaveBeenCalled() - }) + expect(result.output[0].to).toBe("fetched@example.test") + expect(result.output[0].data).toMatchObject({ + locale, + message: reviewUtilities.copy[locale].message, + storefront_base_url: ["https://", domain].join(""), + }) + expect(result.output[0].data.items).toContain( + reviewUtilities.copy[locale].action + ) + expect(productReviews).toEqual([ + expect.objectContaining({ + review_url: ["https://", domain, reviewPath, "token%2Fvalue"].join( + "" + ), + title: reviewUtilities.copy[locale].product, + }), + ]) + expect(createReviewTokens).not.toHaveBeenCalled() + } + ) it("skips an already logged request before resolving market or creating tokens", async () => { await import("../../../../src/workflows/send-product-review-request") diff --git a/apps/medusa-be/tests/unit/storefront-text/storefront-text-lock-error.unit.spec.ts b/apps/medusa-be/tests/unit/storefront-text/storefront-text-lock-error.unit.spec.ts index f0168a6ddf..acd16ce42f 100644 --- a/apps/medusa-be/tests/unit/storefront-text/storefront-text-lock-error.unit.spec.ts +++ b/apps/medusa-be/tests/unit/storefront-text/storefront-text-lock-error.unit.spec.ts @@ -31,14 +31,16 @@ describe("storefront text lock error handling", () => { }) }) - it.each([ - new Error("Workflow failed"), - "Timed-out acquiring lock.", - ])("rethrows unrelated errors", (error) => { - const response = createResponse() + it.each([new Error("Workflow failed"), "Timed-out acquiring lock."])( + "rethrows unrelated errors", + (error) => { + const response = createResponse() - expect(() => handleStorefrontTextLockError(error, response)).toThrow(error) - expect(response.status).not.toHaveBeenCalled() - expect(response.json).not.toHaveBeenCalled() - }) + expect(() => handleStorefrontTextLockError(error, response)).toThrow( + error + ) + expect(response.status).not.toHaveBeenCalled() + expect(response.json).not.toHaveBeenCalled() + } + ) }) diff --git a/apps/medusa-be/tests/unit/workflows/url-registry-product-lifecycle-event.unit.spec.ts b/apps/medusa-be/tests/unit/workflows/url-registry-product-lifecycle-event.unit.spec.ts index b2e56d7352..4343083d28 100644 --- a/apps/medusa-be/tests/unit/workflows/url-registry-product-lifecycle-event.unit.spec.ts +++ b/apps/medusa-be/tests/unit/workflows/url-registry-product-lifecycle-event.unit.spec.ts @@ -111,45 +111,44 @@ const eventBusContext = ( } describe("URL registry product lifecycle workflow event", () => { - it.each([ - "created", - "updated", - "deleted", - ] as const)("builds deterministic %s payloads for every market", (reason) => { - const first = buildProductLifecycleOutboxInputs({ - eventGroupId: GROUP_ID, - productIds: ["prod_2", "prod_1", "prod_1"], - ...(reason === "deleted" ? {} : { productSnapshots }), - reason, - }) - const replay = buildProductLifecycleOutboxInputs({ - eventGroupId: GROUP_ID, - productIds: ["prod_1", "prod_2"], - ...(reason === "deleted" ? {} : { productSnapshots }), - reason, - }) - - expect(replay).toEqual(first) - expect(first).toEqual([ - { - affectedMarketCodes: ["sk", "cz", "hu", "ro"], - eventId: expect.stringMatching(SHA256_FINGERPRINT), - marketAssignments: expect.any(Array), - occurredAt: "2016-07-30T23:54:10.259Z", - productId: "prod_1", + it.each(["created", "updated", "deleted"] as const)( + "builds deterministic %s payloads for every market", + (reason) => { + const first = buildProductLifecycleOutboxInputs({ + eventGroupId: GROUP_ID, + productIds: ["prod_2", "prod_1", "prod_1"], + ...(reason === "deleted" ? {} : { productSnapshots }), reason, - }, - { - affectedMarketCodes: ["sk", "cz", "hu", "ro"], - eventId: expect.stringMatching(SHA256_FINGERPRINT), - marketAssignments: expect.any(Array), - occurredAt: "2016-07-30T23:54:10.259Z", - productId: "prod_2", + }) + const replay = buildProductLifecycleOutboxInputs({ + eventGroupId: GROUP_ID, + productIds: ["prod_1", "prod_2"], + ...(reason === "deleted" ? {} : { productSnapshots }), reason, - }, - ]) - expect(first[0]?.eventId).not.toBe(first[1]?.eventId) - }) + }) + + expect(replay).toEqual(first) + expect(first).toEqual([ + { + affectedMarketCodes: ["sk", "cz", "hu", "ro"], + eventId: expect.stringMatching(SHA256_FINGERPRINT), + marketAssignments: expect.any(Array), + occurredAt: "2016-07-30T23:54:10.259Z", + productId: "prod_1", + reason, + }, + { + affectedMarketCodes: ["sk", "cz", "hu", "ro"], + eventId: expect.stringMatching(SHA256_FINGERPRINT), + marketAssignments: expect.any(Array), + occurredAt: "2016-07-30T23:54:10.259Z", + productId: "prod_2", + reason, + }, + ]) + expect(first[0]?.eventId).not.toBe(first[1]?.eventId) + } + ) it.each([ ["missing", undefined], diff --git a/apps/medusa-be/tests/unit/workflows/validate-cart-completion.ro-demo.unit.spec.ts b/apps/medusa-be/tests/unit/workflows/validate-cart-completion.ro-demo.unit.spec.ts index 90778df19c..36230cbcb5 100644 --- a/apps/medusa-be/tests/unit/workflows/validate-cart-completion.ro-demo.unit.spec.ts +++ b/apps/medusa-be/tests/unit/workflows/validate-cart-completion.ro-demo.unit.spec.ts @@ -193,17 +193,20 @@ describe("RO demo cart-completion validation", () => { ], ["cross-market", { ...makePurchaseAcceptance(), market: "sk" }], ["tampered", { ...makePurchaseAcceptance(), termsVersion: "old" }], - ])("rejects %s purchase acceptance immediately before completion", async (_case, acceptance) => { - const cart = makeRoDemoCart() - cart.metadata.checkout_purchase_acceptance = acceptance - graph.mockResolvedValue({ data: [cart] }) - - await expect( - handler()({ cart: workflowCart(cart) }, { container }) - ).rejects.toThrow( - "Current terms and privacy acceptance is required to complete this cart" - ) - }) + ])( + "rejects %s purchase acceptance immediately before completion", + async (_case, acceptance) => { + const cart = makeRoDemoCart() + cart.metadata.checkout_purchase_acceptance = acceptance + graph.mockResolvedValue({ data: [cart] }) + + await expect( + handler()({ cart: workflowCart(cart) }, { container }) + ).rejects.toThrow( + "Current terms and privacy acceptance is required to complete this cart" + ) + } + ) it("rejects when the fresh cart acceptance differs from the workflow snapshot copied to the order", async () => { const cart = makeRoDemoCart() diff --git a/apps/n1/package.json b/apps/n1/package.json index 6554d899fb..9e3f3e0709 100644 --- a/apps/n1/package.json +++ b/apps/n1/package.json @@ -36,7 +36,7 @@ "tailwind-variants": "^3.1.1" }, "devDependencies": { - "@biomejs/biome": "^2.3.7", + "@biomejs/biome": "^2.5.15", "@iconify/tailwind4": "^1.0.6", "@tailwindcss/postcss": "^4", "@tanstack/react-query-devtools": "^5.90.2", diff --git a/apps/new-engine-ctl/package.json b/apps/new-engine-ctl/package.json index 4f17892727..4edcf6bde1 100644 --- a/apps/new-engine-ctl/package.json +++ b/apps/new-engine-ctl/package.json @@ -21,7 +21,7 @@ "zod": "^4.1.13" }, "devDependencies": { - "@biomejs/biome": "^2.3.7", + "@biomejs/biome": "^2.5.15", "@types/node": "^24.12.4", "esbuild": "^0.27.3", "vite": "catalog:vite8-testing", diff --git a/apps/payload/package.json b/apps/payload/package.json index 7b1032e276..2b1214570b 100644 --- a/apps/payload/package.json +++ b/apps/payload/package.json @@ -58,6 +58,6 @@ }, "engines": { "node": "24.x", - "pnpm": "^10 || ^11" + "pnpm": ">=10" } } diff --git a/biome.json b/biome.json index c44bbd5c0d..a2d85a6863 100644 --- a/biome.json +++ b/biome.json @@ -1,9 +1,13 @@ { "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", - "extends": ["ultracite/core", "ultracite/react", "ultracite/next"], + "extends": [ + "ultracite/biome/core", + "ultracite/biome/react", + "ultracite/biome/next", + "ultracite/biome/vitest" + ], "files": { "includes": [ - "**", "!**/node_modules/**/*", "!**/playwright-report/**/*", "!**/.medusa/**/*", @@ -24,17 +28,50 @@ "useIgnoreFile": true, "defaultBranch": "master" }, + "assist": { + "actions": { + "source": { + "useSortedInterfaceMembers": "off", + "useSortedKeys": "off", + "useSortedPackageJson": "off", + "useSortedProperties": "off", + "useSortedSelectionSet": "off", + "useSortedTypeFields": "off" + } + } + }, "linter": { "rules": { + "style": { + "noIncrementDecrement": { + "level": "error", + "options": { + "allowForLoopAfterthoughts": true + } + }, + "useConsistentMethodSignatures": "off", + "useConsistentTypeDefinitions": { + "level": "error", + "options": { + "style": "type" + } + }, + "useDestructuring": "off", + "useErrorCause": "off" + }, "performance": { + "noAwaitInLoops": "off", + "noJsxPropsBind": "off", "noNamespaceImport": "info", "useTopLevelRegex": "info" }, "suspicious": { + "noEqualsToNull": "off", "noEmptyBlockStatements": "info", "noArrayIndexKey": "warn", "noExplicitAny": "warn", - "noEvolvingTypes": "warn" + "noEvolvingTypes": "warn", + "noUnnecessaryConditions": "off" } } }, @@ -47,6 +84,59 @@ } }, "overrides": [ + { + "includes": ["**", "!libs/ui/**"], + "assist": { + "actions": { + "source": { + "useSortedAttributes": "off" + } + } + }, + "linter": { + "rules": { + "a11y": { + "noSvgWithoutTitle": "off" + }, + "complexity": { + "noExtraBooleanCast": "off", + "noRedundantDefaultExport": "off", + "noUselessCatchBinding": "off", + "noUselessReturn": "off", + "useOptionalChain": "off" + }, + "correctness": { + "noGlobalDirnameFilename": "off", + "noUnknownProperty": "off", + "noUnsafeOptionalChaining": "off", + "useExhaustiveDependencies": "off" + }, + "nursery": { + "useSortedClasses": "off" + }, + "style": { + "noNegationElse": "off", + "noSubstr": "off" + }, + "suspicious": { + "noLeakedRender": "off", + "noMisplacedAssertion": "off", + "noSkippedTests": "off", + "noUnknownAttribute": "off" + } + } + } + }, + { + "includes": ["libs/ui/**/*"], + "linter": { + "rules": { + "suspicious": { + "noLeakedRender": "off" + } + } + } + }, { "includes": ["**/*.spec.ts"], "javascript": { diff --git a/docker/development/playwright/Dockerfile b/docker/development/playwright/Dockerfile index cbb6cee183..7ddb5e4168 100644 --- a/docker/development/playwright/Dockerfile +++ b/docker/development/playwright/Dockerfile @@ -1,13 +1,13 @@ -FROM node:24-bookworm +FROM node:26.11.1-bookworm -ARG PLAYWRIGHT_VERSION=1.58.2 +ARG PLAYWRIGHT_VERSION=1.64.0 ENV PLAYWRIGHT_BROWSERS_PATH=0 WORKDIR /app RUN npm init -y \ - && npm i -D @playwright/test@${PLAYWRIGHT_VERSION} http-server \ + && npm i -D @playwright/test@${PLAYWRIGHT_VERSION} pngjs@7.0.0 \ && npx -y playwright@${PLAYWRIGHT_VERSION} install --with-deps chromium webkit \ && chown -R node:node /app diff --git a/docker/development/pnpm/Dockerfile b/docker/development/pnpm/Dockerfile index b15450c116..933a0aa833 100644 --- a/docker/development/pnpm/Dockerfile +++ b/docker/development/pnpm/Dockerfile @@ -1,4 +1,4 @@ -FROM node:24-slim AS base +FROM node:26.11.1-bookworm-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" @@ -6,11 +6,11 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends git \ && rm -rf /var/lib/apt/lists/* -# Temporary solution to https://github.com/nodejs/corepack/issues/612. -# Keep this aligned with the application development images for reproducibility. -RUN npm i -g corepack@0.34.5 +# Install the package manager pinned by the workspace. +COPY package.json /tmp/pnpm-bootstrap/package.json +RUN npm install --global "$(node -p "require('/tmp/pnpm-bootstrap/package.json').packageManager")" \ + && rm -rf /tmp/pnpm-bootstrap -RUN corepack enable RUN mkdir -p /tmp/pnpm-toolchain-home /tmp/pnpm-toolchain-cache \ && chmod 1777 /tmp/pnpm-toolchain-home /tmp/pnpm-toolchain-cache WORKDIR /var/www diff --git a/knip.json b/knip.json index c49aa57905..ada917806f 100644 --- a/knip.json +++ b/knip.json @@ -1,5 +1,5 @@ { - "$schema": "https://unpkg.com/knip@5/schema.json", + "$schema": "https://unpkg.com/knip@6/schema.json", "paths": { "@libs/ui": ["./libs/ui/src"], "@libs/ui/*": ["./libs/ui/src/*"], @@ -105,21 +105,27 @@ "src/molecules/*.tsx!", "src/organisms/*.tsx!", "src/templates/*.tsx!", + "src/theme/*.{ts,tsx}!", "src/types/*.ts!", "src/utils.ts!", "release.config.mjs" ], "project": [ - "src/**/*.{ts,tsx}" + "src/**/*.{ts,tsx,css}", + "stories/**/*.{ts,tsx,mdx}", + ".storybook/**/*.{ts,tsx}" ], "storybook": { - "config": [".storybook/main.ts"], + "config": [ + ".storybook/main.ts" + ], "entry": [ "**/*.stories.{ts,tsx}", ".storybook/**/*.{ts,tsx}" ] }, "ignoreDependencies": [ + "^@iconify-json/", "^@semantic-release/" ], "ignore": [ diff --git a/libs/storefront-data/src/product-location-availability/hooks.ts b/libs/storefront-data/src/product-location-availability/hooks.ts index c4266dc2f1..3ac80b4d7e 100644 --- a/libs/storefront-data/src/product-location-availability/hooks.ts +++ b/libs/storefront-data/src/product-location-availability/hooks.ts @@ -4,11 +4,11 @@ import { createCacheConfig } from "../shared/cache-config" import { toErrorMessage } from "../shared/error-utils" import type { ReadQueryOptions } from "../shared/hook-types" import type { QueryNamespace } from "../shared/query-keys" +import { createProductLocationAvailabilityQueryKeys } from "./query-keys" import { createProductLocationAvailabilityQueryOptionsFactory, type ProductLocationAvailabilityQueryOptionsFactory, } from "./query-options" -import { createProductLocationAvailabilityQueryKeys } from "./query-keys" import type { ProductLocationAvailabilityInputBase, ProductLocationAvailabilityQueryKeys, diff --git a/libs/storefront-data/src/product-location-availability/medusa-service.ts b/libs/storefront-data/src/product-location-availability/medusa-service.ts index 2ca46579b5..62e920fd82 100644 --- a/libs/storefront-data/src/product-location-availability/medusa-service.ts +++ b/libs/storefront-data/src/product-location-availability/medusa-service.ts @@ -23,10 +23,7 @@ export function createMedusaProductLocationAvailabilityService( const productsPath = config?.productsPath ?? "/store/products" return { - getProductLocationAvailability: ( - params, - signal?: AbortSignal - ) => { + getProductLocationAvailability: (params, signal?: AbortSignal) => { if (!params.productId) { throw new Error("Product id is required for location availability.") } diff --git a/libs/storefront-data/src/product-location-availability/query-options.ts b/libs/storefront-data/src/product-location-availability/query-options.ts index 0585ae7e0e..899c38fcb0 100644 --- a/libs/storefront-data/src/product-location-availability/query-options.ts +++ b/libs/storefront-data/src/product-location-availability/query-options.ts @@ -60,10 +60,7 @@ export function createProductLocationAvailabilityQueryOptionsFactory< buildDetailParams ?? ((input: TInput) => input as unknown as TParams) return { - getDetailQueryOptions: ( - input, - options - ): QueryFactoryOptions => { + getDetailQueryOptions: (input, options): QueryFactoryOptions => { const detailParams = buildDetail(input) const cacheStrategy = options?.cacheStrategy ?? "realtime" diff --git a/libs/storefront-data/src/product-location-availability/types.ts b/libs/storefront-data/src/product-location-availability/types.ts index 4e82a12f24..e9a962c100 100644 --- a/libs/storefront-data/src/product-location-availability/types.ts +++ b/libs/storefront-data/src/product-location-availability/types.ts @@ -22,10 +22,7 @@ export type ProductLocationAvailabilityInputBase = { enabled?: boolean } -export type ProductLocationAvailabilityService< - TResponse, - TParams, -> = { +export type ProductLocationAvailabilityService = { getProductLocationAvailability: ( params: TParams, signal?: AbortSignal @@ -36,7 +33,8 @@ export type ProductLocationAvailabilityQueryKeys = { detail: (params: TParams) => QueryKey } -export type UseProductLocationAvailabilityResult = - ReadResultBase> & { - productLocationAvailability: null | TResponse - } +export type UseProductLocationAvailabilityResult = ReadResultBase< + QueryResult +> & { + productLocationAvailability: null | TResponse +} diff --git a/libs/storefront-data/src/reviews/hooks.ts b/libs/storefront-data/src/reviews/hooks.ts index caf4719abf..428ffa19e6 100644 --- a/libs/storefront-data/src/reviews/hooks.ts +++ b/libs/storefront-data/src/reviews/hooks.ts @@ -15,14 +15,14 @@ import type { ReadQueryOptions, SuspenseQueryOptions, } from "../shared/hook-types" +import { resolvePagination } from "../shared/pagination" import type { PrefetchSkipMode } from "../shared/prefetch" import { shouldSkipPrefetch } from "../shared/prefetch" -import { resolvePagination } from "../shared/pagination" import type { QueryNamespace } from "../shared/query-keys" import { useDelayedPrefetchController } from "../shared/use-delayed-prefetch-controller" import { createDefaultListParams } from "./input-utils" -import { createProductReviewQueryOptionsFactory } from "./query-options" import { createProductReviewQueryKeys } from "./query-keys" +import { createProductReviewQueryOptionsFactory } from "./query-options" import type { CreateProductReviewInput, ProductReviewListInputBase, @@ -226,11 +226,7 @@ export function createProductReviewHooks< const listParams = buildList(input) const queryKey = resolvedQueryKeys.productList(listParams) const id = prefetchId ?? JSON.stringify(queryKey) - return schedulePrefetch( - () => prefetchProductReviews(input), - id, - delay - ) + return schedulePrefetch(() => prefetchProductReviews(input), id, delay) } return { diff --git a/libs/storefront-i18n/tests/messages.test.ts b/libs/storefront-i18n/tests/messages.test.ts index f011329847..80879f5624 100644 --- a/libs/storefront-i18n/tests/messages.test.ts +++ b/libs/storefront-i18n/tests/messages.test.ts @@ -41,13 +41,12 @@ describe("nestStorefrontMessages", () => { }) }) - it.each([ - "cart..title", - "cart.__proto__.title", - "cart.constructor.title", - ])("rejects invalid key %s", (key) => { - expect(() => nestStorefrontMessages({ [key]: "value" })).toThrow( - `Invalid storefront message key: ${key}` - ) - }) + it.each(["cart..title", "cart.__proto__.title", "cart.constructor.title"])( + "rejects invalid key %s", + (key) => { + expect(() => nestStorefrontMessages({ [key]: "value" })).toThrow( + `Invalid storefront message key: ${key}` + ) + } + ) }) diff --git a/libs/storefront-i18n/tests/public-flow-routes.test.ts b/libs/storefront-i18n/tests/public-flow-routes.test.ts index df128171b6..7db9f98448 100644 --- a/libs/storefront-i18n/tests/public-flow-routes.test.ts +++ b/libs/storefront-i18n/tests/public-flow-routes.test.ts @@ -33,24 +33,29 @@ const MARKET_ROUTES = [ ] as const describe("public flow routes", () => { - it.each( - MARKET_ROUTES - )("builds exact $market localized account and review paths", ({ - deactivation, - market, - resetPassword, - review, - }) => { - expect( - buildPublicFlowPath({ kind: "account", section: "resetPassword" }, market) - ).toBe(resetPassword) - expect( - buildPublicFlowPath({ kind: "account", section: "deactivation" }, market) - ).toBe(deactivation) - expect( - buildPublicFlowPath({ kind: "review", token: "Token/Exact+Case" }, market) - ).toBe(review) - }) + it.each(MARKET_ROUTES)( + "builds exact $market localized account and review paths", + ({ deactivation, market, resetPassword, review }) => { + expect( + buildPublicFlowPath( + { kind: "account", section: "resetPassword" }, + market + ) + ).toBe(resetPassword) + expect( + buildPublicFlowPath( + { kind: "account", section: "deactivation" }, + market + ) + ).toBe(deactivation) + expect( + buildPublicFlowPath( + { kind: "review", token: "Token/Exact+Case" }, + market + ) + ).toBe(review) + } + ) it("builds against only the canonical origin", () => { expect( @@ -62,15 +67,12 @@ describe("public flow routes", () => { ).toBe("https://herbatica.sk/ucet/obnova-hesla") }) - it.each([ - "SK", - "de", - "", - null, - undefined, - ])("fails closed for unknown market %s", (market) => { - expect(parsePublicFlowMarket(market)).toBeUndefined() - }) + it.each(["SK", "de", "", null, undefined])( + "fails closed for unknown market %s", + (market) => { + expect(parsePublicFlowMarket(market)).toBeUndefined() + } + ) it("rejects unsafe canonical origins", () => { expect(() => diff --git a/libs/ui/.gitignore b/libs/ui/.gitignore index aa2f4db305..d89be96bd0 100644 --- a/libs/ui/.gitignore +++ b/libs/ui/.gitignore @@ -10,7 +10,6 @@ node-compile-cache # IDE .vscode/* -!.vscode/extensions.json .idea .vercel diff --git a/libs/ui/.storybook/chart-pointer-regression.mjs b/libs/ui/.storybook/chart-pointer-regression.mjs new file mode 100644 index 0000000000..b84dc3d578 --- /dev/null +++ b/libs/ui/.storybook/chart-pointer-regression.mjs @@ -0,0 +1,258 @@ +// @ts-check +/** biome-ignore-all lint/suspicious/noMisplacedAssertion: Storybook's generated Jest test invokes these assertions through postVisit. */ +import assert from "node:assert/strict" + +/** @import { Page, Locator } from "@playwright/test" */ +/** + * @typedef {{ id: string, label: string, value: number, payload: { source: string } }} SourceRow + * @typedef {{ selected: SourceRow | null, calls: number, isSourceRow: boolean }} SelectionState + */ + +/** + * Read exposed points within the source mark, including a narrow padded slice. + * A generated focus circle can share the mark's key and receive its input. + * @param {HTMLElement | SVGElement} element + * @param {{ x: number, y: number } | null} requestedPosition + */ +function exposedMarkPoints(element, requestedPosition) { + if (!(element instanceof SVGGeometryElement)) { + throw new Error("Pointer fixture mark is not SVG geometry") + } + const bounds = element.getBBox() + const matrix = element.getScreenCTM() + const key = element.getAttribute("data-ts-key") + const svg = element.closest("svg") + if (!(matrix && key && svg && bounds.width > 0 && bounds.height > 0)) { + throw new Error("Pointer fixture mark has no visible keyed geometry") + } + const inverse = matrix.inverse() + /** @param {{ x: number, y: number }} position */ + const isExposed = (position) => { + const local = new DOMPoint(position.x, position.y).matrixTransform(inverse) + const target = element.ownerDocument.elementFromPoint( + position.x, + position.y + ) + const targetKey = target?.getAttribute("data-ts-key") + // Line dots append :dot to their ChartPoint key; the focus circle does not. + const isPointOverlay = + element instanceof SVGCircleElement && + target instanceof SVGCircleElement && + key.endsWith(":dot") && + targetKey === key.slice(0, -4) + return ( + element.isPointInFill(local) && + target instanceof SVGGeometryElement && + target.closest("svg") === svg && + (targetKey === key || isPointOverlay) + ) + } + if (requestedPosition) { + return { + key, + positions: isExposed(requestedPosition) ? [requestedPosition] : [], + } + } + // Sample the center first, then points near both edges of concave arcs. + const fractions = [ + 0.5, 0.75, 0.25, 0.875, 0.125, 0.9375, 0.0625, 0.625, 0.375, + ] + /** @type {{ x: number, y: number }[]} */ + const positions = [] + for (const xFraction of fractions) { + for (const yFraction of fractions) { + const screen = new DOMPoint( + bounds.x + bounds.width * xFraction, + bounds.y + bounds.height * yFraction + ).matrixTransform(matrix) + const position = { x: screen.x, y: screen.y } + if (isExposed(position)) { + positions.push(position) + } + } + } + return { key, positions } +} + +/** + * DOM reads supply coordinates only; page.mouse performs every interaction. + * Recheck after hover because a sticky tooltip or focus mark can move. + * @param {Page} page + * @param {Locator} mark + */ +async function hoverMark(page, mark) { + await mark.scrollIntoViewIfNeeded() + for (let attempt = 0; attempt < 2; attempt += 1) { + const probe = await mark.evaluate(exposedMarkPoints, null) + for (const position of probe.positions) { + const beforeHover = await mark.evaluate(exposedMarkPoints, position) + if (!beforeHover.positions.length) { + continue + } + await page.mouse.move(position.x, position.y) + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(resolve)) + }) + ) + const afterHover = await mark.evaluate(exposedMarkPoints, position) + assert.equal( + afterHover.key, + probe.key, + "Hover must retain the source mark" + ) + if (afterHover.positions.length) { + return position + } + } + } + throw new Error( + "Pointer fixture mark has no exposed interior after native hover" + ) +} + +/** + * @param {Page} page + * @param {"pie" | "donut" | "line"} type + * @param {number} calls + * @param {SourceRow | null} selected + */ +async function assertSelection(page, type, calls, selected) { + const testId = `pointer-${type}-selection` + await page.waitForFunction( + ({ outputId, expectedCalls }) => { + const output = document.querySelector(`[data-testid="${outputId}"]`) + return ( + output && + JSON.parse(output.textContent ?? "null")?.calls === expectedCalls + ) + }, + { outputId: testId, expectedCalls: calls }, + { timeout: 5000 } + ) + const text = await page.getByTestId(testId).textContent() + assert.ok(text, `${type} must expose its selected source row`) + const state = /** @type {SelectionState} */ (JSON.parse(text)) + assert.deepEqual( + state, + { selected, calls, isSourceRow: true }, + `${type} must report the exact original source row and update its selection output` + ) +} + +/** @param {Page} page @param {Locator} mark */ +async function clickMark(page, mark) { + const position = await hoverMark(page, mark) + await page.mouse.click(position.x, position.y) +} + +/** @param {Page} page @param {Locator} chart */ +async function clickBackground(page, chart) { + await chart.scrollIntoViewIfNeeded() + const bounds = await chart.boundingBox() + assert.ok(bounds, "Pointer fixture chart must be visible") + await page.mouse.move(bounds.x + 1, bounds.y + 1) + await page.mouse.click(bounds.x + 1, bounds.y + 1) +} + +/** + * Native pointer coverage runs only for molecules-chart--pointer-selection. + * @param {Page} page + */ +export async function checkChartPointerSelection(page) { + const tiny = { + id: "tiny", + label: "Tiny 0.5%", + value: 0.5, + payload: { source: "tiny-source" }, + } + const medium = { + id: "medium", + label: "Medium", + value: 49.5, + payload: { source: "medium-source" }, + } + const large = { + id: "large", + label: "Large", + value: 50, + payload: { source: "large-source" }, + } + const february = { + id: "february", + label: "Feb", + value: 34, + payload: { source: "february-source" }, + } + const pie = page.getByTestId("pointer-pie").getByRole("img") + const pieSlices = pie.locator('path[data-ts-key*="arc-"]') + assert.equal( + await pieSlices.count(), + 3, + "Pie fixture must render all source rows" + ) + await clickMark(page, pieSlices.nth(1)) + await assertSelection(page, "pie", 1, medium) + await clickMark(page, pieSlices.nth(0)) + await assertSelection(page, "pie", 2, tiny) + await clickBackground(page, pie) + await assertSelection(page, "pie", 3, null) + + const donut = page.getByTestId("pointer-donut").getByRole("img") + const donutSlices = donut.locator('path[data-ts-key*="arc-"]') + assert.equal( + await donutSlices.count(), + 3, + "Donut fixture must render all source rows" + ) + await clickMark(page, donutSlices.nth(2)) + await assertSelection(page, "donut", 1, large) + await clickMark(page, donutSlices.nth(0)) + await assertSelection(page, "donut", 2, tiny) + const hole = await donutSlices.nth(0).evaluate((element) => { + if (!(element instanceof SVGGeometryElement)) { + throw new Error("Donut fixture mark is not SVG geometry") + } + const matrix = element.getScreenCTM() + if (!matrix) { + throw new Error("Donut fixture must expose its polar center") + } + const center = new DOMPoint(0, 0).matrixTransform(matrix) + return { x: center.x, y: center.y } + }) + await page.mouse.move(hole.x, hole.y) + await page.mouse.click(hole.x, hole.y) + await assertSelection(page, "donut", 3, null) + await clickMark(page, donutSlices.nth(2)) + await assertSelection(page, "donut", 4, large) + await clickBackground(page, donut) + await assertSelection(page, "donut", 5, null) + + const line = page.getByTestId("pointer-line").getByRole("img") + const points = line.locator("circle") + assert.equal( + await points.count(), + 3, + "Line fixture must render all source rows" + ) + await hoverMark(page, points.nth(1)) + const tooltip = page + .getByTestId("pointer-line") + .locator('.ts-chart-tooltip[role="status"]') + await tooltip.waitFor({ state: "visible" }) + const tooltipText = await tooltip.textContent() + assert.ok( + tooltipText?.includes("Feb"), + "Cartesian hover must show the source label" + ) + assert.ok( + tooltipText?.includes("34"), + "Cartesian hover must show the source value" + ) + await clickMark(page, points.nth(1)) + await assertSelection(page, "line", 1, february) + await clickBackground(page, line) + await assertSelection(page, "line", 2, null) + await tooltip.waitFor({ state: "hidden" }) +} diff --git a/libs/ui/.storybook/main.ts b/libs/ui/.storybook/main.ts index e7679f8574..6db9bbb910 100644 --- a/libs/ui/.storybook/main.ts +++ b/libs/ui/.storybook/main.ts @@ -1,12 +1,12 @@ +import { fileURLToPath } from 'node:url' import type { StorybookConfig } from 'storybook-react-rsbuild' const config: StorybookConfig = { stories: [ - '../stories/**/*.mdx', '../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)', ], addons: [ - '@storybook/addon-themes', + '@storybook/addon-docs', '@techsio/storybook-better-a11y', 'storybook-addon-rslib', ], @@ -18,6 +18,14 @@ const config: StorybookConfig = { reactDocgen: 'react-docgen-typescript', check: true, }, + rsbuildFinal: (rsbuildConfig) => { + // Library declaration builds exclude stories; preview checks include them. + rsbuildConfig.source ??= {} + rsbuildConfig.source.tsconfigPath = fileURLToPath( + new URL('../tsconfig.json', import.meta.url) + ) + return rsbuildConfig + }, } export default config diff --git a/libs/ui/.storybook/preview.ts b/libs/ui/.storybook/preview.ts index 6a0938fc91..33e2e51bb5 100644 --- a/libs/ui/.storybook/preview.ts +++ b/libs/ui/.storybook/preview.ts @@ -75,7 +75,9 @@ const preview: Preview = { level: "gold", useCase: "body", }, - test: "error", + // Let the reporter record each result before enforcing its violation + // policy. The standard test command remains strict by default. + test: "todo", }, }, globalTypes: { diff --git a/libs/ui/.storybook/test-runner-common.mjs b/libs/ui/.storybook/test-runner-common.mjs new file mode 100644 index 0000000000..f7ae46f8e5 --- /dev/null +++ b/libs/ui/.storybook/test-runner-common.mjs @@ -0,0 +1,17 @@ +import { pathToFileURL } from "node:url" +import { getInterpretedFile } from "storybook/internal/common-original" + +// biome-ignore lint/performance/noBarrelFile: Jest's replacement must preserve Storybook's complete common-module API. +export * from "storybook/internal/common-original" + +// This module is mapped only by Jest. Its own import/transform pipeline must +// load configuration instead of installing hooks in the host Node process. +export async function serverRequire(filePaths) { + const paths = Array.isArray(filePaths) ? filePaths : [filePaths] + const candidate = paths.map(getInterpretedFile).find(Boolean) + if (!candidate) { + return null + } + const module = await import(pathToFileURL(candidate).href) + return module.default ?? module +} diff --git a/libs/ui/.storybook/test-runner-environment.mjs b/libs/ui/.storybook/test-runner-environment.mjs new file mode 100644 index 0000000000..6b6f20370f --- /dev/null +++ b/libs/ui/.storybook/test-runner-environment.mjs @@ -0,0 +1,46 @@ +import { setupPage } from "@storybook/test-runner" +import PlaywrightEnvironment from "@storybook/test-runner/dist/jest-playwright-entries/test-environment.js" + +/** @import { BrowserContext, Page } from "@playwright/test" */ +/** + * @typedef {{ + * global: { page?: Page, context?: BrowserContext }, + * setup: () => Promise, + * handleTestEvent: (event: { name: string }, state?: unknown) => Promise, + * }} BrowserEnvironment + */ +const BaseEnvironment = + /** @type {new (...args: unknown[]) => BrowserEnvironment} */ ( + PlaywrightEnvironment + ) + +// Upstream's playwright/custom-environment.js calls setupPage() only in setup(). +// When Jest filters by test name (e.g. the `t` key in --watch), the runner sets +// skipInitialization, so setup() has no page and the base environment creates +// one at run_start instead. Prepare whichever page actually exists. +export default class StorybookEnvironment extends BaseEnvironment { + async setup() { + await super.setup() + await this.#setupNewPage(undefined) + } + + /** + * @param {{ name: string }} event + * @param {unknown} [state] + */ + async handleTestEvent(event, state) { + const previousPage = this.global.page + await super.handleTestEvent(event, state) + if (event.name === "run_start") { + await this.#setupNewPage(previousPage) + } + } + + /** @param {Page | undefined} previousPage */ + async #setupNewPage(previousPage) { + const { page, context } = this.global + if (page && context && page !== previousPage) { + await setupPage(page, context) + } + } +} diff --git a/libs/ui/.storybook/test-runner-jest.config.mjs b/libs/ui/.storybook/test-runner-jest.config.mjs new file mode 100644 index 0000000000..0d12193ad6 --- /dev/null +++ b/libs/ui/.storybook/test-runner-jest.config.mjs @@ -0,0 +1,33 @@ +import { createRequire } from "node:module" +import { fileURLToPath } from "node:url" +import { getJestConfig } from "@storybook/test-runner" + +const config = getJestConfig() +const require = createRequire(import.meta.url) + +export default { + ...config, + testEnvironment: fileURLToPath( + new URL("./test-runner-environment.mjs", import.meta.url) + ), + testTimeout: 60_000, + // Recycle workers between suites before accumulated transforms exhaust V8. + workerIdleMemoryLimit: "512MiB", + // Storybook eagerly registers a Node TypeScript loader for configuration, + // which Jest's sandbox rejects. Load our executable hook inside Jest instead. + moduleNameMapper: { + ...(config.moduleNameMapper ?? {}), + "^storybook/internal/common$": fileURLToPath( + new URL("./test-runner-common.mjs", import.meta.url) + ), + "^storybook/internal/common-original$": require.resolve( + "storybook/internal/common" + ), + }, + modulePathIgnorePatterns: [ + ...(config.modulePathIgnorePatterns ?? []), + String.raw`[/\\]\.schaltwerk[/\\]`, + String.raw`[/\\]\.nx[/\\]`, + String.raw`[/\\]\.medusa[/\\]`, + ], +} diff --git a/libs/ui/.storybook/test-runner.mjs b/libs/ui/.storybook/test-runner.mjs new file mode 100644 index 0000000000..caf7bf7786 --- /dev/null +++ b/libs/ui/.storybook/test-runner.mjs @@ -0,0 +1,96 @@ +// @ts-check +// Jest cannot install Storybook's Node loader for TypeScript configuration. +// Keep this runtime hook directly executable and check its types with JSDoc. +/** @import { TestRunnerConfig } from "@storybook/test-runner" */ +/** @import { Channel } from "storybook/internal/channels" */ +import { createA11yReporter } from "@techsio/storybook-a11y-reporter" +import { checkChartPointerSelection } from "./chart-pointer-regression.mjs" + +/** + * @typedef {Window & { + * __STORYBOOK_ADDONS_CHANNEL__?: Channel, + * __STORYBOOK_PREVIEW__?: { ready: () => Promise }, + * }} StorybookWindow + */ + +// The reporter reads its documented A11Y_REPORT_* environment variables itself +// (A11Y_REPORT_FAIL_ON_VIOLATIONS, A11Y_REPORT_JUNIT, A11Y_REPORT_WAIT_MS, +// A11Y_REPORT_OUTPUT_DIR). Only "false" disables a boolean option. +const reporter = createA11yReporter({ waitForResultsMs: 30_000 }) + +const mode = process.env.A11Y_STORYBOOK_MODE +if (mode !== undefined && mode !== "light" && mode !== "dark") { + throw new Error("A11Y_STORYBOOK_MODE must be light or dark") +} + +/** @type {TestRunnerConfig} */ +const testRunnerConfig = { + async prepare({ + page, + browserContext, + testRunnerConfig: activeRunnerConfig, + }) { + const iframeUrl = new URL( + "iframe.html", + process.env.TARGET_URL ?? "http://127.0.0.1:6006" + ) + if (activeRunnerConfig.getHttpHeaders) { + await browserContext.setExtraHTTPHeaders( + await activeRunnerConfig.getHttpHeaders(iframeUrl.toString()) + ) + } + await page.goto(iframeUrl.toString(), { waitUntil: "domcontentloaded" }) + await page.waitForFunction( + () => + "__STORYBOOK_ADDONS_CHANNEL__" in window && + "__STORYBOOK_PREVIEW__" in window + ) + await page.evaluate(async (colorMode) => { + const storybookWindow = /** @type {StorybookWindow} */ (window) + const preview = storybookWindow.__STORYBOOK_PREVIEW__ + const channel = storybookWindow.__STORYBOOK_ADDONS_CHANNEL__ + if (!(preview && channel)) { + throw new Error("Storybook did not initialize its preview channel") + } + await preview.ready() + if (!colorMode) { + return + } + /** @type {Promise} */ + const globalsUpdated = new Promise((resolve) => { + channel.once("globalsUpdated", () => resolve()) + channel.emit("updateGlobals", { + globals: { brand: "base", mode: colorMode }, + }) + }) + await globalsUpdated + }, mode) + }, + async postVisit(page, context) { + const checks = [() => reporter.postVisit?.(page, context)] + if (context.id === "molecules-chart--pointer-selection") { + checks.push(() => checkChartPointerSelection(page)) + } + // Run every check in order and report all failures together. + /** @type {unknown[]} */ + const errors = [] + for (const check of checks) { + try { + await check() + } catch (error) { + errors.push(error) + } + } + if (errors.length > 1) { + throw new AggregateError( + errors, + "Chart accessibility and native pointer checks failed" + ) + } + if (errors.length === 1) { + throw errors[0] + } + }, +} + +export default testRunnerConfig diff --git a/libs/ui/.storybook/test-runner.ts b/libs/ui/.storybook/test-runner.ts deleted file mode 100644 index 178024d8af..0000000000 --- a/libs/ui/.storybook/test-runner.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { createA11yReporter } from '@techsio/storybook-a11y-reporter'; -import type { TestRunnerConfig } from '@storybook/test-runner'; - -function readBoolEnv(name: string, defaultValue: boolean): boolean { - const raw = process.env[name]; - if (!raw) return defaultValue; - const normalized = raw.trim().toLowerCase(); - if (['0', 'false', 'no', 'off'].includes(normalized)) return false; - if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; - return defaultValue; -} - -function readNumberEnv(name: string, defaultValue: number): number { - const raw = process.env[name]; - if (!raw) return defaultValue; - const parsed = Number(raw); - return Number.isFinite(parsed) ? parsed : defaultValue; -} - -type JestConfig = { - modulePathIgnorePatterns?: string[]; - testTimeout?: number; - [key: string]: unknown; -}; - -type TestRunnerConfigWithJest = TestRunnerConfig & { - getJestConfig: (config: JestConfig) => JestConfig; -}; - -const reporter = createA11yReporter({ - outputDir: process.env.A11Y_REPORT_OUTPUT_DIR ?? 'a11y-report', - failOnViolations: readBoolEnv('A11Y_REPORT_FAIL_ON_VIOLATIONS', true), - writeJUnit: readBoolEnv('A11Y_REPORT_WRITE_JUNIT', true), - waitForResultsMs: readNumberEnv('A11Y_REPORT_WAIT_MS', 30000), -}); - -const testRunnerConfig: TestRunnerConfigWithJest = { - ...reporter, - getJestConfig(config) { - return { - ...config, - testTimeout: 60000, - modulePathIgnorePatterns: [ - ...(config.modulePathIgnorePatterns ?? []), - '/.schaltwerk', - '/.nx', - ], - }; - }, -}; - -export default testRunnerConfig; diff --git a/libs/ui/README.md b/libs/ui/README.md index 0cce7980aa..6f7f33789a 100644 --- a/libs/ui/README.md +++ b/libs/ui/README.md @@ -169,28 +169,34 @@ Docker image is defined in `docker/development/playwright/Dockerfile`. These tests are intentionally Docker-only; running them directly on the host is blocked to keep snapshots reproducible. Make sure Storybook is served at `TEST_BASE_URL` (default `http://127.0.0.1:6006` inside the container). You can run `pnpm -C libs/ui storybook` on the host and set `TEST_BASE_URL=http://host.docker.internal:6006`, -or let Playwright start its own `http-server` inside Docker from `storybook-static`. -For visual stability, stories used in regression tests should rely on local assets (avoid external image URLs). +or let Playwright start its own static server inside Docker from `storybook-static`. +Screenshots must match their baselines byte-for-byte after PNG decoding (Playwright's `toHaveScreenshot` tolerates anti-aliasing differences, so the spec compares decoded RGBA itself). `Math.random` is seeded per story and the clock is fixed. +Prefer local assets in new stories. Existing remote images replay through Playwright's native `routeFromHAR()` from `test/fixtures/visual-assets/assets.har`; attached binaries are committed beside the HAR. Missing URLs or changed resource types fail the test. Snapshot updates keep these files read-only. + +When a story needs a new remote asset, record it using Playwright's [HAR recording](https://playwright.dev/docs/mock#recording-a-har-file). Use one recording context with `recordHar: { path: "assets.har", mode: "minimal", content: "attach", urlFilter: "https://images.unsplash.com/**" }`, visit the affected stories, then close the context to save it. Add the required entries and attachments to the existing HAR; preserve unrelated entries and keep one entry per URL (deduplicate repeated captures). Review image changes before updating screenshots. + +Run the focused replay contracts with `pnpm -C libs/ui test:visual-assets` after installing Chromium (`pnpm exec playwright install chromium`). Optional environment overrides: - `TEST_BASE_URL` (default: `http://127.0.0.1:6006` inside the container) - `PLAYWRIGHT_STORYBOOK_REBUILD` (default: `1`, rebuilds `storybook-static`; set to `0` to reuse an existing build) +- `VISUAL_STORYBOOK_STATIC_DIR` (absolute path to another Storybook build, e.g. one built from `master`, to test against the committed baselines; skips the rebuild) +- `VISUAL_BRANDS` (default: `base,neo,business`; also supports `akros`) - `TEST_STORIES` (comma-separated Storybook story ids to run, e.g. `atoms-button--states,molecules-productcard--layout-variants`) - `PLAYWRIGHT_WORKERS` (override worker count for parallel runs) -- `PLAYWRIGHT_PAGE_RESET` (default: `1`, resets cookies/storage between stories; set to `0` for max speed if stable) -- `DOCKER_PLATFORM` (default: `linux/amd64`) +- `VISUAL_ASSET_HAR_PATH` (absolute path to an alternative HAR; its directory and attachments mount read-only) - `PLAYWRIGHT_DOCKER_IMAGE` (default: `new-engine-ui-playwright`) - `PLAYWRIGHT_DOCKER_SHM_SIZE` (default: `2g`, improves Playwright stability in Docker) - `PLAYWRIGHT_DOCKER_IPC` (default: `host`, improves Playwright stability in Docker) -- `PLAYWRIGHT_DOCKER_SEQUENTIAL` (default: `0`, runs projects in parallel; set to `1` to reduce memory spikes) -- `PLAYWRIGHT_DOCKER_PROJECTS` (comma-separated Playwright project names to run sequentially; default: `desktop,mobile`) +- `PLAYWRIGHT_DOCKER_SEQUENTIAL` (default: `0`, runs projects in parallel; set to `1` to run one project at a time and reduce memory spikes) + +Extra arguments are passed to `playwright test`, e.g. `pnpm -C libs/ui test:components -- --project=neo-mobile-dark`. +Tests always run as `linux/amd64`, matching the committed baselines. A full run must cover every story in every visual project; component behavior specs (`test/*.spec.ts`) run once in the `desktop` and `mobile` projects. ### Recommendation for `PLAYWRIGHT_WORKERS` and parallelism -- If `PLAYWRIGHT_WORKERS` is not set, the Playwright config defaults to - using (CPU cores - 1) workers. This balances parallelism with leaving one - core for system processes. You can override it in CI with `PLAYWRIGHT_WORKERS=4` (or - another suitable value) depending on your runner size. +- If `PLAYWRIGHT_WORKERS` is not set, the Docker runner uses 4 workers to bound + browser memory. Override it depending on your machine or CI runner size. - The test suite enables `fullyParallel` in Playwright config, so tests can run concurrently across files and workers — ensure tests are isolated and use unique snapshot names (the visual tests already include `story.id` in the diff --git a/libs/ui/agent-plugin/skills/accordion-usage/SKILL.md b/libs/ui/agent-plugin/skills/accordion-usage/SKILL.md index 137defee68..852ead9d87 100644 --- a/libs/ui/agent-plugin/skills/accordion-usage/SKILL.md +++ b/libs/ui/agent-plugin/skills/accordion-usage/SKILL.md @@ -5,7 +5,7 @@ description: > collapsible sections using the Zag.js-backed compound anatomy, supported variant, shadow, size, value, multiple, and collapsible props. metadata: - component_version: "1.0.1" + component_version: "1.0.2" type: "core" library: "@techsio/ui-kit" library_version: "0.3.2" diff --git a/libs/ui/agent-plugin/skills/breadcrumb-usage/SKILL.md b/libs/ui/agent-plugin/skills/breadcrumb-usage/SKILL.md index d56e2cd58f..eb6196797c 100644 --- a/libs/ui/agent-plugin/skills/breadcrumb-usage/SKILL.md +++ b/libs/ui/agent-plugin/skills/breadcrumb-usage/SKILL.md @@ -5,7 +5,7 @@ description: > page hierarchy navigation with Link/NextLink adapters, current page state, separators, ellipsis, icons, size, and underline variant. metadata: - component_version: "1.0.0" + component_version: "1.0.1" type: "core" library: "@techsio/ui-kit" library_version: "0.3.2" diff --git a/libs/ui/agent-plugin/skills/carousel-usage/SKILL.md b/libs/ui/agent-plugin/skills/carousel-usage/SKILL.md index 43225cac6f..bad085038a 100644 --- a/libs/ui/agent-plugin/skills/carousel-usage/SKILL.md +++ b/libs/ui/agent-plugin/skills/carousel-usage/SKILL.md @@ -5,7 +5,7 @@ description: > Zag.js-backed slides, images, controls, indicators, autoplay, sizing, aspect ratio, object fit, and framework image adapters. metadata: - component_version: "1.0.0" + component_version: "1.0.1" type: "core" library: "@techsio/ui-kit" library_version: "0.3.2" diff --git a/libs/ui/agent-plugin/skills/chart-usage/SKILL.md b/libs/ui/agent-plugin/skills/chart-usage/SKILL.md index d655e3cc45..1e0bf1de0c 100644 --- a/libs/ui/agent-plugin/skills/chart-usage/SKILL.md +++ b/libs/ui/agent-plugin/skills/chart-usage/SKILL.md @@ -3,12 +3,12 @@ name: chart-usage description: > Use after component-usage-ux when an app needs the @techsio/ui-kit Chart — a declarative data-visualization molecule built on TanStack Charts - (@tanstack/charts + @tanstack/react-charts). One `type` prop covers line, + (@tanstack/charts/react). One `type` prop covers line, area, bar, horizontal bar, scatter, pie and donut charts from the same data/x/y/series channels, with token-driven series colors, legend, tooltip, value formatting and selection callbacks. metadata: - component_version: "1.0.0" + component_version: "1.0.1" type: "core" library: "@techsio/ui-kit" library_version: "0.3.2" diff --git a/libs/ui/agent-plugin/skills/checkbox-usage/SKILL.md b/libs/ui/agent-plugin/skills/checkbox-usage/SKILL.md index ea1bf300d8..c60d27400f 100644 --- a/libs/ui/agent-plugin/skills/checkbox-usage/SKILL.md +++ b/libs/ui/agent-plugin/skills/checkbox-usage/SKILL.md @@ -5,7 +5,7 @@ description: > @techsio/ui-kit Checkbox atom, including invalid and indeterminate states, while preferring form molecules for labeled form rows. metadata: - component_version: "1.0.1" + component_version: "1.0.2" type: "core" library: "@techsio/ui-kit" library_version: "0.3.2" diff --git a/libs/ui/agent-plugin/skills/color-select-usage/SKILL.md b/libs/ui/agent-plugin/skills/color-select-usage/SKILL.md index f892ceda87..47f45451e7 100644 --- a/libs/ui/agent-plugin/skills/color-select-usage/SKILL.md +++ b/libs/ui/agent-plugin/skills/color-select-usage/SKILL.md @@ -5,7 +5,7 @@ description: > for choosing color swatches with supported layout, size, radius, disabled state, selected state, labels, counts, and single/multiple roles. metadata: - component_version: "1.0.1" + component_version: "1.0.2" type: "core" library: "@techsio/ui-kit" library_version: "0.3.2" diff --git a/libs/ui/agent-plugin/skills/combobox-usage/SKILL.md b/libs/ui/agent-plugin/skills/combobox-usage/SKILL.md index 792d9a8501..f80c61d2d7 100644 --- a/libs/ui/agent-plugin/skills/combobox-usage/SKILL.md +++ b/libs/ui/agent-plugin/skills/combobox-usage/SKILL.md @@ -5,7 +5,7 @@ description: > searchable selection with Zag.js collection behavior, controlled value/input, multiple mode, validation status, clear trigger, and token styling. metadata: - component_version: "1.3.1" + component_version: "1.3.2" type: "core" library: "@techsio/ui-kit" library_version: "0.3.2" diff --git a/libs/ui/agent-plugin/skills/data-table-usage/SKILL.md b/libs/ui/agent-plugin/skills/data-table-usage/SKILL.md index 444e9100f6..d6e75f2466 100644 --- a/libs/ui/agent-plugin/skills/data-table-usage/SKILL.md +++ b/libs/ui/agent-plugin/skills/data-table-usage/SKILL.md @@ -8,7 +8,7 @@ description: > row reorder, tree/expanding rows, inline edit, colSpan/rowSpan, virtualization / infinite scroll and pagination — every feature behind a flag with a callback. metadata: - component_version: "1.2.1" + component_version: "1.2.2" type: "core" library: "@techsio/ui-kit" library_version: "0.3.2" diff --git a/libs/ui/agent-plugin/skills/dialog-usage/SKILL.md b/libs/ui/agent-plugin/skills/dialog-usage/SKILL.md index 58695d2f0b..bcb46ddf71 100644 --- a/libs/ui/agent-plugin/skills/dialog-usage/SKILL.md +++ b/libs/ui/agent-plugin/skills/dialog-usage/SKILL.md @@ -5,7 +5,7 @@ description: > modal dialogs, alert dialogs, drawers, actions, focus management, placement, size, and close behavior backed by Zag.js. metadata: - component_version: "1.0.2" + component_version: "1.0.3" type: "core" library: "@techsio/ui-kit" library_version: "0.3.2" diff --git a/libs/ui/agent-plugin/skills/footer-usage/SKILL.md b/libs/ui/agent-plugin/skills/footer-usage/SKILL.md index e194f434c1..dc678a1dea 100644 --- a/libs/ui/agent-plugin/skills/footer-usage/SKILL.md +++ b/libs/ui/agent-plugin/skills/footer-usage/SKILL.md @@ -6,7 +6,7 @@ description: > divider, bottom area, size, layout, direction, section flow, and framework link adapters. metadata: - component_version: "1.0.0" + component_version: "1.0.1" type: "core" library: "@techsio/ui-kit" library_version: "0.3.2" diff --git a/libs/ui/agent-plugin/skills/form-numeric-input-usage/SKILL.md b/libs/ui/agent-plugin/skills/form-numeric-input-usage/SKILL.md index cb731334cd..c2a1640fd1 100644 --- a/libs/ui/agent-plugin/skills/form-numeric-input-usage/SKILL.md +++ b/libs/ui/agent-plugin/skills/form-numeric-input-usage/SKILL.md @@ -5,7 +5,7 @@ description: > FormNumericInput for labeled numeric fields using NumericInput compound children, validation status, help text, and number-specific constraints. metadata: - component_version: "1.1.0" + component_version: "1.1.1" type: "core" library: "@techsio/ui-kit" library_version: "0.3.2" diff --git a/libs/ui/agent-plugin/skills/gallery-usage/SKILL.md b/libs/ui/agent-plugin/skills/gallery-usage/SKILL.md index d920441ad1..4ab0a437dc 100644 --- a/libs/ui/agent-plugin/skills/gallery-usage/SKILL.md +++ b/libs/ui/agent-plugin/skills/gallery-usage/SKILL.md @@ -6,7 +6,7 @@ description: > controlled page, orientation, thumbnail image adapters, thumbnail aria labels, empty state, and NextImage support. metadata: - component_version: "1.0.1" + component_version: "1.0.2" type: "core" library: "@techsio/ui-kit" library_version: "0.3.2" diff --git a/libs/ui/agent-plugin/skills/hotkeys-usage/SKILL.md b/libs/ui/agent-plugin/skills/hotkeys-usage/SKILL.md index 5dcb131246..8bcaabe90d 100644 --- a/libs/ui/agent-plugin/skills/hotkeys-usage/SKILL.md +++ b/libs/ui/agent-plugin/skills/hotkeys-usage/SKILL.md @@ -5,7 +5,7 @@ description: > Zag keyboard registrations with @techsio/ui-kit Hotkeys. Covers compound keycaps, current callbacks, store ownership, scopes and optional registry readback. metadata: - component_version: "1.0.1" + component_version: "1.0.2" type: "core" library: "@techsio/ui-kit" library_version: "0.3.2" diff --git a/libs/ui/agent-plugin/skills/menu-usage/SKILL.md b/libs/ui/agent-plugin/skills/menu-usage/SKILL.md index 59841b2fa4..1087096aa9 100644 --- a/libs/ui/agent-plugin/skills/menu-usage/SKILL.md +++ b/libs/ui/agent-plugin/skills/menu-usage/SKILL.md @@ -5,7 +5,7 @@ description: > action, radio, checkbox, separator, or submenu items using the Zag.js menu wrapper, Button trigger, icons, and supported open/highlight/select props. metadata: - component_version: "1.1.0" + component_version: "1.1.1" type: "core" library: "@techsio/ui-kit" library_version: "0.3.2" diff --git a/libs/ui/agent-plugin/skills/numeric-input-usage/SKILL.md b/libs/ui/agent-plugin/skills/numeric-input-usage/SKILL.md index 533137e6ea..bd2c437e8f 100644 --- a/libs/ui/agent-plugin/skills/numeric-input-usage/SKILL.md +++ b/libs/ui/agent-plugin/skills/numeric-input-usage/SKILL.md @@ -6,7 +6,7 @@ description: > numeric compatibility or controlled string drafts, locale formatting, min/max/step, and token-first styling. metadata: - component_version: "1.1.1" + component_version: "1.1.2" type: "core" library: "@techsio/ui-kit" library_version: "0.3.2" diff --git a/libs/ui/agent-plugin/skills/pagination-usage/SKILL.md b/libs/ui/agent-plugin/skills/pagination-usage/SKILL.md index 9dd6d2fc08..0ce6547253 100644 --- a/libs/ui/agent-plugin/skills/pagination-usage/SKILL.md +++ b/libs/ui/agent-plugin/skills/pagination-usage/SKILL.md @@ -5,7 +5,7 @@ description: > link-based paginated navigation with Zag.js pagination, getPageUrl, LinkButton, NextLink adapters, compact mode, variants, and sizes. metadata: - component_version: "1.0.0" + component_version: "1.0.1" type: "core" library: "@techsio/ui-kit" library_version: "0.3.2" diff --git a/libs/ui/agent-plugin/skills/phone-input-usage/SKILL.md b/libs/ui/agent-plugin/skills/phone-input-usage/SKILL.md index c69d040ff8..a5e960b0d5 100644 --- a/libs/ui/agent-plugin/skills/phone-input-usage/SKILL.md +++ b/libs/ui/agent-plugin/skills/phone-input-usage/SKILL.md @@ -6,7 +6,7 @@ description: > hidden E.164 form value, native validation, validation status, and compound country picker slots. metadata: - component_version: "1.0.1" + component_version: "1.0.2" type: "core" library: "@techsio/ui-kit" library_version: "0.3.2" diff --git a/libs/ui/agent-plugin/skills/popover-usage/SKILL.md b/libs/ui/agent-plugin/skills/popover-usage/SKILL.md index c798551ac4..b6f3f38a6e 100644 --- a/libs/ui/agent-plugin/skills/popover-usage/SKILL.md +++ b/libs/ui/agent-plugin/skills/popover-usage/SKILL.md @@ -5,7 +5,7 @@ description: > anchored non-blocking content using Zag.js compound parts, placement, portalling, close behavior, arrow, title, description, and close trigger. metadata: - component_version: "1.0.1" + component_version: "1.0.2" type: "core" library: "@techsio/ui-kit" library_version: "0.3.2" diff --git a/libs/ui/agent-plugin/skills/product-card-usage/SKILL.md b/libs/ui/agent-plugin/skills/product-card-usage/SKILL.md index 1d98699cb9..b175b32af6 100644 --- a/libs/ui/agent-plugin/skills/product-card-usage/SKILL.md +++ b/libs/ui/agent-plugin/skills/product-card-usage/SKILL.md @@ -5,7 +5,7 @@ description: > for product summaries with image adapter, name, price, stock, badges, rating, actions, and card button variants. metadata: - component_version: "1.0.0" + component_version: "1.0.1" type: "core" library: "@techsio/ui-kit" library_version: "0.3.2" diff --git a/libs/ui/agent-plugin/skills/radio-card-usage/SKILL.md b/libs/ui/agent-plugin/skills/radio-card-usage/SKILL.md index a6cc49259d..0cee29b101 100644 --- a/libs/ui/agent-plugin/skills/radio-card-usage/SKILL.md +++ b/libs/ui/agent-plugin/skills/radio-card-usage/SKILL.md @@ -5,7 +5,7 @@ description: > prominent single-choice cards with Zag.js radio behavior, label, item, hidden input, control, text, description, addon, indicator, and status text. metadata: - component_version: "1.0.1" + component_version: "1.0.2" type: "core" library: "@techsio/ui-kit" library_version: "0.3.2" diff --git a/libs/ui/agent-plugin/skills/radio-group-usage/SKILL.md b/libs/ui/agent-plugin/skills/radio-group-usage/SKILL.md index 63c928363e..ac94035ce0 100644 --- a/libs/ui/agent-plugin/skills/radio-group-usage/SKILL.md +++ b/libs/ui/agent-plugin/skills/radio-group-usage/SKILL.md @@ -6,7 +6,7 @@ description: > hidden inputs, controls, text, descriptions, validation status, variants, and sizes. metadata: - component_version: "1.0.0" + component_version: "1.0.1" type: "core" library: "@techsio/ui-kit" library_version: "0.3.2" diff --git a/libs/ui/agent-plugin/skills/search-form-usage/SKILL.md b/libs/ui/agent-plugin/skills/search-form-usage/SKILL.md index 683dd0bf11..147347e127 100644 --- a/libs/ui/agent-plugin/skills/search-form-usage/SKILL.md +++ b/libs/ui/agent-plugin/skills/search-form-usage/SKILL.md @@ -6,7 +6,7 @@ description: > input, submit button, clear button, icon props, and token-backed field layout. metadata: - component_version: "1.0.0" + component_version: "1.0.1" type: "core" library: "@techsio/ui-kit" library_version: "0.3.2" diff --git a/libs/ui/agent-plugin/skills/select-usage/SKILL.md b/libs/ui/agent-plugin/skills/select-usage/SKILL.md index c86d4f08cf..d355bc2d48 100644 --- a/libs/ui/agent-plugin/skills/select-usage/SKILL.md +++ b/libs/ui/agent-plugin/skills/select-usage/SKILL.md @@ -6,7 +6,7 @@ description: > trigger, value text, clear trigger, item groups, item indicators, validation status, size, and multiple mode. metadata: - component_version: "1.1.1" + component_version: "1.1.2" type: "core" library: "@techsio/ui-kit" library_version: "0.3.2" diff --git a/libs/ui/agent-plugin/skills/skeleton-usage/SKILL.md b/libs/ui/agent-plugin/skills/skeleton-usage/SKILL.md index 08167908d4..59cca23db6 100644 --- a/libs/ui/agent-plugin/skills/skeleton-usage/SKILL.md +++ b/libs/ui/agent-plugin/skills/skeleton-usage/SKILL.md @@ -5,7 +5,7 @@ description: > loading placeholders using Root, Circle, Text, and Rectangle compound parts with token-backed variants, sizes, and animation speeds. metadata: - component_version: "1.0.1" + component_version: "1.0.2" type: "core" library: "@techsio/ui-kit" library_version: "0.3.2" diff --git a/libs/ui/agent-plugin/skills/steps-usage/SKILL.md b/libs/ui/agent-plugin/skills/steps-usage/SKILL.md index 12e472a2f0..b741bb034f 100644 --- a/libs/ui/agent-plugin/skills/steps-usage/SKILL.md +++ b/libs/ui/agent-plugin/skills/steps-usage/SKILL.md @@ -6,7 +6,7 @@ description: > panels, progress, navigation triggers, linear flow, controlled step, and variants. metadata: - component_version: "1.0.1" + component_version: "1.0.2" type: "core" library: "@techsio/ui-kit" library_version: "0.3.2" diff --git a/libs/ui/agent-plugin/skills/table-usage/SKILL.md b/libs/ui/agent-plugin/skills/table-usage/SKILL.md index ae583071cf..3f12067cbe 100644 --- a/libs/ui/agent-plugin/skills/table-usage/SKILL.md +++ b/libs/ui/agent-plugin/skills/table-usage/SKILL.md @@ -6,7 +6,7 @@ description: > headers, numeric cells, selected rows, variants, interactive rows, sticky header/first column, column borders, and size props. metadata: - component_version: "1.2.1" + component_version: "1.2.2" type: "core" library: "@techsio/ui-kit" library_version: "0.3.2" diff --git a/libs/ui/agent-plugin/skills/tabs-usage/SKILL.md b/libs/ui/agent-plugin/skills/tabs-usage/SKILL.md index 5c8df41787..b353aa95d9 100644 --- a/libs/ui/agent-plugin/skills/tabs-usage/SKILL.md +++ b/libs/ui/agent-plugin/skills/tabs-usage/SKILL.md @@ -6,7 +6,7 @@ description: > triggers, content, indicator, orientation, activation mode, variants, fitted, and justify props. metadata: - component_version: "1.0.0" + component_version: "1.0.1" type: "core" library: "@techsio/ui-kit" library_version: "0.3.2" diff --git a/libs/ui/agent-plugin/skills/tooltip-usage/SKILL.md b/libs/ui/agent-plugin/skills/tooltip-usage/SKILL.md index 9fc3f2df48..3b30b7fc88 100644 --- a/libs/ui/agent-plugin/skills/tooltip-usage/SKILL.md +++ b/libs/ui/agent-plugin/skills/tooltip-usage/SKILL.md @@ -5,7 +5,7 @@ description: > supplemental hover/focus help using the Zag.js tooltip wrapper, supported timing, placement, interaction, controlled state, and token styling props. metadata: - component_version: "1.0.0" + component_version: "1.0.1" type: "core" library: "@techsio/ui-kit" library_version: "0.3.2" diff --git a/libs/ui/agent-plugin/skills/tour-usage/SKILL.md b/libs/ui/agent-plugin/skills/tour-usage/SKILL.md index f2c83b4811..b6f62d5697 100644 --- a/libs/ui/agent-plugin/skills/tour-usage/SKILL.md +++ b/libs/ui/agent-plugin/skills/tour-usage/SKILL.md @@ -5,7 +5,7 @@ description: > tooltip/dialog/floating steps, target resolution, interactive wait effects, imperative control, shared action buttons, cleanup and app-owned onboarding policy. metadata: - component_version: "1.0.3" + component_version: "1.0.4" type: "core" library: "@techsio/ui-kit" library_version: "0.3.2" diff --git a/libs/ui/agent-plugin/skills/tree-view-usage/SKILL.md b/libs/ui/agent-plugin/skills/tree-view-usage/SKILL.md index e488c52105..7bf9bf13a1 100644 --- a/libs/ui/agent-plugin/skills/tree-view-usage/SKILL.md +++ b/libs/ui/agent-plugin/skills/tree-view-usage/SKILL.md @@ -6,7 +6,7 @@ description: > item parts, selection behavior, icons, indentation, expansion, selection, and keyboard/typeahead support. metadata: - component_version: "1.0.1" + component_version: "1.0.2" type: "core" library: "@techsio/ui-kit" library_version: "0.3.2" diff --git a/libs/ui/eslint.config.js b/libs/ui/eslint.config.js index 9f0e0360a9..1a631350b5 100644 --- a/libs/ui/eslint.config.js +++ b/libs/ui/eslint.config.js @@ -23,7 +23,7 @@ export default [ }, settings: { tailwindcss: { - // Tailwind CSS v4 reads its configuration from the CSS entrypoint. + // Tailwind CSS v4's compiler reads the CSS-first configuration here. cssConfigPath: tokensCssAbsolutePath, }, }, @@ -35,13 +35,63 @@ export default [ "tailwindcss/classnames-order": "off", "tailwindcss/enforces-negative-arbitrary-values": "error", "tailwindcss/enforces-shorthand": "error", - "tailwindcss/migration-from-tailwind-2": "off", "tailwindcss/no-arbitrary-value": "off", "tailwindcss/no-contradicting-classname": "error", - "tailwindcss/no-custom-classname": "error", + "tailwindcss/no-custom-classname": [ + "error", + { + whitelist: [ + "^text-color-select-(sm|md|lg)$", + "^max-h-dialog-center-h-max$", + "^aspect-product-card-image$", + "^focus-visible:outline-steps-ring$", + "^transition-footer-(title|link)$", + "^max-w-header-max$", + "^@max-header-desktop:(hidden|bg-header-bg)$", + "^@header-desktop:hidden$", + "^data-\\[position=start\\]:justify-items-start-safe$", + "^duration-header$", + "^force-reduced-motion$", + ], + }, + ], "tailwindcss/no-unnecessary-arbitrary-value": "error", }, }, + { + // Story fixtures intentionally demonstrate app-owned utility vocabularies + // alongside the kit. The compiler-backed source checks above stay strict. + files: ["stories/**/*.{js,jsx,ts,tsx}"], + rules: { + "tailwindcss/no-custom-classname": "off", + }, + }, + { + // The theme remaps the spacing scale (h-50 is the --spacing-50 token, not + // 200px), so these fixed demo dimensions must stay arbitrary values. + files: [ + "stories/molecules/dialog.stories.tsx", + "stories/organisms/table.stories.tsx", + ], + rules: { + "tailwindcss/no-unnecessary-arbitrary-value": "off", + }, + }, + { + // These fixtures use custom size tokens and semantic spacing defaults. + // Axis/side utilities preserve their values and cascade; shorthand does not. + files: [ + "stories/atoms/image.stories.tsx", + "stories/molecules/accordion.stories.tsx", + "stories/molecules/select.stories.tsx", + "stories/overview/color-palette.stories.tsx", + "stories/organisms/footer.stories.tsx", + "stories/organisms/header.stories.tsx", + ], + rules: { + "tailwindcss/enforces-shorthand": "off", + }, + }, { // Exclude generated/dist files from linting ignores: ["dist/**/*", "storybook-static/**/*"], diff --git a/libs/ui/package.json b/libs/ui/package.json index 29b53db36a..5ff3b00ac6 100644 --- a/libs/ui/package.json +++ b/libs/ui/package.json @@ -75,112 +75,116 @@ "figma:connect:parse": "figma connect parse --config figma.config.json", "figma:connect:publish": "figma connect publish --config figma.config.json", "test": "vitest run --config vitest.config.ts", - "test-storybook": "test-storybook", + "test-storybook": "test-storybook --index-json", "storybook:a11y": "bash scripts/storybook-a11y.sh", + "test:visual-assets": "node --test test/visual-assets.test.mjs", "test:components": "node scripts/run-component-tests.mjs", "test:components:update": "node scripts/run-component-tests.mjs --update-snapshots", "test:sidebar:ssr": "bun test ./test/sidebar.ssr.bun.tsx", "lint:tailwind": "eslint \"**/*.{js,jsx,ts,tsx}\" --config eslint.config.js", "lint:tailwind:fix": "eslint \"**/*.{js,jsx,ts,tsx}\" --config eslint.config.js --fix", "prepare": "node -e \"if (!process.env.RSLIB_CHECK_OUTPUT) require('node:child_process').execSync('pnpm build', { stdio: 'inherit' })\"", - "check:unused-tokens": "bun scripts/check-unused-tokens.js", - "validate:tokens": "bun scripts/validate-token-usage.js && bun scripts/validate-token-definitions.js", - "validate:token-usage": "bun scripts/validate-token-usage.js", - "validate:token-definitions": "bun scripts/validate-token-definitions.js", + "check:unused-tokens": "node scripts/check-unused-tokens.js", + "validate:tokens": "node scripts/validate-token-usage.js && node scripts/validate-token-definitions.js", + "validate:token-usage": "node scripts/validate-token-usage.js", + "validate:token-definitions": "node scripts/validate-token-definitions.js", "semantic-release": "semantic-release", "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.tests.json" }, "dependencies": { - "@dnd-kit/core": "^6.3.1", - "@dnd-kit/modifiers": "^9.0.0", - "@dnd-kit/sortable": "^10.0.0", - "@dnd-kit/utilities": "^3.2.2", + "@dnd-kit/abstract": "^0.5.0", + "@dnd-kit/collision": "^0.5.0", + "@dnd-kit/dom": "^0.5.0", + "@dnd-kit/helpers": "^0.5.0", + "@dnd-kit/react": "^0.5.0", "@iconify-json/mdi": "^1.2.3", "@iconify-json/mdi-light": "^1.2.2", "@iconify-json/svg-spinners": "^1.2.4", - "@iconify/tailwind4": "^1.1.0", - "@internationalized/date": "^3.12.2", - "@tanstack/charts": "^0.6.5", - "@tanstack/charts-scales": "^0.6.5", - "@tanstack/react-charts": "^0.6.5", - "@tanstack/react-table": "^9.1.2", - "@tanstack/react-virtual": "^3.14.8", - "@zag-js/accordion": "1.43.3", - "@zag-js/carousel": "1.43.3", - "@zag-js/cascade-select": "1.43.3", - "@zag-js/checkbox": "1.43.3", - "@zag-js/collapsible": "1.43.3", - "@zag-js/combobox": "1.43.3", - "@zag-js/date-input": "1.43.3", - "@zag-js/date-picker": "1.43.3", - "@zag-js/dialog": "1.43.3", - "@zag-js/drawer": "1.43.3", - "@zag-js/file-upload": "1.43.3", - "@zag-js/hotkeys": "1.43.3", - "@zag-js/i18n-utils": "1.43.3", - "@zag-js/menu": "1.43.3", - "@zag-js/number-input": "1.43.3", - "@zag-js/pagination": "1.43.3", - "@zag-js/popover": "1.43.3", - "@zag-js/presence": "1.43.3", - "@zag-js/radio-group": "1.43.3", - "@zag-js/rating-group": "1.43.3", - "@zag-js/react": "1.43.3", - "@zag-js/select": "1.43.3", - "@zag-js/slider": "1.43.3", - "@zag-js/steps": "1.43.3", - "@zag-js/switch": "1.43.3", - "@zag-js/tabs": "1.43.3", - "@zag-js/toast": "1.43.3", - "@zag-js/tooltip": "1.43.3", - "@zag-js/tour": "1.43.3", - "@zag-js/tree-view": "1.43.3", - "better-themes": "^1.1.0", + "@iconify/tailwind4": "^1.2.3", + "@internationalized/date": "^3.12.4", + "@tanstack/charts": "^1.1.0", + "@tanstack/react-table": "^9.2.8", + "@tanstack/react-virtual": "^3.14.14", + "@zag-js/accordion": "^1.45.0", + "@zag-js/carousel": "^1.45.0", + "@zag-js/cascade-select": "^1.45.0", + "@zag-js/checkbox": "^1.45.0", + "@zag-js/collapsible": "^1.45.0", + "@zag-js/combobox": "^1.45.0", + "@zag-js/date-input": "^1.45.0", + "@zag-js/date-picker": "^1.45.0", + "@zag-js/dialog": "^1.45.0", + "@zag-js/drawer": "^1.45.0", + "@zag-js/file-upload": "^1.45.0", + "@zag-js/hotkeys": "^1.45.0", + "@zag-js/i18n-utils": "^1.45.0", + "@zag-js/menu": "^1.45.0", + "@zag-js/number-input": "^1.45.0", + "@zag-js/pagination": "^1.45.0", + "@zag-js/popover": "^1.45.0", + "@zag-js/presence": "^1.45.0", + "@zag-js/radio-group": "^1.45.0", + "@zag-js/rating-group": "^1.45.0", + "@zag-js/react": "^1.45.0", + "@zag-js/select": "^1.45.0", + "@zag-js/slider": "^1.45.0", + "@zag-js/steps": "^1.45.0", + "@zag-js/switch": "^1.45.0", + "@zag-js/tabs": "^1.45.0", + "@zag-js/toast": "^1.45.0", + "@zag-js/tooltip": "^1.45.0", + "@zag-js/tour": "^1.45.0", + "@zag-js/tree-view": "^1.45.0", + "better-themes": "^1.1.1", "d3-scale": "^4.0.2", "d3-shape": "^3.2.0", - "libphonenumber-js": "^1.13.2", - "tailwind-merge": "^3.4.0", - "tailwind-variants": "^3.1.1", - "tailwindcss-animate": "^1.0.7" + "libphonenumber-js": "^1.13.15", + "tailwind-merge": "^3.7.0", + "tailwind-variants": "^3.3.1", + "tw-animate-css": "^1.4.0" }, "devDependencies": { - "@eslint/js": "^9.39.1", - "@figma/code-connect": "^2.0.0", - "@iconify/json": "^2.2.409", - "@rsbuild/core": "2.2.2", - "@rsbuild/plugin-react": "^2.1.0", - "@rslib/core": "^1.0.0", - "@semantic-release/changelog": "^6.0.3", + "@figma/code-connect": "^2.0.1", + "@iconify-json/cif": "^1.2.7", + "@iconify-json/twemoji": "^1.2.5", + "@rsbuild/core": "2.2.12", + "@rsbuild/plugin-react": "^2.1.1", + "@rslib/core": "^1.0.3", "@semantic-release/commit-analyzer": "^13.0.1", - "@semantic-release/git": "^10.0.1", - "@semantic-release/github": "^12.0.2", - "@semantic-release/npm": "^13.1.2", - "@semantic-release/release-notes-generator": "^14.1.0", - "@storybook/addon-a11y": "^10.6.0", - "@storybook/addon-themes": "^10.6.0", - "@storybook/react": "^10.6.0", - "@storybook/test-runner": "^0.24.4", - "@tailwindcss/postcss": "^4.1.17", + "@semantic-release/github": "^12.0.10", + "@semantic-release/npm": "^13.2.0", + "@semantic-release/release-notes-generator": "^14.1.1", + "@storybook/addon-docs": "^10.6.1", + "@storybook/react": "^10.6.1", + "@storybook/test-runner": "^0.26.0", + "@tailwindcss/postcss": "^4.3.3", "@techsio/storybook-a11y-reporter": "0.1.3", "@techsio/storybook-better-a11y": "0.1.3", "@types/d3-scale": "^4.0.9", - "@types/d3-shape": "^3.1.8", - "@typescript-eslint/parser": "^8.47.0", - "cross-env": "^7.0.3", - "eslint": "^9.39.1", - "eslint-plugin-tailwindcss": "beta", - "glob": "^11.0.2", - "postcss": "^8.5.6", - "rsbuild-plugin-arethetypeswrong": "0.3.1", + "@types/d3-shape": "^3.2.0", + "@types/node": "26.6.5", + "@types/pngjs": "6.0.5", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "@typescript-eslint/parser": "^8.71.0", + "@typescript/native": "npm:typescript@^7.0.2", + "cross-env": "^10.1.0", + "eslint": "^10.12.0", + "eslint-plugin-tailwindcss": "4.4.0", + "pngjs": "7.0.0", + "postcss": "^8.5.28", + "react": "19.3.0", + "react-dom": "19.3.0", + "rsbuild-plugin-arethetypeswrong": "0.4.0", "rsbuild-plugin-publint": "^1.0.0", - "semantic-release": "^25.0.2", - "storybook": "^10.6.0", - "storybook-addon-rslib": "^3.4.2", - "storybook-react-rsbuild": "^3.4.2", - "tailwindcss": "^4.1.17", - "typescript": "^5.9.3", - "vite": "catalog:vite5-testing", - "vitest": "catalog:vite5-testing" + "semantic-release": "^25.0.9", + "storybook": "^10.6.1", + "storybook-addon-rslib": "^3.6.1", + "storybook-react-rsbuild": "^3.6.1", + "tailwindcss": "^4.3.3", + "typescript": "npm:@typescript/typescript6@^6.0.2", + "vite": "^8.3.4", + "vitest": "^5.0.3" }, "peerDependencies": { "@types/react": ">=19.2.0", diff --git a/libs/ui/playwright.config.cts b/libs/ui/playwright.config.cts deleted file mode 100644 index f6d5996beb..0000000000 --- a/libs/ui/playwright.config.cts +++ /dev/null @@ -1,58 +0,0 @@ -import os from "node:os" -import path from "node:path" -import { defineConfig, devices } from "@playwright/test" - -const baseUrl = new URL(process.env.TEST_BASE_URL ?? "http://127.0.0.1:6006") -const storybookUrl = `${baseUrl.protocol}//${baseUrl.host}` -const staticDir = path.join(__dirname, "storybook-static") -const workersEnv = process.env.PLAYWRIGHT_WORKERS -// Recommend using (CPU cores - 1) when PLAYWRIGHT_WORKERS is not specified. -// This provides concurrency while leaving one core free for system/background tasks. -const cpuCount = typeof os.cpus === "function" ? os.cpus().length : 2 -const recommendedWorkers = Math.max(1, cpuCount - 1) -const workersValue = workersEnv ? Number(workersEnv) : recommendedWorkers -const workers = - Number.isFinite(workersValue) ? Math.max(1, Math.floor(workersValue)) : undefined - -// Increased timeouts for Docker (qemu emulation is slow) -const testTimeout = 120_000 -const expectTimeout = 30_000 - -export default defineConfig({ - testDir: "./test", - globalSetup: "./test/docker-only.global-setup.js", - reporter: "html", - fullyParallel: true, - timeout: testTimeout, - expect: { - timeout: expectTimeout, - toHaveScreenshot: { - maxDiffPixelRatio: 0.01, - }, - }, - workers, - use: { - baseURL: storybookUrl, - }, - projects: [ - { - name: "desktop", - use: { - ...devices["Desktop Chrome"], - }, - }, - { - name: "mobile", - use: { - ...devices["iPhone 15"], - }, - }, - ], - webServer: { - command: `npx --no-install http-server -p ${baseUrl.port || 6006}`, - url: storybookUrl, - cwd: staticDir, - reuseExistingServer: true, - timeout: 120_000, - }, -}) diff --git a/libs/ui/playwright.config.mts b/libs/ui/playwright.config.mts new file mode 100644 index 0000000000..14af57b391 --- /dev/null +++ b/libs/ui/playwright.config.mts @@ -0,0 +1,78 @@ +import os from "node:os" +import { defineConfig, devices } from "@playwright/test" +import { createVisualMatrix } from "./test/visual-projects.mjs" + +const baseUrl = new URL(process.env.TEST_BASE_URL ?? "http://127.0.0.1:6006") +const storybookUrl = `${baseUrl.protocol}//${baseUrl.host}` +const workersEnv = process.env.PLAYWRIGHT_WORKERS +const matrix = createVisualMatrix(process.env.VISUAL_BRANDS) +// Bound browser memory while leaving a core for the static server. +const cpuCount = typeof os.cpus === "function" ? os.cpus().length : 2 +const recommendedWorkers = Math.min(4, Math.max(1, cpuCount - 1)) +const workersValue = workersEnv ? Number(workersEnv) : recommendedWorkers +const workers = Number.isFinite(workersValue) + ? Math.max(1, Math.floor(workersValue)) + : undefined + +// Increased timeouts for Docker (qemu emulation is slow) +const testTimeout = 120_000 +const expectTimeout = 30_000 + +export default defineConfig({ + testDir: "./test", + testMatch: "*.spec.ts", + globalSetup: "./test/docker-only.global-setup.js", + reporter: "html", + updateSnapshots: "none", + fullyParallel: true, + forbidOnly: true, + retries: 0, + timeout: testTimeout, + expect: { timeout: expectTimeout }, + workers, + use: { + baseURL: storybookUrl, + locale: "en-US", + timezoneId: "UTC", + reducedMotion: "reduce", + serviceWorkers: "block", + }, + projects: [ + // Component behavior specs run once per device; screenshots run per brand matrix. + ...(["desktop", "mobile"] as const).map((name) => ({ + name, + testIgnore: "visual.spec.ts", + use: { + ...devices[name === "mobile" ? "iPhone 15" : "Desktop Chrome"], + browserName: + name === "mobile" ? ("webkit" as const) : ("chromium" as const), + }, + })), + ...matrix.map((project) => { + const device = project.isMobile ? "mobile" : "desktop" + const snapshotName = + project.brand === "base" + ? `${device}${project.colorScheme === "dark" ? "-dark" : ""}` + : project.name + return { + name: project.name, + metadata: { brand: project.brand }, + testMatch: "visual.spec.ts", + snapshotPathTemplate: `{testDir}/{testFilePath}-snapshots/{arg}-${snapshotName}-{platform}{ext}`, + use: { + ...devices[project.isMobile ? "iPhone 15" : "Desktop Chrome"], + browserName: project.isMobile ? "webkit" : "chromium", + colorScheme: project.colorScheme, + }, + } + }), + ], + webServer: { + command: "node test/visual-static-server.mjs", + url: storybookUrl, + env: { VISUAL_STORYBOOK_PORT: baseUrl.port || "6006" }, + // An explicit TEST_BASE_URL may point at an already running Storybook. + reuseExistingServer: process.env.TEST_BASE_URL !== undefined, + timeout: 120_000, + }, +}) diff --git a/libs/ui/rslib.config.ts b/libs/ui/rslib.config.ts index 92fd44e5ca..3f6eb0928a 100644 --- a/libs/ui/rslib.config.ts +++ b/libs/ui/rslib.config.ts @@ -1,3 +1,4 @@ +import { fileURLToPath } from "node:url" import { pluginReact } from "@rsbuild/plugin-react" import { defineConfig } from "@rslib/core" import { pluginAreTheTypesWrong } from "rsbuild-plugin-arethetypeswrong" @@ -7,8 +8,14 @@ const checkBuildOutput = Boolean(process.env.RSLIB_CHECK_OUTPUT) export default defineConfig({ bundle: false, - dts: true, + dts: { + // The compatibility alias supplies the JS API to tooling; emit declarations + // with the native TypeScript compiler installed alongside it. + typescriptPath: fileURLToPath(import.meta.resolve("@typescript/native")), + tsgo: true, + }, source: { + tsconfigPath: "./tsconfig.lib.json", entry: { // *.figma.ts are Code Connect templates: they are uploaded to Figma and // executed there against a virtual "figma" module, so they are neither diff --git a/libs/ui/scripts/check-unused-tokens.js b/libs/ui/scripts/check-unused-tokens.js index 4232d801e5..aafd4b5c69 100755 --- a/libs/ui/scripts/check-unused-tokens.js +++ b/libs/ui/scripts/check-unused-tokens.js @@ -7,9 +7,18 @@ * in both CSS and JS/TS files with better accuracy and performance. */ -import fs from "fs" -import { globSync } from "glob" -import path from "path" +import fs, { globSync } from "node:fs" +import path from "node:path" +import { + COLOR_PREFIXES, + INSET_PREFIXES, + MARGIN_PREFIXES, + PADDING_PREFIXES, + SINGLE_PREFIX_NAMESPACES, + SIZING_PREFIXES, + SPACE_PREFIXES, + without, +} from "./token-utility-prefixes.js" // Configuration const TOKEN_DIRS = ["src/tokens"] @@ -17,78 +26,54 @@ const SEARCH_DIRS = ["src/atoms", "src/molecules", "stories"] const TOKEN_FILE_PATTERN = "**/*.css" const SOURCE_FILE_PATTERN = "**/*.{ts,tsx,js,jsx,css}" +// This checker predates logical (ps/pe/ms/me) and axis inset utilities but +// also accepts gradient stops, divide colors and flex-basis. +const NO_LOGICAL = ["ps", "pe", "ms", "me"] +const PADDING = without(PADDING_PREFIXES, NO_LOGICAL) +const MARGIN = without(MARGIN_PREFIXES, NO_LOGICAL) + // Tailwind v4 namespace to utility class prefixes mapping const NAMESPACE_TO_UTILITIES = { - color: [ - "bg", - "text", - "border", - "ring", - "fill", - "stroke", - "from", - "to", - "via", - "decoration", - "accent", - "caret", - "divide", - "outline", - "shadow", - "ring-offset", - ], + ...SINGLE_PREFIX_NAMESPACES, + color: [...COLOR_PREFIXES, "from", "to", "via", "divide"], spacing: [ - "p", - "px", - "py", - "pt", - "pb", - "pl", - "pr", - "m", - "mx", - "my", - "mt", - "mb", - "ml", - "mr", + ...PADDING, + ...MARGIN, "gap", - "space-x", - "space-y", - "w", - "h", - "max-w", - "max-h", - "min-w", - "min-h", + ...SPACE_PREFIXES, + ...SIZING_PREFIXES, "size", "basis", - "inset", - "top", - "right", - "bottom", - "left", + ...without(INSET_PREFIXES, ["inset-x", "inset-y"]), ], - text: ["text"], - "font-weight": ["font"], - font: ["font"], - border: ["border"], - radius: ["rounded"], - shadow: ["shadow"], - opacity: ["opacity"], width: ["w"], height: ["h"], gap: ["gap"], - padding: ["p", "px", "py", "pt", "pb", "pl", "pr"], - margin: ["m", "mx", "my", "mt", "mb", "ml", "mr"], + padding: PADDING, + margin: MARGIN, "line-height": ["leading"], ring: ["ring"], aspect: ["aspect"], leading: ["leading"], } -// Cache for file contents to avoid re-reading +const THEME_BLOCK_REGEX = /@theme\s+(?:static|inline)\s*{([^}]+)}/gs +const ROOT_BLOCK_REGEX = /:root\s*{([^}]+)}/gs +const TOKEN_LINE_REGEX = /^\s*(--[\w-]+)\s*:/ +const NAMESPACE_REGEX = /^--([^-]+)-/ +const NAME_WITHOUT_NAMESPACE_REGEX = /^--[^-]+-(.+)$/ +const REGEXP_SPECIAL_CHARS = /[.*+?^${}()|[\]\\]/g +// Kept instead of RegExp.escape so --debug prints readable patterns. +const escapeRegExp = (text) => text.replace(REGEXP_SPECIAL_CHARS, "\\$&") +// Tailwind v4 arbitrary-property forms such as border-(length:--token) +const SPECIAL_SYNTAX_KINDS = ["length", "width", "height", "size"] + +// Global debug mode flag +let debugMode = false + +// Cache for file contents and glob results to avoid re-reading const fileCache = new Map() +const globCache = new Map() /** * Read file with caching @@ -100,13 +85,23 @@ function readFileWithCache(filePath) { return fileCache.get(filePath) } +function globWithCache(pattern) { + if (!globCache.has(pattern)) { + globCache.set(pattern, globSync(pattern)) + } + return globCache.get(pattern) +} + +const getTokenFiles = () => + globWithCache(path.join(TOKEN_DIRS[0], TOKEN_FILE_PATTERN)) + /** * Extract namespace from token name * --color-btn-primary -> 'color' * --spacing-button-sm -> 'spacing' */ function getTokenNamespace(token) { - const match = token.match(/^--([^-]+)-/) + const match = token.match(NAMESPACE_REGEX) return match ? match[1] : null } @@ -116,8 +111,8 @@ function getTokenNamespace(token) { * --spacing-button-sm -> button-sm */ function getTokenNameWithoutNamespace(token) { - const match = token.match(/^--[^-]+-(.+)$/) - return match ? match[1] : token.replace(/^--/, "") + const match = token.match(NAME_WITHOUT_NAMESPACE_REGEX) + return match ? match[1] : token.slice(2) } /** @@ -126,29 +121,24 @@ function getTokenNameWithoutNamespace(token) { function generatePossibleUtilities(token) { const namespace = getTokenNamespace(token) const nameWithoutNamespace = getTokenNameWithoutNamespace(token) + const prefixes = namespace && NAMESPACE_TO_UTILITIES[namespace] - if (!(namespace && NAMESPACE_TO_UTILITIES[namespace])) { + if (!prefixes) { // If no namespace match, return the token name for direct usage checks return [nameWithoutNamespace] } - const utilities = [] - const prefixes = NAMESPACE_TO_UTILITIES[namespace] - - prefixes.forEach((prefix) => { + const utilities = prefixes.flatMap((prefix) => [ // Direct mapping - utilities.push(`${prefix}-${nameWithoutNamespace}`) - + `${prefix}-${nameWithoutNamespace}`, // Handle negative utilities (like -m-4) - utilities.push(`-${prefix}-${nameWithoutNamespace}`) - + `-${prefix}-${nameWithoutNamespace}`, // Handle arbitrary value syntax (though less common with custom properties) - utilities.push(`${prefix}-[var(${token})]`) - }) + `${prefix}-[var(${token})]`, + ]) // Also check for direct CSS variable usage - utilities.push(`var(${token})`) - utilities.push(token) + utilities.push(`var(${token})`, token) return [...new Set(utilities)] } @@ -160,15 +150,15 @@ function extractTokensFromFile(filePath) { const content = readFileWithCache(filePath) const tokens = new Map() - // Match tokens inside @theme blocks and regular :root blocks - const themeBlockRegex = /@theme\s+(?:static|inline)\s*{([^}]+)}/gs - const rootBlockRegex = /:root\s*{([^}]+)}/gs + // Match tokens inside @theme blocks, then regular :root blocks (legacy tokens) + const blocks = [ + ...content.matchAll(THEME_BLOCK_REGEX), + ...content.matchAll(ROOT_BLOCK_REGEX), + ].map((match) => match[0]) - const extractFromBlock = (block) => { - const lines = block.split("\n") - lines.forEach((line, index) => { - // Match CSS custom properties (more precise regex) - const match = line.match(/^\s*(--[\w-]+)\s*:/) + for (const block of blocks) { + for (const [index, line] of block.split("\n").entries()) { + const match = line.match(TOKEN_LINE_REGEX) if (match) { tokens.set(match[1], { file: filePath, @@ -176,18 +166,7 @@ function extractTokensFromFile(filePath) { inThemeBlock: block.includes("@theme"), }) } - }) - } - - // Extract from @theme blocks - let match - while ((match = themeBlockRegex.exec(content)) !== null) { - extractFromBlock(match[0]) - } - - // Extract from :root blocks (legacy tokens) - while ((match = rootBlockRegex.exec(content)) !== null) { - extractFromBlock(match[0]) + } } return tokens @@ -198,8 +177,7 @@ function extractTokensFromFile(filePath) { */ function createSearchPatterns(utilities) { return utilities.flatMap((utility) => { - // Escape special regex characters - const escaped = utility.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + const escaped = escapeRegExp(utility) // Create patterns that match utility usage in various contexts return [ @@ -224,131 +202,102 @@ function createSearchPatterns(utilities) { } /** - * Check if a token is used in the codebase + * Check token usage in token CSS files (helper tokens and var() references) */ -function isTokenUsed(token, allTokens) { - const possibleUtilities = generatePossibleUtilities(token) - const searchPatterns = createSearchPatterns(possibleUtilities) - - // First, check if it's used in other CSS files (calc, var references, etc.) - const tokenFiles = globSync(path.join(TOKEN_DIRS[0], TOKEN_FILE_PATTERN)) - const tokenInfo = allTokens.get(token) - - // Check if token is used within the same file (helper tokens) +function findCssUsage(token, tokenInfo) { + // Count how many times the token appears in its own file const sameFileContent = readFileWithCache(tokenInfo.file) - // Count how many times the token appears in the file - const tokenRegex = new RegExp( - token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), - "g" - ) - const matches = sameFileContent.match(tokenRegex) || [] + const matches = sameFileContent.match(new RegExp(escapeRegExp(token), "g")) // If it appears more than once (definition + usage), it's a helper token - if (matches.length > 1) { + if (matches && matches.length > 1) { return { used: true, location: tokenInfo.file, type: "helper-token" } } - // Check usage in other CSS files - for (const file of tokenFiles) { - if (file === tokenInfo.file) continue // Skip the file where it's defined + // Check for direct token usage in calc(), var(), or other CSS functions + const file = getTokenFiles().find( + (candidate) => + candidate !== tokenInfo.file && + readFileWithCache(candidate).includes(token) + ) + return file ? { used: true, location: file, type: "css-reference" } : null +} - const content = readFileWithCache(file) - // Check for direct token usage in calc(), var(), or other CSS functions - if (content.includes(token)) { - return { used: true, location: file, type: "css-reference" } +/** + * Check token usage in one source file + */ +function findSourceUsage(token, file, possibleUtilities, searchPatterns) { + const content = readFileWithCache(file) + + // Quick check: if none of the possible utilities appear in the file, skip it + const quickHit = possibleUtilities.find((util) => content.includes(util)) + if (debugMode && quickHit && !quickHit.includes("var(")) { + console.log(` Quick check found "${quickHit}" in ${file}`) + } + + const specialMatch = SPECIAL_SYNTAX_KINDS.map( + (kind) => `(${kind}:${token})` + ).find((syntax) => content.includes(syntax)) + + if (!(quickHit || specialMatch)) { + if (debugMode) { + console.log(` Skipping ${file} - no utilities found in quick check`) } + return null } - // Search in source files - for (const dir of SEARCH_DIRS) { - const files = globSync(path.join(dir, SOURCE_FILE_PATTERN)) - - for (const file of files) { - const content = readFileWithCache(file) - - // Quick check: if none of the possible utilities appear in the file, skip it - const quickCheck = possibleUtilities.some((util) => { - // For simple utility names, check if they appear with common delimiters - if (!util.includes("var(")) { - // Simply check if the utility name appears anywhere in the file - const found = content.includes(util) - if (debugMode && found) { - console.log(` Quick check found "${util}" in ${file}`) - } - return found - } - return content.includes(util) - }) - - // Also check for special Tailwind v4 syntax like border-(length:--token) - const specialSyntaxCheck = - content.includes(`(length:${token})`) || - content.includes(`(width:${token})`) || - content.includes(`(height:${token})`) || - content.includes(`(size:${token})`) - - if (!(quickCheck || specialSyntaxCheck)) { - if (debugMode) { - console.log(` Skipping ${file} - no utilities found in quick check`) - } - continue - } + // Check for special Tailwind v4 syntax first + if (specialMatch) { + if (debugMode) { + console.log(` Special syntax matched: ${specialMatch}`) + } + return { + used: true, + location: file, + type: "tailwind-v4-syntax", + match: specialMatch, + } + } - // Check for special Tailwind v4 syntax first - if (specialSyntaxCheck) { - const specialMatches = [ - content.match( - new RegExp( - `\\(length:${token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\)` - ) - ), - content.match( - new RegExp( - `\\(width:${token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\)` - ) - ), - content.match( - new RegExp( - `\\(height:${token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\)` - ) - ), - content.match( - new RegExp( - `\\(size:${token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\)` - ) - ), - ].filter((m) => m !== null) - - if (specialMatches.length > 0) { - const match = specialMatches[0] - if (debugMode) { - console.log(` Special syntax matched: ${match[0]}`) - } - return { - used: true, - location: file, - type: "tailwind-v4-syntax", - match: match[0], - } - } + // Detailed pattern matching + for (const [i, pattern] of searchPatterns.entries()) { + const match = content.match(pattern) + if (match) { + if (debugMode) { + console.log(` Pattern ${i} matched: ${pattern}`) + console.log(` Match: "${match[0]}"`) } + return { + used: true, + location: file, + type: "utility-class", + match: match[0], + } + } + } + return null +} + +/** + * Check if a token is used in the codebase + */ +function isTokenUsed(token, allTokens) { + const cssUsage = findCssUsage(token, allTokens.get(token)) + if (cssUsage) { + return cssUsage + } - // Detailed pattern matching - for (let i = 0; i < searchPatterns.length; i++) { - const pattern = searchPatterns[i] - if (pattern.test(content)) { - // Find the actual match for reporting - const match = content.match(pattern) - if (debugMode) { - console.log(` Pattern ${i} matched: ${pattern}`) - console.log(` Match: "${match[0]}"`) - } - return { - used: true, - location: file, - type: "utility-class", - match: match[0], - } - } + const possibleUtilities = generatePossibleUtilities(token) + const searchPatterns = createSearchPatterns(possibleUtilities) + for (const dir of SEARCH_DIRS) { + for (const file of globWithCache(path.join(dir, SOURCE_FILE_PATTERN))) { + const usage = findSourceUsage( + token, + file, + possibleUtilities, + searchPatterns + ) + if (usage) { + return usage } } } @@ -360,26 +309,18 @@ function isTokenUsed(token, allTokens) { * Generate detailed report */ function generateReport(unusedTokens, allTokens) { - const report = [] - - report.push("# Unused Tokens Report\n") - report.push(`Generated on: ${new Date().toISOString()}\n`) - report.push(`Total tokens analyzed: ${allTokens.size}`) - report.push(`Unused tokens found: ${unusedTokens.length}\n`) - - // Group by file - const byFile = {} - unusedTokens.forEach(({ token, ...info }) => { - if (!byFile[info.file]) { - byFile[info.file] = [] - } - byFile[info.file].push({ token, ...info }) - }) + const report = [ + "# Unused Tokens Report\n", + `Generated on: ${new Date().toISOString()}\n`, + `Total tokens analyzed: ${allTokens.size}`, + `Unused tokens found: ${unusedTokens.length}\n`, + ] // Generate file sections - Object.entries(byFile).forEach(([file, tokens]) => { + const byFile = Map.groupBy(unusedTokens, ({ file }) => file) + for (const [file, tokens] of byFile) { report.push(`\n## ${file}\n`) - tokens.forEach(({ token, line }) => { + for (const { token, line } of tokens) { const namespace = getTokenNamespace(token) const possibleUtils = generatePossibleUtilities(token) .slice(0, 5) @@ -387,122 +328,136 @@ function generateReport(unusedTokens, allTokens) { report.push(`- Line ${line}: \`${token}\``) report.push(` - Namespace: ${namespace || "none"}`) report.push(` - Expected utilities: ${possibleUtils}...`) - }) - }) + } + } // Summary statistics - const namespaceStats = {} - unusedTokens.forEach(({ token }) => { - const namespace = getTokenNamespace(token) || "other" - namespaceStats[namespace] = (namespaceStats[namespace] || 0) + 1 - }) - + const byNamespace = Map.groupBy( + unusedTokens, + ({ token }) => getTokenNamespace(token) || "other" + ) report.push("\n## Statistics by Namespace\n") - Object.entries(namespaceStats).forEach(([namespace, count]) => { - const percentage = ((count / unusedTokens.length) * 100).toFixed(1) - report.push(`- ${namespace}: ${count} tokens (${percentage}%)`) - }) + for (const [namespace, tokens] of byNamespace) { + const percentage = ((tokens.length / unusedTokens.length) * 100).toFixed(1) + report.push(`- ${namespace}: ${tokens.length} tokens (${percentage}%)`) + } return report.join("\n") } -// Global debug mode flag -let debugMode = false +function logDebugUsage(usage) { + if (usage.used) { + console.log(`✅ Found in: ${usage.location}`) + console.log(` Type: ${usage.type}`) + if (usage.match) { + console.log(` Match: "${usage.match}"`) + } + } else { + console.log("❌ Token not found") + } +} /** - * Main function + * Check every token (or only the --token= one) and return the unused ones */ -async function main() { - const args = process.argv.slice(2) - debugMode = args.includes("--debug") - const debugToken = args - .find((arg) => arg.startsWith("--token=")) - ?.split("=")[1] - - console.log("🔍 Checking for unused CSS tokens (Tailwind v4 optimized)...\n") - - // Find all token files - const tokenFiles = globSync(path.join(TOKEN_DIRS[0], TOKEN_FILE_PATTERN)) - console.log(`Found ${tokenFiles.length} token files to analyze`) - - // Extract all tokens - const allTokens = new Map() - tokenFiles.forEach((file) => { - const tokens = extractTokensFromFile(file) - tokens.forEach((info, token) => { - allTokens.set(token, info) - }) - }) - - console.log(`Found ${allTokens.size} total tokens\n`) - console.log("Checking usage (this may take a moment)...\n") - - // Check each token +function findUnusedTokens(allTokens, debugToken) { const unusedTokens = [] - const usageDetails = new Map() let checked = 0 for (const [token, info] of allTokens) { // Debug specific token - if (debugToken && token !== debugToken) continue + if (debugToken && token !== debugToken) { + continue + } - checked++ + checked += 1 if (!debugMode && checked % 25 === 0) { process.stdout.write(`\rChecked ${checked}/${allTokens.size} tokens...`) } - if (debugMode && (!debugToken || token === debugToken)) { + if (debugMode) { console.log(`\nChecking token: ${token}`) const utilities = generatePossibleUtilities(token) + const more = utilities.length > 10 ? "..." : "" console.log( - `Possible utilities: ${utilities.slice(0, 10).join(", ")}${utilities.length > 10 ? "..." : ""}` + `Possible utilities: ${utilities.slice(0, 10).join(", ")}${more}` ) } const usage = isTokenUsed(token, allTokens) - if (usage.used) { - usageDetails.set(token, usage) - if (debugMode) { - console.log(`✅ Found in: ${usage.location}`) - console.log(` Type: ${usage.type}`) - if (usage.match) console.log(` Match: "${usage.match}"`) - } - } else { + if (!usage.used) { unusedTokens.push({ token, ...info }) - if (debugMode) { - console.log("❌ Token not found") - } + } + if (debugMode) { + logDebugUsage(usage) } } + return unusedTokens +} + +function reportUnusedTokens(unusedTokens, allTokens) { + console.log(`⚠️ Found ${unusedTokens.length} unused tokens`) + + // Generate and save detailed report + const report = generateReport(unusedTokens, allTokens) + const reportPath = "unused-tokens-report.md" + fs.writeFileSync(reportPath, report) + console.log(`\n📄 Detailed report saved to: ${reportPath}`) + + // Show summary + const percent = (count) => ((count / allTokens.size) * 100).toFixed(1) + const usedCount = allTokens.size - unusedTokens.length + console.log("\n📊 Summary:") + console.log(` Total tokens: ${allTokens.size}`) + console.log( + ` Unused tokens: ${unusedTokens.length} (${percent(unusedTokens.length)}%)` + ) + console.log(` Used tokens: ${usedCount} (${percent(usedCount)}%)`) +} + +/** + * Main function + */ +function main() { + const args = process.argv.slice(2) + debugMode = args.includes("--debug") + const debugToken = args + .find((arg) => arg.startsWith("--token=")) + ?.split("=")[1] + + console.log("🔍 Checking for unused CSS tokens (Tailwind v4 optimized)...\n") + + // Find all token files + const tokenFiles = getTokenFiles() + console.log(`Found ${tokenFiles.length} token files to analyze`) + + // Extract all tokens + const allTokens = new Map() + for (const file of tokenFiles) { + for (const [token, info] of extractTokensFromFile(file)) { + allTokens.set(token, info) + } + } + + console.log(`Found ${allTokens.size} total tokens\n`) + console.log("Checking usage (this may take a moment)...\n") + + const unusedTokens = findUnusedTokens(allTokens, debugToken) + console.log("\n") // Display results if (unusedTokens.length === 0) { console.log("✅ All tokens are being used!") } else { - console.log(`⚠️ Found ${unusedTokens.length} unused tokens`) - - // Generate and save detailed report - const report = generateReport(unusedTokens, allTokens) - const reportPath = "unused-tokens-report.md" - fs.writeFileSync(reportPath, report) - console.log(`\n📄 Detailed report saved to: ${reportPath}`) - - // Show summary - console.log("\n📊 Summary:") - console.log(` Total tokens: ${allTokens.size}`) - console.log( - ` Unused tokens: ${unusedTokens.length} (${((unusedTokens.length / allTokens.size) * 100).toFixed(1)}%)` - ) - console.log( - ` Used tokens: ${allTokens.size - unusedTokens.length} (${(((allTokens.size - unusedTokens.length) / allTokens.size) * 100).toFixed(1)}%)` - ) + reportUnusedTokens(unusedTokens, allTokens) } - - // Clear cache - fileCache.clear() } // Run the script -main().catch(console.error) +try { + main() +} catch (error) { + console.error(error) +} diff --git a/libs/ui/scripts/run-component-tests.mjs b/libs/ui/scripts/run-component-tests.mjs index 36ab2aa977..f8fb2618a3 100644 --- a/libs/ui/scripts/run-component-tests.mjs +++ b/libs/ui/scripts/run-component-tests.mjs @@ -1,278 +1,293 @@ import { spawnSync } from "node:child_process" import crypto from "node:crypto" import fs from "node:fs" +import { createRequire } from "node:module" import path from "node:path" -import { fileURLToPath } from "node:url" +import { createVisualMatrix } from "../test/visual-projects.mjs" -// Runs Playwright component visual tests inside Docker for reproducible snapshots. -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const uiRoot = path.resolve(__dirname, "..") +// Runs the Playwright visual tests inside Docker for reproducible snapshots. +const uiRoot = path.resolve(import.meta.dirname, "..") const repoRoot = path.resolve(uiRoot, "../..") +const playwrightVersionPattern = /^ARG\s+PLAYWRIGHT_VERSION\s*=\s*([^\s]+)\s*$/m +const partialSelectionPattern = + /^(?:--project|--grep|--grep-invert|--shard|--test-list|--test-list-invert|--last-failed|--only-changed)(?:=|$)|^-[gG]/ +const updateSnapshotsPattern = /^(?:-u|--update-snapshots)(?:=(\w+))?$/ -const dockerfilePath = path.resolve( +const dockerfilePath = path.join( repoRoot, "docker/development/playwright/Dockerfile" ) -const dockerfileContents = fs.readFileSync(dockerfilePath, "utf8") -const playwrightVersion = dockerfileContents.match( - /^ARG\s+PLAYWRIGHT_VERSION\s*=\s*([^\s]+)\s*$/m -)?.[1] +const dockerfile = fs.readFileSync(dockerfilePath, "utf8") const dockerfileHash = crypto .createHash("sha256") - .update(dockerfileContents) + .update(dockerfile) .digest("hex") .slice(0, 12) -const defaultImageName = playwrightVersion - ? `new-engine-ui-playwright:${playwrightVersion}-${dockerfileHash}` - : `new-engine-ui-playwright:${dockerfileHash}` -const imageName = process.env.PLAYWRIGHT_DOCKER_IMAGE ?? defaultImageName -const platform = process.env.DOCKER_PLATFORM ?? "linux/amd64" -const testBaseUrl = process.env.TEST_BASE_URL -const shmSize = process.env.PLAYWRIGHT_DOCKER_SHM_SIZE ?? "2g" -const ipcMode = process.env.PLAYWRIGHT_DOCKER_IPC ?? "host" +const playwrightVersion = dockerfile.match(playwrightVersionPattern)?.[1] +const imageName = + process.env.PLAYWRIGHT_DOCKER_IMAGE ?? + `new-engine-ui-playwright:${playwrightVersion ? `${playwrightVersion}-` : ""}${dockerfileHash}` +const platform = "linux/amd64" +const projects = createVisualMatrix(process.env.VISUAL_BRANDS).map( + (project) => project.name +) const sequentialProjects = (process.env.PLAYWRIGHT_DOCKER_SEQUENTIAL ?? "0") !== "0" -const dockerProjectsEnv = process.env.PLAYWRIGHT_DOCKER_PROJECTS ?? "" -const dockerProjects = dockerProjectsEnv +const storybookOverride = process.env.VISUAL_STORYBOOK_STATIC_DIR +const storybookDir = storybookOverride ?? path.join(uiRoot, "storybook-static") +const harPath = + process.env.VISUAL_ASSET_HAR_PATH ?? + path.join(uiRoot, "test/fixtures/visual-assets/assets.har") +const selectedStories = (process.env.TEST_STORIES ?? "") .split(",") - .map((project) => project.trim()) + .map((story) => story.trim()) .filter(Boolean) -const storybookDir = path.resolve(uiRoot, "storybook-static") -const storybookIframe = path.resolve(storybookDir, "iframe.html") -const snapshotsDir = path.resolve(uiRoot, "test/visual.spec.ts-snapshots") -const containerName = `pw-visual-${Date.now()}` -const rebuildStorybook = - (process.env.PLAYWRIGHT_STORYBOOK_REBUILD ?? "1") !== "0" -const optionalContainerEnv = [ - ["TEST_BASE_URL", testBaseUrl], - ["PLAYWRIGHT_WORKERS", process.env.PLAYWRIGHT_WORKERS], - ["PLAYWRIGHT_PAGE_RESET", process.env.PLAYWRIGHT_PAGE_RESET], - ["TEST_STORIES", process.env.TEST_STORIES], -] - -console.log(`Using Docker image: ${imageName}`) +const extraArgs = process.argv.slice(2) +const updatesSnapshots = extraArgs.some((arg, index) => { + const match = updateSnapshotsPattern.exec(arg) + const mode = match?.[1] ?? (match && extraArgs[index + 1]) + return match && mode !== "none" +}) +const containerName = `pw-visual-${crypto.randomUUID()}` +// Each `playwright test` run empties test-results, so reports live beside it. +const reportRoot = "/app/visual-reports" -const run = (command, args, options = {}) => { - const result = spawnSync(command, args, { - stdio: "inherit", - shell: process.platform === "win32", - ...options, - }) - if (result.error) { - throw new Error( - `Failed to spawn command: ${command} ${args.join(" ")} (${result.error.message})` - ) - } - if (result.status !== 0) { +/** @param {string} command @param {string[]} args @param {import("node:child_process").SpawnSyncOptions} [options] */ +function run(command, args, options = {}) { + const result = spawnSync(command, args, { stdio: "inherit", ...options }) + if (result.error || result.status !== 0) { throw new Error(`Command failed: ${command} ${args.join(" ")}`) } return result } -const runSilent = (command, args) => - spawnSync(command, args, { - stdio: "pipe", - shell: process.platform === "win32", - }) - -const cleanup = () => { - runSilent("docker", ["rm", "-f", containerName]) +/** @param {string[]} args */ +function docker(args) { + return spawnSync("docker", args, { stdio: "pipe" }) } -const handleExit = (signal) => { - console.warn(`Received ${signal}, cleaning up container...`) - cleanup() - process.exit(1) +function cleanup() { + docker(["container", "rm", "--force", containerName]) } -process.on("SIGINT", () => handleExit("SIGINT")) -process.on("SIGTERM", () => handleExit("SIGTERM")) -process.on("exit", cleanup) +/** @typedef {{status: string}} ReportTest */ +/** @typedef {{specs?: {file: string, tests: ReportTest[]}[], suites?: ReportSuite[]}} ReportSuite */ +/** @param {ReportSuite[]} suites @param {string} [file] @returns {ReportTest[]} */ +function reportTests(suites, file) { + return suites.flatMap((suite) => [ + ...(suite.specs ?? []) + .filter((spec) => file === undefined || spec.file === file) + .flatMap((spec) => spec.tests), + ...reportTests(suite.suites ?? [], file), + ]) +} -const getProcessOutcome = (result, contextLabel) => { - if (result.error) { - console.error(`${contextLabel} failed to spawn:`, result.error.message) +/** @param {{errors: unknown[], stats: {unexpected: number, flaky: number}, suites: ReportSuite[]}[]} reports */ +function verifyCompletion(reports) { + const stories = Object.values( + JSON.parse(fs.readFileSync(path.join(storybookDir, "index.json"), "utf8")) + .entries + ).filter((entry) => entry.type === "story") + const requireComplete = !( + selectedStories.length || + extraArgs.some((arg) => partialSelectionPattern.test(arg)) + ) + let expected = 0 + let ran = 0 + for (const { errors, stats, suites } of reports) { + const tests = reportTests(suites, "visual.spec.ts") + ran += reportTests(suites).length + if ( + errors.length || + stats.unexpected || + stats.flaky || + (requireComplete && tests.some((test) => test.status !== "expected")) + ) { + throw new Error( + `Visual test run was not clean: ${JSON.stringify({ errors, stats })}` + ) + } + expected += tests.filter((test) => test.status === "expected").length } - return { - status: result.status ?? (result.signal || result.error ? 1 : 0), - signal: result.signal, + // Behavior specs share the run; completeness counts screenshot cases only. + // Focused behavior-only runs (e.g. --project=desktop) need no screenshots. + const required = stories.length * projects.length + if (!ran || (requireComplete && expected !== required)) { + throw new Error( + `Visual test run covered ${expected} of ${required} story/project cases` + ) } } -const copyArtifact = (label, sourcePath, destinationPath) => { - console.log(`Copying ${label} back to host...`) - const copyResult = runSilent("docker", [ - "cp", - `${containerName}:${sourcePath}`, - destinationPath, - ]) - if (copyResult.status !== 0) { - console.warn(`Warning: Could not copy ${label} (may not exist yet)`) +function main() { + if ( + storybookOverride === undefined && + process.env.PLAYWRIGHT_STORYBOOK_REBUILD !== "0" + ) { + // Run the Storybook CLI with Node: Windows cannot spawn the pnpm.cmd shim. + const storybookManifest = createRequire( + path.join(uiRoot, "package.json") + ).resolve("storybook/package.json") + const storybookBin = JSON.parse( + fs.readFileSync(storybookManifest, "utf8") + ).bin + run( + process.execPath, + [ + path.join(path.dirname(storybookManifest), storybookBin), + "build", + ], + { cwd: uiRoot } + ) } -} - -// Build storybook (default) or when missing -if (rebuildStorybook) { - console.log("Building Storybook...") - run("pnpm", ["-C", uiRoot, "build:storybook"]) -} else if (!fs.existsSync(storybookIframe)) { - console.log("storybook-static not found, building Storybook...") - run("pnpm", ["-C", uiRoot, "build:storybook"]) -} + if (!fs.existsSync(path.join(storybookDir, "iframe.html"))) { + throw new Error(`Storybook build not found: ${storybookDir}`) + } + if (docker(["image", "inspect", imageName]).status !== 0) { + run("docker", [ + "build", + `--platform=${platform}`, + "-t", + imageName, + "-f", + dockerfilePath, + repoRoot, + ]) + } + console.log(`Using Docker image: ${imageName}`) -// Build docker image if needed -const imageInspect = runSilent("docker", ["image", "inspect", imageName]) -if (imageInspect.status !== 0) { - console.log("Building Docker image...") + const env = { + CI: "true", + PLAYWRIGHT_DOCKER: "1", + PLAYWRIGHT_WORKERS: process.env.PLAYWRIGHT_WORKERS ?? "4", + TEST_BASE_URL: process.env.TEST_BASE_URL, + TEST_STORIES: process.env.TEST_STORIES, + VISUAL_BRANDS: process.env.VISUAL_BRANDS, + VISUAL_ASSET_HAR_PATH: `/app/visual-assets/${path.basename(harPath)}`, + } + const mounts = [ + [storybookDir, "/app/storybook-static"], + [path.join(uiRoot, "playwright.config.mts"), "/app/playwright.config.mts"], + [path.join(uiRoot, "package.json"), "/app/package.json"], + [path.dirname(harPath), "/app/visual-assets"], + ] + run( + "docker", + [ + "run", + "--detach", + "--name", + containerName, + "--label", + "io.techsio.visual-tests=1", + `--platform=${platform}`, + `--shm-size=${process.env.PLAYWRIGHT_DOCKER_SHM_SIZE ?? "2g"}`, + `--ipc=${process.env.PLAYWRIGHT_DOCKER_IPC ?? "host"}`, + "--add-host=host.docker.internal:host-gateway", + ...Object.entries(env).flatMap(([key, value]) => + value === undefined ? [] : ["--env", `${key}=${value}`] + ), + ...mounts.flatMap(([source, destination]) => [ + "--volume", + `${fs.realpathSync(source)}:${destination}:ro`, + ]), + "--entrypoint", + "sleep", + imageName, + "infinity", + ], + { stdio: ["ignore", "ignore", "inherit"] } + ) + // docker cp streams a tar archive, avoiding copy_file_range corruption on VM + // bind mounts; the copy stays writable for snapshot updates. run("docker", [ - "build", - `--platform=${platform}`, - "-t", - imageName, - "-f", - dockerfilePath, - repoRoot, + "cp", + path.join(uiRoot, "test/."), + `${containerName}:/app/test`, ]) -} - -const extraArgs = process.argv.slice(2) - -console.log("Starting container...") - -// Start container in background (keeps running) -const containerEnvArgs = optionalContainerEnv.flatMap(([key, value]) => - value ? ["-e", `${key}=${value}`] : [] -) -const dockerRunArgs = [ - "run", - "-d", - "--name", - containerName, - `--platform=${platform}`, - // With --ipc=host this is a no-op, but it still applies for non-host IPC modes. - `--shm-size=${shmSize}`, - `--ipc=${ipcMode}`, - "--add-host=host.docker.internal:host-gateway", - "-e", - "CI=true", - "-e", - "PLAYWRIGHT_DOCKER=1", - ...containerEnvArgs, - "--entrypoint", - "sleep", - // Mount sources as read-only - "-v", - `${storybookDir}:/app/storybook-static:ro`, - "-v", - `${path.resolve(uiRoot, "test")}:/app/test-src:ro`, - "-v", - `${path.resolve(uiRoot, "playwright.config.cts")}:/app/playwright.config.cts:ro`, - "-v", - `${path.resolve(uiRoot, "package.json")}:/app/package.json:ro`, - imageName, - "infinity", -] - -const startResult = runSilent("docker", dockerRunArgs) - -if (startResult.status !== 0) { - console.error("Failed to start container") - console.error(startResult.stderr?.toString()) - process.exit(1) -} - -const runningCheck = runSilent("docker", [ - "inspect", - containerName, - "--format", - "{{.State.Running}}", -]) -if (runningCheck.status !== 0 || runningCheck.stdout?.toString().trim() !== "true") { - console.error("Container is not running after start.") - const logsResult = runSilent("docker", ["logs", containerName]) - if (logsResult.status === 0) { - console.error(logsResult.stdout?.toString()) - } - cleanup() - process.exit(1) -} - -try { - // Copy test files into container (internal I/O is faster) - console.log("Copying test files into container...") run("docker", [ "exec", + "--user", + "0", containerName, - "cp", - "-r", - "/app/test-src", + "chown", + "-R", + "node:node", "/app/test", ]) - // Run playwright tests (all I/O happens inside container) - console.log("Running Playwright tests...") - const runPlaywright = (project) => - spawnSync( + const reportNames = [] + let status = 0 + for (const project of sequentialProjects + ? ["desktop", "mobile", ...projects] + : [undefined]) { + const reportName = `${project ?? "all"}.json` + reportNames.push(reportName) + const result = spawnSync( "docker", [ "exec", - "-t", + "--tty", + "--env", + `PLAYWRIGHT_JSON_OUTPUT_FILE=${reportRoot}/${reportName}`, containerName, "npx", "playwright", "test", "-c", - "playwright.config.cts", - "--reporter=list,html", + "playwright.config.mts", + "--reporter=list,html,json", ...(project ? ["--project", project] : []), ...extraArgs, ], { stdio: "inherit" } ) - - let testStatus = 0 - let testSignal = null - if (sequentialProjects) { - const projectsToRun = - dockerProjects.length > 0 ? dockerProjects : ["desktop", "mobile"] - for (const project of projectsToRun) { - const result = runPlaywright(project) - const outcome = getProcessOutcome(result, `Playwright project "${project}"`) - if (outcome.status !== 0 || outcome.signal) { - testStatus = outcome.status - testSignal = outcome.signal - break - } + status = result.status ?? 1 + if (status !== 0) { + break } - } else { - const outcome = getProcessOutcome(runPlaywright(), "Playwright") - testStatus = outcome.status - testSignal = outcome.signal } - if (testStatus !== 0 || testSignal) { - console.warn("Playwright exited with a non-zero status.") - const inspectResult = runSilent("docker", [ - "inspect", - containerName, - "--format", - "{{json .State}}", + if (updatesSnapshots) { + run("docker", [ + "cp", + `${containerName}:/app/test/visual.spec.ts-snapshots/.`, + path.join(uiRoot, "test/visual.spec.ts-snapshots"), ]) - if (inspectResult.status === 0) { - console.warn("Container state:", inspectResult.stdout?.toString().trim()) - } } + for (const [source, destination] of [ + ["/app/playwright-report/.", "playwright-report"], + ["/app/test-results/.", "test-results"], + [`${reportRoot}/.`, "test-results/visual-reports"], + ]) { + docker(["cp", `${containerName}:${source}`, path.join(uiRoot, destination)]) + } + if (status !== 0) { + return status + } + verifyCompletion( + reportNames.map((name) => + JSON.parse( + run("docker", ["exec", containerName, "cat", `${reportRoot}/${name}`], { + stdio: "pipe", + }).stdout.toString() + ) + ) + ) + return 0 +} - const reportDir = path.resolve(uiRoot, "playwright-report") - const resultsDir = path.resolve(uiRoot, "test-results") - copyArtifact("snapshots", "/app/test/visual.spec.ts-snapshots/.", snapshotsDir) - copyArtifact("HTML report", "/app/playwright-report/.", reportDir) - copyArtifact("test results", "/app/test-results/.", resultsDir) - - cleanup() - process.exit(testStatus ?? 0) +for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => { + cleanup() + process.exit(1) + }) +} +try { + process.exitCode = main() } catch (error) { - console.error("Error:", error.message) + console.error("Error:", error instanceof Error ? error.message : error) + process.exitCode = 1 +} finally { cleanup() - process.exit(1) } diff --git a/libs/ui/scripts/storybook-a11y-summary.mjs b/libs/ui/scripts/storybook-a11y-summary.mjs index fdb48d4cc6..ebefbb47f4 100644 --- a/libs/ui/scripts/storybook-a11y-summary.mjs +++ b/libs/ui/scripts/storybook-a11y-summary.mjs @@ -1,6 +1,9 @@ #!/usr/bin/env node -import fs from 'node:fs'; -import path from 'node:path'; +import fs from "node:fs" +import path from "node:path" + +const NEWLINE_REGEX = /\r?\n/ +const JSON_EXTENSION_REGEX = /\.json$/i /** * Read a CLI argument value following the given flag. @@ -8,105 +11,118 @@ import path from 'node:path'; * @returns {string | null} */ function readArg(name) { - const direct = process.argv.find((arg) => arg.startsWith(`${name}=`)); + const direct = process.argv.find((arg) => arg.startsWith(`${name}=`)) if (direct) { - return direct.slice(name.length + 1); + return direct.slice(name.length + 1) + } + const index = process.argv.indexOf(name) + if (index === -1 || index + 1 >= process.argv.length) { + return null } - const index = process.argv.indexOf(name); - if (index === -1 || index + 1 >= process.argv.length) return null; - return process.argv[index + 1]; + return process.argv[index + 1] } -const inputPath = readArg('--input'); -const outputPath = readArg('--output'); -const baselinePath = readArg('--baseline'); -const baselineLabel = readArg('--baseline-label') ?? 'baseline'; +const inputPath = readArg("--input") +const outputPath = readArg("--output") +const baselinePath = readArg("--baseline") +const baselineLabel = readArg("--baseline-label") ?? "baseline" -if (!inputPath || !outputPath) { +if (!(inputPath && outputPath)) { console.error( - 'Usage: storybook-a11y-summary.mjs --input --output [--baseline ] [--baseline-label