From dd2adfc2bc67130adeb775c71c3b4c2eacea1752 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Thu, 12 Mar 2026 16:43:55 -0700 Subject: [PATCH 01/21] fix(catalogue): fix 'chidlren' typo in GraphQL query builder The onFolder helper in create-catalogue-fetcher.ts produced a field named 'chidlren' instead of 'children' in generated GraphQL queries, causing incorrect query results for folder children. --- components/js-api-client/PRD.json | 431 ++++++++++++++++++ components/js-api-client/progress.txt | 1 + .../catalogue/create-catalogue-fetcher.ts | 2 +- 3 files changed, 433 insertions(+), 1 deletion(-) create mode 100644 components/js-api-client/PRD.json create mode 100644 components/js-api-client/progress.txt diff --git a/components/js-api-client/PRD.json b/components/js-api-client/PRD.json new file mode 100644 index 00000000..658a279e --- /dev/null +++ b/components/js-api-client/PRD.json @@ -0,0 +1,431 @@ +{ + "project": "@crystallize/js-api-client", + "version": "5.3.0", + "goal": "Improve developer experience, code quality, type safety, and reliability of the JS API client", + "tasks": [ + { + "id": "DX-001", + "title": "Remove dead try/catch block in API caller", + "priority": "P0", + "category": "code-quality", + "effort": "5min", + "context": "In `src/core/client/create-api-caller.ts` lines 187-188, there is a catch block that simply rethrows the exception. This is a no-op that adds visual noise and misleads readers into thinking error handling is happening.", + "files": ["src/core/client/create-api-caller.ts"], + "requirements": [ + "Remove the outer `try { ... } catch (exception) { throw exception; }` wrapper from the `post` function", + "Keep all the inner logic intact — only the wrapping try/catch is removed", + "The function behavior must remain identical" + ], + "verification": [ + "Run `pnpm test` — all tests must pass", + "Verify the `post` function no longer has a useless catch block" + ] + }, + { + "id": "DX-002", + "title": "Delete commented-out subscription.ts file", + "priority": "P0", + "category": "code-quality", + "effort": "5min", + "context": "The file `src/core/subscription.ts` contains 543 lines of entirely commented-out code. This functionality has been replaced by the modules in `src/core/pim/subscriptions/`. The dead code creates confusion about what's active. Git history preserves it if ever needed.", + "files": ["src/core/subscription.ts"], + "requirements": [ + "Delete the file `src/core/subscription.ts` entirely", + "Verify it is not imported or referenced anywhere in the codebase (it currently is not exported from `src/index.ts`)", + "No other files should be modified" + ], + "verification": [ + "Run `pnpm build` — build must succeed", + "Run `pnpm test` — all tests must pass", + "Grep the codebase for any imports of `subscription.ts` from the core directory (not the pim/subscriptions/ path)" + ] + }, + { + "id": "DX-003", + "status": "done", + "title": "Fix 'chidlren' typo in catalogue fetcher", + "priority": "P0", + "category": "code-quality", + "effort": "5min", + "context": "In `src/core/catalogue/create-catalogue-fetcher.ts` around line 67, there is a typo: `chidlren` instead of `children`. This is in a GraphQL query builder, so it likely produces an incorrect field name in queries. This is a bug.", + "files": ["src/core/catalogue/create-catalogue-fetcher.ts"], + "requirements": [ + "Find and replace `chidlren` with `children` in the catalogue fetcher", + "Check if the same typo appears anywhere else in the codebase and fix those too" + ], + "verification": [ + "Run `pnpm build` — build must succeed", + "Run `pnpm test` — all tests must pass", + "Grep the entire codebase for `chidlren` to ensure no remaining instances" + ] + }, + { + "id": "DX-004", + "title": "Remove unnecessary double spread in product hydrater", + "priority": "P0", + "category": "code-quality", + "effort": "5min", + "context": "In `src/core/catalogue/create-product-hydrater.ts` around line 90, there is `{ ...{ ...productListQuery } }` — a double spread that is functionally identical to `{ ...productListQuery }`. This is confusing to read.", + "files": ["src/core/catalogue/create-product-hydrater.ts"], + "requirements": [ + "Simplify `{ ...{ ...productListQuery } }` to `{ ...productListQuery }`", + "No behavioral change" + ], + "verification": [ + "Run `pnpm build` — build must succeed", + "Run `pnpm test` — all tests must pass" + ] + }, + { + "id": "DX-005", + "title": "Fix results variable initialization in mass call client", + "priority": "P0", + "category": "code-quality", + "effort": "5min", + "context": "In `src/core/create-mass-call-client.ts` line 101, `results` is declared as `let results: { [key: string]: any } = []`. It's typed as an object but initialized as an array. It's then used as an object (`results[result.key] = ...`). While JavaScript allows this (arrays are objects), it's misleading and technically wrong.", + "files": ["src/core/create-mass-call-client.ts"], + "requirements": [ + "Change `let results: { [key: string]: any } = []` to `let results: { [key: string]: any } = {}`", + "No behavioral change expected" + ], + "verification": [ + "Run `pnpm build` — build must succeed", + "Run `pnpm test` — all tests must pass" + ] + }, + { + "id": "DX-006", + "title": "Fix stale 'module' field in package.json", + "priority": "P0", + "category": "build", + "effort": "5min", + "context": "The `package.json` has `\"module\": \"./dist/index.mjs\"` but the build (tsup) outputs `./dist/index.js` for ESM. The `.mjs` file does not exist. Some bundlers (Webpack, Rollup) use the `module` field to resolve ESM entry points, so this can cause import failures in certain setups.", + "files": ["package.json"], + "requirements": [ + "Either change `\"module\": \"./dist/index.mjs\"` to `\"module\": \"./dist/index.js\"` to match the actual build output", + "Or remove the `module` field entirely since the `exports` map already handles ESM resolution correctly", + "Verify the `exports` field is correct and consistent with the chosen approach" + ], + "verification": [ + "Run `pnpm build` and verify the referenced file exists in `dist/`", + "Check that `ls dist/index.js` exists (ESM output)", + "Check that `ls dist/index.cjs` exists (CJS output)" + ] + }, + { + "id": "DX-007", + "title": "Set error name on JSApiClientCallError", + "priority": "P1", + "category": "error-handling", + "effort": "5min", + "context": "The custom error class `JSApiClientCallError` in `src/core/client/create-api-caller.ts` extends `Error` but never sets `this.name`. This means stack traces and `error.name` show generic `Error` instead of `JSApiClientCallError`, making debugging harder. All well-designed custom error classes should set their name.", + "files": ["src/core/client/create-api-caller.ts"], + "requirements": [ + "Add `this.name = 'JSApiClientCallError';` in the constructor, after the `super()` call", + "No other changes needed" + ], + "verification": [ + "Run `pnpm build` — build must succeed", + "Run `pnpm test` — all tests must pass", + "Manually verify: `new JSApiClientCallError({...}).name === 'JSApiClientCallError'`" + ] + }, + { + "id": "DX-008", + "title": "Remove 'any' from union types in grabber and API caller signatures", + "priority": "P1", + "category": "type-safety", + "effort": "30min", + "context": "In `src/core/client/create-grabber.ts` line 14 and `src/core/client/create-api-caller.ts` line 95, the parameter type is `RequestInit | any | undefined`. The `| any` makes the entire union collapse to `any`, completely defeating TypeScript's type checking. This means any caller can pass anything without type errors.", + "files": [ + "src/core/client/create-grabber.ts", + "src/core/client/create-api-caller.ts" + ], + "requirements": [ + "Define a proper `GrabOptions` type that extends or replaces the `RequestInit | any` pattern", + "The type should cover what the grabber actually uses: `method`, `headers`, `body`", + "Update the `Grab` type, `grab` function signature, and `post` function signature", + "Ensure HTTP/2 code path still compiles (it uses `headers` with `:method` and `:path` pseudo-headers)", + "No behavioral changes" + ], + "verification": [ + "Run `pnpm build` — build must succeed with no type errors", + "Run `pnpm test` — all tests must pass", + "Verify that passing an invalid option to `grab()` now produces a TypeScript error" + ] + }, + { + "id": "DX-009", + "title": "Replace tracked .env with .env.example", + "priority": "P1", + "category": "security", + "effort": "10min", + "context": "There is a `.env` file (519 bytes) tracked in the repository root. Even if it only contains test tenant credentials, tracking `.env` files in git is bad practice — it trains contributors to commit secrets. The file should be replaced with a `.env.example` template.", + "files": [".env", ".env.example", ".gitignore"], + "requirements": [ + "Create a `.env.example` file with the same keys but placeholder values (e.g., `CRYSTALLIZE_ACCESS_TOKEN_ID=your-token-id-here`)", + "Add `.env` to `.gitignore` if not already present", + "Remove `.env` from git tracking (but don't delete the local file): `git rm --cached .env`", + "Update any documentation that references the `.env` file" + ], + "verification": [ + "Verify `.env` is in `.gitignore`", + "Verify `.env.example` exists with placeholder values", + "Run `pnpm test` — tests should still work (they use `dotenv/config` which reads the local `.env`)" + ] + }, + { + "id": "DX-010", + "title": "Improve authentication error messaging", + "priority": "P1", + "category": "error-handling", + "effort": "30min", + "context": "In `src/core/client/create-api-caller.ts` lines 71-74, when no authentication is configured (no sessionId, no staticAuthToken, no accessToken), the `authenticationHeaders` function silently sends empty string values for `X-Crystallize-Access-Token-Id` and `X-Crystallize-Access-Token-Secret`. This results in a confusing 401/403 error from the server instead of a clear local error message telling the developer they forgot to configure authentication.", + "files": ["src/core/client/create-api-caller.ts"], + "requirements": [ + "When the fallback case is reached (no sessionId, no staticAuthToken) AND both `accessTokenId` and `accessTokenSecret` are empty/undefined, log a warning or throw a descriptive error", + "The warning should say something like: 'No authentication credentials configured. Set accessTokenId/accessTokenSecret, staticAuthToken, or sessionId in the client configuration.'", + "Consider using `console.warn` rather than throwing, since some catalogue endpoints may work without auth", + "Only warn once per client instance to avoid spam" + ], + "verification": [ + "Run `pnpm test` — all tests must pass", + "Manually test: create a client with no auth config, call `pimApi()`, verify a warning is logged" + ] + }, + { + "id": "DX-011", + "title": "Improve generic parameter naming in fetchers", + "priority": "P2", + "category": "type-safety", + "effort": "30min", + "context": "Across order, customer, and subscription fetchers/managers, generic type parameters use cryptic abbreviations: `OO`, `OOI`, `OC`. These are mixed with clearer names like `OnOrder`, `OnCustomer` in the same files. This inconsistency makes the generics hard to understand for library consumers who see them in IDE tooltips.", + "files": [ + "src/core/pim/orders/create-order-fetcher.ts", + "src/core/pim/orders/create-order-manager.ts", + "src/core/pim/customers/create-customer-manager.ts", + "src/core/pim/customers/create-customer-fetcher.ts", + "src/core/pim/subscriptions/create-subscription-contract-fetcher.ts", + "src/core/pim/subscriptions/create-subscription-contract-manager.ts" + ], + "requirements": [ + "Rename cryptic generic parameters to descriptive names consistently across all fetchers/managers", + "Suggested mapping: `OO` → `OrderExtra`, `OOI` → `OrderItemExtra`, `OC` → `CustomerExtra`", + "Ensure the public API types (visible to consumers) use the new names", + "This is a non-breaking change since generic names are not part of the runtime API" + ], + "verification": [ + "Run `pnpm build` — build must succeed", + "Run `pnpm test` — all tests must pass" + ] + }, + { + "id": "DX-012", + "title": "Add Symbol.dispose support for automatic cleanup", + "priority": "P2", + "category": "api-design", + "effort": "1h", + "context": "The `ClientInterface` requires calling `close()` to clean up HTTP/2 connections. If a developer forgets, connections leak. TypeScript 5.2+ supports the `using` declaration with `Symbol.dispose` / `Symbol.asyncDispose`, which provides automatic cleanup. This is a modern ergonomic improvement that makes the library safer to use.", + "files": [ + "src/core/client/create-client.ts" + ], + "requirements": [ + "Add `[Symbol.dispose]` to the `ClientInterface` type that calls `close()`", + "Implement it in the `createClient` return value", + "Keep the existing `close()` method for backward compatibility", + "This allows: `using client = createClient({...})` — auto-closes when scope exits", + "Ensure the TypeScript target/lib settings support `Symbol.dispose` (may need to add `esnext.disposable` to `lib`)", + "Do NOT break existing usage patterns" + ], + "verification": [ + "Run `pnpm build` — build must succeed", + "Run `pnpm test` — all tests must pass", + "Write a quick TypeScript snippet using `using client = createClient({...})` and verify it compiles" + ] + }, + { + "id": "DX-013", + "title": "Add request timeout support via AbortController", + "priority": "P2", + "category": "reliability", + "effort": "1h", + "context": "There is no way to set a timeout on API calls. A hanging request will hang forever, which is especially problematic in serverless environments with execution time limits. The library should support an optional timeout that automatically aborts requests.", + "files": [ + "src/core/client/create-client.ts", + "src/core/client/create-api-caller.ts", + "src/core/client/create-grabber.ts" + ], + "requirements": [ + "Add an optional `timeout` field to `CreateClientOptions` (in milliseconds)", + "In the `post` function, create an `AbortController` with `AbortSignal.timeout(ms)` when timeout is configured", + "Pass the signal to the `grab` function (for fetch path: pass as part of RequestInit; for HTTP/2 path: use `req.close()` on timeout)", + "Throw a descriptive error when a timeout occurs (e.g., `JSApiClientCallError` with a clear message)", + "Allow per-request timeout override (optional, can be deferred)", + "Default: no timeout (backward compatible)" + ], + "verification": [ + "Run `pnpm build` — build must succeed", + "Run `pnpm test` — all tests must pass", + "Manually test: create a client with `timeout: 1` (1ms) and verify it times out on a real request" + ] + }, + { + "id": "DX-014", + "title": "Make Server-Timing header parsing robust", + "priority": "P2", + "category": "reliability", + "effort": "15min", + "context": "In `src/core/client/create-api-caller.ts` lines 127-131, the Server-Timing header is parsed with `split(';')[1]?.split('=')[1]`. This assumes a specific format and will produce garbage values with non-standard headers. The profiling feature should not break or report wrong values due to header format variations.", + "files": ["src/core/client/create-api-caller.ts"], + "requirements": [ + "Parse the Server-Timing header according to the spec: `;dur=`", + "Use a regex like `/dur=([\\d.]+)/` to extract the duration value reliably", + "If parsing fails, fall back to `-1` (current fallback) without throwing", + "Handle the case where the header is missing entirely (already handled with `?? undefined`)" + ], + "verification": [ + "Run `pnpm build` — build must succeed", + "Run `pnpm test` — all tests must pass (especially `catalogue-profiled.test.ts`)" + ] + }, + { + "id": "DX-015", + "title": "Reduce boilerplate in mass call client enqueue methods", + "priority": "P3", + "category": "code-quality", + "effort": "15min", + "context": "In `src/core/create-mass-call-client.ts` lines 209-234, the five `enqueue` methods (`catalogueApi`, `discoveryApi`, `pimApi`, `nextPimApi`, `shopCartApi`) are identical except for the key prefix and the caller they reference. This is ~25 lines of repetitive code that can be generated programmatically.", + "files": ["src/core/create-mass-call-client.ts"], + "requirements": [ + "Replace the five repetitive `enqueue` method definitions with a generated approach", + "Use a helper function or `Object.fromEntries` pattern to create the enqueue methods dynamically", + "The public API and types must remain identical — this is an internal refactor only", + "Ensure type safety is preserved (the `QueuedApiCaller` type must still apply)" + ], + "verification": [ + "Run `pnpm build` — build must succeed", + "Run `pnpm test` — all tests must pass" + ] + }, + { + "id": "DX-016", + "title": "Reduce any usage in mass call client types", + "priority": "P2", + "category": "type-safety", + "effort": "1h", + "context": "The `create-mass-call-client.ts` file has 10+ usages of `any` in its types: `execute()` returns `Promise`, `afterRequest` callback receives `any`, `onFailure` receives `any` for the exception, `results` is `{ [key: string]: any }`. This defeats TypeScript's ability to help consumers of the mass call client.", + "files": ["src/core/create-mass-call-client.ts"], + "requirements": [ + "Replace `Promise` return type of `execute()` with `Promise>` or a generic", + "Replace `any` in `afterRequest` callback with the actual result shape", + "Replace `any` for `exception` in `onFailure` with `unknown` (TypeScript best practice for caught errors)", + "Replace `results` type with `Record` or a generic keyed type", + "Keep the `experimental` nature — don't over-engineer, just remove the `any` holes", + "Fix the typo `situaion` → `situation` in the `changeIncrementFor` callback parameter (line 85)" + ], + "verification": [ + "Run `pnpm build` — build must succeed", + "Run `pnpm test` — all tests must pass" + ] + }, + { + "id": "DX-017", + "title": "Add unit tests with mocked HTTP for core modules", + "priority": "P3", + "category": "testing", + "effort": "4h", + "context": "Currently all 15 test files are integration tests that hit real Crystallize APIs. There are zero unit tests. Critical modules like `create-grabber.ts`, `create-mass-call-client.ts`, and `create-binary-file-manager.ts` have no tests at all. Unit tests with mocked HTTP would: (1) allow tests to run without credentials, (2) test error paths, (3) run fast enough for PR CI.", + "files": [ + "tests/unit/create-api-caller.test.ts", + "tests/unit/create-grabber.test.ts", + "tests/unit/create-mass-call-client.test.ts" + ], + "requirements": [ + "Create a `tests/unit/` directory for unit tests", + "Add unit tests for `create-api-caller.ts` covering: successful response, GraphQL errors in 200 response, HTTP error responses, Core Next wrapped errors, 204 No Content handling", + "Add unit tests for `create-mass-call-client.ts` covering: basic enqueue and execute, retry logic, batch size adaptation (increment/decrement), Fibonacci backoff", + "Mock the `grab` function (it's a simple function signature, easy to mock)", + "Do not require API credentials or network access", + "Test error paths: network failures, malformed JSON responses, timeout scenarios" + ], + "verification": [ + "Run `pnpm test` — all tests (integration + unit) must pass", + "Unit tests must pass without any `.env` credentials" + ] + }, + { + "id": "DX-018", + "title": "Add error-path tests for API caller", + "priority": "P3", + "category": "testing", + "effort": "2h", + "context": "The current test suite only covers happy paths. There are no tests for: authentication failures (401/403), rate limiting (429), server errors (500/502/503), network timeouts, malformed JSON responses, or missing required fields. These are common real-world scenarios that should be covered.", + "files": ["tests/unit/error-handling.test.ts"], + "requirements": [ + "Create tests that verify `JSApiClientCallError` is thrown with correct properties for various HTTP error codes", + "Test that GraphQL errors in 200 responses are properly detected and thrown", + "Test that Core Next wrapped errors are detected via `getCoreNextError`", + "Test that the error includes the query and variables for debugging", + "Test 204 No Content returns empty object", + "Mock the grab function for all tests — no network required" + ], + "verification": [ + "Run `pnpm test` — all tests must pass", + "Tests must pass without credentials" + ] + }, + { + "id": "DX-019", + "title": "Add CI workflow for pull requests", + "priority": "P3", + "category": "ci-cd", + "effort": "30min", + "context": "Currently `.github/workflows/release.yaml` only runs on git tags (releases). There is no CI that runs on pull requests. This means broken code can be merged without any automated checks. A PR workflow should at minimum run build and unit tests.", + "files": [".github/workflows/ci.yaml"], + "requirements": [ + "Create a new workflow file `.github/workflows/ci.yaml`", + "Trigger on: pull requests to main, pushes to main", + "Steps: checkout, setup Node.js (same version as release.yaml), install deps with pnpm, run `pnpm build`, run `pnpm test` (unit tests only — integration tests need credentials)", + "Consider testing on multiple Node.js versions (20, 22, 24)", + "Keep it fast — unit tests should not require API credentials" + ], + "verification": [ + "Push a branch and create a PR to verify the workflow triggers", + "Verify the workflow passes with build + unit tests" + ] + }, + { + "id": "DX-020", + "title": "Add JSDoc comments to main exported functions", + "priority": "P3", + "category": "documentation", + "effort": "2h", + "context": "Most exported factory functions have no JSDoc comments. IDE users (the primary consumers of this library) get no inline documentation when hovering over `createClient`, `createCatalogueFetcher`, `createOrderManager`, etc. Good JSDoc with `@param`, `@returns`, and `@example` tags significantly improves discoverability.", + "files": [ + "src/core/client/create-client.ts", + "src/core/catalogue/create-catalogue-fetcher.ts", + "src/core/catalogue/create-navigation-fetcher.ts", + "src/core/catalogue/create-product-hydrater.ts", + "src/core/pim/orders/create-order-manager.ts", + "src/core/pim/orders/create-order-fetcher.ts", + "src/core/pim/customers/create-customer-manager.ts", + "src/core/pim/subscriptions/create-subscription-contract-manager.ts", + "src/core/shop/create-cart-manager.ts", + "src/core/create-signature-verifier.ts", + "src/core/pim/create-binary-file-manager.ts", + "src/core/create-mass-call-client.ts" + ], + "requirements": [ + "Add JSDoc to all exported factory functions with: description, @param tags, @returns description, and at least one @example", + "Focus on the main entry points that library consumers use directly", + "Keep descriptions concise — one sentence for what it does, one for when to use it", + "Include authentication requirements in the description where relevant (e.g., 'Requires PIM API credentials')", + "Do not add JSDoc to internal/helper functions" + ], + "verification": [ + "Run `pnpm build` — build must succeed", + "Open the source files in an IDE and verify JSDoc appears on hover for the exported functions" + ] + } + ] +} diff --git a/components/js-api-client/progress.txt b/components/js-api-client/progress.txt new file mode 100644 index 00000000..043c152b --- /dev/null +++ b/components/js-api-client/progress.txt @@ -0,0 +1 @@ +DX-003: Fix 'chidlren' typo in catalogue fetcher - DONE diff --git a/components/js-api-client/src/core/catalogue/create-catalogue-fetcher.ts b/components/js-api-client/src/core/catalogue/create-catalogue-fetcher.ts index 2d049afa..0a1ea1df 100644 --- a/components/js-api-client/src/core/catalogue/create-catalogue-fetcher.ts +++ b/components/js-api-client/src/core/catalogue/create-catalogue-fetcher.ts @@ -64,7 +64,7 @@ function onFolder(onFolder?: OF, c?: CatalogueFetcherGrapqhqlOnFol const children = () => { if (c?.onChildren) { return { - chidlren: { + children: { ...c.onChildren, }, }; From 232dee0f713172b839edfbcfac89c8342130f86e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Thu, 12 Mar 2026 16:45:29 -0700 Subject: [PATCH 02/21] fix(api-caller): remove dead try/catch block that only rethrows The outer try/catch in the `post` function was a no-op that added visual noise and misleadingly suggested error handling was happening. --- components/js-api-client/PRD.json | 1 + components/js-api-client/progress.txt | 1 + .../src/core/client/create-api-caller.ts | 162 +++++++++--------- 3 files changed, 81 insertions(+), 83 deletions(-) diff --git a/components/js-api-client/PRD.json b/components/js-api-client/PRD.json index 658a279e..d6a9e22d 100644 --- a/components/js-api-client/PRD.json +++ b/components/js-api-client/PRD.json @@ -5,6 +5,7 @@ "tasks": [ { "id": "DX-001", + "status": "done", "title": "Remove dead try/catch block in API caller", "priority": "P0", "category": "code-quality", diff --git a/components/js-api-client/progress.txt b/components/js-api-client/progress.txt index 043c152b..0927dbbb 100644 --- a/components/js-api-client/progress.txt +++ b/components/js-api-client/progress.txt @@ -1 +1,2 @@ DX-003: Fix 'chidlren' typo in catalogue fetcher - DONE +DX-001: Remove dead try/catch block in API caller - DONE diff --git a/components/js-api-client/src/core/client/create-api-caller.ts b/components/js-api-client/src/core/client/create-api-caller.ts index 4e864f70..e4a25116 100644 --- a/components/js-api-client/src/core/client/create-api-caller.ts +++ b/components/js-api-client/src/core/client/create-api-caller.ts @@ -95,96 +95,92 @@ export const post = async ( init?: RequestInit | any | undefined, options?: CreateClientOptions, ): Promise => { - try { - const { headers: initHeaders, ...initRest } = init || {}; - const profiling = options?.profiling; + const { headers: initHeaders, ...initRest } = init || {}; + const profiling = options?.profiling; - const headers = { - 'Content-type': 'application/json; charset=UTF-8', - Accept: 'application/json', - ...authenticationHeaders(config), - ...initHeaders, - }; + const headers = { + 'Content-type': 'application/json; charset=UTF-8', + Accept: 'application/json', + ...authenticationHeaders(config), + ...initHeaders, + }; - const body = JSON.stringify({ query, variables }); - let start: number = 0; - if (profiling) { - start = Date.now(); - if (profiling.onRequest) { - profiling.onRequest(query, variables); - } + const body = JSON.stringify({ query, variables }); + let start: number = 0; + if (profiling) { + start = Date.now(); + if (profiling.onRequest) { + profiling.onRequest(query, variables); } + } - const response = await grab(path, { - ...initRest, - method: 'POST', - headers, - body, - }); + const response = await grab(path, { + ...initRest, + method: 'POST', + headers, + body, + }); - if (profiling) { - const ms = Date.now() - start; - let serverTiming = response.headers.get('server-timing') ?? undefined; - if (Array.isArray(serverTiming)) { - serverTiming = serverTiming[0]; - } - const duration = serverTiming?.split(';')[1]?.split('=')[1] ?? -1; - profiling.onRequestResolved( - { - resolutionTimeMs: ms, - serverTimeMs: Number(duration), - }, - query, - variables, - ); - } - if (response.ok && 204 === response.status) { - return {}; - } - if (!response.ok) { - const json = await response.json<{ - message: string; - errors: unknown; - }>(); - throw new JSApiClientCallError({ - code: response.status, - statusText: response.statusText, - message: json.message, - query, - variables: variables || {}, - errors: json.errors || {}, - }); + if (profiling) { + const ms = Date.now() - start; + let serverTiming = response.headers.get('server-timing') ?? undefined; + if (Array.isArray(serverTiming)) { + serverTiming = serverTiming[0]; } - // we still need to check for error as the API can return 200 with errors + const duration = serverTiming?.split(';')[1]?.split('=')[1] ?? -1; + profiling.onRequestResolved( + { + resolutionTimeMs: ms, + serverTimeMs: Number(duration), + }, + query, + variables, + ); + } + if (response.ok && 204 === response.status) { + return {}; + } + if (!response.ok) { const json = await response.json<{ - errors: { - message: string; - }[]; - data: T; + message: string; + errors: unknown; }>(); - if (json.errors) { - throw new JSApiClientCallError({ - code: 400, - statusText: 'Error was returned from the API', - message: json.errors[0].message, - query, - variables: variables || {}, - errors: json.errors || {}, - }); - } - // let's try to find `errorName` at the second level to handle Core Next errors more gracefully - const err = getCoreNextError(json.data); - if (err) { - throw new JSApiClientCallError({ - code: 400, - query, - variables: variables || {}, - statusText: 'Error was returned (wrapped) from the API. (most likely Core Next)', - message: `[${err.errorName}] ${err.message ?? 'An error occurred'}`, - }); - } - return json.data; - } catch (exception) { - throw exception; + throw new JSApiClientCallError({ + code: response.status, + statusText: response.statusText, + message: json.message, + query, + variables: variables || {}, + errors: json.errors || {}, + }); + } + // we still need to check for error as the API can return 200 with errors + const json = await response.json<{ + errors: { + message: string; + }[]; + data: T; + }>(); + if (json.errors) { + throw new JSApiClientCallError({ + code: 400, + statusText: 'Error was returned from the API', + message: json.errors[0].message, + query, + variables: variables || {}, + errors: json.errors || {}, + }); + } + // let's try to find `errorName` at the second level to handle Core Next errors more gracefully + const err = getCoreNextError(json.data); + if (err) { + throw new JSApiClientCallError({ + code: 400, + query, + variables: variables || {}, + statusText: 'Error was returned (wrapped) from the API. (most likely Core Next)', + message: `[${err.errorName}] ${err.message ?? 'An error occurred'}`, + }); } + return json.data; }; From c06d619322d464047a8c42c60852e88ac18e56a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Thu, 12 Mar 2026 16:46:17 -0700 Subject: [PATCH 03/21] chore: delete dead commented-out subscription.ts file The file contained 543 lines of entirely commented-out code, replaced by the modules in src/core/pim/subscriptions/. Git history preserves it if ever needed. --- components/js-api-client/PRD.json | 1 + components/js-api-client/progress.txt | 1 + .../js-api-client/src/core/subscription.ts | 543 ------------------ 3 files changed, 2 insertions(+), 543 deletions(-) delete mode 100644 components/js-api-client/src/core/subscription.ts diff --git a/components/js-api-client/PRD.json b/components/js-api-client/PRD.json index d6a9e22d..4861f1a2 100644 --- a/components/js-api-client/PRD.json +++ b/components/js-api-client/PRD.json @@ -24,6 +24,7 @@ }, { "id": "DX-002", + "status": "done", "title": "Delete commented-out subscription.ts file", "priority": "P0", "category": "code-quality", diff --git a/components/js-api-client/progress.txt b/components/js-api-client/progress.txt index 0927dbbb..e95aeef1 100644 --- a/components/js-api-client/progress.txt +++ b/components/js-api-client/progress.txt @@ -1,2 +1,3 @@ DX-003: Fix 'chidlren' typo in catalogue fetcher - DONE DX-001: Remove dead try/catch block in API caller - DONE +DX-002: Delete commented-out subscription.ts file - DONE diff --git a/components/js-api-client/src/core/subscription.ts b/components/js-api-client/src/core/subscription.ts deleted file mode 100644 index e6b6589b..00000000 --- a/components/js-api-client/src/core/subscription.ts +++ /dev/null @@ -1,543 +0,0 @@ -// import { EnumType, jsonToGraphQLQuery } from 'json-to-graphql-query'; -// import { -// ProductPriceVariant, -// ProductVariant, -// ProductVariantSubscriptionPlan, -// ProductVariantSubscriptionPlanPeriod, -// ProductVariantSubscriptionMeteredVariable, -// ProductVariantSubscriptionPlanTier, -// ProductVariantSubscriptionPlanPricing, -// } from '../types/product.js'; -// import { -// createSubscriptionContractInputRequest, -// CreateSubscriptionContractInputRequest, -// SubscriptionContract, -// SubscriptionContractMeteredVariableReferenceInputRequest, -// SubscriptionContractMeteredVariableTierInputRequest, -// SubscriptionContractPhaseInput, -// updateSubscriptionContractInputRequest, -// UpdateSubscriptionContractInputRequest, -// } from '../types/subscription.js'; -// import { catalogueFetcherGraphqlBuilder, createCatalogueFetcher } from './catalogue/create-catalogue-fetcher.js'; -// import { ClientInterface } from './client/create-client.js'; - -// function convertDates(intent: CreateSubscriptionContractInputRequest | UpdateSubscriptionContractInputRequest) { -// if (!intent.status) { -// return { -// ...intent, -// }; -// } - -// let results: any = { -// ...intent, -// }; - -// if (intent.status.renewAt) { -// results = { -// ...results, -// status: { -// ...results.status, -// renewAt: intent.status.renewAt.toISOString(), -// }, -// }; -// } - -// if (intent.status.activeUntil) { -// results = { -// ...results, -// status: { -// ...results.status, -// activeUntil: intent.status.activeUntil.toISOString(), -// }, -// }; -// } -// return results; -// } - -// function convertEnums(intent: CreateSubscriptionContractInputRequest | UpdateSubscriptionContractInputRequest) { -// let results: any = { -// ...intent, -// }; - -// if (intent.initial && intent.initial.meteredVariables) { -// results = { -// ...results, -// initial: { -// ...intent.initial, -// meteredVariables: intent.initial.meteredVariables.map((variable: any) => { -// return { -// ...variable, -// tierType: typeof variable.tierType === 'string' ? variable.tierType : variable.tierType.value, -// }; -// }), -// }, -// }; -// } - -// if (intent.recurring && intent.recurring.meteredVariables) { -// results = { -// ...results, -// recurring: { -// ...intent.recurring, -// meteredVariables: intent.recurring.meteredVariables.map((variable: any) => { -// return { -// ...variable, -// tierType: typeof variable.tierType === 'string' ? variable.tierType : variable.tierType.value, -// }; -// }), -// }, -// }; -// } - -// return results; -// } - -// export function createSubscriptionContractManager(apiClient: ClientInterface) { -// const create = async ( -// intentSubsctiptionContract: CreateSubscriptionContractInputRequest, -// extraResultQuery?: any, -// ): Promise => { -// const intent = createSubscriptionContractInputRequest.parse(convertEnums(intentSubsctiptionContract)); -// const api = apiClient.pimApi; - -// const mutation = { -// mutation: { -// subscriptionContract: { -// create: { -// __args: { -// input: convertDates(intent), -// }, -// id: true, -// createdAt: true, -// ...(extraResultQuery !== undefined ? extraResultQuery : {}), -// }, -// }, -// }, -// }; -// const confirmation = await api(jsonToGraphQLQuery(mutation)); -// return confirmation.subscriptionContract.create; -// }; - -// const update = async ( -// id: string, -// intentSubsctiptionContract: UpdateSubscriptionContractInputRequest, -// extraResultQuery?: any, -// ): Promise => { -// const intent = updateSubscriptionContractInputRequest.parse(convertEnums(intentSubsctiptionContract)); -// const api = apiClient.pimApi; - -// const mutation = { -// mutation: { -// subscriptionContract: { -// update: { -// __args: { -// id, -// input: convertDates(intent), -// }, -// id: true, -// updatedAt: true, -// ...(extraResultQuery !== undefined ? extraResultQuery : {}), -// }, -// }, -// }, -// }; -// const confirmation = await api(jsonToGraphQLQuery(mutation)); -// return confirmation.subscriptionContract.update; -// }; - -// /** -// * This function assumes that the variant contains the subscriptions plans -// */ -// const createSubscriptionContractTemplateBasedOnVariant = async ( -// variant: ProductVariant, -// planIdentifier: string, -// periodId: string, -// priceVariantIdentifier: string, -// ) => { -// const matchingPlan: ProductVariantSubscriptionPlan | undefined = variant?.subscriptionPlans?.find( -// (plan: ProductVariantSubscriptionPlan) => plan.identifier === planIdentifier, -// ); -// const matchingPeriod: ProductVariantSubscriptionPlanPeriod | undefined = matchingPlan?.periods?.find( -// (period: ProductVariantSubscriptionPlanPeriod) => period.id === periodId, -// ); -// if (!matchingPlan || !matchingPeriod) { -// throw new Error( -// `Impossible to find the Subscription Plans for SKU ${variant.sku}, plan: ${planIdentifier}, period: ${periodId}`, -// ); -// } - -// const getPriceVariant = ( -// priceVariants: ProductPriceVariant[], -// identifier: string, -// ): ProductPriceVariant | undefined => { -// return priceVariants.find((priceVariant: ProductPriceVariant) => priceVariant.identifier === identifier); -// }; - -// const transformPeriod = (period: ProductVariantSubscriptionPlanPricing): SubscriptionContractPhaseInput => { -// return { -// currency: getPriceVariant(period.priceVariants || [], priceVariantIdentifier)?.currency || 'USD', -// price: getPriceVariant(period.priceVariants || [], priceVariantIdentifier)?.price || 0.0, -// meteredVariables: (period.meteredVariables || []).map( -// ( -// meteredVariable: ProductVariantSubscriptionMeteredVariable, -// ): SubscriptionContractMeteredVariableReferenceInputRequest => { -// return { -// id: meteredVariable.id, -// tierType: new EnumType(meteredVariable.tierType), -// tiers: meteredVariable.tiers.map( -// ( -// tier: ProductVariantSubscriptionPlanTier, -// ): SubscriptionContractMeteredVariableTierInputRequest => { -// return { -// threshold: tier.threshold, -// currency: -// getPriceVariant(tier.priceVariants || [], priceVariantIdentifier) -// ?.currency || 'USD', -// price: -// getPriceVariant(tier.priceVariants || [], priceVariantIdentifier)?.price || -// 0.0, -// }; -// }, -// ), -// }; -// }, -// ), -// }; -// }; -// const contract: Omit< -// CreateSubscriptionContractInputRequest, -// 'customerIdentifier' | 'payment' | 'addresses' | 'tenantId' | 'status' -// > = { -// item: { -// sku: variant.sku, -// name: variant.name || '', -// quantity: 1, -// imageUrl: variant.firstImage?.url || '', -// }, -// subscriptionPlan: { -// identifier: matchingPlan.identifier, -// periodId: matchingPeriod.id, -// }, -// initial: !matchingPeriod.initial ? undefined : transformPeriod(matchingPeriod.initial), -// recurring: !matchingPeriod.recurring ? undefined : transformPeriod(matchingPeriod.recurring), -// }; - -// return contract; -// }; - -// const createSubscriptionContractTemplateBasedOnVariantIdentity = async ( -// path: string, -// productVariantIdentifier: { sku?: string; id?: string }, -// planIdentifier: string, -// periodId: string, -// priceVariantIdentifier: string, -// language: string = 'en', -// ) => { -// if (!productVariantIdentifier.sku && !productVariantIdentifier.id) { -// throw new Error( -// `Impossible to find the Subscription Plans for Path ${path} with and empty Variant Identity`, -// ); -// } - -// // let's ask the catalog for the data we need to create the subscription contract template -// const fetcher = createCatalogueFetcher(apiClient); -// const builder = catalogueFetcherGraphqlBuilder; -// const data: any = await fetcher({ -// catalogue: { -// __args: { -// path, -// language, -// }, -// __on: [ -// builder.onProduct( -// {}, -// { -// onVariant: { -// id: true, -// name: true, -// sku: true, -// ...builder.onSubscriptionPlan(), -// }, -// }, -// ), -// ], -// }, -// }); - -// const matchingVariant: ProductVariant | undefined = data.catalogue?.variants?.find( -// (variant: ProductVariant) => { -// if (productVariantIdentifier.sku && variant.sku === productVariantIdentifier.sku) { -// return true; -// } -// if (productVariantIdentifier.id && variant.id === productVariantIdentifier.id) { -// return true; -// } -// return false; -// }, -// ); - -// if (!matchingVariant) { -// throw new Error( -// `Impossible to find the Subscription Plans for Path ${path} and Variant: (sku: ${productVariantIdentifier.sku} id: ${productVariantIdentifier.id}), plan: ${planIdentifier}, period: ${periodId} in lang: ${language}`, -// ); -// } - -// return createSubscriptionContractTemplateBasedOnVariant( -// matchingVariant, -// planIdentifier, -// periodId, -// priceVariantIdentifier, -// ); -// }; - -// const fetchById = async (id: string, onCustomer?: any, extraQuery?: any): Promise => { -// const query = { -// subscriptionContract: { -// get: { -// __args: { -// id, -// }, -// ...SubscriptionContractQuery(onCustomer, extraQuery), -// }, -// }, -// }; -// const data = await apiClient.pimApi(jsonToGraphQLQuery({ query })); -// return data.subscriptionContract.get; -// }; - -// const fetchByCustomerIdentifier = async ( -// customerIdentifier: string, -// extraQueryArgs?: any, -// onCustomer?: any, -// extraQuery?: any, -// ): Promise<{ -// pageInfo: { -// hasNextPage: boolean; -// hasPreviousPage: boolean; -// startCursor: string; -// endCursor: string; -// totalNodes: number; -// }; -// contracts: SubscriptionContract[]; -// }> => { -// const query = { -// subscriptionContract: { -// getMany: { -// __args: { -// customerIdentifier: customerIdentifier, -// tenantId: apiClient.config.tenantId, -// ...(extraQueryArgs !== undefined ? extraQueryArgs : {}), -// }, -// pageInfo: { -// hasPreviousPage: true, -// hasNextPage: true, -// startCursor: true, -// endCursor: true, -// totalNodes: true, -// }, -// edges: { -// cursor: true, -// node: SubscriptionContractQuery(onCustomer, extraQuery), -// }, -// }, -// }, -// }; -// const response = await apiClient.pimApi(jsonToGraphQLQuery({ query })); -// return { -// pageInfo: response.subscriptionContract.getMany.pageInfo, -// contracts: response.subscriptionContract.getMany?.edges?.map((edge: any) => edge.node) || [], -// }; -// }; - -// const getCurrentPhase = async (id: string): Promise<'initial' | 'recurring'> => { -// const query = { -// subscriptionContractEvent: { -// getMany: { -// __args: { -// subscriptionContractId: id, -// tenantId: apiClient.config.tenantId, -// sort: new EnumType('asc'), -// first: 1, -// eventTypes: new EnumType('renewed'), -// }, -// edges: { -// node: { -// id: true, -// }, -// }, -// }, -// }, -// }; -// const contractUsage = await apiClient.pimApi(jsonToGraphQLQuery({ query })); -// return contractUsage.subscriptionContractEvent.getMany.edges.length > 0 ? 'recurring' : 'initial'; -// }; - -// const getUsageForPeriod = async ( -// id: string, -// from: Date, -// to: Date, -// ): Promise< -// { -// meteredVariableId: string; -// quantity: number; -// }[] -// > => { -// const query = { -// subscriptionContract: { -// get: { -// __args: { -// id, -// }, -// id: true, -// usage: { -// __args: { -// start: from.toISOString(), -// end: to.toISOString(), -// }, -// meteredVariableId: true, -// quantity: true, -// }, -// }, -// }, -// }; -// const contractUsage = await apiClient.pimApi(jsonToGraphQLQuery({ query })); -// return contractUsage.subscriptionContract.get.usage; -// }; - -// return { -// create, -// update, -// fetchById, -// fetchByCustomerIdentifier, -// getCurrentPhase, -// getUsageForPeriod, -// createSubscriptionContractTemplateBasedOnVariantIdentity, -// createSubscriptionContractTemplateBasedOnVariant, -// }; -// } - -// const buildGenericSubscriptionContractQuery = (onCustomer?: any, extraQuery?: any) => { -// return { -// id: true, -// tenantId: true, -// subscriptionPlan: { -// name: true, -// identifier: true, -// meteredVariables: { -// id: true, -// identifier: true, -// name: true, -// unit: true, -// }, -// }, -// item: { -// name: true, -// sku: true, -// quantity: true, -// meta: { -// key: true, -// value: true, -// }, -// }, -// initial: { -// period: true, -// unit: true, -// price: true, -// currency: true, -// meteredVariables: { -// id: true, -// name: true, -// identifier: true, -// unit: true, -// tierType: true, -// tiers: { -// currency: true, -// threshold: true, -// price: true, -// }, -// }, -// }, -// recurring: { -// period: true, -// unit: true, -// price: true, -// currency: true, -// meteredVariables: { -// id: true, -// name: true, -// identifier: true, -// unit: true, -// tierType: true, -// tiers: { -// currency: true, -// threshold: true, -// price: true, -// }, -// }, -// }, -// status: { -// renewAt: true, -// activeUntil: true, -// price: true, -// currency: true, -// }, -// meta: { -// key: true, -// value: true, -// }, -// addresses: { -// type: true, -// lastName: true, -// firstName: true, -// email: true, -// middleName: true, -// street: true, -// street2: true, -// city: true, -// country: true, -// state: true, -// postalCode: true, -// phone: true, -// streetNumber: true, -// }, -// customerIdentifier: true, -// customer: { -// identifier: true, -// email: true, -// firstName: true, -// lastName: true, -// companyName: true, -// phone: true, -// taxNumber: true, -// meta: { -// key: true, -// value: true, -// }, -// externalReferences: { -// key: true, -// value: true, -// }, -// addresses: { -// type: true, -// lastName: true, -// firstName: true, -// email: true, -// middleName: true, -// street: true, -// street2: true, -// city: true, -// country: true, -// state: true, -// postalCode: true, -// phone: true, -// streetNumber: true, -// meta: { -// key: true, -// value: true, -// }, -// }, -// ...(onCustomer !== undefined ? onCustomer : {}), -// }, -// ...(extraQuery !== undefined ? extraQuery : {}), -// }; -// }; From 8a68d8b8d546dae89aa39dcab5c63890b8efa1c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Thu, 12 Mar 2026 16:47:11 -0700 Subject: [PATCH 04/21] fix(catalogue): simplify double spread to single spread in product hydrater The query object construction used `{ ...{ ...productListQuery } }` which is functionally identical to `{ ...productListQuery }`. Simplified to remove the unnecessary nesting. --- components/js-api-client/PRD.json | 1 + components/js-api-client/progress.txt | 1 + .../js-api-client/src/core/catalogue/create-product-hydrater.ts | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/components/js-api-client/PRD.json b/components/js-api-client/PRD.json index 4861f1a2..1ee62675 100644 --- a/components/js-api-client/PRD.json +++ b/components/js-api-client/PRD.json @@ -63,6 +63,7 @@ }, { "id": "DX-004", + "status": "done", "title": "Remove unnecessary double spread in product hydrater", "priority": "P0", "category": "code-quality", diff --git a/components/js-api-client/progress.txt b/components/js-api-client/progress.txt index e95aeef1..0fea05d0 100644 --- a/components/js-api-client/progress.txt +++ b/components/js-api-client/progress.txt @@ -1,3 +1,4 @@ DX-003: Fix 'chidlren' typo in catalogue fetcher - DONE DX-001: Remove dead try/catch block in API caller - DONE DX-002: Delete commented-out subscription.ts file - DONE +DX-004: Remove unnecessary double spread in product hydrater - DONE diff --git a/components/js-api-client/src/core/catalogue/create-product-hydrater.ts b/components/js-api-client/src/core/catalogue/create-product-hydrater.ts index abca70e8..a8e32938 100644 --- a/components/js-api-client/src/core/catalogue/create-product-hydrater.ts +++ b/components/js-api-client/src/core/catalogue/create-product-hydrater.ts @@ -87,7 +87,7 @@ function byPaths(client: ClientInterface, options?: ProductHydraterOptions): Pro }, {} as any); const query = { - ...{ ...productListQuery }, + ...productListQuery, ...(extraQuery !== undefined ? extraQuery : {}), }; From a6f03e9579902b9d5a5b2f41a4f47689273c6c82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Thu, 12 Mar 2026 16:48:04 -0700 Subject: [PATCH 05/21] fix(mass-call): use object literal instead of array for results initialization The `results` variable was typed as `{ [key: string]: any }` but initialized as `[]`. While JS allows property assignment on arrays, this is misleading. Changed to `{}` to match the declared type and actual usage. --- components/js-api-client/PRD.json | 1 + components/js-api-client/progress.txt | 1 + components/js-api-client/src/core/create-mass-call-client.ts | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/components/js-api-client/PRD.json b/components/js-api-client/PRD.json index 1ee62675..b2855f89 100644 --- a/components/js-api-client/PRD.json +++ b/components/js-api-client/PRD.json @@ -81,6 +81,7 @@ }, { "id": "DX-005", + "status": "done", "title": "Fix results variable initialization in mass call client", "priority": "P0", "category": "code-quality", diff --git a/components/js-api-client/progress.txt b/components/js-api-client/progress.txt index 0fea05d0..49692fc1 100644 --- a/components/js-api-client/progress.txt +++ b/components/js-api-client/progress.txt @@ -2,3 +2,4 @@ DX-003: Fix 'chidlren' typo in catalogue fetcher - DONE DX-001: Remove dead try/catch block in API caller - DONE DX-002: Delete commented-out subscription.ts file - DONE DX-004: Remove unnecessary double spread in product hydrater - DONE +DX-005: Fix results variable initialization in mass call client - DONE diff --git a/components/js-api-client/src/core/create-mass-call-client.ts b/components/js-api-client/src/core/create-mass-call-client.ts index 22d0af65..c2a3afa9 100644 --- a/components/js-api-client/src/core/create-mass-call-client.ts +++ b/components/js-api-client/src/core/create-mass-call-client.ts @@ -99,7 +99,7 @@ export function createMassCallClient( let batch = []; let results: { [key: string]: any; - } = []; + } = {}; do { let batchErrorCount = 0; const to = seek + increment; From 6886cd73e4a9de6cebb0724d2eb977a7facde7c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Thu, 12 Mar 2026 16:48:54 -0700 Subject: [PATCH 06/21] fix(package): correct module field to match actual ESM build output The module field pointed to ./dist/index.mjs which doesn't exist. The build outputs ./dist/index.js for ESM. This mismatch could cause import failures in bundlers (Webpack, Rollup) that use the module field. --- components/js-api-client/PRD.json | 1 + components/js-api-client/package.json | 2 +- components/js-api-client/progress.txt | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/components/js-api-client/PRD.json b/components/js-api-client/PRD.json index b2855f89..be0c9385 100644 --- a/components/js-api-client/PRD.json +++ b/components/js-api-client/PRD.json @@ -99,6 +99,7 @@ }, { "id": "DX-006", + "status": "done", "title": "Fix stale 'module' field in package.json", "priority": "P0", "category": "build", diff --git a/components/js-api-client/package.json b/components/js-api-client/package.json index 65715f32..0b21259d 100644 --- a/components/js-api-client/package.json +++ b/components/js-api-client/package.json @@ -26,7 +26,7 @@ }, "types": "./dist/index.d.ts", "main": "./dist/index.cjs", - "module": "./dist/index.mjs", + "module": "./dist/index.js", "devDependencies": { "@tsconfig/node22": "^22.0.2", "@types/node": "^24.2.0", diff --git a/components/js-api-client/progress.txt b/components/js-api-client/progress.txt index 49692fc1..899fc353 100644 --- a/components/js-api-client/progress.txt +++ b/components/js-api-client/progress.txt @@ -3,3 +3,4 @@ DX-001: Remove dead try/catch block in API caller - DONE DX-002: Delete commented-out subscription.ts file - DONE DX-004: Remove unnecessary double spread in product hydrater - DONE DX-005: Fix results variable initialization in mass call client - DONE +DX-006: Fix stale 'module' field in package.json - DONE From c0f86dc2cf68e894aafab2ed0eb587f02f213acd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Thu, 12 Mar 2026 16:49:39 -0700 Subject: [PATCH 07/21] fix(error): set name property on JSApiClientCallError for better debugging Stack traces and error.name now correctly show 'JSApiClientCallError' instead of generic 'Error', making it easier to identify API client errors in logs and error handlers. --- components/js-api-client/PRD.json | 1 + components/js-api-client/progress.txt | 1 + components/js-api-client/src/core/client/create-api-caller.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/components/js-api-client/PRD.json b/components/js-api-client/PRD.json index be0c9385..64e5585f 100644 --- a/components/js-api-client/PRD.json +++ b/components/js-api-client/PRD.json @@ -119,6 +119,7 @@ }, { "id": "DX-007", + "status": "done", "title": "Set error name on JSApiClientCallError", "priority": "P1", "category": "error-handling", diff --git a/components/js-api-client/progress.txt b/components/js-api-client/progress.txt index 899fc353..98530e66 100644 --- a/components/js-api-client/progress.txt +++ b/components/js-api-client/progress.txt @@ -4,3 +4,4 @@ DX-002: Delete commented-out subscription.ts file - DONE DX-004: Remove unnecessary double spread in product hydrater - DONE DX-005: Fix results variable initialization in mass call client - DONE DX-006: Fix stale 'module' field in package.json - DONE +DX-007: Set error name on JSApiClientCallError - DONE diff --git a/components/js-api-client/src/core/client/create-api-caller.ts b/components/js-api-client/src/core/client/create-api-caller.ts index e4a25116..cad1783e 100644 --- a/components/js-api-client/src/core/client/create-api-caller.ts +++ b/components/js-api-client/src/core/client/create-api-caller.ts @@ -27,6 +27,7 @@ export class JSApiClientCallError extends Error { variables: VariablesType; }) { super(message); + this.name = 'JSApiClientCallError'; this.code = code; this.statusText = statusText; this.errors = errors; From 662ec7d7bef12a659480042cfe86741970919970 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Thu, 12 Mar 2026 16:52:17 -0700 Subject: [PATCH 08/21] fix(types): replace 'any' with proper GrabOptions type in grabber and API caller The `RequestInit | any | undefined` union types collapsed to `any`, defeating TypeScript's type checking for all library consumers. Introduced a focused GrabOptions type covering method, headers, and body, and updated all signatures. --- components/js-api-client/PRD.json | 1 + components/js-api-client/progress.txt | 1 + .../src/core/client/create-api-caller.ts | 4 ++-- .../src/core/client/create-client.ts | 2 +- .../src/core/client/create-grabber.ts | 15 ++++++++++----- 5 files changed, 15 insertions(+), 8 deletions(-) diff --git a/components/js-api-client/PRD.json b/components/js-api-client/PRD.json index 64e5585f..4fde7fcc 100644 --- a/components/js-api-client/PRD.json +++ b/components/js-api-client/PRD.json @@ -138,6 +138,7 @@ }, { "id": "DX-008", + "status": "done", "title": "Remove 'any' from union types in grabber and API caller signatures", "priority": "P1", "category": "type-safety", diff --git a/components/js-api-client/progress.txt b/components/js-api-client/progress.txt index 98530e66..1b3ab9b7 100644 --- a/components/js-api-client/progress.txt +++ b/components/js-api-client/progress.txt @@ -5,3 +5,4 @@ DX-004: Remove unnecessary double spread in product hydrater - DONE DX-005: Fix results variable initialization in mass call client - DONE DX-006: Fix stale 'module' field in package.json - DONE DX-007: Set error name on JSApiClientCallError - DONE +DX-008: Remove 'any' from union types in grabber and API caller signatures - DONE diff --git a/components/js-api-client/src/core/client/create-api-caller.ts b/components/js-api-client/src/core/client/create-api-caller.ts index cad1783e..b564379d 100644 --- a/components/js-api-client/src/core/client/create-api-caller.ts +++ b/components/js-api-client/src/core/client/create-api-caller.ts @@ -1,5 +1,5 @@ import { CreateClientOptions, ClientConfiguration } from './create-client.js'; -import { Grab } from './create-grabber.js'; +import { Grab, GrabOptions } from './create-grabber.js'; export type VariablesType = Record; export type ApiCaller = (query: string, variables?: VariablesType) => Promise; @@ -93,7 +93,7 @@ export const post = async ( config: ClientConfiguration, query: string, variables?: VariablesType, - init?: RequestInit | any | undefined, + init?: GrabOptions, options?: CreateClientOptions, ): Promise => { const { headers: initHeaders, ...initRest } = init || {}; diff --git a/components/js-api-client/src/core/client/create-client.ts b/components/js-api-client/src/core/client/create-client.ts index 18958b37..1adf28f9 100644 --- a/components/js-api-client/src/core/client/create-client.ts +++ b/components/js-api-client/src/core/client/create-client.ts @@ -29,7 +29,7 @@ export type ClientConfiguration = { export type CreateClientOptions = { useHttp2?: boolean; profiling?: ProfilingOptions; - extraHeaders?: RequestInit['headers']; + extraHeaders?: Record; shopApiToken?: { doNotFetch?: boolean; scopes?: string[]; diff --git a/components/js-api-client/src/core/client/create-grabber.ts b/components/js-api-client/src/core/client/create-grabber.ts index 81490987..63a22055 100644 --- a/components/js-api-client/src/core/client/create-grabber.ts +++ b/components/js-api-client/src/core/client/create-grabber.ts @@ -10,8 +10,13 @@ export type GrabResponse = { json: () => Promise; text: () => Promise; }; +export type GrabOptions = { + method?: string; + headers?: Record; + body?: string; +}; export type Grab = { - grab: (url: string, options?: RequestInit | any | undefined) => Promise; + grab: (url: string, options?: GrabOptions) => Promise; close: () => void; }; @@ -21,7 +26,7 @@ type Options = { export const createGrabber = (options?: Options): Grab => { const clients = new Map(); const IDLE_TIMEOUT = 300000; // 5 min idle timeout - const grab = async (url: string, grabOptions?: RequestInit | any): Promise => { + const grab = async (url: string, grabOptions?: GrabOptions): Promise => { if (options?.useHttp2 !== true) { return fetch(url, grabOptions); } @@ -62,12 +67,12 @@ export const createGrabber = (options?: Options): Grab => { const client = getClient(origin); resetIdleTimeout(origin); const headers = { - ':method': grabOptions.method || 'GET', + ':method': grabOptions?.method || 'GET', ':path': urlObj.pathname + urlObj.search, - ...grabOptions.headers, + ...grabOptions?.headers, }; const req = client.request(headers); - if (grabOptions.body) { + if (grabOptions?.body) { req.write(grabOptions.body); } req.setEncoding('utf8'); From 8b2fe5f12425157fb24ce6930ba66804b8f5d268 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Thu, 12 Mar 2026 16:53:13 -0700 Subject: [PATCH 09/21] fix(security): replace tracked .env with .env.example template Add .env to .gitignore and create .env.example with placeholder values to prevent accidental credential commits. --- components/js-api-client/.env.example | 12 ++++++++++++ components/js-api-client/.gitignore | 1 + components/js-api-client/PRD.json | 1 + components/js-api-client/progress.txt | 1 + 4 files changed, 15 insertions(+) create mode 100644 components/js-api-client/.env.example diff --git a/components/js-api-client/.env.example b/components/js-api-client/.env.example new file mode 100644 index 00000000..c180243a --- /dev/null +++ b/components/js-api-client/.env.example @@ -0,0 +1,12 @@ +# PROD +CRYSTALLIZE_TENANT_ID=your-tenant-id-here +CRYSTALLIZE_TENANT_IDENTIFIER=your-tenant-identifier-here +CRYSTALLIZE_ACCESS_TOKEN_ID=your-token-id-here +CRYSTALLIZE_ACCESS_TOKEN_SECRET=your-token-secret-here + +# # DEV +# CRYSTALLIZE_TENANT_ID=your-dev-tenant-id-here +# CRYSTALLIZE_TENANT_IDENTIFIER=your-dev-tenant-identifier-here +# CRYSTALLIZE_ACCESS_TOKEN_ID=your-dev-token-id-here +# CRYSTALLIZE_ACCESS_TOKEN_SECRET=your-dev-token-secret-here +# CRYSTALLIZE_ORIGIN=-dev.crystallize.digital diff --git a/components/js-api-client/.gitignore b/components/js-api-client/.gitignore index 70e72faa..b820f4d7 100644 --- a/components/js-api-client/.gitignore +++ b/components/js-api-client/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +.env yarn.lock package-lock.json diff --git a/components/js-api-client/PRD.json b/components/js-api-client/PRD.json index 4fde7fcc..e67ae8d6 100644 --- a/components/js-api-client/PRD.json +++ b/components/js-api-client/PRD.json @@ -163,6 +163,7 @@ }, { "id": "DX-009", + "status": "done", "title": "Replace tracked .env with .env.example", "priority": "P1", "category": "security", diff --git a/components/js-api-client/progress.txt b/components/js-api-client/progress.txt index 1b3ab9b7..3a6bd439 100644 --- a/components/js-api-client/progress.txt +++ b/components/js-api-client/progress.txt @@ -6,3 +6,4 @@ DX-005: Fix results variable initialization in mass call client - DONE DX-006: Fix stale 'module' field in package.json - DONE DX-007: Set error name on JSApiClientCallError - DONE DX-008: Remove 'any' from union types in grabber and API caller signatures - DONE +DX-009: Replace tracked .env with .env.example - DONE From 950ff2efdd4be410c83717e89d8dd964ed0be58f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Thu, 12 Mar 2026 16:54:11 -0700 Subject: [PATCH 10/21] feat(auth): warn when no authentication credentials are configured Adds a console.warn when the fallback auth path is reached with empty accessTokenId and accessTokenSecret, helping developers catch missing auth configuration early instead of getting cryptic 401/403 errors. Uses a WeakSet to ensure the warning fires only once per config object. --- components/js-api-client/PRD.json | 1 + components/js-api-client/progress.txt | 1 + .../js-api-client/src/core/client/create-api-caller.ts | 9 +++++++++ 3 files changed, 11 insertions(+) diff --git a/components/js-api-client/PRD.json b/components/js-api-client/PRD.json index e67ae8d6..dd9f5945 100644 --- a/components/js-api-client/PRD.json +++ b/components/js-api-client/PRD.json @@ -184,6 +184,7 @@ }, { "id": "DX-010", + "status": "done", "title": "Improve authentication error messaging", "priority": "P1", "category": "error-handling", diff --git a/components/js-api-client/progress.txt b/components/js-api-client/progress.txt index 3a6bd439..811782fb 100644 --- a/components/js-api-client/progress.txt +++ b/components/js-api-client/progress.txt @@ -7,3 +7,4 @@ DX-006: Fix stale 'module' field in package.json - DONE DX-007: Set error name on JSApiClientCallError - DONE DX-008: Remove 'any' from union types in grabber and API caller signatures - DONE DX-009: Replace tracked .env with .env.example - DONE +DX-010: Improve authentication error messaging - DONE diff --git a/components/js-api-client/src/core/client/create-api-caller.ts b/components/js-api-client/src/core/client/create-api-caller.ts index b564379d..7df1c471 100644 --- a/components/js-api-client/src/core/client/create-api-caller.ts +++ b/components/js-api-client/src/core/client/create-api-caller.ts @@ -58,6 +58,8 @@ export const createApiCaller = ( }; }; +const warnedConfigs = new WeakSet(); + export const authenticationHeaders = (config: ClientConfiguration): Record => { if (config.sessionId) { return { @@ -69,6 +71,13 @@ export const authenticationHeaders = (config: ClientConfiguration): Record Date: Thu, 12 Mar 2026 16:55:06 -0700 Subject: [PATCH 11/21] fix(profiling): use regex to parse Server-Timing header reliably Replace fragile split-based parsing with a regex that extracts the dur= value per the Server-Timing spec, avoiding garbage results from non-standard header formats. --- components/js-api-client/PRD.json | 1 + components/js-api-client/progress.txt | 1 + components/js-api-client/src/core/client/create-api-caller.ts | 3 ++- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/components/js-api-client/PRD.json b/components/js-api-client/PRD.json index dd9f5945..470a6747 100644 --- a/components/js-api-client/PRD.json +++ b/components/js-api-client/PRD.json @@ -280,6 +280,7 @@ }, { "id": "DX-014", + "status": "done", "title": "Make Server-Timing header parsing robust", "priority": "P2", "category": "reliability", diff --git a/components/js-api-client/progress.txt b/components/js-api-client/progress.txt index 811782fb..f5aceafd 100644 --- a/components/js-api-client/progress.txt +++ b/components/js-api-client/progress.txt @@ -8,3 +8,4 @@ DX-007: Set error name on JSApiClientCallError - DONE DX-008: Remove 'any' from union types in grabber and API caller signatures - DONE DX-009: Replace tracked .env with .env.example - DONE DX-010: Improve authentication error messaging - DONE +DX-014: Make Server-Timing header parsing robust - DONE diff --git a/components/js-api-client/src/core/client/create-api-caller.ts b/components/js-api-client/src/core/client/create-api-caller.ts index 7df1c471..710c5c13 100644 --- a/components/js-api-client/src/core/client/create-api-caller.ts +++ b/components/js-api-client/src/core/client/create-api-caller.ts @@ -137,7 +137,8 @@ export const post = async ( if (Array.isArray(serverTiming)) { serverTiming = serverTiming[0]; } - const duration = serverTiming?.split(';')[1]?.split('=')[1] ?? -1; + const durMatch = serverTiming?.match(/dur=([\d.]+)/); + const duration = durMatch ? durMatch[1] : -1; profiling.onRequestResolved( { resolutionTimeMs: ms, From e81416773b3c03cae462ee3dd391d05482d84d7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Thu, 12 Mar 2026 16:58:21 -0700 Subject: [PATCH 12/21] refactor(types): rename cryptic generic parameters to descriptive names in fetchers/managers Replace abbreviations like OO, OOI, OC, OSC, OP with readable names (OrderExtra, OrderItemExtra, CustomerExtra, SubscriptionContractExtra, PaymentExtra) across order, customer, and subscription modules for better IDE tooltip readability. --- components/js-api-client/PRD.json | 1 + components/js-api-client/progress.txt | 1 + .../pim/customers/create-customer-fetcher.ts | 10 ++--- .../pim/customers/create-customer-manager.ts | 32 +++++++------- .../core/pim/orders/create-order-fetcher.ts | 26 ++++++------ .../core/pim/orders/create-order-manager.ts | 42 +++++++++---------- .../create-subscription-contract-fetcher.ts | 18 ++++---- .../create-subscription-contract-manager.ts | 26 ++++++------ 8 files changed, 79 insertions(+), 77 deletions(-) diff --git a/components/js-api-client/PRD.json b/components/js-api-client/PRD.json index 470a6747..7e59867b 100644 --- a/components/js-api-client/PRD.json +++ b/components/js-api-client/PRD.json @@ -204,6 +204,7 @@ }, { "id": "DX-011", + "status": "done", "title": "Improve generic parameter naming in fetchers", "priority": "P2", "category": "type-safety", diff --git a/components/js-api-client/progress.txt b/components/js-api-client/progress.txt index f5aceafd..74415ab3 100644 --- a/components/js-api-client/progress.txt +++ b/components/js-api-client/progress.txt @@ -9,3 +9,4 @@ DX-008: Remove 'any' from union types in grabber and API caller signatures - DON DX-009: Replace tracked .env with .env.example - DONE DX-010: Improve authentication error messaging - DONE DX-014: Make Server-Timing header parsing robust - DONE +DX-011: Improve generic parameter naming in fetchers - DONE diff --git a/components/js-api-client/src/core/pim/customers/create-customer-fetcher.ts b/components/js-api-client/src/core/pim/customers/create-customer-fetcher.ts index 79794a43..879a2edd 100644 --- a/components/js-api-client/src/core/pim/customers/create-customer-fetcher.ts +++ b/components/js-api-client/src/core/pim/customers/create-customer-fetcher.ts @@ -4,7 +4,7 @@ import { Customer } from '@crystallize/schema/pim'; export type DefaultCustomerType = R & Required>; -const buildBaseQuery = (onCustomer?: OC) => { +const buildBaseQuery = (onCustomer?: CustomerExtra) => { return { identifier: true, email: true, @@ -15,9 +15,9 @@ const buildBaseQuery = (onCustomer?: OC) => { }; export const createCustomerFetcher = (apiClient: ClientInterface) => { - const fetchByIdentifier = async ( + const fetchByIdentifier = async ( identifier: string, - onCustomer?: OC, + onCustomer?: CustomerExtra, ): Promise | null> => { const query = { customer: { @@ -42,9 +42,9 @@ export const createCustomerFetcher = (apiClient: ClientInterface) => { ).customer; }; - const fetchByExternalReference = async ( + const fetchByExternalReference = async ( { key, value }: { key: string; value?: string }, - onCustomer?: OC, + onCustomer?: CustomerExtra, ): Promise | null> => { const query = { customer: { diff --git a/components/js-api-client/src/core/pim/customers/create-customer-manager.ts b/components/js-api-client/src/core/pim/customers/create-customer-manager.ts index 5ada9bfe..745619c7 100644 --- a/components/js-api-client/src/core/pim/customers/create-customer-manager.ts +++ b/components/js-api-client/src/core/pim/customers/create-customer-manager.ts @@ -13,9 +13,9 @@ import { createCustomerFetcher } from './create-customer-fetcher.js'; type WithIdentifier = R & { identifier: string }; export const createCustomerManager = (apiClient: ClientInterface) => { - const create = async ( + const create = async ( intentCustomer: CreateCustomerInput, - onCustomer?: OC, + onCustomer?: CustomerExtra, ): Promise> => { const input = CreateCustomerInputSchema.parse(intentCustomer); const mutation = { @@ -43,9 +43,9 @@ export const createCustomerManager = (apiClient: ClientInterface) => { return confirmation.createCustomer; }; - const update = async ( + const update = async ( intentCustomer: UpdateCustomerInput, - onCustomer?: OC, + onCustomer?: CustomerExtra, ): Promise> => { const { identifier, ...input } = UpdateCustomerInputSchema.parse(intentCustomer); const mutation = { @@ -75,30 +75,30 @@ export const createCustomerManager = (apiClient: ClientInterface) => { }; // this is overriding completely the previous meta (there is no merge method yes on the API) - const setMeta = async ( + const setMeta = async ( identifier: string, intentMeta: NonNullable, - onCustomer?: OC, + onCustomer?: CustomerExtra, ): Promise> => { const meta = UpdateCustomerInputSchema.shape.meta.parse(intentMeta); - return await update({ identifier, meta }, onCustomer); + return await update({ identifier, meta }, onCustomer); }; // this is overriding completely the previous references (there is no merge method yes on the API) - const setExternalReference = async ( + const setExternalReference = async ( identifier: string, intentReferences: NonNullable, - onCustomer?: OC, + onCustomer?: CustomerExtra, ): Promise> => { const references = UpdateCustomerInputSchema.shape.meta.parse(intentReferences); - return await update({ identifier, externalReferences: references }, onCustomer); + return await update({ identifier, externalReferences: references }, onCustomer); }; - const setMetaKey = async ( + const setMetaKey = async ( identifier: string, key: string, value: string, - onCustomer?: OC, + onCustomer?: CustomerExtra, ): Promise> => { const fetcher = createCustomerFetcher(apiClient); const customer = await fetcher.byIdentifier<{ meta: Customer['meta'] }>(identifier, { @@ -114,14 +114,14 @@ export const createCustomerManager = (apiClient: ClientInterface) => { const newMeta = existingMeta.filter((m) => m.key !== key).concat({ key, value }) as NonNullable< UpdateCustomerInput['meta'] >; - return await setMeta(identifier, newMeta, onCustomer); + return await setMeta(identifier, newMeta, onCustomer); }; - const setExternalReferenceKey = async ( + const setExternalReferenceKey = async ( identifier: string, key: string, value: string, - onCustomer?: OC, + onCustomer?: CustomerExtra, ): Promise> => { const fetcher = createCustomerFetcher(apiClient); const customer = await fetcher.byIdentifier<{ externalReferences: Customer['externalReferences'] }>( @@ -140,7 +140,7 @@ export const createCustomerManager = (apiClient: ClientInterface) => { const newReferences = existingReferences.filter((m) => m.key !== key).concat({ key, value }) as NonNullable< UpdateCustomerInput['externalReferences'] >; - return await setExternalReference(identifier, newReferences, onCustomer); + return await setExternalReference(identifier, newReferences, onCustomer); }; return { create, diff --git a/components/js-api-client/src/core/pim/orders/create-order-fetcher.ts b/components/js-api-client/src/core/pim/orders/create-order-fetcher.ts index bdc81c30..8a206c81 100644 --- a/components/js-api-client/src/core/pim/orders/create-order-fetcher.ts +++ b/components/js-api-client/src/core/pim/orders/create-order-fetcher.ts @@ -20,7 +20,7 @@ export type DefaultOrderType; } & OnOrder; -const buildBaseQuery = (onOrder?: OO, onOrderItem?: OOI, onCustomer?: OC) => { +const buildBaseQuery = (onOrder?: OrderExtra, onOrderItem?: OrderItemExtra, onCustomer?: CustomerExtra) => { const priceQuery = { gross: true, net: true, @@ -62,10 +62,10 @@ type PageInfo = { endCursor: string; }; -type EnhanceQuery = { - onOrder?: OO; - onOrderItem?: OOI; - onCustomer?: OC; +type EnhanceQuery = { + onOrder?: OrderExtra; + onOrderItem?: OrderItemExtra; + onCustomer?: CustomerExtra; }; export function createOrderFetcher(apiClient: ClientInterface) { @@ -74,13 +74,13 @@ export function createOrderFetcher(apiClient: ClientInterface) { OnOrderItem = unknown, OnCustomer = unknown, EA extends Record = Record, - OC = unknown, - OOI = unknown, - OO = unknown, + CustomerExtra = unknown, + OrderItemExtra = unknown, + OrderExtra = unknown, >( customerIdentifier: string, extraArgs?: EA & { filter?: Record & { customer?: Record } }, - enhancements?: EnhanceQuery, + enhancements?: EnhanceQuery, ): Promise<{ pageInfo: PageInfo; orders: Array>; @@ -142,12 +142,12 @@ export function createOrderFetcher(apiClient: ClientInterface) { OnOrder = unknown, OnOrderItem = unknown, OnCustomer = unknown, - OC = unknown, - OOI = unknown, - OO = unknown, + CustomerExtra = unknown, + OrderItemExtra = unknown, + OrderExtra = unknown, >( id: string, - enhancements?: EnhanceQuery, + enhancements?: EnhanceQuery, ): Promise | null> => { const query = { order: { diff --git a/components/js-api-client/src/core/pim/orders/create-order-manager.ts b/components/js-api-client/src/core/pim/orders/create-order-manager.ts index 65ea1dfb..b5ee6143 100644 --- a/components/js-api-client/src/core/pim/orders/create-order-manager.ts +++ b/components/js-api-client/src/core/pim/orders/create-order-manager.ts @@ -9,7 +9,7 @@ import { ClientInterface } from '../../client/create-client.js'; import { jsonToGraphQLQuery } from 'json-to-graphql-query'; import { transformOrderInput } from './helpers.js'; -const baseQuery = (enhancements?: { onCustomer?: OC; onOrder?: OO }) => ({ +const baseQuery = (enhancements?: { onCustomer?: CustomerExtra; onOrder?: OrderExtra }) => ({ __on: [ { __typeName: 'Order', @@ -63,9 +63,9 @@ export const createOrderManager = (apiClient: ClientInterface) => { }; // --- - const update = async ( + const update = async ( intentOrder: UpdateOrderInput, - onOrder?: OO, + onOrder?: OrderExtra, ): Promise> & OnOrder> => { const { id, ...input } = UpdateOrderInputSchema.parse(intentOrder); const mutation = { @@ -101,16 +101,16 @@ export const createOrderManager = (apiClient: ClientInterface) => { pipelineId: string; stageId: string; }; - type PutInPipelineStageEnhancedQuery = { - onCustomer?: OC; - onOrder?: OO; + type PutInPipelineStageEnhancedQuery = { + onCustomer?: CustomerExtra; + onOrder?: OrderExtra; }; type PutInPipelineStageDefaultOrderType = Required> & { customer: Required, 'identifier'>> & OnCustomer; } & OnOrder; - const putInPipelineStage = async ( + const putInPipelineStage = async ( { id, pipelineId, stageId }: PutInPipelineStageArgs, - enhancements?: PutInPipelineStageEnhancedQuery, + enhancements?: PutInPipelineStageEnhancedQuery, ): Promise> => { const mutation = { updateOrderPipelineStage: { @@ -133,16 +133,16 @@ export const createOrderManager = (apiClient: ClientInterface) => { id: string; pipelineId: string; }; - type RemoveFromPipelineEnhancedQuery = { - onCustomer?: OC; - onOrder?: OO; + type RemoveFromPipelineEnhancedQuery = { + onCustomer?: CustomerExtra; + onOrder?: OrderExtra; }; type RemoveFromPipelineDefaultOrderType = Required> & { customer: Required, 'identifier'>> & OnCustomer; } & OnOrder; - const removeFromPipeline = async ( + const removeFromPipeline = async ( { id, pipelineId }: RemoveFromPipelineArgs, - enhancements?: RemoveFromPipelineEnhancedQuery, + enhancements?: RemoveFromPipelineEnhancedQuery, ): Promise> => { const mutation = { deleteOrderPipeline: { @@ -160,10 +160,10 @@ export const createOrderManager = (apiClient: ClientInterface) => { }; // --- - type SetPaymentsEnhancedQuery = { - onCustomer?: OC; - onPayment?: OP; - onOrder?: OO; + type SetPaymentsEnhancedQuery = { + onCustomer?: CustomerExtra; + onPayment?: PaymentExtra; + onOrder?: OrderExtra; }; type SetPaymentsDefaultOrderType = Required> & { customer: Required, 'identifier'>> & OnCustomer; @@ -173,13 +173,13 @@ export const createOrderManager = (apiClient: ClientInterface) => { OnCustomer = unknown, OnPayment = unknown, OnOrder = unknown, - OC = unknown, - OP = unknown, - OO = unknown, + CustomerExtra = unknown, + PaymentExtra = unknown, + OrderExtra = unknown, >( id: string, payments: UpdateOrderInput['payment'], - enhancements?: SetPaymentsEnhancedQuery, + enhancements?: SetPaymentsEnhancedQuery, ): Promise> => { const paymentSchema = UpdateOrderInputSchema.shape.payment; const input = paymentSchema.parse(payments); diff --git a/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-fetcher.ts b/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-fetcher.ts index 8cef4889..3bc66e98 100644 --- a/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-fetcher.ts +++ b/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-fetcher.ts @@ -19,7 +19,7 @@ type DefaultSubscriptionContractType = Requi customer: Required, 'identifier'>> & OnCustomer; } & OnSubscriptionContract; -const buildBaseQuery = (onSubscriptionContract?: OSC, onCustomer?: OC) => { +const buildBaseQuery = (onSubscriptionContract?: SubscriptionContractExtra, onCustomer?: CustomerExtra) => { const phaseQuery = { period: true, unit: true, @@ -108,15 +108,15 @@ type PageInfo = { endCursor: string; }; -type EnhanceQuery = { - onSubscriptionContract?: OSC; - onCustomer?: OC; +type EnhanceQuery = { + onSubscriptionContract?: SubscriptionContractExtra; + onCustomer?: CustomerExtra; }; export const createSubscriptionContractFetcher = (apiClient: ClientInterface) => { - const fetchById = async ( + const fetchById = async ( id: string, - enhancements?: EnhanceQuery, + enhancements?: EnhanceQuery, ): Promise | null> => { const query = { subscriptionContract: { @@ -147,12 +147,12 @@ export const createSubscriptionContractFetcher = (apiClient: ClientInterface) => OnSubscriptionContract = unknown, OnCustomer = unknown, EA extends Record = Record, - OSC = unknown, - OC = unknown, + SubscriptionContractExtra = unknown, + CustomerExtra = unknown, >( customerIdentifier: string, extraArgs?: EA & { filter?: Record }, - enhancements?: EnhanceQuery, + enhancements?: EnhanceQuery, ): Promise<{ pageInfo: PageInfo; subscriptionContracts: Array>; diff --git a/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-manager.ts b/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-manager.ts index aaa6b1aa..b4dc7d91 100644 --- a/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-manager.ts +++ b/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-manager.ts @@ -25,7 +25,7 @@ type WithIdentifiersAndStatus = R & { } & (R extends { status: infer S } ? S : {}); }; -const baseQuery = }>(onSubscriptionContract?: OSC) => ({ +const baseQuery = }>(onSubscriptionContract?: SubscriptionContractExtra) => ({ __on: [ { __typeName: 'SubscriptionContractAggregate', @@ -46,9 +46,9 @@ const baseQuery = }>(onSubscript }); export const createSubscriptionContractManager = (apiClient: ClientInterface) => { - const create = async } = {}>( + const create = async } = {}>( intentSubscriptionContract: CreateSubscriptionContractInput, - onSubscriptionContract?: OSC, + onSubscriptionContract?: SubscriptionContractExtra, ): Promise> => { const input = CreateSubscriptionContractInputSchema.parse(intentSubscriptionContract); const api = apiClient.nextPimApi; @@ -82,9 +82,9 @@ export const createSubscriptionContractManager = (apiClient: ClientInterface) => return confirmation.createSubscriptionContract; }; - const update = async } = {}>( + const update = async } = {}>( intentSubscriptionContract: UpdateSubscriptionContractInput, - onSubscriptionContract?: OSC, + onSubscriptionContract?: SubscriptionContractExtra, ): Promise> => { const { id, ...input } = UpdateSubscriptionContractInputSchema.parse(intentSubscriptionContract); const api = apiClient.nextPimApi; @@ -103,10 +103,10 @@ export const createSubscriptionContractManager = (apiClient: ClientInterface) => return confirmation.updateSubscriptionContract; }; - const cancel = async } = {}>( + const cancel = async } = {}>( id: UpdateSubscriptionContractInput['id'], deactivate = false, - onSubscriptionContract?: OSC, + onSubscriptionContract?: SubscriptionContractExtra, ): Promise> => { const api = apiClient.nextPimApi; const mutation = { @@ -126,9 +126,9 @@ export const createSubscriptionContractManager = (apiClient: ClientInterface) => return confirmation.cancelSubscriptionContract; }; - const pause = async } = {}>( + const pause = async } = {}>( id: UpdateSubscriptionContractInput['id'], - onSubscriptionContract?: OSC, + onSubscriptionContract?: SubscriptionContractExtra, ): Promise> => { const api = apiClient.nextPimApi; const mutation = { @@ -145,9 +145,9 @@ export const createSubscriptionContractManager = (apiClient: ClientInterface) => return confirmation.pauseSubscriptionContract; }; - const resume = async } = {}>( + const resume = async } = {}>( id: UpdateSubscriptionContractInput['id'], - onSubscriptionContract?: OSC, + onSubscriptionContract?: SubscriptionContractExtra, ): Promise> => { const api = apiClient.nextPimApi; const mutation = { @@ -164,9 +164,9 @@ export const createSubscriptionContractManager = (apiClient: ClientInterface) => return confirmation.resumeSubscriptionContract; }; - const renew = async } = {}>( + const renew = async } = {}>( id: UpdateSubscriptionContractInput['id'], - onSubscriptionContract?: OSC, + onSubscriptionContract?: SubscriptionContractExtra, ): Promise> => { const api = apiClient.nextPimApi; const mutation = { From d609b13c23a46c054f215cfa61db5db6ae907c19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Thu, 12 Mar 2026 16:59:59 -0700 Subject: [PATCH 13/21] feat(client): add Symbol.dispose support for automatic cleanup Enables `using client = createClient({...})` syntax (TypeScript 5.2+) so HTTP/2 connections are automatically closed when the scope exits. Added `esnext.disposable` to tsconfig lib and implemented Symbol.dispose on both ClientInterface and MassClientInterface. --- components/js-api-client/PRD.json | 1 + components/js-api-client/progress.txt | 1 + components/js-api-client/src/core/client/create-client.ts | 2 ++ components/js-api-client/src/core/create-mass-call-client.ts | 1 + components/js-api-client/tsconfig.json | 2 +- 5 files changed, 6 insertions(+), 1 deletion(-) diff --git a/components/js-api-client/PRD.json b/components/js-api-client/PRD.json index 7e59867b..9de933ba 100644 --- a/components/js-api-client/PRD.json +++ b/components/js-api-client/PRD.json @@ -231,6 +231,7 @@ }, { "id": "DX-012", + "status": "done", "title": "Add Symbol.dispose support for automatic cleanup", "priority": "P2", "category": "api-design", diff --git a/components/js-api-client/progress.txt b/components/js-api-client/progress.txt index 74415ab3..190a738c 100644 --- a/components/js-api-client/progress.txt +++ b/components/js-api-client/progress.txt @@ -10,3 +10,4 @@ DX-009: Replace tracked .env with .env.example - DONE DX-010: Improve authentication error messaging - DONE DX-014: Make Server-Timing header parsing robust - DONE DX-011: Improve generic parameter naming in fetchers - DONE +DX-012: Add Symbol.dispose support for automatic cleanup - DONE diff --git a/components/js-api-client/src/core/client/create-client.ts b/components/js-api-client/src/core/client/create-client.ts index 1adf28f9..da9e71c2 100644 --- a/components/js-api-client/src/core/client/create-client.ts +++ b/components/js-api-client/src/core/client/create-client.ts @@ -13,6 +13,7 @@ export type ClientInterface = { shopCartApi: ApiCaller; config: Pick; close: () => void; + [Symbol.dispose]: () => void; }; export type ClientConfiguration = { tenantIdentifier: string; @@ -100,5 +101,6 @@ export const createClient = (configuration: ClientConfiguration, options?: Creat origin: configuration.origin, }, close: grabClose, + [Symbol.dispose]: grabClose, }; }; diff --git a/components/js-api-client/src/core/create-mass-call-client.ts b/components/js-api-client/src/core/create-mass-call-client.ts index c2a3afa9..b55fb8a2 100644 --- a/components/js-api-client/src/core/create-mass-call-client.ts +++ b/components/js-api-client/src/core/create-mass-call-client.ts @@ -206,6 +206,7 @@ export function createMassCallClient( nextPimApi: client.nextPimApi, config: client.config, close: client.close, + [Symbol.dispose]: client[Symbol.dispose], enqueue: { catalogueApi: (query: string, variables?: VariablesType): string => { const key = `catalogueApi-${counter++}`; diff --git a/components/js-api-client/tsconfig.json b/components/js-api-client/tsconfig.json index 0c1b91d2..baddaa4d 100644 --- a/components/js-api-client/tsconfig.json +++ b/components/js-api-client/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "outDir": "./dist", "declaration": true, - "lib": ["es2021", "DOM"], + "lib": ["es2021", "DOM", "esnext.disposable"], "sourceMap": true }, "include": ["./src/**/*"] From d43d11cbec6355a8aad8d24148719fd754bbf342 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Thu, 12 Mar 2026 17:01:25 -0700 Subject: [PATCH 14/21] feat(client): add request timeout support via AbortController Add optional `timeout` field (in milliseconds) to CreateClientOptions. When configured, requests that exceed the timeout are automatically aborted using AbortSignal.timeout(). Works for both fetch and HTTP/2 code paths. Default is no timeout (backward compatible). --- components/js-api-client/PRD.json | 1 + components/js-api-client/progress.txt | 1 + .../src/core/client/create-api-caller.ts | 4 ++++ .../src/core/client/create-client.ts | 2 ++ .../src/core/client/create-grabber.ts | 21 ++++++++++++++++++- 5 files changed, 28 insertions(+), 1 deletion(-) diff --git a/components/js-api-client/PRD.json b/components/js-api-client/PRD.json index 9de933ba..819cdd74 100644 --- a/components/js-api-client/PRD.json +++ b/components/js-api-client/PRD.json @@ -256,6 +256,7 @@ }, { "id": "DX-013", + "status": "done", "title": "Add request timeout support via AbortController", "priority": "P2", "category": "reliability", diff --git a/components/js-api-client/progress.txt b/components/js-api-client/progress.txt index 190a738c..1a10677b 100644 --- a/components/js-api-client/progress.txt +++ b/components/js-api-client/progress.txt @@ -11,3 +11,4 @@ DX-010: Improve authentication error messaging - DONE DX-014: Make Server-Timing header parsing robust - DONE DX-011: Improve generic parameter naming in fetchers - DONE DX-012: Add Symbol.dispose support for automatic cleanup - DONE +DX-013: Add request timeout support via AbortController - DONE diff --git a/components/js-api-client/src/core/client/create-api-caller.ts b/components/js-api-client/src/core/client/create-api-caller.ts index 710c5c13..98988ea9 100644 --- a/components/js-api-client/src/core/client/create-api-caller.ts +++ b/components/js-api-client/src/core/client/create-api-caller.ts @@ -124,11 +124,15 @@ export const post = async ( } } + const timeout = options?.timeout; + const signal = timeout ? AbortSignal.timeout(timeout) : undefined; + const response = await grab(path, { ...initRest, method: 'POST', headers, body, + signal, }); if (profiling) { diff --git a/components/js-api-client/src/core/client/create-client.ts b/components/js-api-client/src/core/client/create-client.ts index da9e71c2..c617b9b5 100644 --- a/components/js-api-client/src/core/client/create-client.ts +++ b/components/js-api-client/src/core/client/create-client.ts @@ -31,6 +31,8 @@ export type CreateClientOptions = { useHttp2?: boolean; profiling?: ProfilingOptions; extraHeaders?: Record; + /** Request timeout in milliseconds. When set, requests that take longer will be aborted. */ + timeout?: number; shopApiToken?: { doNotFetch?: boolean; scopes?: string[]; diff --git a/components/js-api-client/src/core/client/create-grabber.ts b/components/js-api-client/src/core/client/create-grabber.ts index 63a22055..7912be84 100644 --- a/components/js-api-client/src/core/client/create-grabber.ts +++ b/components/js-api-client/src/core/client/create-grabber.ts @@ -14,6 +14,7 @@ export type GrabOptions = { method?: string; headers?: Record; body?: string; + signal?: AbortSignal; }; export type Grab = { grab: (url: string, options?: GrabOptions) => Promise; @@ -28,7 +29,8 @@ export const createGrabber = (options?: Options): Grab => { const IDLE_TIMEOUT = 300000; // 5 min idle timeout const grab = async (url: string, grabOptions?: GrabOptions): Promise => { if (options?.useHttp2 !== true) { - return fetch(url, grabOptions); + const { signal, ...fetchOptions } = grabOptions || {}; + return fetch(url, { ...fetchOptions, signal }); } const closeAndDeleteClient = (origin: string) => { const clientObj = clients.get(origin); @@ -72,6 +74,23 @@ export const createGrabber = (options?: Options): Grab => { ...grabOptions?.headers, }; const req = client.request(headers); + + if (grabOptions?.signal) { + const signal = grabOptions.signal; + if (signal.aborted) { + req.close(); + reject(signal.reason); + return; + } + const onAbort = () => { + req.close(); + reject(signal.reason); + }; + signal.addEventListener('abort', onAbort, { once: true }); + req.on('end', () => signal.removeEventListener('abort', onAbort)); + req.on('error', () => signal.removeEventListener('abort', onAbort)); + } + if (grabOptions?.body) { req.write(grabOptions.body); } From eee2987864f3f38109fa3219f2e8e1dc6ba6b31d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Thu, 12 Mar 2026 17:03:01 -0700 Subject: [PATCH 15/21] refactor(types): replace any with proper types in mass call client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace Promise with Promise (Record) - Replace any in afterRequest callback with Record - Replace any for exception in onFailure with unknown - Type buildStandardPromise return as { key: string; result: unknown } | undefined - Fix typo: situaion → situation in changeIncrementFor parameter --- components/js-api-client/PRD.json | 1 + components/js-api-client/progress.txt | 1 + .../src/core/create-mass-call-client.ts | 20 +++++++++---------- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/components/js-api-client/PRD.json b/components/js-api-client/PRD.json index 819cdd74..71d9b84b 100644 --- a/components/js-api-client/PRD.json +++ b/components/js-api-client/PRD.json @@ -322,6 +322,7 @@ }, { "id": "DX-016", + "status": "done", "title": "Reduce any usage in mass call client types", "priority": "P2", "category": "type-safety", diff --git a/components/js-api-client/progress.txt b/components/js-api-client/progress.txt index 1a10677b..593c664e 100644 --- a/components/js-api-client/progress.txt +++ b/components/js-api-client/progress.txt @@ -12,3 +12,4 @@ DX-014: Make Server-Timing header parsing robust - DONE DX-011: Improve generic parameter naming in fetchers - DONE DX-012: Add Symbol.dispose support for automatic cleanup - DONE DX-013: Add request timeout support via AbortController - DONE +DX-016: Reduce any usage in mass call client types - DONE diff --git a/components/js-api-client/src/core/create-mass-call-client.ts b/components/js-api-client/src/core/create-mass-call-client.ts index b55fb8a2..5e258564 100644 --- a/components/js-api-client/src/core/create-mass-call-client.ts +++ b/components/js-api-client/src/core/create-mass-call-client.ts @@ -14,12 +14,14 @@ export type MassCallClientBatch = { }; export type QueuedApiCaller = (query: string, variables?: VariablesType) => string; +export type MassCallResults = Record; + export type MassClientInterface = ClientInterface & { - execute: () => Promise; + execute: () => Promise; reset: () => void; hasFailed: () => boolean; failureCount: () => number; - retry: () => Promise; + retry: () => Promise; catalogueApi: ApiCaller; discoveryApi: ApiCaller; pimApi: ApiCaller; @@ -74,15 +76,15 @@ export function createMassCallClient( maxSpawn?: number; onBatchDone?: (batch: MassCallClientBatch) => Promise; beforeRequest?: (batch: MassCallClientBatch, promise: CrystallizePromise) => Promise; - afterRequest?: (batch: MassCallClientBatch, promise: CrystallizePromise, results: any) => Promise; + afterRequest?: (batch: MassCallClientBatch, promise: CrystallizePromise, results: Record) => Promise; onFailure?: ( batch: { from: number; to: number }, - exception: any, + exception: unknown, promise: CrystallizePromise, ) => Promise; sleeper?: Sleeper; changeIncrementFor?: ( - situaion: 'more-than-half-have-failed' | 'some-have-failed' | 'none-have-failed', + situation: 'more-than-half-have-failed' | 'some-have-failed' | 'none-have-failed', currentIncrement: number, ) => number; }, @@ -97,16 +99,14 @@ export function createMassCallClient( const execute = async () => { failedPromises = []; let batch = []; - let results: { - [key: string]: any; - } = {}; + let results: MassCallResults = {}; do { let batchErrorCount = 0; const to = seek + increment; batch = promises.slice(seek, to); const batchResults = await Promise.all( batch.map(async (promise: CrystallizePromise) => { - const buildStandardPromise = async (promise: CrystallizePromise): Promise => { + const buildStandardPromise = async (promise: CrystallizePromise): Promise<{ key: string; result: unknown } | undefined> => { try { return { key: promise.key, @@ -129,7 +129,7 @@ export function createMassCallClient( } // otherwise we wrap it - return new Promise(async (resolve) => { + return new Promise<{ key: string; result: unknown } | undefined>(async (resolve) => { let alteredPromise; if (options.beforeRequest) { alteredPromise = await options.beforeRequest({ from: seek, to: to }, promise); From f122d54dbc83527c3b287ef4b93e3f80d8587602 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Thu, 12 Mar 2026 17:04:02 -0700 Subject: [PATCH 16/21] refactor(mass-call): replace repetitive enqueue methods with generated approach The five identical enqueue methods (catalogueApi, discoveryApi, pimApi, nextPimApi, shopCartApi) differed only in their key prefix and caller reference. Replaced with Object.fromEntries to eliminate ~20 lines of boilerplate while preserving the public API and types. --- components/js-api-client/PRD.json | 1 + components/js-api-client/progress.txt | 1 + .../src/core/create-mass-call-client.ts | 37 +++++-------------- 3 files changed, 12 insertions(+), 27 deletions(-) diff --git a/components/js-api-client/PRD.json b/components/js-api-client/PRD.json index 71d9b84b..007d6fef 100644 --- a/components/js-api-client/PRD.json +++ b/components/js-api-client/PRD.json @@ -303,6 +303,7 @@ }, { "id": "DX-015", + "status": "done", "title": "Reduce boilerplate in mass call client enqueue methods", "priority": "P3", "category": "code-quality", diff --git a/components/js-api-client/progress.txt b/components/js-api-client/progress.txt index 593c664e..0e82e2de 100644 --- a/components/js-api-client/progress.txt +++ b/components/js-api-client/progress.txt @@ -13,3 +13,4 @@ DX-011: Improve generic parameter naming in fetchers - DONE DX-012: Add Symbol.dispose support for automatic cleanup - DONE DX-013: Add request timeout support via AbortController - DONE DX-016: Reduce any usage in mass call client types - DONE +DX-015: Reduce boilerplate in mass call client enqueue methods - DONE diff --git a/components/js-api-client/src/core/create-mass-call-client.ts b/components/js-api-client/src/core/create-mass-call-client.ts index 5e258564..af60a030 100644 --- a/components/js-api-client/src/core/create-mass-call-client.ts +++ b/components/js-api-client/src/core/create-mass-call-client.ts @@ -207,32 +207,15 @@ export function createMassCallClient( config: client.config, close: client.close, [Symbol.dispose]: client[Symbol.dispose], - enqueue: { - catalogueApi: (query: string, variables?: VariablesType): string => { - const key = `catalogueApi-${counter++}`; - promises.push({ key, caller: client.catalogueApi, query, variables }); - return key; - }, - discoveryApi: (query: string, variables?: VariablesType): string => { - const key = `discoveryApi-${counter++}`; - promises.push({ key, caller: client.discoveryApi, query, variables }); - return key; - }, - pimApi: (query: string, variables?: VariablesType): string => { - const key = `pimApi-${counter++}`; - promises.push({ key, caller: client.pimApi, query, variables }); - return key; - }, - nextPimApi: (query: string, variables?: VariablesType): string => { - const key = `nextPimApi-${counter++}`; - promises.push({ key, caller: client.nextPimApi, query, variables }); - return key; - }, - shopCartApi: (query: string, variables?: VariablesType): string => { - const key = `shopCartApi-${counter++}`; - promises.push({ key, caller: client.shopCartApi, query, variables }); - return key; - }, - }, + enqueue: Object.fromEntries( + (['catalogueApi', 'discoveryApi', 'pimApi', 'nextPimApi', 'shopCartApi'] as const).map((apiName) => [ + apiName, + (query: string, variables?: VariablesType): string => { + const key = `${apiName}-${counter++}`; + promises.push({ key, caller: client[apiName], query, variables }); + return key; + }, + ]), + ) as MassClientInterface['enqueue'], }; } From 5bf0ea01f58e3c90fab5e263b276b27207fc1509 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Thu, 12 Mar 2026 17:07:07 -0700 Subject: [PATCH 17/21] test: add unit tests with mocked HTTP for core modules Add 38 unit tests covering create-api-caller, create-grabber, and create-mass-call-client without requiring API credentials or network access. Tests cover: authentication headers, successful responses, HTTP errors, GraphQL errors, Core Next wrapped errors, 204 handling, profiling, mass call batching, retry logic, and adaptive concurrency. --- components/js-api-client/PRD.json | 1 + components/js-api-client/progress.txt | 1 + .../tests/unit/create-api-caller.test.ts | 277 ++++++++++++++++++ .../tests/unit/create-grabber.test.ts | 78 +++++ .../unit/create-mass-call-client.test.ts | 228 ++++++++++++++ 5 files changed, 585 insertions(+) create mode 100644 components/js-api-client/tests/unit/create-api-caller.test.ts create mode 100644 components/js-api-client/tests/unit/create-grabber.test.ts create mode 100644 components/js-api-client/tests/unit/create-mass-call-client.test.ts diff --git a/components/js-api-client/PRD.json b/components/js-api-client/PRD.json index 007d6fef..5e96b7fa 100644 --- a/components/js-api-client/PRD.json +++ b/components/js-api-client/PRD.json @@ -345,6 +345,7 @@ }, { "id": "DX-017", + "status": "done", "title": "Add unit tests with mocked HTTP for core modules", "priority": "P3", "category": "testing", diff --git a/components/js-api-client/progress.txt b/components/js-api-client/progress.txt index 0e82e2de..f72eac7e 100644 --- a/components/js-api-client/progress.txt +++ b/components/js-api-client/progress.txt @@ -14,3 +14,4 @@ DX-012: Add Symbol.dispose support for automatic cleanup - DONE DX-013: Add request timeout support via AbortController - DONE DX-016: Reduce any usage in mass call client types - DONE DX-015: Reduce boilerplate in mass call client enqueue methods - DONE +DX-017: Add unit tests with mocked HTTP for core modules - DONE diff --git a/components/js-api-client/tests/unit/create-api-caller.test.ts b/components/js-api-client/tests/unit/create-api-caller.test.ts new file mode 100644 index 00000000..7efde203 --- /dev/null +++ b/components/js-api-client/tests/unit/create-api-caller.test.ts @@ -0,0 +1,277 @@ +import { describe, test, expect, vi } from 'vitest'; +import { createApiCaller, post, authenticationHeaders, JSApiClientCallError } from '../../src/core/client/create-api-caller.js'; +import type { Grab, GrabResponse } from '../../src/core/client/create-grabber.js'; +import type { ClientConfiguration } from '../../src/core/client/create-client.js'; + +const mockGrabResponse = (overrides: Partial & { jsonData?: unknown } = {}): GrabResponse => { + const { jsonData = { data: { test: true } }, ...rest } = overrides; + return { + ok: true, + status: 200, + statusText: 'OK', + headers: { get: () => null }, + json: () => Promise.resolve(jsonData as any), + text: () => Promise.resolve(JSON.stringify(jsonData)), + ...rest, + }; +}; + +const mockGrab = (response: GrabResponse): Grab['grab'] => { + return vi.fn().mockResolvedValue(response); +}; + +const defaultConfig: ClientConfiguration = { + tenantIdentifier: 'test-tenant', + tenantId: 'test-id', + accessTokenId: 'token-id', + accessTokenSecret: 'token-secret', +}; + +describe('authenticationHeaders', () => { + test('returns session cookie when sessionId is set', () => { + const headers = authenticationHeaders({ ...defaultConfig, sessionId: 'sess123' }); + expect(headers).toEqual({ Cookie: 'connect.sid=sess123' }); + }); + + test('returns static auth token when set (and no sessionId)', () => { + const headers = authenticationHeaders({ ...defaultConfig, staticAuthToken: 'static-tok' }); + expect(headers).toEqual({ 'X-Crystallize-Static-Auth-Token': 'static-tok' }); + }); + + test('sessionId takes priority over staticAuthToken', () => { + const headers = authenticationHeaders({ + ...defaultConfig, + sessionId: 'sess123', + staticAuthToken: 'static-tok', + }); + expect(headers).toEqual({ Cookie: 'connect.sid=sess123' }); + }); + + test('returns access token headers when no session or static token', () => { + const headers = authenticationHeaders(defaultConfig); + expect(headers).toEqual({ + 'X-Crystallize-Access-Token-Id': 'token-id', + 'X-Crystallize-Access-Token-Secret': 'token-secret', + }); + }); + + test('warns when no auth is configured', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const config: ClientConfiguration = { tenantIdentifier: 'test' }; + authenticationHeaders(config); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No authentication credentials configured')); + warnSpy.mockRestore(); + }); + + test('warns only once per config object', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const config: ClientConfiguration = { tenantIdentifier: 'test-once' }; + authenticationHeaders(config); + authenticationHeaders(config); + authenticationHeaders(config); + expect(warnSpy).toHaveBeenCalledTimes(1); + warnSpy.mockRestore(); + }); +}); + +describe('post', () => { + test('successful response returns data', async () => { + const grab = mockGrab(mockGrabResponse({ jsonData: { data: { items: [1, 2, 3] } } })); + const result = await post(grab, 'https://api.test.com', defaultConfig, '{ items }'); + expect(result).toEqual({ items: [1, 2, 3] }); + }); + + test('passes query and variables in request body', async () => { + const grab = mockGrab(mockGrabResponse()); + await post(grab, 'https://api.test.com', defaultConfig, '{ items }', { limit: 10 }); + expect(grab).toHaveBeenCalledWith('https://api.test.com', expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ query: '{ items }', variables: { limit: 10 } }), + })); + }); + + test('includes authentication headers', async () => { + const grab = mockGrab(mockGrabResponse()); + await post(grab, 'https://api.test.com', defaultConfig, '{ items }'); + expect(grab).toHaveBeenCalledWith('https://api.test.com', expect.objectContaining({ + headers: expect.objectContaining({ + 'X-Crystallize-Access-Token-Id': 'token-id', + 'X-Crystallize-Access-Token-Secret': 'token-secret', + 'Content-type': 'application/json; charset=UTF-8', + }), + })); + }); + + test('204 No Content returns empty object', async () => { + const grab = mockGrab(mockGrabResponse({ ok: true, status: 204, statusText: 'No Content' })); + const result = await post(grab, 'https://api.test.com', defaultConfig, 'mutation { delete }'); + expect(result).toEqual({}); + }); + + test('throws JSApiClientCallError on HTTP error', async () => { + const grab = mockGrab(mockGrabResponse({ + ok: false, + status: 401, + statusText: 'Unauthorized', + jsonData: { message: 'Invalid credentials', errors: [{ field: 'token' }] }, + })); + try { + await post(grab, 'https://api.test.com', defaultConfig, '{ items }'); + expect.unreachable('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(JSApiClientCallError); + const err = e as JSApiClientCallError; + expect(err.name).toBe('JSApiClientCallError'); + expect(err.code).toBe(401); + expect(err.statusText).toBe('Unauthorized'); + expect(err.query).toBe('{ items }'); + } + }); + + test('throws on GraphQL errors in 200 response', async () => { + const grab = mockGrab(mockGrabResponse({ + jsonData: { + errors: [{ message: 'Field "foo" not found' }], + data: null, + }, + })); + try { + await post(grab, 'https://api.test.com', defaultConfig, '{ foo }'); + expect.unreachable('should have thrown'); + } catch (e) { + const err = e as JSApiClientCallError; + expect(err.code).toBe(400); + expect(err.message).toBe('Field "foo" not found'); + expect(err.statusText).toBe('Error was returned from the API'); + } + }); + + test('detects Core Next wrapped errors', async () => { + const grab = mockGrab(mockGrabResponse({ + jsonData: { + data: { + someOperation: { + errorName: 'ItemNotFound', + message: 'The item does not exist', + }, + }, + }, + })); + try { + await post(grab, 'https://api.test.com', defaultConfig, '{ someOperation }'); + expect.unreachable('should have thrown'); + } catch (e) { + const err = e as JSApiClientCallError; + expect(err.code).toBe(400); + expect(err.message).toBe('[ItemNotFound] The item does not exist'); + expect(err.statusText).toContain('Core Next'); + } + }); + + test('Core Next error without message uses fallback', async () => { + const grab = mockGrab(mockGrabResponse({ + jsonData: { + data: { + op: { errorName: 'GenericError' }, + }, + }, + })); + try { + await post(grab, 'https://api.test.com', defaultConfig, '{ op }'); + expect.unreachable('should have thrown'); + } catch (e) { + const err = e as JSApiClientCallError; + expect(err.message).toBe('[GenericError] An error occurred'); + } + }); + + test('includes extra headers from options', async () => { + const grab = mockGrab(mockGrabResponse()); + await post(grab, 'https://api.test.com', defaultConfig, '{ items }', undefined, { + headers: { 'X-Custom': 'value' }, + }); + expect(grab).toHaveBeenCalledWith('https://api.test.com', expect.objectContaining({ + headers: expect.objectContaining({ 'X-Custom': 'value' }), + })); + }); + + test('error includes query and variables for debugging', async () => { + const grab = mockGrab(mockGrabResponse({ + ok: false, status: 500, statusText: 'Internal Server Error', + jsonData: { message: 'Server error', errors: [] }, + })); + const variables = { id: '123' }; + try { + await post(grab, 'https://api.test.com', defaultConfig, '{ item(id: $id) }', variables); + expect.unreachable('should have thrown'); + } catch (e) { + const err = e as JSApiClientCallError; + expect(err.query).toBe('{ item(id: $id) }'); + expect(err.variables).toEqual({ id: '123' }); + } + }); +}); + +describe('createApiCaller', () => { + test('returns a callable function', () => { + const grab = mockGrab(mockGrabResponse()); + const caller = createApiCaller(grab, 'https://api.test.com', defaultConfig); + expect(typeof caller).toBe('function'); + }); + + test('caller delegates to post with correct URL', async () => { + const grab = mockGrab(mockGrabResponse({ jsonData: { data: { result: 42 } } })); + const caller = createApiCaller(grab, 'https://api.test.com/graphql', defaultConfig); + const result = await caller('{ result }'); + expect(result).toEqual({ result: 42 }); + expect(grab).toHaveBeenCalledWith('https://api.test.com/graphql', expect.anything()); + }); + + test('passes extra headers from options', async () => { + const grab = mockGrab(mockGrabResponse()); + const caller = createApiCaller(grab, 'https://api.test.com', defaultConfig, { + extraHeaders: { 'X-Tenant': 'test' }, + }); + await caller('{ items }'); + expect(grab).toHaveBeenCalledWith('https://api.test.com', expect.objectContaining({ + headers: expect.objectContaining({ 'X-Tenant': 'test' }), + })); + }); +}); + +describe('profiling', () => { + test('calls onRequest and onRequestResolved', async () => { + const onRequest = vi.fn(); + const onRequestResolved = vi.fn(); + const grab = mockGrab(mockGrabResponse({ + headers: { get: (name: string) => name === 'server-timing' ? 'total;dur=42.5' : null }, + })); + const caller = createApiCaller(grab, 'https://api.test.com', defaultConfig, { + profiling: { onRequest, onRequestResolved }, + }); + await caller('{ items }', { limit: 5 }); + expect(onRequest).toHaveBeenCalledWith('{ items }', { limit: 5 }); + expect(onRequestResolved).toHaveBeenCalledWith( + expect.objectContaining({ + serverTimeMs: 42.5, + resolutionTimeMs: expect.any(Number), + }), + '{ items }', + { limit: 5 }, + ); + }); + + test('handles missing server-timing header', async () => { + const onRequestResolved = vi.fn(); + const grab = mockGrab(mockGrabResponse()); + const caller = createApiCaller(grab, 'https://api.test.com', defaultConfig, { + profiling: { onRequestResolved }, + }); + await caller('{ items }'); + expect(onRequestResolved).toHaveBeenCalledWith( + expect.objectContaining({ serverTimeMs: -1 }), + '{ items }', + undefined, + ); + }); +}); diff --git a/components/js-api-client/tests/unit/create-grabber.test.ts b/components/js-api-client/tests/unit/create-grabber.test.ts new file mode 100644 index 00000000..e6cc8789 --- /dev/null +++ b/components/js-api-client/tests/unit/create-grabber.test.ts @@ -0,0 +1,78 @@ +import { describe, test, expect, vi } from 'vitest'; +import { createGrabber } from '../../src/core/client/create-grabber.js'; + +describe('createGrabber (fetch mode)', () => { + test('returns grab and close functions', () => { + const grabber = createGrabber(); + expect(typeof grabber.grab).toBe('function'); + expect(typeof grabber.close).toBe('function'); + }); + + test('grab delegates to fetch with correct options', async () => { + const mockResponse = { + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers({ 'content-type': 'application/json' }), + json: () => Promise.resolve({ data: 'test' }), + text: () => Promise.resolve('{"data":"test"}'), + }; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as Response); + + const { grab } = createGrabber(); + const response = await grab('https://api.test.com/graphql', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{"query":"{ test }"}', + }); + + expect(fetchSpy).toHaveBeenCalledWith('https://api.test.com/graphql', expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{"query":"{ test }"}', + })); + expect(response.ok).toBe(true); + expect(response.status).toBe(200); + + const json = await response.json(); + expect(json).toEqual({ data: 'test' }); + + fetchSpy.mockRestore(); + }); + + test('passes signal to fetch for abort support', async () => { + const mockResponse = { + ok: true, + status: 200, + statusText: 'OK', + headers: new Headers(), + json: () => Promise.resolve({}), + text: () => Promise.resolve('{}'), + }; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(mockResponse as Response); + const controller = new AbortController(); + + const { grab } = createGrabber(); + await grab('https://api.test.com', { signal: controller.signal }); + + expect(fetchSpy).toHaveBeenCalledWith('https://api.test.com', expect.objectContaining({ + signal: controller.signal, + })); + + fetchSpy.mockRestore(); + }); + + test('close is callable without error', () => { + const { close } = createGrabber(); + expect(() => close()).not.toThrow(); + }); + + test('propagates fetch errors', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network failure')); + + const { grab } = createGrabber(); + await expect(grab('https://api.test.com')).rejects.toThrow('Network failure'); + + fetchSpy.mockRestore(); + }); +}); diff --git a/components/js-api-client/tests/unit/create-mass-call-client.test.ts b/components/js-api-client/tests/unit/create-mass-call-client.test.ts new file mode 100644 index 00000000..abcfef20 --- /dev/null +++ b/components/js-api-client/tests/unit/create-mass-call-client.test.ts @@ -0,0 +1,228 @@ +import { describe, test, expect, vi } from 'vitest'; +import { createMassCallClient } from '../../src/core/create-mass-call-client.js'; +import type { ClientInterface } from '../../src/core/client/create-client.js'; +import type { ApiCaller } from '../../src/core/client/create-api-caller.js'; + +const createMockCaller = (results?: Record): ApiCaller => { + let callCount = 0; + return vi.fn(async (query: string) => { + callCount++; + return results?.[query] ?? { success: true, call: callCount }; + }) as unknown as ApiCaller; +}; + +const createMockClient = (overrides?: Partial>): ClientInterface => { + return { + catalogueApi: overrides?.catalogueApi ?? createMockCaller(), + discoveryApi: overrides?.discoveryApi ?? createMockCaller(), + pimApi: overrides?.pimApi ?? createMockCaller(), + nextPimApi: overrides?.nextPimApi ?? createMockCaller(), + shopCartApi: overrides?.shopCartApi ?? createMockCaller(), + config: { tenantIdentifier: 'test', tenantId: 'test-id' }, + close: vi.fn(), + [Symbol.dispose]: vi.fn(), + }; +}; + +const noopSleeper = () => ({ + wait: () => Promise.resolve(), + reset: () => {}, +}); + +describe('createMassCallClient', () => { + test('enqueue returns a unique key', () => { + const client = createMockClient(); + const mass = createMassCallClient(client, {}); + const key1 = mass.enqueue.pimApi('{ query1 }'); + const key2 = mass.enqueue.pimApi('{ query2 }'); + expect(key1).not.toBe(key2); + expect(key1).toContain('pimApi'); + expect(key2).toContain('pimApi'); + }); + + test('execute runs all enqueued requests and returns results', async () => { + const pimCaller = createMockCaller(); + const client = createMockClient({ pimApi: pimCaller }); + const mass = createMassCallClient(client, { sleeper: noopSleeper() }); + + const key1 = mass.enqueue.pimApi('{ query1 }'); + const key2 = mass.enqueue.pimApi('{ query2 }'); + const results = await mass.execute(); + + expect(results[key1]).toBeDefined(); + expect(results[key2]).toBeDefined(); + expect(pimCaller).toHaveBeenCalledTimes(2); + }); + + test('execute with different API types', async () => { + const client = createMockClient(); + const mass = createMassCallClient(client, { sleeper: noopSleeper() }); + + const k1 = mass.enqueue.catalogueApi('{ catalogue }'); + const k2 = mass.enqueue.pimApi('{ pim }'); + const k3 = mass.enqueue.discoveryApi('{ discovery }'); + const results = await mass.execute(); + + expect(results[k1]).toBeDefined(); + expect(results[k2]).toBeDefined(); + expect(results[k3]).toBeDefined(); + }); + + test('reset clears queue and state', async () => { + const client = createMockClient(); + const mass = createMassCallClient(client, { sleeper: noopSleeper() }); + + mass.enqueue.pimApi('{ query1 }'); + mass.reset(); + + const results = await mass.execute(); + expect(Object.keys(results)).toHaveLength(0); + }); + + test('hasFailed and failureCount track failures', async () => { + const failingCaller = vi.fn().mockRejectedValue(new Error('fail')) as unknown as ApiCaller; + const client = createMockClient({ pimApi: failingCaller }); + const mass = createMassCallClient(client, { sleeper: noopSleeper() }); + + mass.enqueue.pimApi('{ fail1 }'); + mass.enqueue.pimApi('{ fail2 }'); + await mass.execute(); + + expect(mass.hasFailed()).toBe(true); + expect(mass.failureCount()).toBe(2); + }); + + test('retry re-executes failed requests', async () => { + let callCount = 0; + const sometimesFails = vi.fn(async () => { + callCount++; + if (callCount <= 2) throw new Error('temporary failure'); + return { recovered: true }; + }) as unknown as ApiCaller; + + const client = createMockClient({ pimApi: sometimesFails }); + const mass = createMassCallClient(client, { sleeper: noopSleeper() }); + + mass.enqueue.pimApi('{ q1 }'); + mass.enqueue.pimApi('{ q2 }'); + await mass.execute(); + + expect(mass.hasFailed()).toBe(true); + const retryResults = await mass.retry(); + expect(mass.hasFailed()).toBe(false); + expect(Object.values(retryResults).every((r: any) => r.recovered)).toBe(true); + }); + + test('onFailure callback controls retry queuing', async () => { + const failingCaller = vi.fn().mockRejectedValue(new Error('fail')) as unknown as ApiCaller; + const client = createMockClient({ pimApi: failingCaller }); + const onFailure = vi.fn().mockResolvedValue(false); // don't retry + + const mass = createMassCallClient(client, { onFailure, sleeper: noopSleeper() }); + mass.enqueue.pimApi('{ q1 }'); + await mass.execute(); + + expect(onFailure).toHaveBeenCalled(); + expect(mass.hasFailed()).toBe(false); // not queued for retry + }); + + test('batch size adapts: increases on success', async () => { + const caller = createMockCaller(); + const client = createMockClient({ pimApi: caller }); + const batches: Array<{ from: number; to: number }> = []; + const mass = createMassCallClient(client, { + initialSpawn: 1, + maxSpawn: 5, + sleeper: noopSleeper(), + onBatchDone: async (batch) => { batches.push(batch); }, + }); + + for (let i = 0; i < 6; i++) { + mass.enqueue.pimApi(`{ q${i} }`); + } + await mass.execute(); + + // First batch: 1 item, second: 2 items, third: 3 items = 6 total + expect(batches[0]).toEqual({ from: 0, to: 1 }); + expect(batches[1]).toEqual({ from: 1, to: 3 }); + expect(batches[2]).toEqual({ from: 3, to: 6 }); + }); + + test('batch size does not exceed maxSpawn', async () => { + const caller = createMockCaller(); + const client = createMockClient({ pimApi: caller }); + const mass = createMassCallClient(client, { + initialSpawn: 3, + maxSpawn: 3, + sleeper: noopSleeper(), + }); + + for (let i = 0; i < 9; i++) { + mass.enqueue.pimApi(`{ q${i} }`); + } + await mass.execute(); + // All batches should be size 3 + expect(caller).toHaveBeenCalledTimes(9); + }); + + test('batch size decreases on errors', async () => { + let callNum = 0; + const mixedCaller = vi.fn(async () => { + callNum++; + // First batch (3 items): 2 fail = more than half + if (callNum <= 2) throw new Error('fail'); + return { ok: true }; + }) as unknown as ApiCaller; + + const client = createMockClient({ pimApi: mixedCaller }); + const changeIncrementFor = vi.fn((situation: string, current: number) => { + if (situation === 'more-than-half-have-failed') return 1; + if (situation === 'some-have-failed') return current - 1; + return current + 1; + }); + + const mass = createMassCallClient(client, { + initialSpawn: 3, + maxSpawn: 5, + sleeper: noopSleeper(), + changeIncrementFor, + }); + + for (let i = 0; i < 5; i++) { + mass.enqueue.pimApi(`{ q${i} }`); + } + await mass.execute(); + + expect(changeIncrementFor).toHaveBeenCalled(); + }); + + test('beforeRequest and afterRequest hooks are called', async () => { + const caller = createMockCaller(); + const client = createMockClient({ pimApi: caller }); + const beforeRequest = vi.fn().mockResolvedValue(undefined); + const afterRequest = vi.fn().mockResolvedValue(undefined); + + const mass = createMassCallClient(client, { + beforeRequest, + afterRequest, + sleeper: noopSleeper(), + }); + + mass.enqueue.pimApi('{ q1 }'); + await mass.execute(); + + expect(beforeRequest).toHaveBeenCalledTimes(1); + expect(afterRequest).toHaveBeenCalledTimes(1); + }); + + test('passes through API callers from original client', () => { + const client = createMockClient(); + const mass = createMassCallClient(client, {}); + + expect(mass.catalogueApi).toBe(client.catalogueApi); + expect(mass.pimApi).toBe(client.pimApi); + expect(mass.discoveryApi).toBe(client.discoveryApi); + expect(mass.nextPimApi).toBe(client.nextPimApi); + expect(mass.shopCartApi).toBe(client.shopCartApi); + }); +}); From f6696696fe0f3ee5d370c0ed84b6247d9a8c369f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Thu, 12 Mar 2026 17:08:56 -0700 Subject: [PATCH 18/21] test: add comprehensive error-path tests for API caller Cover HTTP error codes (400-503), GraphQL errors in 200 responses, Core Next wrapped errors, network failures, timeout scenarios, malformed JSON responses, 204 No Content, and JSApiClientCallError property validation. --- components/js-api-client/PRD.json | 1 + components/js-api-client/progress.txt | 1 + .../tests/unit/error-handling.test.ts | 454 ++++++++++++++++++ 3 files changed, 456 insertions(+) create mode 100644 components/js-api-client/tests/unit/error-handling.test.ts diff --git a/components/js-api-client/PRD.json b/components/js-api-client/PRD.json index 5e96b7fa..b1bbb9e7 100644 --- a/components/js-api-client/PRD.json +++ b/components/js-api-client/PRD.json @@ -371,6 +371,7 @@ }, { "id": "DX-018", + "status": "done", "title": "Add error-path tests for API caller", "priority": "P3", "category": "testing", diff --git a/components/js-api-client/progress.txt b/components/js-api-client/progress.txt index f72eac7e..d2deced2 100644 --- a/components/js-api-client/progress.txt +++ b/components/js-api-client/progress.txt @@ -15,3 +15,4 @@ DX-013: Add request timeout support via AbortController - DONE DX-016: Reduce any usage in mass call client types - DONE DX-015: Reduce boilerplate in mass call client enqueue methods - DONE DX-017: Add unit tests with mocked HTTP for core modules - DONE +DX-018: Add error-path tests for API caller - DONE diff --git a/components/js-api-client/tests/unit/error-handling.test.ts b/components/js-api-client/tests/unit/error-handling.test.ts new file mode 100644 index 00000000..bf770a0e --- /dev/null +++ b/components/js-api-client/tests/unit/error-handling.test.ts @@ -0,0 +1,454 @@ +import { describe, test, expect, vi } from 'vitest'; +import { post, JSApiClientCallError } from '../../src/core/client/create-api-caller.js'; +import type { GrabResponse } from '../../src/core/client/create-grabber.js'; +import type { ClientConfiguration } from '../../src/core/client/create-client.js'; + +const mockGrabResponse = (overrides: Partial & { jsonData?: unknown } = {}): GrabResponse => { + const { jsonData = { data: {} }, ...rest } = overrides; + return { + ok: true, + status: 200, + statusText: 'OK', + headers: { get: () => null }, + json: () => Promise.resolve(jsonData as any), + text: () => Promise.resolve(JSON.stringify(jsonData)), + ...rest, + }; +}; + +const defaultConfig: ClientConfiguration = { + tenantIdentifier: 'test-tenant', + tenantId: 'test-id', + accessTokenId: 'token-id', + accessTokenSecret: 'token-secret', +}; + +const query = '{ items { id name } }'; +const variables = { lang: 'en' }; + +describe('HTTP error codes', () => { + const errorCases = [ + { status: 400, statusText: 'Bad Request', message: 'Invalid query syntax' }, + { status: 401, statusText: 'Unauthorized', message: 'Invalid credentials' }, + { status: 403, statusText: 'Forbidden', message: 'Access denied' }, + { status: 404, statusText: 'Not Found', message: 'Endpoint not found' }, + { status: 429, statusText: 'Too Many Requests', message: 'Rate limit exceeded' }, + { status: 500, statusText: 'Internal Server Error', message: 'Server error' }, + { status: 502, statusText: 'Bad Gateway', message: 'Upstream failure' }, + { status: 503, statusText: 'Service Unavailable', message: 'Service down' }, + ]; + + test.each(errorCases)( + 'throws JSApiClientCallError for HTTP $status ($statusText)', + async ({ status, statusText, message }) => { + const grab = vi.fn().mockResolvedValue( + mockGrabResponse({ + ok: false, + status, + statusText, + jsonData: { message, errors: [] }, + }), + ); + + try { + await post(grab, 'https://api.test.com', defaultConfig, query, variables); + expect.unreachable('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(JSApiClientCallError); + const err = e as JSApiClientCallError; + expect(err.name).toBe('JSApiClientCallError'); + expect(err.code).toBe(status); + expect(err.statusText).toBe(statusText); + expect(err.message).toBe(message); + expect(err.query).toBe(query); + expect(err.variables).toEqual(variables); + } + }, + ); + + test('error includes errors array from response', async () => { + const errors = [{ field: 'token', message: 'expired' }, { field: 'scope', message: 'insufficient' }]; + const grab = vi.fn().mockResolvedValue( + mockGrabResponse({ + ok: false, + status: 403, + statusText: 'Forbidden', + jsonData: { message: 'Forbidden', errors }, + }), + ); + + try { + await post(grab, 'https://api.test.com', defaultConfig, query); + expect.unreachable('should have thrown'); + } catch (e) { + const err = e as JSApiClientCallError; + expect(err.errors).toEqual(errors); + } + }); + + test('error defaults variables to empty object when undefined', async () => { + const grab = vi.fn().mockResolvedValue( + mockGrabResponse({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + jsonData: { message: 'fail', errors: [] }, + }), + ); + + try { + await post(grab, 'https://api.test.com', defaultConfig, query); + expect.unreachable('should have thrown'); + } catch (e) { + const err = e as JSApiClientCallError; + expect(err.variables).toEqual({}); + } + }); +}); + +describe('GraphQL errors in 200 response', () => { + test('throws on single GraphQL error', async () => { + const grab = vi.fn().mockResolvedValue( + mockGrabResponse({ + jsonData: { + errors: [{ message: 'Cannot query field "foo" on type "Query"' }], + data: null, + }, + }), + ); + + try { + await post(grab, 'https://api.test.com', defaultConfig, '{ foo }'); + expect.unreachable('should have thrown'); + } catch (e) { + const err = e as JSApiClientCallError; + expect(err).toBeInstanceOf(JSApiClientCallError); + expect(err.code).toBe(400); + expect(err.message).toBe('Cannot query field "foo" on type "Query"'); + expect(err.statusText).toBe('Error was returned from the API'); + expect(err.errors).toEqual([{ message: 'Cannot query field "foo" on type "Query"' }]); + } + }); + + test('uses first error message when multiple GraphQL errors', async () => { + const grab = vi.fn().mockResolvedValue( + mockGrabResponse({ + jsonData: { + errors: [ + { message: 'First error' }, + { message: 'Second error' }, + { message: 'Third error' }, + ], + data: null, + }, + }), + ); + + try { + await post(grab, 'https://api.test.com', defaultConfig, '{ bad }'); + expect.unreachable('should have thrown'); + } catch (e) { + const err = e as JSApiClientCallError; + expect(err.message).toBe('First error'); + expect(err.errors).toHaveLength(3); + } + }); + + test('preserves query and variables in GraphQL error', async () => { + const grab = vi.fn().mockResolvedValue( + mockGrabResponse({ + jsonData: { + errors: [{ message: 'Validation error' }], + data: null, + }, + }), + ); + + const vars = { id: 'abc', limit: 5 }; + try { + await post(grab, 'https://api.test.com', defaultConfig, 'query Q($id: ID!) { item(id: $id) }', vars); + expect.unreachable('should have thrown'); + } catch (e) { + const err = e as JSApiClientCallError; + expect(err.query).toBe('query Q($id: ID!) { item(id: $id) }'); + expect(err.variables).toEqual(vars); + } + }); +}); + +describe('Core Next wrapped errors', () => { + test('detects errorName at second level of data', async () => { + const grab = vi.fn().mockResolvedValue( + mockGrabResponse({ + jsonData: { + data: { + createItem: { + errorName: 'ValidationError', + message: 'Name is required', + }, + }, + }, + }), + ); + + try { + await post(grab, 'https://api.test.com', defaultConfig, 'mutation { createItem }'); + expect.unreachable('should have thrown'); + } catch (e) { + const err = e as JSApiClientCallError; + expect(err.code).toBe(400); + expect(err.message).toBe('[ValidationError] Name is required'); + expect(err.statusText).toContain('Core Next'); + } + }); + + test('uses fallback message when errorName has no message', async () => { + const grab = vi.fn().mockResolvedValue( + mockGrabResponse({ + jsonData: { + data: { + deleteItem: { errorName: 'InternalError' }, + }, + }, + }), + ); + + try { + await post(grab, 'https://api.test.com', defaultConfig, 'mutation { deleteItem }'); + expect.unreachable('should have thrown'); + } catch (e) { + const err = e as JSApiClientCallError; + expect(err.message).toBe('[InternalError] An error occurred'); + } + }); + + test('does not trigger on normal data without errorName', async () => { + const grab = vi.fn().mockResolvedValue( + mockGrabResponse({ + jsonData: { + data: { + item: { id: '123', name: 'Test' }, + }, + }, + }), + ); + + const result = await post(grab, 'https://api.test.com', defaultConfig, '{ item }'); + expect(result).toEqual({ item: { id: '123', name: 'Test' } }); + }); + + test('does not trigger when errorName is not a string', async () => { + const grab = vi.fn().mockResolvedValue( + mockGrabResponse({ + jsonData: { + data: { + item: { errorName: 42, message: 'not a real error' }, + }, + }, + }), + ); + + const result = await post(grab, 'https://api.test.com', defaultConfig, '{ item }'); + expect(result).toEqual({ item: { errorName: 42, message: 'not a real error' } }); + }); +}); + +describe('network failures', () => { + test('propagates network error from grab', async () => { + const grab = vi.fn().mockRejectedValue(new TypeError('fetch failed')); + + await expect( + post(grab, 'https://api.test.com', defaultConfig, query), + ).rejects.toThrow('fetch failed'); + }); + + test('propagates DNS resolution failure', async () => { + const grab = vi.fn().mockRejectedValue(new TypeError('getaddrinfo ENOTFOUND api.test.com')); + + await expect( + post(grab, 'https://api.test.com', defaultConfig, query), + ).rejects.toThrow('ENOTFOUND'); + }); + + test('propagates connection refused', async () => { + const grab = vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED 127.0.0.1:443')); + + await expect( + post(grab, 'https://api.test.com', defaultConfig, query), + ).rejects.toThrow('ECONNREFUSED'); + }); + + test('propagates connection reset', async () => { + const grab = vi.fn().mockRejectedValue(new Error('read ECONNRESET')); + + await expect( + post(grab, 'https://api.test.com', defaultConfig, query), + ).rejects.toThrow('ECONNRESET'); + }); +}); + +describe('timeout scenarios', () => { + test('passes abort signal when timeout is configured', async () => { + const grab = vi.fn().mockResolvedValue( + mockGrabResponse({ jsonData: { data: { ok: true } } }), + ); + + await post(grab, 'https://api.test.com', defaultConfig, query, undefined, undefined, { + timeout: 5000, + }); + + expect(grab).toHaveBeenCalledWith( + 'https://api.test.com', + expect.objectContaining({ + signal: expect.any(AbortSignal), + }), + ); + }); + + test('does not pass signal when no timeout configured', async () => { + const grab = vi.fn().mockResolvedValue( + mockGrabResponse({ jsonData: { data: { ok: true } } }), + ); + + await post(grab, 'https://api.test.com', defaultConfig, query); + + const callArgs = grab.mock.calls[0][1]; + expect(callArgs.signal).toBeUndefined(); + }); + + test('propagates abort error on timeout', async () => { + const grab = vi.fn().mockRejectedValue(new DOMException('The operation was aborted', 'TimeoutError')); + + await expect( + post(grab, 'https://api.test.com', defaultConfig, query, undefined, undefined, { + timeout: 1, + }), + ).rejects.toThrow('The operation was aborted'); + }); +}); + +describe('malformed responses', () => { + test('propagates JSON parse error on HTTP error with invalid body', async () => { + const grab = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + headers: { get: () => null }, + json: () => Promise.reject(new SyntaxError('Unexpected token < in JSON')), + text: () => Promise.resolve('Server Error'), + }); + + await expect( + post(grab, 'https://api.test.com', defaultConfig, query), + ).rejects.toThrow(SyntaxError); + }); + + test('propagates JSON parse error on 200 with invalid body', async () => { + const grab = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + headers: { get: () => null }, + json: () => Promise.reject(new SyntaxError('Unexpected end of JSON input')), + text: () => Promise.resolve(''), + }); + + await expect( + post(grab, 'https://api.test.com', defaultConfig, query), + ).rejects.toThrow(SyntaxError); + }); +}); + +describe('204 No Content', () => { + test('returns empty object', async () => { + const grab = vi.fn().mockResolvedValue( + mockGrabResponse({ ok: true, status: 204, statusText: 'No Content' }), + ); + + const result = await post(grab, 'https://api.test.com', defaultConfig, 'mutation { delete }'); + expect(result).toEqual({}); + }); + + test('does not attempt to parse JSON body', async () => { + const jsonFn = vi.fn(); + const grab = vi.fn().mockResolvedValue({ + ok: true, + status: 204, + statusText: 'No Content', + headers: { get: () => null }, + json: jsonFn, + text: () => Promise.resolve(''), + }); + + await post(grab, 'https://api.test.com', defaultConfig, 'mutation { delete }'); + expect(jsonFn).not.toHaveBeenCalled(); + }); +}); + +describe('JSApiClientCallError properties', () => { + test('is an instance of Error', () => { + const err = new JSApiClientCallError({ + message: 'test', + code: 500, + statusText: 'Error', + query: '{ q }', + variables: {}, + }); + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(JSApiClientCallError); + }); + + test('has correct name property', () => { + const err = new JSApiClientCallError({ + message: 'test', + code: 400, + statusText: 'Bad Request', + query: '{ q }', + variables: {}, + }); + expect(err.name).toBe('JSApiClientCallError'); + }); + + test('stores all constructor properties', () => { + const errors = [{ field: 'x' }]; + const err = new JSApiClientCallError({ + message: 'Something broke', + code: 422, + statusText: 'Unprocessable Entity', + query: 'mutation M { m }', + variables: { id: '1' }, + errors, + }); + expect(err.message).toBe('Something broke'); + expect(err.code).toBe(422); + expect(err.statusText).toBe('Unprocessable Entity'); + expect(err.query).toBe('mutation M { m }'); + expect(err.variables).toEqual({ id: '1' }); + expect(err.errors).toEqual(errors); + }); + + test('has a stack trace', () => { + const err = new JSApiClientCallError({ + message: 'test', + code: 500, + statusText: 'Error', + query: '', + variables: {}, + }); + expect(err.stack).toBeDefined(); + expect(err.stack).toContain('JSApiClientCallError'); + }); + + test('uses default values when provided', () => { + const err = new JSApiClientCallError({ + message: 'An error occurred while calling the API', + code: 500, + statusText: 'Internal Server Error', + query: '', + variables: {}, + }); + expect(err.message).toBe('An error occurred while calling the API'); + expect(err.code).toBe(500); + expect(err.errors).toBeUndefined(); + }); +}); From 43aceebfec1358645380b546a7ca5a25c6f22c04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Thu, 12 Mar 2026 17:09:51 -0700 Subject: [PATCH 19/21] ci: add GitHub Actions workflow for PR and main branch checks Runs build and unit tests across Node.js 20, 22, and 24 on every pull request and push to main, so broken code is caught before merge. --- .../js-api-client/.github/workflows/ci.yaml | 37 +++++++++++++++++++ components/js-api-client/PRD.json | 1 + components/js-api-client/progress.txt | 1 + 3 files changed, 39 insertions(+) create mode 100644 components/js-api-client/.github/workflows/ci.yaml diff --git a/components/js-api-client/.github/workflows/ci.yaml b/components/js-api-client/.github/workflows/ci.yaml new file mode 100644 index 00000000..80b76017 --- /dev/null +++ b/components/js-api-client/.github/workflows/ci.yaml @@ -0,0 +1,37 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + build-and-test: + name: Build & Unit Tests (Node ${{ matrix.node-version }}) + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20, 22, 24] + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@v5 + + - name: ⎔ Setup node + uses: actions/setup-node@v5 + with: + node-version: ${{ matrix.node-version }} + + - name: ⎔ Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.14.0 + + - name: 📥 Download deps + run: pnpm install + + - name: 🔨 Build + run: pnpm build + + - name: 🧪 Run unit tests + run: pnpm vitest run tests/unit/ diff --git a/components/js-api-client/PRD.json b/components/js-api-client/PRD.json index b1bbb9e7..5bdce6a8 100644 --- a/components/js-api-client/PRD.json +++ b/components/js-api-client/PRD.json @@ -393,6 +393,7 @@ }, { "id": "DX-019", + "status": "done", "title": "Add CI workflow for pull requests", "priority": "P3", "category": "ci-cd", diff --git a/components/js-api-client/progress.txt b/components/js-api-client/progress.txt index d2deced2..86c8e8da 100644 --- a/components/js-api-client/progress.txt +++ b/components/js-api-client/progress.txt @@ -16,3 +16,4 @@ DX-016: Reduce any usage in mass call client types - DONE DX-015: Reduce boilerplate in mass call client enqueue methods - DONE DX-017: Add unit tests with mocked HTTP for core modules - DONE DX-018: Add error-path tests for API caller - DONE +DX-019: Add CI workflow for pull requests - DONE From 91ffb1c04ef42805b0747509710ecb700c19231c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Thu, 12 Mar 2026 17:12:51 -0700 Subject: [PATCH 20/21] docs: add JSDoc comments to all main exported factory functions Add comprehensive JSDoc with @param, @returns, and @example tags to 12 exported factory functions for better IDE discoverability. --- components/js-api-client/PRD.json | 1 + components/js-api-client/progress.txt | 1 + .../catalogue/create-catalogue-fetcher.ts | 19 +++++++++++++++++ .../catalogue/create-navigation-fetcher.ts | 14 +++++++++++++ .../core/catalogue/create-product-hydrater.ts | 15 +++++++++++++ .../src/core/client/create-client.ts | 18 ++++++++++++++++ .../src/core/create-mass-call-client.ts | 21 ++++++++++++++----- .../src/core/create-signature-verifier.ts | 17 +++++++++++++++ .../core/pim/create-binary-file-manager.ts | 14 +++++++++++++ .../pim/customers/create-customer-manager.ts | 17 +++++++++++++++ .../core/pim/orders/create-order-fetcher.ts | 14 +++++++++++++ .../core/pim/orders/create-order-manager.ts | 17 +++++++++++++++ .../create-subscription-contract-manager.ts | 18 ++++++++++++++++ .../src/core/shop/create-cart-manager.ts | 17 +++++++++++++++ 14 files changed, 198 insertions(+), 5 deletions(-) diff --git a/components/js-api-client/PRD.json b/components/js-api-client/PRD.json index 5bdce6a8..67b120d8 100644 --- a/components/js-api-client/PRD.json +++ b/components/js-api-client/PRD.json @@ -414,6 +414,7 @@ }, { "id": "DX-020", + "status": "done", "title": "Add JSDoc comments to main exported functions", "priority": "P3", "category": "documentation", diff --git a/components/js-api-client/progress.txt b/components/js-api-client/progress.txt index 86c8e8da..09ab74a7 100644 --- a/components/js-api-client/progress.txt +++ b/components/js-api-client/progress.txt @@ -17,3 +17,4 @@ DX-015: Reduce boilerplate in mass call client enqueue methods - DONE DX-017: Add unit tests with mocked HTTP for core modules - DONE DX-018: Add error-path tests for API caller - DONE DX-019: Add CI workflow for pull requests - DONE +DX-020: Add JSDoc comments to main exported functions - DONE diff --git a/components/js-api-client/src/core/catalogue/create-catalogue-fetcher.ts b/components/js-api-client/src/core/catalogue/create-catalogue-fetcher.ts index 0a1ea1df..da500ed1 100644 --- a/components/js-api-client/src/core/catalogue/create-catalogue-fetcher.ts +++ b/components/js-api-client/src/core/catalogue/create-catalogue-fetcher.ts @@ -22,6 +22,25 @@ export type CatalogueFetcherGrapqhqlOnFolder = { onChildren?: OC; }; +/** + * Creates a catalogue fetcher that executes queries against the Crystallize Catalogue API using JSON-based query objects. + * Use this when you want to build catalogue queries programmatically instead of writing raw GraphQL strings. + * + * @param client - A Crystallize client instance created via `createClient`. + * @returns A function that accepts a JSON query object and optional variables, and returns the catalogue data. + * + * @example + * ```ts + * const fetcher = createCatalogueFetcher(client); + * const data = await fetcher({ + * catalogue: { + * __args: { path: '/my-product', language: 'en' }, + * name: true, + * path: true, + * }, + * }); + * ``` + */ export const createCatalogueFetcher = (client: ClientInterface) => { return (query: object, variables?: VariablesType): Promise => { return client.catalogueApi(jsonToGraphQLQuery({ query }), variables); diff --git a/components/js-api-client/src/core/catalogue/create-navigation-fetcher.ts b/components/js-api-client/src/core/catalogue/create-navigation-fetcher.ts index eb2b76a3..1018eb37 100644 --- a/components/js-api-client/src/core/catalogue/create-navigation-fetcher.ts +++ b/components/js-api-client/src/core/catalogue/create-navigation-fetcher.ts @@ -112,6 +112,20 @@ function buildNestedNavigationQuery( return jsonToGraphQLQuery({ query }); } +/** + * Creates a navigation fetcher that builds nested tree queries for folder-based or topic-based navigation. + * Use this to retrieve hierarchical navigation structures from the Crystallize catalogue. + * + * @param client - A Crystallize client instance created via `createClient`. + * @returns An object with `byFolders` and `byTopics` methods for fetching navigation trees at a given depth. + * + * @example + * ```ts + * const nav = createNavigationFetcher(client); + * const folderTree = await nav.byFolders('/', 'en', 3); + * const topicTree = await nav.byTopics('/', 'en', 2); + * ``` + */ export function createNavigationFetcher(client: ClientInterface): { byFolders: TreeFetcher; byTopics: TreeFetcher; diff --git a/components/js-api-client/src/core/catalogue/create-product-hydrater.ts b/components/js-api-client/src/core/catalogue/create-product-hydrater.ts index a8e32938..285e3c23 100644 --- a/components/js-api-client/src/core/catalogue/create-product-hydrater.ts +++ b/components/js-api-client/src/core/catalogue/create-product-hydrater.ts @@ -140,6 +140,21 @@ function bySkus(client: ClientInterface, options?: ProductHydraterOptions): Prod }; } +/** + * Creates a product hydrater that fetches full product data from the catalogue by paths or SKUs. + * Use this to enrich a list of product references with complete variant, pricing, and attribute data. + * + * @param client - A Crystallize client instance created via `createClient`. + * @param options - Optional settings for market identifiers, price lists, and price-for-everyone inclusion. + * @returns An object with `byPaths` and `bySkus` methods for hydrating products. + * + * @example + * ```ts + * const hydrater = createProductHydrater(client); + * const products = await hydrater.byPaths(['/shop/my-product'], 'en'); + * const productsBySkus = await hydrater.bySkus(['SKU-001', 'SKU-002'], 'en'); + * ``` + */ export function createProductHydrater(client: ClientInterface, options?: ProductHydraterOptions) { return { byPaths: byPaths(client, options), diff --git a/components/js-api-client/src/core/client/create-client.ts b/components/js-api-client/src/core/client/create-client.ts index c617b9b5..43bfddc9 100644 --- a/components/js-api-client/src/core/client/create-client.ts +++ b/components/js-api-client/src/core/client/create-client.ts @@ -50,6 +50,24 @@ export const apiHost = (configuration: ClientConfiguration) => { }; }; +/** + * Creates a Crystallize API client that provides access to catalogue, discovery, PIM, and shop cart APIs. + * Use this as the main entry point for all interactions with the Crystallize APIs. + * + * @param configuration - The tenant configuration including identifier and authentication credentials. + * @param options - Optional settings for HTTP/2, profiling, timeouts, and extra headers. + * @returns A client interface with pre-configured API callers for each Crystallize endpoint. + * + * @example + * ```ts + * const client = createClient({ + * tenantIdentifier: 'my-tenant', + * accessTokenId: 'my-token-id', + * accessTokenSecret: 'my-token-secret', + * }); + * const data = await client.catalogueApi(query); + * ``` + */ export const createClient = (configuration: ClientConfiguration, options?: CreateClientOptions): ClientInterface => { const identifier = configuration.tenantIdentifier; const { grab, close: grabClose } = createGrabber({ diff --git a/components/js-api-client/src/core/create-mass-call-client.ts b/components/js-api-client/src/core/create-mass-call-client.ts index af60a030..b9b7c463 100644 --- a/components/js-api-client/src/core/create-mass-call-client.ts +++ b/components/js-api-client/src/core/create-mass-call-client.ts @@ -62,12 +62,23 @@ const createFibonacciSleeper = (): Sleeper => { }; /** - * Note: MassCallClient is experimental and may not work as expected. - * Creates a mass call client based on an existing ClientInterface. + * Creates a mass call client that batches and throttles multiple API requests with automatic retry and concurrency control. + * Use this when you need to execute many API calls efficiently, such as bulk imports or migrations. Note: this feature is experimental. * - * @param client ClientInterface - * @param options Object - * @returns MassClientInterface + * @param client - A Crystallize client instance created via `createClient`. + * @param options - Configuration for concurrency, batching callbacks, failure handling, and sleep strategy. + * @returns A mass client interface that extends `ClientInterface` with `enqueue`, `execute`, `retry`, `reset`, `hasFailed`, and `failureCount` capabilities. + * + * @example + * ```ts + * const massClient = createMassCallClient(client, { initialSpawn: 2, maxSpawn: 5 }); + * massClient.enqueue.pimApi(`mutation { ... }`, { id: '1' }); + * massClient.enqueue.pimApi(`mutation { ... }`, { id: '2' }); + * const results = await massClient.execute(); + * if (massClient.hasFailed()) { + * const retryResults = await massClient.retry(); + * } + * ``` */ export function createMassCallClient( client: ClientInterface, diff --git a/components/js-api-client/src/core/create-signature-verifier.ts b/components/js-api-client/src/core/create-signature-verifier.ts index 10f0ca85..ac7fd73b 100644 --- a/components/js-api-client/src/core/create-signature-verifier.ts +++ b/components/js-api-client/src/core/create-signature-verifier.ts @@ -65,6 +65,23 @@ const buildGETSituationChallenge = (request: SimplifiedRequest) => { return null; }; +/** + * Creates a signature verifier for validating Crystallize webhook and app signatures. + * Use this to verify that incoming requests genuinely originate from Crystallize. + * + * @param params - An object containing a `sha256` hash function, a `jwtVerify` function, and the webhook `secret`. + * @returns An async function that takes a signature string and a simplified request, and resolves to the verified payload or throws on invalid signatures. + * + * @example + * ```ts + * const verifier = createSignatureVerifier({ + * sha256: async (data) => createHash('sha256').update(data).digest('hex'), + * jwtVerify: async (token, secret) => jwt.verify(token, secret), + * secret: process.env.CRYSTALLIZE_WEBHOOK_SECRET, + * }); + * const payload = await verifier(signatureHeader, { url, method, body }); + * ``` + */ export const createSignatureVerifier = ({ sha256, jwtVerify, secret }: CreateAsyncSignatureVerifierParams) => { return async (signature: string, request: SimplifiedRequest): Promise => { try { diff --git a/components/js-api-client/src/core/pim/create-binary-file-manager.ts b/components/js-api-client/src/core/pim/create-binary-file-manager.ts index 666248f5..c70fb7b3 100644 --- a/components/js-api-client/src/core/pim/create-binary-file-manager.ts +++ b/components/js-api-client/src/core/pim/create-binary-file-manager.ts @@ -34,6 +34,20 @@ const generatePresignedUploadRequest = `#graphql } }`; +/** + * Creates a binary file manager for uploading images, static files, and mass operation files to a Crystallize tenant. + * Requires PIM API credentials (accessTokenId/accessTokenSecret) in the client configuration. + * + * @param apiClient - A Crystallize client instance created via `createClient` with PIM credentials. + * @returns An object with methods to `uploadToTenant`, `uploadImage`, `uploadFile`, and `uploadMassOperationFile`. + * + * @example + * ```ts + * const fileManager = createBinaryFileManager(client); + * const imageKey = await fileManager.uploadImage('/path/to/image.png'); + * const fileKey = await fileManager.uploadFile('/path/to/document.pdf'); + * ``` + */ export const createBinaryFileManager = (apiClient: ClientInterface) => { // this function returns the key of the uploaded file const uploadToTenant = async ({ type = 'MEDIA', mimeType, filename, buffer }: BinaryHandler): Promise => { diff --git a/components/js-api-client/src/core/pim/customers/create-customer-manager.ts b/components/js-api-client/src/core/pim/customers/create-customer-manager.ts index 745619c7..b72ebd25 100644 --- a/components/js-api-client/src/core/pim/customers/create-customer-manager.ts +++ b/components/js-api-client/src/core/pim/customers/create-customer-manager.ts @@ -12,6 +12,23 @@ import { createCustomerFetcher } from './create-customer-fetcher.js'; type WithIdentifier = R & { identifier: string }; +/** + * Creates a customer manager for creating, updating, and managing customer records via the Crystallize PIM API. + * Requires PIM API credentials (accessTokenId/accessTokenSecret) in the client configuration. + * + * @param apiClient - A Crystallize client instance created via `createClient` with PIM credentials. + * @returns An object with methods to `create`, `update`, `setMeta`, `setMetaKey`, `setExternalReference`, and `setExternalReferenceKey`. + * + * @example + * ```ts + * const customerManager = createCustomerManager(client); + * const customer = await customerManager.create({ + * identifier: 'customer@example.com', + * firstName: 'Jane', + * lastName: 'Doe', + * }); + * ``` + */ export const createCustomerManager = (apiClient: ClientInterface) => { const create = async ( intentCustomer: CreateCustomerInput, diff --git a/components/js-api-client/src/core/pim/orders/create-order-fetcher.ts b/components/js-api-client/src/core/pim/orders/create-order-fetcher.ts index 8a206c81..281f5bf1 100644 --- a/components/js-api-client/src/core/pim/orders/create-order-fetcher.ts +++ b/components/js-api-client/src/core/pim/orders/create-order-fetcher.ts @@ -68,6 +68,20 @@ type EnhanceQuery(enhancements?: { onCustomer?: Cust ], }); +/** + * Creates an order manager for registering, updating, and managing orders via the Crystallize PIM API. + * Requires PIM API credentials (accessTokenId/accessTokenSecret) in the client configuration. + * + * @param apiClient - A Crystallize client instance created via `createClient` with PIM credentials. + * @returns An object with methods to `register`, `update`, `setPayments`, `putInPipelineStage`, and `removeFromPipeline`. + * + * @example + * ```ts + * const orderManager = createOrderManager(client); + * const { id, createdAt } = await orderManager.register({ + * customer: { identifier: 'customer@example.com' }, + * cart: [{ sku: 'SKU-001', name: 'My Product', quantity: 1 }], + * total: { gross: 100, net: 80, currency: 'USD' }, + * }); + * ``` + */ export const createOrderManager = (apiClient: ClientInterface) => { const register = async (intentOrder: RegisterOrderInput) => { const intent = RegisterOrderInputSchema.parse(intentOrder); diff --git a/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-manager.ts b/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-manager.ts index b4dc7d91..923f5fad 100644 --- a/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-manager.ts +++ b/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-manager.ts @@ -45,6 +45,24 @@ const baseQuery = { const create = async } = {}>( intentSubscriptionContract: CreateSubscriptionContractInput, diff --git a/components/js-api-client/src/core/shop/create-cart-manager.ts b/components/js-api-client/src/core/shop/create-cart-manager.ts index bfabc40e..8729a501 100644 --- a/components/js-api-client/src/core/shop/create-cart-manager.ts +++ b/components/js-api-client/src/core/shop/create-cart-manager.ts @@ -13,6 +13,23 @@ import { transformCartCustomerInput, transformCartInput } from './helpers.js'; type WithId = R & { id: string }; +/** + * Creates a cart manager for hydrating, placing, and managing shopping carts via the Crystallize Shop Cart API. + * Requires a shop API token or appropriate credentials in the client configuration. + * + * @param apiClient - A Crystallize client instance created via `createClient`. + * @returns An object with methods to `hydrate`, `fetch`, `place`, `fulfill`, `abandon`, `addSkuItem`, `removeItem`, `setMeta`, and `setCustomer`. + * + * @example + * ```ts + * const cartManager = createCartManager(client); + * const cart = await cartManager.hydrate({ + * items: [{ sku: 'SKU-001', quantity: 2 }], + * locale: { displayName: 'English', language: 'en' }, + * }); + * const placed = await cartManager.place(cart.id); + * ``` + */ export const createCartManager = (apiClient: ClientInterface) => { const fetch = async (id: string, onCart?: OC) => { const query = { From 4b76e7bc3e8a5d8f0effc19e41d44e156583c5a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Thu, 12 Mar 2026 17:35:08 -0700 Subject: [PATCH 21/21] feat: review --- .../js-api-client/.claude/settings.local.json | 3 + components/js-api-client/.mcp.json | 12 + components/js-api-client/PRD.json | 873 +++++++++--------- .../src/core/create-mass-call-client.ts | 10 +- .../core/pim/orders/create-order-fetcher.ts | 6 +- .../core/pim/orders/create-order-manager.ts | 14 +- .../create-subscription-contract-fetcher.ts | 12 +- .../create-subscription-contract-manager.ts | 34 +- .../tests/unit/create-api-caller.test.ts | 131 ++- .../tests/unit/create-grabber.test.ts | 22 +- .../unit/create-mass-call-client.test.ts | 4 +- .../tests/unit/error-handling.test.ts | 47 +- 12 files changed, 614 insertions(+), 554 deletions(-) create mode 100644 components/js-api-client/.claude/settings.local.json create mode 100644 components/js-api-client/.mcp.json diff --git a/components/js-api-client/.claude/settings.local.json b/components/js-api-client/.claude/settings.local.json new file mode 100644 index 00000000..47ae10ef --- /dev/null +++ b/components/js-api-client/.claude/settings.local.json @@ -0,0 +1,3 @@ +{ + "enabledMcpjsonServers": ["crystallize"] +} diff --git a/components/js-api-client/.mcp.json b/components/js-api-client/.mcp.json new file mode 100644 index 00000000..7828dbae --- /dev/null +++ b/components/js-api-client/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "crystallize": { + "type": "http", + "url": "https://mcp.crystallize.com/mcp", + "headers": { + "X-Crystallize-Access-Token-Id": "558f95141de1c4112f34", + "X-Crystallize-Access-Token-Secret": "8f56c78c874ce55b2629139b9061cefacaff7d17" + } + } + } +} diff --git a/components/js-api-client/PRD.json b/components/js-api-client/PRD.json index 67b120d8..f1015d48 100644 --- a/components/js-api-client/PRD.json +++ b/components/js-api-client/PRD.json @@ -1,450 +1,427 @@ { - "project": "@crystallize/js-api-client", - "version": "5.3.0", - "goal": "Improve developer experience, code quality, type safety, and reliability of the JS API client", - "tasks": [ - { - "id": "DX-001", - "status": "done", - "title": "Remove dead try/catch block in API caller", - "priority": "P0", - "category": "code-quality", - "effort": "5min", - "context": "In `src/core/client/create-api-caller.ts` lines 187-188, there is a catch block that simply rethrows the exception. This is a no-op that adds visual noise and misleads readers into thinking error handling is happening.", - "files": ["src/core/client/create-api-caller.ts"], - "requirements": [ - "Remove the outer `try { ... } catch (exception) { throw exception; }` wrapper from the `post` function", - "Keep all the inner logic intact — only the wrapping try/catch is removed", - "The function behavior must remain identical" - ], - "verification": [ - "Run `pnpm test` — all tests must pass", - "Verify the `post` function no longer has a useless catch block" - ] - }, - { - "id": "DX-002", - "status": "done", - "title": "Delete commented-out subscription.ts file", - "priority": "P0", - "category": "code-quality", - "effort": "5min", - "context": "The file `src/core/subscription.ts` contains 543 lines of entirely commented-out code. This functionality has been replaced by the modules in `src/core/pim/subscriptions/`. The dead code creates confusion about what's active. Git history preserves it if ever needed.", - "files": ["src/core/subscription.ts"], - "requirements": [ - "Delete the file `src/core/subscription.ts` entirely", - "Verify it is not imported or referenced anywhere in the codebase (it currently is not exported from `src/index.ts`)", - "No other files should be modified" - ], - "verification": [ - "Run `pnpm build` — build must succeed", - "Run `pnpm test` — all tests must pass", - "Grep the codebase for any imports of `subscription.ts` from the core directory (not the pim/subscriptions/ path)" - ] - }, - { - "id": "DX-003", - "status": "done", - "title": "Fix 'chidlren' typo in catalogue fetcher", - "priority": "P0", - "category": "code-quality", - "effort": "5min", - "context": "In `src/core/catalogue/create-catalogue-fetcher.ts` around line 67, there is a typo: `chidlren` instead of `children`. This is in a GraphQL query builder, so it likely produces an incorrect field name in queries. This is a bug.", - "files": ["src/core/catalogue/create-catalogue-fetcher.ts"], - "requirements": [ - "Find and replace `chidlren` with `children` in the catalogue fetcher", - "Check if the same typo appears anywhere else in the codebase and fix those too" - ], - "verification": [ - "Run `pnpm build` — build must succeed", - "Run `pnpm test` — all tests must pass", - "Grep the entire codebase for `chidlren` to ensure no remaining instances" - ] - }, - { - "id": "DX-004", - "status": "done", - "title": "Remove unnecessary double spread in product hydrater", - "priority": "P0", - "category": "code-quality", - "effort": "5min", - "context": "In `src/core/catalogue/create-product-hydrater.ts` around line 90, there is `{ ...{ ...productListQuery } }` — a double spread that is functionally identical to `{ ...productListQuery }`. This is confusing to read.", - "files": ["src/core/catalogue/create-product-hydrater.ts"], - "requirements": [ - "Simplify `{ ...{ ...productListQuery } }` to `{ ...productListQuery }`", - "No behavioral change" - ], - "verification": [ - "Run `pnpm build` — build must succeed", - "Run `pnpm test` — all tests must pass" - ] - }, - { - "id": "DX-005", - "status": "done", - "title": "Fix results variable initialization in mass call client", - "priority": "P0", - "category": "code-quality", - "effort": "5min", - "context": "In `src/core/create-mass-call-client.ts` line 101, `results` is declared as `let results: { [key: string]: any } = []`. It's typed as an object but initialized as an array. It's then used as an object (`results[result.key] = ...`). While JavaScript allows this (arrays are objects), it's misleading and technically wrong.", - "files": ["src/core/create-mass-call-client.ts"], - "requirements": [ - "Change `let results: { [key: string]: any } = []` to `let results: { [key: string]: any } = {}`", - "No behavioral change expected" - ], - "verification": [ - "Run `pnpm build` — build must succeed", - "Run `pnpm test` — all tests must pass" - ] - }, - { - "id": "DX-006", - "status": "done", - "title": "Fix stale 'module' field in package.json", - "priority": "P0", - "category": "build", - "effort": "5min", - "context": "The `package.json` has `\"module\": \"./dist/index.mjs\"` but the build (tsup) outputs `./dist/index.js` for ESM. The `.mjs` file does not exist. Some bundlers (Webpack, Rollup) use the `module` field to resolve ESM entry points, so this can cause import failures in certain setups.", - "files": ["package.json"], - "requirements": [ - "Either change `\"module\": \"./dist/index.mjs\"` to `\"module\": \"./dist/index.js\"` to match the actual build output", - "Or remove the `module` field entirely since the `exports` map already handles ESM resolution correctly", - "Verify the `exports` field is correct and consistent with the chosen approach" - ], - "verification": [ - "Run `pnpm build` and verify the referenced file exists in `dist/`", - "Check that `ls dist/index.js` exists (ESM output)", - "Check that `ls dist/index.cjs` exists (CJS output)" - ] - }, - { - "id": "DX-007", - "status": "done", - "title": "Set error name on JSApiClientCallError", - "priority": "P1", - "category": "error-handling", - "effort": "5min", - "context": "The custom error class `JSApiClientCallError` in `src/core/client/create-api-caller.ts` extends `Error` but never sets `this.name`. This means stack traces and `error.name` show generic `Error` instead of `JSApiClientCallError`, making debugging harder. All well-designed custom error classes should set their name.", - "files": ["src/core/client/create-api-caller.ts"], - "requirements": [ - "Add `this.name = 'JSApiClientCallError';` in the constructor, after the `super()` call", - "No other changes needed" - ], - "verification": [ - "Run `pnpm build` — build must succeed", - "Run `pnpm test` — all tests must pass", - "Manually verify: `new JSApiClientCallError({...}).name === 'JSApiClientCallError'`" - ] - }, - { - "id": "DX-008", - "status": "done", - "title": "Remove 'any' from union types in grabber and API caller signatures", - "priority": "P1", - "category": "type-safety", - "effort": "30min", - "context": "In `src/core/client/create-grabber.ts` line 14 and `src/core/client/create-api-caller.ts` line 95, the parameter type is `RequestInit | any | undefined`. The `| any` makes the entire union collapse to `any`, completely defeating TypeScript's type checking. This means any caller can pass anything without type errors.", - "files": [ - "src/core/client/create-grabber.ts", - "src/core/client/create-api-caller.ts" - ], - "requirements": [ - "Define a proper `GrabOptions` type that extends or replaces the `RequestInit | any` pattern", - "The type should cover what the grabber actually uses: `method`, `headers`, `body`", - "Update the `Grab` type, `grab` function signature, and `post` function signature", - "Ensure HTTP/2 code path still compiles (it uses `headers` with `:method` and `:path` pseudo-headers)", - "No behavioral changes" - ], - "verification": [ - "Run `pnpm build` — build must succeed with no type errors", - "Run `pnpm test` — all tests must pass", - "Verify that passing an invalid option to `grab()` now produces a TypeScript error" - ] - }, - { - "id": "DX-009", - "status": "done", - "title": "Replace tracked .env with .env.example", - "priority": "P1", - "category": "security", - "effort": "10min", - "context": "There is a `.env` file (519 bytes) tracked in the repository root. Even if it only contains test tenant credentials, tracking `.env` files in git is bad practice — it trains contributors to commit secrets. The file should be replaced with a `.env.example` template.", - "files": [".env", ".env.example", ".gitignore"], - "requirements": [ - "Create a `.env.example` file with the same keys but placeholder values (e.g., `CRYSTALLIZE_ACCESS_TOKEN_ID=your-token-id-here`)", - "Add `.env` to `.gitignore` if not already present", - "Remove `.env` from git tracking (but don't delete the local file): `git rm --cached .env`", - "Update any documentation that references the `.env` file" - ], - "verification": [ - "Verify `.env` is in `.gitignore`", - "Verify `.env.example` exists with placeholder values", - "Run `pnpm test` — tests should still work (they use `dotenv/config` which reads the local `.env`)" - ] - }, - { - "id": "DX-010", - "status": "done", - "title": "Improve authentication error messaging", - "priority": "P1", - "category": "error-handling", - "effort": "30min", - "context": "In `src/core/client/create-api-caller.ts` lines 71-74, when no authentication is configured (no sessionId, no staticAuthToken, no accessToken), the `authenticationHeaders` function silently sends empty string values for `X-Crystallize-Access-Token-Id` and `X-Crystallize-Access-Token-Secret`. This results in a confusing 401/403 error from the server instead of a clear local error message telling the developer they forgot to configure authentication.", - "files": ["src/core/client/create-api-caller.ts"], - "requirements": [ - "When the fallback case is reached (no sessionId, no staticAuthToken) AND both `accessTokenId` and `accessTokenSecret` are empty/undefined, log a warning or throw a descriptive error", - "The warning should say something like: 'No authentication credentials configured. Set accessTokenId/accessTokenSecret, staticAuthToken, or sessionId in the client configuration.'", - "Consider using `console.warn` rather than throwing, since some catalogue endpoints may work without auth", - "Only warn once per client instance to avoid spam" - ], - "verification": [ - "Run `pnpm test` — all tests must pass", - "Manually test: create a client with no auth config, call `pimApi()`, verify a warning is logged" - ] - }, - { - "id": "DX-011", - "status": "done", - "title": "Improve generic parameter naming in fetchers", - "priority": "P2", - "category": "type-safety", - "effort": "30min", - "context": "Across order, customer, and subscription fetchers/managers, generic type parameters use cryptic abbreviations: `OO`, `OOI`, `OC`. These are mixed with clearer names like `OnOrder`, `OnCustomer` in the same files. This inconsistency makes the generics hard to understand for library consumers who see them in IDE tooltips.", - "files": [ - "src/core/pim/orders/create-order-fetcher.ts", - "src/core/pim/orders/create-order-manager.ts", - "src/core/pim/customers/create-customer-manager.ts", - "src/core/pim/customers/create-customer-fetcher.ts", - "src/core/pim/subscriptions/create-subscription-contract-fetcher.ts", - "src/core/pim/subscriptions/create-subscription-contract-manager.ts" - ], - "requirements": [ - "Rename cryptic generic parameters to descriptive names consistently across all fetchers/managers", - "Suggested mapping: `OO` → `OrderExtra`, `OOI` → `OrderItemExtra`, `OC` → `CustomerExtra`", - "Ensure the public API types (visible to consumers) use the new names", - "This is a non-breaking change since generic names are not part of the runtime API" - ], - "verification": [ - "Run `pnpm build` — build must succeed", - "Run `pnpm test` — all tests must pass" - ] - }, - { - "id": "DX-012", - "status": "done", - "title": "Add Symbol.dispose support for automatic cleanup", - "priority": "P2", - "category": "api-design", - "effort": "1h", - "context": "The `ClientInterface` requires calling `close()` to clean up HTTP/2 connections. If a developer forgets, connections leak. TypeScript 5.2+ supports the `using` declaration with `Symbol.dispose` / `Symbol.asyncDispose`, which provides automatic cleanup. This is a modern ergonomic improvement that makes the library safer to use.", - "files": [ - "src/core/client/create-client.ts" - ], - "requirements": [ - "Add `[Symbol.dispose]` to the `ClientInterface` type that calls `close()`", - "Implement it in the `createClient` return value", - "Keep the existing `close()` method for backward compatibility", - "This allows: `using client = createClient({...})` — auto-closes when scope exits", - "Ensure the TypeScript target/lib settings support `Symbol.dispose` (may need to add `esnext.disposable` to `lib`)", - "Do NOT break existing usage patterns" - ], - "verification": [ - "Run `pnpm build` — build must succeed", - "Run `pnpm test` — all tests must pass", - "Write a quick TypeScript snippet using `using client = createClient({...})` and verify it compiles" - ] - }, - { - "id": "DX-013", - "status": "done", - "title": "Add request timeout support via AbortController", - "priority": "P2", - "category": "reliability", - "effort": "1h", - "context": "There is no way to set a timeout on API calls. A hanging request will hang forever, which is especially problematic in serverless environments with execution time limits. The library should support an optional timeout that automatically aborts requests.", - "files": [ - "src/core/client/create-client.ts", - "src/core/client/create-api-caller.ts", - "src/core/client/create-grabber.ts" - ], - "requirements": [ - "Add an optional `timeout` field to `CreateClientOptions` (in milliseconds)", - "In the `post` function, create an `AbortController` with `AbortSignal.timeout(ms)` when timeout is configured", - "Pass the signal to the `grab` function (for fetch path: pass as part of RequestInit; for HTTP/2 path: use `req.close()` on timeout)", - "Throw a descriptive error when a timeout occurs (e.g., `JSApiClientCallError` with a clear message)", - "Allow per-request timeout override (optional, can be deferred)", - "Default: no timeout (backward compatible)" - ], - "verification": [ - "Run `pnpm build` — build must succeed", - "Run `pnpm test` — all tests must pass", - "Manually test: create a client with `timeout: 1` (1ms) and verify it times out on a real request" - ] - }, - { - "id": "DX-014", - "status": "done", - "title": "Make Server-Timing header parsing robust", - "priority": "P2", - "category": "reliability", - "effort": "15min", - "context": "In `src/core/client/create-api-caller.ts` lines 127-131, the Server-Timing header is parsed with `split(';')[1]?.split('=')[1]`. This assumes a specific format and will produce garbage values with non-standard headers. The profiling feature should not break or report wrong values due to header format variations.", - "files": ["src/core/client/create-api-caller.ts"], - "requirements": [ - "Parse the Server-Timing header according to the spec: `;dur=`", - "Use a regex like `/dur=([\\d.]+)/` to extract the duration value reliably", - "If parsing fails, fall back to `-1` (current fallback) without throwing", - "Handle the case where the header is missing entirely (already handled with `?? undefined`)" - ], - "verification": [ - "Run `pnpm build` — build must succeed", - "Run `pnpm test` — all tests must pass (especially `catalogue-profiled.test.ts`)" - ] - }, - { - "id": "DX-015", - "status": "done", - "title": "Reduce boilerplate in mass call client enqueue methods", - "priority": "P3", - "category": "code-quality", - "effort": "15min", - "context": "In `src/core/create-mass-call-client.ts` lines 209-234, the five `enqueue` methods (`catalogueApi`, `discoveryApi`, `pimApi`, `nextPimApi`, `shopCartApi`) are identical except for the key prefix and the caller they reference. This is ~25 lines of repetitive code that can be generated programmatically.", - "files": ["src/core/create-mass-call-client.ts"], - "requirements": [ - "Replace the five repetitive `enqueue` method definitions with a generated approach", - "Use a helper function or `Object.fromEntries` pattern to create the enqueue methods dynamically", - "The public API and types must remain identical — this is an internal refactor only", - "Ensure type safety is preserved (the `QueuedApiCaller` type must still apply)" - ], - "verification": [ - "Run `pnpm build` — build must succeed", - "Run `pnpm test` — all tests must pass" - ] - }, - { - "id": "DX-016", - "status": "done", - "title": "Reduce any usage in mass call client types", - "priority": "P2", - "category": "type-safety", - "effort": "1h", - "context": "The `create-mass-call-client.ts` file has 10+ usages of `any` in its types: `execute()` returns `Promise`, `afterRequest` callback receives `any`, `onFailure` receives `any` for the exception, `results` is `{ [key: string]: any }`. This defeats TypeScript's ability to help consumers of the mass call client.", - "files": ["src/core/create-mass-call-client.ts"], - "requirements": [ - "Replace `Promise` return type of `execute()` with `Promise>` or a generic", - "Replace `any` in `afterRequest` callback with the actual result shape", - "Replace `any` for `exception` in `onFailure` with `unknown` (TypeScript best practice for caught errors)", - "Replace `results` type with `Record` or a generic keyed type", - "Keep the `experimental` nature — don't over-engineer, just remove the `any` holes", - "Fix the typo `situaion` → `situation` in the `changeIncrementFor` callback parameter (line 85)" - ], - "verification": [ - "Run `pnpm build` — build must succeed", - "Run `pnpm test` — all tests must pass" - ] - }, - { - "id": "DX-017", - "status": "done", - "title": "Add unit tests with mocked HTTP for core modules", - "priority": "P3", - "category": "testing", - "effort": "4h", - "context": "Currently all 15 test files are integration tests that hit real Crystallize APIs. There are zero unit tests. Critical modules like `create-grabber.ts`, `create-mass-call-client.ts`, and `create-binary-file-manager.ts` have no tests at all. Unit tests with mocked HTTP would: (1) allow tests to run without credentials, (2) test error paths, (3) run fast enough for PR CI.", - "files": [ - "tests/unit/create-api-caller.test.ts", - "tests/unit/create-grabber.test.ts", - "tests/unit/create-mass-call-client.test.ts" - ], - "requirements": [ - "Create a `tests/unit/` directory for unit tests", - "Add unit tests for `create-api-caller.ts` covering: successful response, GraphQL errors in 200 response, HTTP error responses, Core Next wrapped errors, 204 No Content handling", - "Add unit tests for `create-mass-call-client.ts` covering: basic enqueue and execute, retry logic, batch size adaptation (increment/decrement), Fibonacci backoff", - "Mock the `grab` function (it's a simple function signature, easy to mock)", - "Do not require API credentials or network access", - "Test error paths: network failures, malformed JSON responses, timeout scenarios" - ], - "verification": [ - "Run `pnpm test` — all tests (integration + unit) must pass", - "Unit tests must pass without any `.env` credentials" - ] - }, - { - "id": "DX-018", - "status": "done", - "title": "Add error-path tests for API caller", - "priority": "P3", - "category": "testing", - "effort": "2h", - "context": "The current test suite only covers happy paths. There are no tests for: authentication failures (401/403), rate limiting (429), server errors (500/502/503), network timeouts, malformed JSON responses, or missing required fields. These are common real-world scenarios that should be covered.", - "files": ["tests/unit/error-handling.test.ts"], - "requirements": [ - "Create tests that verify `JSApiClientCallError` is thrown with correct properties for various HTTP error codes", - "Test that GraphQL errors in 200 responses are properly detected and thrown", - "Test that Core Next wrapped errors are detected via `getCoreNextError`", - "Test that the error includes the query and variables for debugging", - "Test 204 No Content returns empty object", - "Mock the grab function for all tests — no network required" - ], - "verification": [ - "Run `pnpm test` — all tests must pass", - "Tests must pass without credentials" - ] - }, - { - "id": "DX-019", - "status": "done", - "title": "Add CI workflow for pull requests", - "priority": "P3", - "category": "ci-cd", - "effort": "30min", - "context": "Currently `.github/workflows/release.yaml` only runs on git tags (releases). There is no CI that runs on pull requests. This means broken code can be merged without any automated checks. A PR workflow should at minimum run build and unit tests.", - "files": [".github/workflows/ci.yaml"], - "requirements": [ - "Create a new workflow file `.github/workflows/ci.yaml`", - "Trigger on: pull requests to main, pushes to main", - "Steps: checkout, setup Node.js (same version as release.yaml), install deps with pnpm, run `pnpm build`, run `pnpm test` (unit tests only — integration tests need credentials)", - "Consider testing on multiple Node.js versions (20, 22, 24)", - "Keep it fast — unit tests should not require API credentials" - ], - "verification": [ - "Push a branch and create a PR to verify the workflow triggers", - "Verify the workflow passes with build + unit tests" - ] - }, - { - "id": "DX-020", - "status": "done", - "title": "Add JSDoc comments to main exported functions", - "priority": "P3", - "category": "documentation", - "effort": "2h", - "context": "Most exported factory functions have no JSDoc comments. IDE users (the primary consumers of this library) get no inline documentation when hovering over `createClient`, `createCatalogueFetcher`, `createOrderManager`, etc. Good JSDoc with `@param`, `@returns`, and `@example` tags significantly improves discoverability.", - "files": [ - "src/core/client/create-client.ts", - "src/core/catalogue/create-catalogue-fetcher.ts", - "src/core/catalogue/create-navigation-fetcher.ts", - "src/core/catalogue/create-product-hydrater.ts", - "src/core/pim/orders/create-order-manager.ts", - "src/core/pim/orders/create-order-fetcher.ts", - "src/core/pim/customers/create-customer-manager.ts", - "src/core/pim/subscriptions/create-subscription-contract-manager.ts", - "src/core/shop/create-cart-manager.ts", - "src/core/create-signature-verifier.ts", - "src/core/pim/create-binary-file-manager.ts", - "src/core/create-mass-call-client.ts" - ], - "requirements": [ - "Add JSDoc to all exported factory functions with: description, @param tags, @returns description, and at least one @example", - "Focus on the main entry points that library consumers use directly", - "Keep descriptions concise — one sentence for what it does, one for when to use it", - "Include authentication requirements in the description where relevant (e.g., 'Requires PIM API credentials')", - "Do not add JSDoc to internal/helper functions" - ], - "verification": [ - "Run `pnpm build` — build must succeed", - "Open the source files in an IDE and verify JSDoc appears on hover for the exported functions" - ] - } - ] + "project": "@crystallize/js-api-client", + "version": "5.3.0", + "goal": "Improve developer experience, code quality, type safety, and reliability of the JS API client", + "tasks": [ + { + "id": "DX-001", + "status": "done", + "title": "Remove dead try/catch block in API caller", + "priority": "P0", + "category": "code-quality", + "effort": "5min", + "context": "In `src/core/client/create-api-caller.ts` lines 187-188, there is a catch block that simply rethrows the exception. This is a no-op that adds visual noise and misleads readers into thinking error handling is happening.", + "files": ["src/core/client/create-api-caller.ts"], + "requirements": [ + "Remove the outer `try { ... } catch (exception) { throw exception; }` wrapper from the `post` function", + "Keep all the inner logic intact — only the wrapping try/catch is removed", + "The function behavior must remain identical" + ], + "verification": [ + "Run `pnpm test` — all tests must pass", + "Verify the `post` function no longer has a useless catch block" + ] + }, + { + "id": "DX-002", + "status": "done", + "title": "Delete commented-out subscription.ts file", + "priority": "P0", + "category": "code-quality", + "effort": "5min", + "context": "The file `src/core/subscription.ts` contains 543 lines of entirely commented-out code. This functionality has been replaced by the modules in `src/core/pim/subscriptions/`. The dead code creates confusion about what's active. Git history preserves it if ever needed.", + "files": ["src/core/subscription.ts"], + "requirements": [ + "Delete the file `src/core/subscription.ts` entirely", + "Verify it is not imported or referenced anywhere in the codebase (it currently is not exported from `src/index.ts`)", + "No other files should be modified" + ], + "verification": [ + "Run `pnpm build` — build must succeed", + "Run `pnpm test` — all tests must pass", + "Grep the codebase for any imports of `subscription.ts` from the core directory (not the pim/subscriptions/ path)" + ] + }, + { + "id": "DX-003", + "status": "done", + "title": "Fix 'chidlren' typo in catalogue fetcher", + "priority": "P0", + "category": "code-quality", + "effort": "5min", + "context": "In `src/core/catalogue/create-catalogue-fetcher.ts` around line 67, there is a typo: `chidlren` instead of `children`. This is in a GraphQL query builder, so it likely produces an incorrect field name in queries. This is a bug.", + "files": ["src/core/catalogue/create-catalogue-fetcher.ts"], + "requirements": [ + "Find and replace `chidlren` with `children` in the catalogue fetcher", + "Check if the same typo appears anywhere else in the codebase and fix those too" + ], + "verification": [ + "Run `pnpm build` — build must succeed", + "Run `pnpm test` — all tests must pass", + "Grep the entire codebase for `chidlren` to ensure no remaining instances" + ] + }, + { + "id": "DX-004", + "status": "done", + "title": "Remove unnecessary double spread in product hydrater", + "priority": "P0", + "category": "code-quality", + "effort": "5min", + "context": "In `src/core/catalogue/create-product-hydrater.ts` around line 90, there is `{ ...{ ...productListQuery } }` — a double spread that is functionally identical to `{ ...productListQuery }`. This is confusing to read.", + "files": ["src/core/catalogue/create-product-hydrater.ts"], + "requirements": [ + "Simplify `{ ...{ ...productListQuery } }` to `{ ...productListQuery }`", + "No behavioral change" + ], + "verification": ["Run `pnpm build` — build must succeed", "Run `pnpm test` — all tests must pass"] + }, + { + "id": "DX-005", + "status": "done", + "title": "Fix results variable initialization in mass call client", + "priority": "P0", + "category": "code-quality", + "effort": "5min", + "context": "In `src/core/create-mass-call-client.ts` line 101, `results` is declared as `let results: { [key: string]: any } = []`. It's typed as an object but initialized as an array. It's then used as an object (`results[result.key] = ...`). While JavaScript allows this (arrays are objects), it's misleading and technically wrong.", + "files": ["src/core/create-mass-call-client.ts"], + "requirements": [ + "Change `let results: { [key: string]: any } = []` to `let results: { [key: string]: any } = {}`", + "No behavioral change expected" + ], + "verification": ["Run `pnpm build` — build must succeed", "Run `pnpm test` — all tests must pass"] + }, + { + "id": "DX-006", + "status": "done", + "title": "Fix stale 'module' field in package.json", + "priority": "P0", + "category": "build", + "effort": "5min", + "context": "The `package.json` has `\"module\": \"./dist/index.mjs\"` but the build (tsup) outputs `./dist/index.js` for ESM. The `.mjs` file does not exist. Some bundlers (Webpack, Rollup) use the `module` field to resolve ESM entry points, so this can cause import failures in certain setups.", + "files": ["package.json"], + "requirements": [ + "Either change `\"module\": \"./dist/index.mjs\"` to `\"module\": \"./dist/index.js\"` to match the actual build output", + "Or remove the `module` field entirely since the `exports` map already handles ESM resolution correctly", + "Verify the `exports` field is correct and consistent with the chosen approach" + ], + "verification": [ + "Run `pnpm build` and verify the referenced file exists in `dist/`", + "Check that `ls dist/index.js` exists (ESM output)", + "Check that `ls dist/index.cjs` exists (CJS output)" + ] + }, + { + "id": "DX-007", + "status": "done", + "title": "Set error name on JSApiClientCallError", + "priority": "P1", + "category": "error-handling", + "effort": "5min", + "context": "The custom error class `JSApiClientCallError` in `src/core/client/create-api-caller.ts` extends `Error` but never sets `this.name`. This means stack traces and `error.name` show generic `Error` instead of `JSApiClientCallError`, making debugging harder. All well-designed custom error classes should set their name.", + "files": ["src/core/client/create-api-caller.ts"], + "requirements": [ + "Add `this.name = 'JSApiClientCallError';` in the constructor, after the `super()` call", + "No other changes needed" + ], + "verification": [ + "Run `pnpm build` — build must succeed", + "Run `pnpm test` — all tests must pass", + "Manually verify: `new JSApiClientCallError({...}).name === 'JSApiClientCallError'`" + ] + }, + { + "id": "DX-008", + "status": "done", + "title": "Remove 'any' from union types in grabber and API caller signatures", + "priority": "P1", + "category": "type-safety", + "effort": "30min", + "context": "In `src/core/client/create-grabber.ts` line 14 and `src/core/client/create-api-caller.ts` line 95, the parameter type is `RequestInit | any | undefined`. The `| any` makes the entire union collapse to `any`, completely defeating TypeScript's type checking. This means any caller can pass anything without type errors.", + "files": ["src/core/client/create-grabber.ts", "src/core/client/create-api-caller.ts"], + "requirements": [ + "Define a proper `GrabOptions` type that extends or replaces the `RequestInit | any` pattern", + "The type should cover what the grabber actually uses: `method`, `headers`, `body`", + "Update the `Grab` type, `grab` function signature, and `post` function signature", + "Ensure HTTP/2 code path still compiles (it uses `headers` with `:method` and `:path` pseudo-headers)", + "No behavioral changes" + ], + "verification": [ + "Run `pnpm build` — build must succeed with no type errors", + "Run `pnpm test` — all tests must pass", + "Verify that passing an invalid option to `grab()` now produces a TypeScript error" + ] + }, + { + "id": "DX-009", + "status": "done", + "title": "Replace tracked .env with .env.example", + "priority": "P1", + "category": "security", + "effort": "10min", + "context": "There is a `.env` file (519 bytes) tracked in the repository root. Even if it only contains test tenant credentials, tracking `.env` files in git is bad practice — it trains contributors to commit secrets. The file should be replaced with a `.env.example` template.", + "files": [".env", ".env.example", ".gitignore"], + "requirements": [ + "Create a `.env.example` file with the same keys but placeholder values (e.g., `CRYSTALLIZE_ACCESS_TOKEN_ID=your-token-id-here`)", + "Add `.env` to `.gitignore` if not already present", + "Remove `.env` from git tracking (but don't delete the local file): `git rm --cached .env`", + "Update any documentation that references the `.env` file" + ], + "verification": [ + "Verify `.env` is in `.gitignore`", + "Verify `.env.example` exists with placeholder values", + "Run `pnpm test` — tests should still work (they use `dotenv/config` which reads the local `.env`)" + ] + }, + { + "id": "DX-010", + "status": "done", + "title": "Improve authentication error messaging", + "priority": "P1", + "category": "error-handling", + "effort": "30min", + "context": "In `src/core/client/create-api-caller.ts` lines 71-74, when no authentication is configured (no sessionId, no staticAuthToken, no accessToken), the `authenticationHeaders` function silently sends empty string values for `X-Crystallize-Access-Token-Id` and `X-Crystallize-Access-Token-Secret`. This results in a confusing 401/403 error from the server instead of a clear local error message telling the developer they forgot to configure authentication.", + "files": ["src/core/client/create-api-caller.ts"], + "requirements": [ + "When the fallback case is reached (no sessionId, no staticAuthToken) AND both `accessTokenId` and `accessTokenSecret` are empty/undefined, log a warning or throw a descriptive error", + "The warning should say something like: 'No authentication credentials configured. Set accessTokenId/accessTokenSecret, staticAuthToken, or sessionId in the client configuration.'", + "Consider using `console.warn` rather than throwing, since some catalogue endpoints may work without auth", + "Only warn once per client instance to avoid spam" + ], + "verification": [ + "Run `pnpm test` — all tests must pass", + "Manually test: create a client with no auth config, call `pimApi()`, verify a warning is logged" + ] + }, + { + "id": "DX-011", + "status": "done", + "title": "Improve generic parameter naming in fetchers", + "priority": "P2", + "category": "type-safety", + "effort": "30min", + "context": "Across order, customer, and subscription fetchers/managers, generic type parameters use cryptic abbreviations: `OO`, `OOI`, `OC`. These are mixed with clearer names like `OnOrder`, `OnCustomer` in the same files. This inconsistency makes the generics hard to understand for library consumers who see them in IDE tooltips.", + "files": [ + "src/core/pim/orders/create-order-fetcher.ts", + "src/core/pim/orders/create-order-manager.ts", + "src/core/pim/customers/create-customer-manager.ts", + "src/core/pim/customers/create-customer-fetcher.ts", + "src/core/pim/subscriptions/create-subscription-contract-fetcher.ts", + "src/core/pim/subscriptions/create-subscription-contract-manager.ts" + ], + "requirements": [ + "Rename cryptic generic parameters to descriptive names consistently across all fetchers/managers", + "Suggested mapping: `OO` → `OrderExtra`, `OOI` → `OrderItemExtra`, `OC` → `CustomerExtra`", + "Ensure the public API types (visible to consumers) use the new names", + "This is a non-breaking change since generic names are not part of the runtime API" + ], + "verification": ["Run `pnpm build` — build must succeed", "Run `pnpm test` — all tests must pass"] + }, + { + "id": "DX-012", + "status": "done", + "title": "Add Symbol.dispose support for automatic cleanup", + "priority": "P2", + "category": "api-design", + "effort": "1h", + "context": "The `ClientInterface` requires calling `close()` to clean up HTTP/2 connections. If a developer forgets, connections leak. TypeScript 5.2+ supports the `using` declaration with `Symbol.dispose` / `Symbol.asyncDispose`, which provides automatic cleanup. This is a modern ergonomic improvement that makes the library safer to use.", + "files": ["src/core/client/create-client.ts"], + "requirements": [ + "Add `[Symbol.dispose]` to the `ClientInterface` type that calls `close()`", + "Implement it in the `createClient` return value", + "Keep the existing `close()` method for backward compatibility", + "This allows: `using client = createClient({...})` — auto-closes when scope exits", + "Ensure the TypeScript target/lib settings support `Symbol.dispose` (may need to add `esnext.disposable` to `lib`)", + "Do NOT break existing usage patterns" + ], + "verification": [ + "Run `pnpm build` — build must succeed", + "Run `pnpm test` — all tests must pass", + "Write a quick TypeScript snippet using `using client = createClient({...})` and verify it compiles" + ] + }, + { + "id": "DX-013", + "status": "done", + "title": "Add request timeout support via AbortController", + "priority": "P2", + "category": "reliability", + "effort": "1h", + "context": "There is no way to set a timeout on API calls. A hanging request will hang forever, which is especially problematic in serverless environments with execution time limits. The library should support an optional timeout that automatically aborts requests.", + "files": [ + "src/core/client/create-client.ts", + "src/core/client/create-api-caller.ts", + "src/core/client/create-grabber.ts" + ], + "requirements": [ + "Add an optional `timeout` field to `CreateClientOptions` (in milliseconds)", + "In the `post` function, create an `AbortController` with `AbortSignal.timeout(ms)` when timeout is configured", + "Pass the signal to the `grab` function (for fetch path: pass as part of RequestInit; for HTTP/2 path: use `req.close()` on timeout)", + "Throw a descriptive error when a timeout occurs (e.g., `JSApiClientCallError` with a clear message)", + "Allow per-request timeout override (optional, can be deferred)", + "Default: no timeout (backward compatible)" + ], + "verification": [ + "Run `pnpm build` — build must succeed", + "Run `pnpm test` — all tests must pass", + "Manually test: create a client with `timeout: 1` (1ms) and verify it times out on a real request" + ] + }, + { + "id": "DX-014", + "status": "done", + "title": "Make Server-Timing header parsing robust", + "priority": "P2", + "category": "reliability", + "effort": "15min", + "context": "In `src/core/client/create-api-caller.ts` lines 127-131, the Server-Timing header is parsed with `split(';')[1]?.split('=')[1]`. This assumes a specific format and will produce garbage values with non-standard headers. The profiling feature should not break or report wrong values due to header format variations.", + "files": ["src/core/client/create-api-caller.ts"], + "requirements": [ + "Parse the Server-Timing header according to the spec: `;dur=`", + "Use a regex like `/dur=([\\d.]+)/` to extract the duration value reliably", + "If parsing fails, fall back to `-1` (current fallback) without throwing", + "Handle the case where the header is missing entirely (already handled with `?? undefined`)" + ], + "verification": [ + "Run `pnpm build` — build must succeed", + "Run `pnpm test` — all tests must pass (especially `catalogue-profiled.test.ts`)" + ] + }, + { + "id": "DX-015", + "status": "done", + "title": "Reduce boilerplate in mass call client enqueue methods", + "priority": "P3", + "category": "code-quality", + "effort": "15min", + "context": "In `src/core/create-mass-call-client.ts` lines 209-234, the five `enqueue` methods (`catalogueApi`, `discoveryApi`, `pimApi`, `nextPimApi`, `shopCartApi`) are identical except for the key prefix and the caller they reference. This is ~25 lines of repetitive code that can be generated programmatically.", + "files": ["src/core/create-mass-call-client.ts"], + "requirements": [ + "Replace the five repetitive `enqueue` method definitions with a generated approach", + "Use a helper function or `Object.fromEntries` pattern to create the enqueue methods dynamically", + "The public API and types must remain identical — this is an internal refactor only", + "Ensure type safety is preserved (the `QueuedApiCaller` type must still apply)" + ], + "verification": ["Run `pnpm build` — build must succeed", "Run `pnpm test` — all tests must pass"] + }, + { + "id": "DX-016", + "status": "done", + "title": "Reduce any usage in mass call client types", + "priority": "P2", + "category": "type-safety", + "effort": "1h", + "context": "The `create-mass-call-client.ts` file has 10+ usages of `any` in its types: `execute()` returns `Promise`, `afterRequest` callback receives `any`, `onFailure` receives `any` for the exception, `results` is `{ [key: string]: any }`. This defeats TypeScript's ability to help consumers of the mass call client.", + "files": ["src/core/create-mass-call-client.ts"], + "requirements": [ + "Replace `Promise` return type of `execute()` with `Promise>` or a generic", + "Replace `any` in `afterRequest` callback with the actual result shape", + "Replace `any` for `exception` in `onFailure` with `unknown` (TypeScript best practice for caught errors)", + "Replace `results` type with `Record` or a generic keyed type", + "Keep the `experimental` nature — don't over-engineer, just remove the `any` holes", + "Fix the typo `situaion` → `situation` in the `changeIncrementFor` callback parameter (line 85)" + ], + "verification": ["Run `pnpm build` — build must succeed", "Run `pnpm test` — all tests must pass"] + }, + { + "id": "DX-017", + "status": "done", + "title": "Add unit tests with mocked HTTP for core modules", + "priority": "P3", + "category": "testing", + "effort": "4h", + "context": "Currently all 15 test files are integration tests that hit real Crystallize APIs. There are zero unit tests. Critical modules like `create-grabber.ts`, `create-mass-call-client.ts`, and `create-binary-file-manager.ts` have no tests at all. Unit tests with mocked HTTP would: (1) allow tests to run without credentials, (2) test error paths, (3) run fast enough for PR CI.", + "files": [ + "tests/unit/create-api-caller.test.ts", + "tests/unit/create-grabber.test.ts", + "tests/unit/create-mass-call-client.test.ts" + ], + "requirements": [ + "Create a `tests/unit/` directory for unit tests", + "Add unit tests for `create-api-caller.ts` covering: successful response, GraphQL errors in 200 response, HTTP error responses, Core Next wrapped errors, 204 No Content handling", + "Add unit tests for `create-mass-call-client.ts` covering: basic enqueue and execute, retry logic, batch size adaptation (increment/decrement), Fibonacci backoff", + "Mock the `grab` function (it's a simple function signature, easy to mock)", + "Do not require API credentials or network access", + "Test error paths: network failures, malformed JSON responses, timeout scenarios" + ], + "verification": [ + "Run `pnpm test` — all tests (integration + unit) must pass", + "Unit tests must pass without any `.env` credentials" + ] + }, + { + "id": "DX-018", + "status": "done", + "title": "Add error-path tests for API caller", + "priority": "P3", + "category": "testing", + "effort": "2h", + "context": "The current test suite only covers happy paths. There are no tests for: authentication failures (401/403), rate limiting (429), server errors (500/502/503), network timeouts, malformed JSON responses, or missing required fields. These are common real-world scenarios that should be covered.", + "files": ["tests/unit/error-handling.test.ts"], + "requirements": [ + "Create tests that verify `JSApiClientCallError` is thrown with correct properties for various HTTP error codes", + "Test that GraphQL errors in 200 responses are properly detected and thrown", + "Test that Core Next wrapped errors are detected via `getCoreNextError`", + "Test that the error includes the query and variables for debugging", + "Test 204 No Content returns empty object", + "Mock the grab function for all tests — no network required" + ], + "verification": ["Run `pnpm test` — all tests must pass", "Tests must pass without credentials"] + }, + { + "id": "DX-019", + "status": "done", + "title": "Add CI workflow for pull requests", + "priority": "P3", + "category": "ci-cd", + "effort": "30min", + "context": "Currently `.github/workflows/release.yaml` only runs on git tags (releases). There is no CI that runs on pull requests. This means broken code can be merged without any automated checks. A PR workflow should at minimum run build and unit tests.", + "files": [".github/workflows/ci.yaml"], + "requirements": [ + "Create a new workflow file `.github/workflows/ci.yaml`", + "Trigger on: pull requests to main, pushes to main", + "Steps: checkout, setup Node.js (same version as release.yaml), install deps with pnpm, run `pnpm build`, run `pnpm test` (unit tests only — integration tests need credentials)", + "Consider testing on multiple Node.js versions (20, 22, 24)", + "Keep it fast — unit tests should not require API credentials" + ], + "verification": [ + "Push a branch and create a PR to verify the workflow triggers", + "Verify the workflow passes with build + unit tests" + ] + }, + { + "id": "DX-020", + "status": "done", + "title": "Add JSDoc comments to main exported functions", + "priority": "P3", + "category": "documentation", + "effort": "2h", + "context": "Most exported factory functions have no JSDoc comments. IDE users (the primary consumers of this library) get no inline documentation when hovering over `createClient`, `createCatalogueFetcher`, `createOrderManager`, etc. Good JSDoc with `@param`, `@returns`, and `@example` tags significantly improves discoverability.", + "files": [ + "src/core/client/create-client.ts", + "src/core/catalogue/create-catalogue-fetcher.ts", + "src/core/catalogue/create-navigation-fetcher.ts", + "src/core/catalogue/create-product-hydrater.ts", + "src/core/pim/orders/create-order-manager.ts", + "src/core/pim/orders/create-order-fetcher.ts", + "src/core/pim/customers/create-customer-manager.ts", + "src/core/pim/subscriptions/create-subscription-contract-manager.ts", + "src/core/shop/create-cart-manager.ts", + "src/core/create-signature-verifier.ts", + "src/core/pim/create-binary-file-manager.ts", + "src/core/create-mass-call-client.ts" + ], + "requirements": [ + "Add JSDoc to all exported factory functions with: description, @param tags, @returns description, and at least one @example", + "Focus on the main entry points that library consumers use directly", + "Keep descriptions concise — one sentence for what it does, one for when to use it", + "Include authentication requirements in the description where relevant (e.g., 'Requires PIM API credentials')", + "Do not add JSDoc to internal/helper functions" + ], + "verification": [ + "Run `pnpm build` — build must succeed", + "Open the source files in an IDE and verify JSDoc appears on hover for the exported functions" + ] + } + ] } diff --git a/components/js-api-client/src/core/create-mass-call-client.ts b/components/js-api-client/src/core/create-mass-call-client.ts index b9b7c463..e49f338d 100644 --- a/components/js-api-client/src/core/create-mass-call-client.ts +++ b/components/js-api-client/src/core/create-mass-call-client.ts @@ -87,7 +87,11 @@ export function createMassCallClient( maxSpawn?: number; onBatchDone?: (batch: MassCallClientBatch) => Promise; beforeRequest?: (batch: MassCallClientBatch, promise: CrystallizePromise) => Promise; - afterRequest?: (batch: MassCallClientBatch, promise: CrystallizePromise, results: Record) => Promise; + afterRequest?: ( + batch: MassCallClientBatch, + promise: CrystallizePromise, + results: Record, + ) => Promise; onFailure?: ( batch: { from: number; to: number }, exception: unknown, @@ -117,7 +121,9 @@ export function createMassCallClient( batch = promises.slice(seek, to); const batchResults = await Promise.all( batch.map(async (promise: CrystallizePromise) => { - const buildStandardPromise = async (promise: CrystallizePromise): Promise<{ key: string; result: unknown } | undefined> => { + const buildStandardPromise = async ( + promise: CrystallizePromise, + ): Promise<{ key: string; result: unknown } | undefined> => { try { return { key: promise.key, diff --git a/components/js-api-client/src/core/pim/orders/create-order-fetcher.ts b/components/js-api-client/src/core/pim/orders/create-order-fetcher.ts index 281f5bf1..52cad9df 100644 --- a/components/js-api-client/src/core/pim/orders/create-order-fetcher.ts +++ b/components/js-api-client/src/core/pim/orders/create-order-fetcher.ts @@ -20,7 +20,11 @@ export type DefaultOrderType; } & OnOrder; -const buildBaseQuery = (onOrder?: OrderExtra, onOrderItem?: OrderItemExtra, onCustomer?: CustomerExtra) => { +const buildBaseQuery = ( + onOrder?: OrderExtra, + onOrderItem?: OrderItemExtra, + onCustomer?: CustomerExtra, +) => { const priceQuery = { gross: true, net: true, diff --git a/components/js-api-client/src/core/pim/orders/create-order-manager.ts b/components/js-api-client/src/core/pim/orders/create-order-manager.ts index 4d774a4d..8d880d7d 100644 --- a/components/js-api-client/src/core/pim/orders/create-order-manager.ts +++ b/components/js-api-client/src/core/pim/orders/create-order-manager.ts @@ -125,7 +125,12 @@ export const createOrderManager = (apiClient: ClientInterface) => { type PutInPipelineStageDefaultOrderType = Required> & { customer: Required, 'identifier'>> & OnCustomer; } & OnOrder; - const putInPipelineStage = async ( + const putInPipelineStage = async < + OnOrder = unknown, + OnCustomer = unknown, + CustomerExtra = unknown, + OrderExtra = unknown, + >( { id, pipelineId, stageId }: PutInPipelineStageArgs, enhancements?: PutInPipelineStageEnhancedQuery, ): Promise> => { @@ -157,7 +162,12 @@ export const createOrderManager = (apiClient: ClientInterface) => { type RemoveFromPipelineDefaultOrderType = Required> & { customer: Required, 'identifier'>> & OnCustomer; } & OnOrder; - const removeFromPipeline = async ( + const removeFromPipeline = async < + OnOrder = unknown, + OnCustomer = unknown, + CustomerExtra = unknown, + OrderExtra = unknown, + >( { id, pipelineId }: RemoveFromPipelineArgs, enhancements?: RemoveFromPipelineEnhancedQuery, ): Promise> => { diff --git a/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-fetcher.ts b/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-fetcher.ts index 3bc66e98..195167e4 100644 --- a/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-fetcher.ts +++ b/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-fetcher.ts @@ -19,7 +19,10 @@ type DefaultSubscriptionContractType = Requi customer: Required, 'identifier'>> & OnCustomer; } & OnSubscriptionContract; -const buildBaseQuery = (onSubscriptionContract?: SubscriptionContractExtra, onCustomer?: CustomerExtra) => { +const buildBaseQuery = ( + onSubscriptionContract?: SubscriptionContractExtra, + onCustomer?: CustomerExtra, +) => { const phaseQuery = { period: true, unit: true, @@ -114,7 +117,12 @@ type EnhanceQuery }; export const createSubscriptionContractFetcher = (apiClient: ClientInterface) => { - const fetchById = async ( + const fetchById = async < + OnSubscriptionContract = unknown, + OnCustomer = unknown, + SubscriptionContractExtra = unknown, + CustomerExtra = unknown, + >( id: string, enhancements?: EnhanceQuery, ): Promise | null> => { diff --git a/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-manager.ts b/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-manager.ts index 923f5fad..3ca2ad50 100644 --- a/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-manager.ts +++ b/components/js-api-client/src/core/pim/subscriptions/create-subscription-contract-manager.ts @@ -25,7 +25,9 @@ type WithIdentifiersAndStatus = R & { } & (R extends { status: infer S } ? S : {}); }; -const baseQuery = }>(onSubscriptionContract?: SubscriptionContractExtra) => ({ +const baseQuery = }>( + onSubscriptionContract?: SubscriptionContractExtra, +) => ({ __on: [ { __typeName: 'SubscriptionContractAggregate', @@ -64,7 +66,10 @@ const baseQuery = { - const create = async } = {}>( + const create = async < + OnSubscriptionContract, + SubscriptionContractExtra extends { status?: Record } = {}, + >( intentSubscriptionContract: CreateSubscriptionContractInput, onSubscriptionContract?: SubscriptionContractExtra, ): Promise> => { @@ -100,7 +105,10 @@ export const createSubscriptionContractManager = (apiClient: ClientInterface) => return confirmation.createSubscriptionContract; }; - const update = async } = {}>( + const update = async < + OnSubscriptionContract, + SubscriptionContractExtra extends { status?: Record } = {}, + >( intentSubscriptionContract: UpdateSubscriptionContractInput, onSubscriptionContract?: SubscriptionContractExtra, ): Promise> => { @@ -121,7 +129,10 @@ export const createSubscriptionContractManager = (apiClient: ClientInterface) => return confirmation.updateSubscriptionContract; }; - const cancel = async } = {}>( + const cancel = async < + OnSubscriptionContract, + SubscriptionContractExtra extends { status?: Record } = {}, + >( id: UpdateSubscriptionContractInput['id'], deactivate = false, onSubscriptionContract?: SubscriptionContractExtra, @@ -144,7 +155,10 @@ export const createSubscriptionContractManager = (apiClient: ClientInterface) => return confirmation.cancelSubscriptionContract; }; - const pause = async } = {}>( + const pause = async < + OnSubscriptionContract, + SubscriptionContractExtra extends { status?: Record } = {}, + >( id: UpdateSubscriptionContractInput['id'], onSubscriptionContract?: SubscriptionContractExtra, ): Promise> => { @@ -163,7 +177,10 @@ export const createSubscriptionContractManager = (apiClient: ClientInterface) => return confirmation.pauseSubscriptionContract; }; - const resume = async } = {}>( + const resume = async < + OnSubscriptionContract, + SubscriptionContractExtra extends { status?: Record } = {}, + >( id: UpdateSubscriptionContractInput['id'], onSubscriptionContract?: SubscriptionContractExtra, ): Promise> => { @@ -182,7 +199,10 @@ export const createSubscriptionContractManager = (apiClient: ClientInterface) => return confirmation.resumeSubscriptionContract; }; - const renew = async } = {}>( + const renew = async < + OnSubscriptionContract, + SubscriptionContractExtra extends { status?: Record } = {}, + >( id: UpdateSubscriptionContractInput['id'], onSubscriptionContract?: SubscriptionContractExtra, ): Promise> => { diff --git a/components/js-api-client/tests/unit/create-api-caller.test.ts b/components/js-api-client/tests/unit/create-api-caller.test.ts index 7efde203..3d7b8dcf 100644 --- a/components/js-api-client/tests/unit/create-api-caller.test.ts +++ b/components/js-api-client/tests/unit/create-api-caller.test.ts @@ -1,5 +1,10 @@ import { describe, test, expect, vi } from 'vitest'; -import { createApiCaller, post, authenticationHeaders, JSApiClientCallError } from '../../src/core/client/create-api-caller.js'; +import { + createApiCaller, + post, + authenticationHeaders, + JSApiClientCallError, +} from '../../src/core/client/create-api-caller.js'; import type { Grab, GrabResponse } from '../../src/core/client/create-grabber.js'; import type { ClientConfiguration } from '../../src/core/client/create-client.js'; @@ -84,22 +89,28 @@ describe('post', () => { test('passes query and variables in request body', async () => { const grab = mockGrab(mockGrabResponse()); await post(grab, 'https://api.test.com', defaultConfig, '{ items }', { limit: 10 }); - expect(grab).toHaveBeenCalledWith('https://api.test.com', expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ query: '{ items }', variables: { limit: 10 } }), - })); + expect(grab).toHaveBeenCalledWith( + 'https://api.test.com', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ query: '{ items }', variables: { limit: 10 } }), + }), + ); }); test('includes authentication headers', async () => { const grab = mockGrab(mockGrabResponse()); await post(grab, 'https://api.test.com', defaultConfig, '{ items }'); - expect(grab).toHaveBeenCalledWith('https://api.test.com', expect.objectContaining({ - headers: expect.objectContaining({ - 'X-Crystallize-Access-Token-Id': 'token-id', - 'X-Crystallize-Access-Token-Secret': 'token-secret', - 'Content-type': 'application/json; charset=UTF-8', + expect(grab).toHaveBeenCalledWith( + 'https://api.test.com', + expect.objectContaining({ + headers: expect.objectContaining({ + 'X-Crystallize-Access-Token-Id': 'token-id', + 'X-Crystallize-Access-Token-Secret': 'token-secret', + 'Content-type': 'application/json; charset=UTF-8', + }), }), - })); + ); }); test('204 No Content returns empty object', async () => { @@ -109,12 +120,14 @@ describe('post', () => { }); test('throws JSApiClientCallError on HTTP error', async () => { - const grab = mockGrab(mockGrabResponse({ - ok: false, - status: 401, - statusText: 'Unauthorized', - jsonData: { message: 'Invalid credentials', errors: [{ field: 'token' }] }, - })); + const grab = mockGrab( + mockGrabResponse({ + ok: false, + status: 401, + statusText: 'Unauthorized', + jsonData: { message: 'Invalid credentials', errors: [{ field: 'token' }] }, + }), + ); try { await post(grab, 'https://api.test.com', defaultConfig, '{ items }'); expect.unreachable('should have thrown'); @@ -129,12 +142,14 @@ describe('post', () => { }); test('throws on GraphQL errors in 200 response', async () => { - const grab = mockGrab(mockGrabResponse({ - jsonData: { - errors: [{ message: 'Field "foo" not found' }], - data: null, - }, - })); + const grab = mockGrab( + mockGrabResponse({ + jsonData: { + errors: [{ message: 'Field "foo" not found' }], + data: null, + }, + }), + ); try { await post(grab, 'https://api.test.com', defaultConfig, '{ foo }'); expect.unreachable('should have thrown'); @@ -147,16 +162,18 @@ describe('post', () => { }); test('detects Core Next wrapped errors', async () => { - const grab = mockGrab(mockGrabResponse({ - jsonData: { - data: { - someOperation: { - errorName: 'ItemNotFound', - message: 'The item does not exist', + const grab = mockGrab( + mockGrabResponse({ + jsonData: { + data: { + someOperation: { + errorName: 'ItemNotFound', + message: 'The item does not exist', + }, }, }, - }, - })); + }), + ); try { await post(grab, 'https://api.test.com', defaultConfig, '{ someOperation }'); expect.unreachable('should have thrown'); @@ -169,13 +186,15 @@ describe('post', () => { }); test('Core Next error without message uses fallback', async () => { - const grab = mockGrab(mockGrabResponse({ - jsonData: { - data: { - op: { errorName: 'GenericError' }, + const grab = mockGrab( + mockGrabResponse({ + jsonData: { + data: { + op: { errorName: 'GenericError' }, + }, }, - }, - })); + }), + ); try { await post(grab, 'https://api.test.com', defaultConfig, '{ op }'); expect.unreachable('should have thrown'); @@ -190,16 +209,23 @@ describe('post', () => { await post(grab, 'https://api.test.com', defaultConfig, '{ items }', undefined, { headers: { 'X-Custom': 'value' }, }); - expect(grab).toHaveBeenCalledWith('https://api.test.com', expect.objectContaining({ - headers: expect.objectContaining({ 'X-Custom': 'value' }), - })); + expect(grab).toHaveBeenCalledWith( + 'https://api.test.com', + expect.objectContaining({ + headers: expect.objectContaining({ 'X-Custom': 'value' }), + }), + ); }); test('error includes query and variables for debugging', async () => { - const grab = mockGrab(mockGrabResponse({ - ok: false, status: 500, statusText: 'Internal Server Error', - jsonData: { message: 'Server error', errors: [] }, - })); + const grab = mockGrab( + mockGrabResponse({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + jsonData: { message: 'Server error', errors: [] }, + }), + ); const variables = { id: '123' }; try { await post(grab, 'https://api.test.com', defaultConfig, '{ item(id: $id) }', variables); @@ -233,9 +259,12 @@ describe('createApiCaller', () => { extraHeaders: { 'X-Tenant': 'test' }, }); await caller('{ items }'); - expect(grab).toHaveBeenCalledWith('https://api.test.com', expect.objectContaining({ - headers: expect.objectContaining({ 'X-Tenant': 'test' }), - })); + expect(grab).toHaveBeenCalledWith( + 'https://api.test.com', + expect.objectContaining({ + headers: expect.objectContaining({ 'X-Tenant': 'test' }), + }), + ); }); }); @@ -243,9 +272,11 @@ describe('profiling', () => { test('calls onRequest and onRequestResolved', async () => { const onRequest = vi.fn(); const onRequestResolved = vi.fn(); - const grab = mockGrab(mockGrabResponse({ - headers: { get: (name: string) => name === 'server-timing' ? 'total;dur=42.5' : null }, - })); + const grab = mockGrab( + mockGrabResponse({ + headers: { get: (name: string) => (name === 'server-timing' ? 'total;dur=42.5' : null) }, + }), + ); const caller = createApiCaller(grab, 'https://api.test.com', defaultConfig, { profiling: { onRequest, onRequestResolved }, }); diff --git a/components/js-api-client/tests/unit/create-grabber.test.ts b/components/js-api-client/tests/unit/create-grabber.test.ts index e6cc8789..1bb97708 100644 --- a/components/js-api-client/tests/unit/create-grabber.test.ts +++ b/components/js-api-client/tests/unit/create-grabber.test.ts @@ -26,11 +26,14 @@ describe('createGrabber (fetch mode)', () => { body: '{"query":"{ test }"}', }); - expect(fetchSpy).toHaveBeenCalledWith('https://api.test.com/graphql', expect.objectContaining({ - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: '{"query":"{ test }"}', - })); + expect(fetchSpy).toHaveBeenCalledWith( + 'https://api.test.com/graphql', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{"query":"{ test }"}', + }), + ); expect(response.ok).toBe(true); expect(response.status).toBe(200); @@ -55,9 +58,12 @@ describe('createGrabber (fetch mode)', () => { const { grab } = createGrabber(); await grab('https://api.test.com', { signal: controller.signal }); - expect(fetchSpy).toHaveBeenCalledWith('https://api.test.com', expect.objectContaining({ - signal: controller.signal, - })); + expect(fetchSpy).toHaveBeenCalledWith( + 'https://api.test.com', + expect.objectContaining({ + signal: controller.signal, + }), + ); fetchSpy.mockRestore(); }); diff --git a/components/js-api-client/tests/unit/create-mass-call-client.test.ts b/components/js-api-client/tests/unit/create-mass-call-client.test.ts index abcfef20..acad1082 100644 --- a/components/js-api-client/tests/unit/create-mass-call-client.test.ts +++ b/components/js-api-client/tests/unit/create-mass-call-client.test.ts @@ -134,7 +134,9 @@ describe('createMassCallClient', () => { initialSpawn: 1, maxSpawn: 5, sleeper: noopSleeper(), - onBatchDone: async (batch) => { batches.push(batch); }, + onBatchDone: async (batch) => { + batches.push(batch); + }, }); for (let i = 0; i < 6; i++) { diff --git a/components/js-api-client/tests/unit/error-handling.test.ts b/components/js-api-client/tests/unit/error-handling.test.ts index bf770a0e..4cd0cec3 100644 --- a/components/js-api-client/tests/unit/error-handling.test.ts +++ b/components/js-api-client/tests/unit/error-handling.test.ts @@ -67,7 +67,10 @@ describe('HTTP error codes', () => { ); test('error includes errors array from response', async () => { - const errors = [{ field: 'token', message: 'expired' }, { field: 'scope', message: 'insufficient' }]; + const errors = [ + { field: 'token', message: 'expired' }, + { field: 'scope', message: 'insufficient' }, + ]; const grab = vi.fn().mockResolvedValue( mockGrabResponse({ ok: false, @@ -134,11 +137,7 @@ describe('GraphQL errors in 200 response', () => { const grab = vi.fn().mockResolvedValue( mockGrabResponse({ jsonData: { - errors: [ - { message: 'First error' }, - { message: 'Second error' }, - { message: 'Third error' }, - ], + errors: [{ message: 'First error' }, { message: 'Second error' }, { message: 'Third error' }], data: null, }, }), @@ -257,41 +256,31 @@ describe('network failures', () => { test('propagates network error from grab', async () => { const grab = vi.fn().mockRejectedValue(new TypeError('fetch failed')); - await expect( - post(grab, 'https://api.test.com', defaultConfig, query), - ).rejects.toThrow('fetch failed'); + await expect(post(grab, 'https://api.test.com', defaultConfig, query)).rejects.toThrow('fetch failed'); }); test('propagates DNS resolution failure', async () => { const grab = vi.fn().mockRejectedValue(new TypeError('getaddrinfo ENOTFOUND api.test.com')); - await expect( - post(grab, 'https://api.test.com', defaultConfig, query), - ).rejects.toThrow('ENOTFOUND'); + await expect(post(grab, 'https://api.test.com', defaultConfig, query)).rejects.toThrow('ENOTFOUND'); }); test('propagates connection refused', async () => { const grab = vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED 127.0.0.1:443')); - await expect( - post(grab, 'https://api.test.com', defaultConfig, query), - ).rejects.toThrow('ECONNREFUSED'); + await expect(post(grab, 'https://api.test.com', defaultConfig, query)).rejects.toThrow('ECONNREFUSED'); }); test('propagates connection reset', async () => { const grab = vi.fn().mockRejectedValue(new Error('read ECONNRESET')); - await expect( - post(grab, 'https://api.test.com', defaultConfig, query), - ).rejects.toThrow('ECONNRESET'); + await expect(post(grab, 'https://api.test.com', defaultConfig, query)).rejects.toThrow('ECONNRESET'); }); }); describe('timeout scenarios', () => { test('passes abort signal when timeout is configured', async () => { - const grab = vi.fn().mockResolvedValue( - mockGrabResponse({ jsonData: { data: { ok: true } } }), - ); + const grab = vi.fn().mockResolvedValue(mockGrabResponse({ jsonData: { data: { ok: true } } })); await post(grab, 'https://api.test.com', defaultConfig, query, undefined, undefined, { timeout: 5000, @@ -306,9 +295,7 @@ describe('timeout scenarios', () => { }); test('does not pass signal when no timeout configured', async () => { - const grab = vi.fn().mockResolvedValue( - mockGrabResponse({ jsonData: { data: { ok: true } } }), - ); + const grab = vi.fn().mockResolvedValue(mockGrabResponse({ jsonData: { data: { ok: true } } })); await post(grab, 'https://api.test.com', defaultConfig, query); @@ -338,9 +325,7 @@ describe('malformed responses', () => { text: () => Promise.resolve('Server Error'), }); - await expect( - post(grab, 'https://api.test.com', defaultConfig, query), - ).rejects.toThrow(SyntaxError); + await expect(post(grab, 'https://api.test.com', defaultConfig, query)).rejects.toThrow(SyntaxError); }); test('propagates JSON parse error on 200 with invalid body', async () => { @@ -353,17 +338,13 @@ describe('malformed responses', () => { text: () => Promise.resolve(''), }); - await expect( - post(grab, 'https://api.test.com', defaultConfig, query), - ).rejects.toThrow(SyntaxError); + await expect(post(grab, 'https://api.test.com', defaultConfig, query)).rejects.toThrow(SyntaxError); }); }); describe('204 No Content', () => { test('returns empty object', async () => { - const grab = vi.fn().mockResolvedValue( - mockGrabResponse({ ok: true, status: 204, statusText: 'No Content' }), - ); + const grab = vi.fn().mockResolvedValue(mockGrabResponse({ ok: true, status: 204, statusText: 'No Content' })); const result = await post(grab, 'https://api.test.com', defaultConfig, 'mutation { delete }'); expect(result).toEqual({});