From d07c4df52de891d3fef70fd9f49f2a5602a57722 Mon Sep 17 00:00:00 2001 From: quentin_duwa Date: Sun, 8 Nov 2026 13:45:08 +0000 Subject: [PATCH] feat(sync): harden the Hiboutik sync, share rate limits across instances, update dependencies (#4) --- CLAUDE.md | 20 +- eslint.config.mjs | 2 +- package-lock.json | 185 ++++++------------ package.json | 14 +- prisma/prisma.service.ts | 13 +- prisma/schema.prisma | 18 +- src/app.controller.ts | 3 +- src/app.module.ts | 21 +- src/config/env.validation.spec.ts | 31 +++ src/config/env.validation.ts | 40 +++- src/database/customer.database.repository.ts | 13 +- src/database/order.database.repository.ts | 13 +- src/database/product.database.repository.ts | 11 +- .../productVersion.database.repository.ts | 20 +- .../syncLock.database.repository.spec.ts | 2 +- src/database/syncLock.database.repository.ts | 3 +- ...rottlerStorage.database.repository.spec.ts | 56 ++++++ .../throttlerStorage.database.repository.ts | 90 +++++++++ src/hiboutik/brand.hiboutik.repository.ts | 5 +- src/hiboutik/category.hiboutik.repository.ts | 11 +- src/hiboutik/customer.hiboutik.repository.ts | 31 ++- src/hiboutik/hiboutik.client.spec.ts | 172 ++++++++++++++-- src/hiboutik/hiboutik.client.ts | 139 +++++++++++-- src/hiboutik/product.hiboutik.repository.ts | 9 +- src/hiboutik/sale.hiboutik.repository.ts | 7 +- src/hiboutik/stock.hiboutik.repository.ts | 10 +- src/hiboutik/supplier.hiboutik.repository.ts | 4 +- src/models/converter/brand.converter.ts | 2 +- src/models/converter/category.converter.ts | 6 +- .../converter/customer.converter.spec.ts | 32 +-- src/models/converter/customer.converter.ts | 6 +- .../customerAddress.converter.spec.ts | 44 ++--- .../converter/customerAddress.converter.ts | 6 +- src/models/converter/order.converter.spec.ts | 30 ++- src/models/converter/order.converter.ts | 17 +- .../converter/product.converter.spec.ts | 4 +- src/models/converter/product.converter.ts | 4 +- src/models/converter/supplier.converter.ts | 2 +- src/models/hiboutikDTO/brand.hiboutik.dto.ts | 17 +- .../hiboutikDTO/category.hiboutik.dto.ts | 28 +-- .../hiboutikDTO/customer.hiboutik.dto.ts | 140 ++++++------- .../hiboutikDTO/hiboutik.schema.spec.ts | 87 ++++++++ src/models/hiboutikDTO/hiboutik.schema.ts | 58 ++++++ .../hiboutikDTO/product.hiboutik.dto.ts | 89 +++++---- src/models/hiboutikDTO/sale.hiboutik.dto.ts | 71 ++++--- src/models/hiboutikDTO/stock.hiboutik.dto.ts | 19 +- .../hiboutikDTO/supplier.hiboutik.dto.ts | 17 +- .../hiboutik/brand.hiboutik.service.ts | 2 +- .../category.hiboutik.service.spec.ts | 19 +- .../hiboutik/category.hiboutik.service.ts | 4 +- .../customer.hiboutik.service.spec.ts | 90 ++++++--- .../hiboutik/customer.hiboutik.service.ts | 62 ++++-- .../hiboutik/hiboutik.service.spec.ts | 75 ++++++- src/services/hiboutik/hiboutik.service.ts | 63 +++++- .../hiboutik/product.hiboutik.service.spec.ts | 84 +++++--- .../hiboutik/product.hiboutik.service.ts | 59 ++++-- .../hiboutik/supplier.hiboutik.service.ts | 2 +- src/utils/date.spec.ts | 30 ++- src/utils/date.ts | 53 ++++- src/utils/sync.ts | 3 + test/app.e2e-spec.ts | 13 +- tsconfig.json | 4 +- 62 files changed, 1546 insertions(+), 639 deletions(-) create mode 100644 src/database/throttlerStorage.database.repository.spec.ts create mode 100644 src/database/throttlerStorage.database.repository.ts create mode 100644 src/models/hiboutikDTO/hiboutik.schema.spec.ts create mode 100644 src/models/hiboutikDTO/hiboutik.schema.ts diff --git a/CLAUDE.md b/CLAUDE.md index 6719dc3..e41389c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,8 @@ npm run test:e2e # e2e tests (test/jest-e2e.json) Jest runs in CommonJS: its config (in `package.json` and `test/jest-e2e.json`) transpiles the ESM-only `@nestjs/config` (`transformIgnorePatterns`) and maps `./x.js` imports to `./x` (`moduleNameMapper`) for the generated Prisma client. Keep both configs in sync. Unit tests build services directly with mocked repositories; the e2e test boots `AppModule` with `PrismaService` and `HiboutikService` overridden, so it needs neither a database nor Hiboutik. +TypeScript runs in `strict` mode and ESLint treats floating promises as errors: a deliberate fire-and-forget call is written `void promise`. + Prisma: the config file is `prisma7.config.ts` (non-default name), so pass it explicitly: ```bash @@ -34,11 +36,11 @@ The client is generated into `prisma/generated/` (generator `prisma-client`, not ## Environment -Read from `.env` (`@nestjs/config`, global): `DATABASE_URL` (used by Prisma and `PrismaService`), `HIBOUTIK_BASE_URL`, `HIBOUTIK_USERNAME`, `HIBOUTIK_PASSWORD` (HTTP Basic auth). HTTP calls to Hiboutik time out after 30 s (`HttpModule.register` in `AppModule`). +Read from `.env` (`@nestjs/config`, global): `DATABASE_URL` (used by Prisma and `PrismaService`), `HIBOUTIK_BASE_URL`, `HIBOUTIK_USERNAME`, `HIBOUTIK_PASSWORD` (HTTP Basic auth). HTTP calls to Hiboutik (`HttpModule.register` in `AppModule`) time out after 30 s, never follow redirects (the Basic auth credentials must not leave) and are capped at 50 MB. `HiboutikClient` retries a 429 three times with backoff (2/4/8 s, or the `Retry-After` delay capped at 60 s), then throws `HiboutikRateLimitError`: per-item `catch` blocks must rethrow it (`rethrowIfRateLimited`) and `syncAll` stops on it. Hiboutik allows 10,000 calls per month and 30 concurrent calls per shop. -The environment is validated at startup (`src/config/env.validation.ts`, wired in `ConfigModule.forRoot`): a missing or malformed required variable stops the app, and `HIBOUTIK_BASE_URL` must be HTTPS (Basic auth). Optional variables: `PORT`, `CORS_ORIGINS` (comma-separated; CORS stays disabled when unset) and `TRUST_PROXY` (number of reverse proxies, so rate limiting sees the real client IP). Any new variable goes in `EnvironmentVariables`; the e2e test gets dummy values from `test/setup-env.ts`. +The environment is validated at startup (`src/config/env.validation.ts`, wired in `ConfigModule.forRoot`): a missing or malformed required variable stops the app, and `HIBOUTIK_BASE_URL` must be HTTPS (Basic auth). Optional variables: `NODE_ENV` (`development`/`production`/`test`; in `production`, `DATABASE_URL` must set `sslmode=require`, `verify-ca` or `verify-full`), `PORT`, `CORS_ORIGINS` (comma-separated; CORS stays disabled when unset), `TRUST_PROXY` (number of reverse proxies, so rate limiting sees the real client IP) and `SYNC_SHUTDOWN_TIMEOUT` (seconds a stopping instance waits for its running sync, default 25; keep it below the orchestrator's grace period). Any new variable goes in `EnvironmentVariables`; the e2e test gets dummy values from `test/setup-env.ts`. -Security defaults in `main.ts`/`AppModule`: Helmet, global `ValidationPipe`, `ThrottlerGuard` on every route (60 req/min/IP; `@Throttle` for stricter limits, e.g. `POST /sync` at 5/min), `AllExceptionsFilter` (unexpected errors are logged with message + stack and answered with a generic 500; deliberate `HttpException`s and 4xx HTTP-layer errors pass through), HTTP header/request timeouts (15 s / 30 s), shutdown hooks. Long work must run in the background (like `POST /sync`), never inside a request. +Security defaults in `main.ts`/`AppModule`: Helmet, global `ValidationPipe`, `ThrottlerGuard` on every route (60 req/min/IP; `@Throttle` for stricter limits, e.g. `POST /sync` at 5/min; counters shared by every instance in the `throttler_records` table through `ThrottlerDatabaseStorage`, one atomic upsert per request on the database clock, failing open if the database is unreachable), `AllExceptionsFilter` (unexpected errors are logged with message + stack and answered with a generic 500; deliberate `HttpException`s and 4xx HTTP-layer errors pass through), HTTP header/request timeouts (15 s / 30 s), shutdown hooks. The app is meant to run as several instances. On shutdown, `HiboutikService.beforeApplicationShutdown` refuses new syncs (503) and waits for the running one up to `SYNC_SHUTDOWN_TIMEOUT`; Prisma disconnects last, in `onApplicationShutdown` (never move it back to `onModuleDestroy`, which runs first). Long work must run in the background (like `POST /sync`), never inside a request. ## Architecture @@ -46,21 +48,23 @@ Everything is registered as providers in a single `AppModule` (`src/app.module.t Sync pipeline, one entity per layer: -1. **`src/hiboutik/*.hiboutik.repository.ts`** – fetch raw data from Hiboutik through `HiboutikClient` (`get`, and `getAllPages`, which walks the `p` page param until a page smaller than 250 results, Hiboutik's page size). `/suppliers` and `/stock_available/all_wh` are not paginated. -2. **`src/models/hiboutikDTO/`** – snake_case types mirroring Hiboutik API payloads. +1. **`src/hiboutik/*.hiboutik.repository.ts`** – fetch data from Hiboutik through `HiboutikClient`: `getList`, `getAllPages` (walks the `p` page param until a page smaller than 250 results, Hiboutik's page size) and `getOne` (single resource: `null` on a 404 or an empty 2xx answer, throws on anything else). `/suppliers` and `/stock_available/all_wh` are not paginated. +2. **`src/models/hiboutikDTO/`** – zod schemas of the Hiboutik payloads (snake_case, only the fields used), the DTO types being inferred from them. `HiboutikClient` validates every answer: shared field types in `hiboutik.schema.ts` turn numeric strings into numbers and empty values into `0`/`""`/`[]`; an invalid id, relation or price rejects the whole answer (so nothing is deleted). Validation errors name fields, never values. 3. **`src/models/converter/`** – classes extending abstract `Converter`; `fromHiboutik` maps a DTO to a Prisma `*CreateInput`. Relations use `connect: { hiboutikId }`. 4. **`src/database/*.database.repository.ts`** – Prisma access, mainly `upsert` keyed on the unique `hiboutikId`. 5. **`src/services/hiboutik/*.hiboutik.service.ts`** – per-entity sync orchestration; `HiboutikService.syncAll` runs them in dependency order: brands → categories → suppliers → products → customers (products `connect` to the first three by `hiboutikId`, so order matters). Domain notes: - Synced models carry a unique `hiboutikId` (join key with Hiboutik), except `Order` (nullable `hiboutikSaleId`), `ProductVersion` (unique `[productId, hiboutikSizeId]`) and `OrderLine`; the internal `id` is a UUID. Empty Hiboutik barcodes are stored as `null`. `ProductVersion.barcode` is indexed but **not unique** (Hiboutik may reuse a barcode). -- Hiboutik is the source of truth: brands, categories, suppliers, products and customers missing from it are deleted. Hiboutik is trusted: a 2xx answer is the truth, even an empty list (everything is then deleted). Deletion only requires that the call did not fail: `HiboutikClient` throws on HTTP errors (4xx/5xx) and `getList`/`getAllPages` also throw on a 2xx body that is not a list (a 2xx without body is an empty list), so a failed step deletes nothing. List endpoints must go through `getList`/`getAllPages`, never a bare `get`. Products are deleted before the upserts (so a recreated product can reuse its barcode); their versions go by cascade, and versions missing from a product are deleted before its versions are upserted. Customer addresses go by cascade with their customer; other relations (product brand/category/supplier, sub-categories, order customer) are set to null and orders are kept. +- Hiboutik is the source of truth: brands, categories, suppliers, products and customers missing from it are deleted. Hiboutik is trusted: a 2xx answer is the truth, even an empty list (everything is then deleted). Deletion only requires that the call did not fail: `HiboutikClient` throws on HTTP errors (4xx/5xx) and `getList`/`getAllPages` also throw on a 2xx body that is not a list (a 2xx without body is an empty list), so a failed step deletes nothing. Every call goes through `getList`/`getAllPages`/`getAllPagesForSync`/`getOne` with a schema (the bare `get` is private). A paginated list that drives deletions must guard against page shifts (an item created or deleted during the walk shifts the following pages, so an item can be skipped and wrongly deleted), and be sorted on a unique key. Brands, categories and products use `getAllPagesForSync`: a list spanning several pages is walked twice and both walks are merged by id. Customers do not, as each one is fetched anyway: as a customer created or deleted during the walk shifts the pages, a stored customer missing from the list is deleted only once `GET /customer/:id` confirms it (404 or empty answer); if it still exists it is synced, if the check fails it is kept. Products are deleted before the upserts; their versions go by cascade. Each product is written in one transaction (upsert, deletion of its missing versions, version upserts), with the repositories taking an optional `Prisma.TransactionClient`. Customer addresses go by cascade with their customer; other relations (product brand/category/supplier, sub-categories, order customer) are set to null and orders are kept. - Brands, categories and suppliers are upserted through `forEachSafely` (`src/utils/sync.ts`): a failing row is logged and skipped. Each sync step and each product/customer is in a try/catch: failures are logged, the sync goes on, and a summary is logged at the end. - Categories form a tree: parents are linked in a second pass (`setParent`). `enabled` mirrors `category_enabled` (POS), `webEnabled` mirrors `category_enabled_www`. - Products: `ProductVersion` rows come from `product_size_details` (barcode, price/discount overrides from `product_specific_rules`, stock from the stocks endpoint); no sizes means one version with `hiboutikSizeId: 0`. `globalStock` is the sum of available stock. `archived` mirrors `product_arch`. - `GET /customer/:id` embeds `addresses` and `sales` (typed in `customer.hiboutik.dto.ts`); `sales` only has headers, lines come from `GET /sales/:id` (`SaleHiboutikRepository`). - Addresses (from the embedded array): missing ones are deleted, the rest upserted via `forEachSafely`; no array means stored addresses are left untouched. Each has its own recipient fields, empty optional values stored as `null`. Company details (`taxNumber`, `companyNumber`, `legalStatus`) come from `GET /customers_addresses/:id`, only for addresses with a `company` (`null` otherwise, untouched if the call fails). -- Orders come from the customer's `sales`: sales already stored as `COMPLETED` are skipped, the others are fetched and upserted on `hiboutikSaleId` (`source: STORE` on creation only). `status` is `COMPLETED` or `PENDING`, `orderedAt` is the completion date else the creation date. Lines are replaced on each upsert and snapshot name, version, barcode, price and Hiboutik ids; the `ProductVersion` link is optional (`SetNull`). `unitPrice` is the paid price, taxes and discount included. Quotations are skipped, anonymous sales are never synced (only customers' sales are read), and orders are never deleted. +- Customers: an email or phone removed in Hiboutik is erased in database (`null`, data minimization). +- Dates: Hiboutik sends French local time without offset; `parseHiboutikDate` reads datetimes as `Europe/Paris` whatever the server time zone, and date-only values (date of birth) as midnight UTC. +- Orders come from the customer's `sales`: sales already stored as `COMPLETED` and linked to this customer are skipped (an order that lost its customer is fetched again to be linked back), the others are fetched and upserted on `hiboutikSaleId` (`source: STORE` on creation only). `status` is `COMPLETED` or `PENDING`, `orderedAt` is the completion date else the creation date. Lines are replaced on each upsert and snapshot name, version, barcode, price and Hiboutik ids; the `ProductVersion` link is optional (`SetNull`). `unitPrice` is the paid price, taxes and discount included. Quotations are skipped, anonymous sales are never synced (only customers' sales are read), and orders are never deleted. - Shared Prisma payload types (e.g. `ProductWithVersions`) live in `prisma/customObjects/`. ## Input validation @@ -73,7 +77,7 @@ All data coming from API clients (`@Body()`, `@Query()`, `@Param()`) must be val ## Security rules -- **Logs**: never log a raw error object, a request/response or an axios config: they carry the Hiboutik `Authorization` header (credentials). Log `(error as Error).message` and `.stack` only, as the sync services do. No personal data (emails, names, addresses, phone numbers, tax numbers) in logs: ids only. +- **Logs**: never log a raw error object, a request/response or an axios config: they carry the Hiboutik `Authorization` header (credentials). Log `(error as Error).message` and `.stack` only, as the sync services do. No personal data (emails, names, addresses, phone numbers, tax numbers) in logs: ids only. `PrismaService` uses `errorFormat: 'minimal'` because the default error format prints the query arguments; keep it. - **SQL**: use the Prisma query API. `$queryRaw`/`$executeRaw` are allowed only as tagged templates with parameters (`` $queryRaw`... ${value}` ``); never `$queryRawUnsafe`/`$executeRawUnsafe` nor string concatenation or interpolation into SQL text. - **Sync lock**: only one synchronization runs at a time across all instances. `HiboutikService.startSync` takes a Postgres advisory lock (`SyncLockDatabaseRepository`, dedicated `pg` connection held for the whole sync, released by Postgres itself if the process dies). Start syncs only through `startSync`, never call `syncAll` directly from a controller or a scheduler. diff --git a/eslint.config.mjs b/eslint.config.mjs index 088587d..fbc79b3 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -28,7 +28,7 @@ export default tseslint.config( { rules: { '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-floating-promises': 'warn', + '@typescript-eslint/no-floating-promises': 'error', '@typescript-eslint/no-unsafe-argument': 'warn', "prettier/prettier": "error", }, diff --git a/package-lock.json b/package-lock.json index 99b44f8..98db4f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,10 +11,10 @@ "license": "UNLICENSED", "dependencies": { "@nestjs/axios": "^4.0.1", - "@nestjs/common": "^11.0.1", + "@nestjs/common": "^11.1.29", "@nestjs/config": "^12.0.0", - "@nestjs/core": "^11.0.1", - "@nestjs/platform-express": "^11.0.1", + "@nestjs/core": "^11.1.29", + "@nestjs/platform-express": "^11.1.29", "@nestjs/throttler": "^6.7.1", "@prisma/adapter-pg": "^7.10.0", "@prisma/client": "^7.10.0", @@ -24,14 +24,15 @@ "helmet": "^8.3.0", "pg": "^8.23.0", "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.1" + "rxjs": "^7.8.1", + "zod": "4.6.7" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", "@eslint/js": "^9.18.0", "@nestjs/cli": "^12.0.11", "@nestjs/schematics": "^11.0.0", - "@nestjs/testing": "^11.0.1", + "@nestjs/testing": "^11.1.29", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/node": "^24.0.0", @@ -2554,9 +2555,9 @@ } }, "node_modules/@nestjs/common": { - "version": "11.1.26", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.26.tgz", - "integrity": "sha512-0VARQyzuGbprvjO+slWq9Jtj1P0jYCSKAUSv9LWFNWD39ZbDzXXM1pMs35kReVXwchra0urMfTQxw4uAOfdSzA==", + "version": "11.1.29", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.29.tgz", + "integrity": "sha512-zkeNRlfiQIH/044r5zphjNzKYxBRC4O00onTdCsH2qq0R30Ixo0gOS9so5TadEbJJCuGNm0Vx3PBE2FG2vkBdA==", "license": "MIT", "dependencies": { "file-type": "21.3.4", @@ -2601,9 +2602,9 @@ } }, "node_modules/@nestjs/core": { - "version": "11.1.26", - "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.26.tgz", - "integrity": "sha512-K45zUwYpowEsVqm8qNIzsMcl4LJev0MK9zVhDnmym7YRTJ2/caslqVeKYhPRd5+Fh81IkvWUVu6vEo46uZ5mgQ==", + "version": "11.1.29", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.29.tgz", + "integrity": "sha512-ANXnZxirMNAY+JuRHCIYIdTfbhX3rizeSbJloub/4EYwpIfCuUoaz9woOofWmywYYc3gsVkLxVZV03gqntI8vA==", "license": "MIT", "dependencies": { "fast-safe-stringify": "2.1.1", @@ -2640,15 +2641,15 @@ } }, "node_modules/@nestjs/platform-express": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.1.tgz", - "integrity": "sha512-IUxk380qnUtz0PCRQ5i+o9UHlGMrFzGPIJxDwyt3JZZwx2AngOlcEcm5e+7YeJQEr2QYX2QyC4tUQg0zde+D7A==", + "version": "11.1.29", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.29.tgz", + "integrity": "sha512-OngsAjdzM+SbbmP1XojKfBgl0Lj7u8b7IK5duwpFymzNP5EmjS8ujvqck9M8h0L14I91aRIFSjIpVfHj8WVtqw==", "license": "MIT", "dependencies": { - "cors": "2.8.5", - "express": "5.1.0", - "multer": "1.4.5-lts.2", - "path-to-regexp": "8.2.0", + "cors": "2.8.6", + "express": "5.2.1", + "multer": "2.2.0", + "path-to-regexp": "8.4.2", "tslib": "2.8.1" }, "funding": { @@ -2660,15 +2661,6 @@ "@nestjs/core": "^11.0.0" } }, - "node_modules/@nestjs/platform-express/node_modules/path-to-regexp": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", - "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", - "license": "MIT", - "engines": { - "node": ">=16" - } - }, "node_modules/@nestjs/schematics": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz", @@ -2774,9 +2766,9 @@ } }, "node_modules/@nestjs/testing": { - "version": "11.1.26", - "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.1.26.tgz", - "integrity": "sha512-PNf/TSIoF525pb3O9bu06S5QFWl4gArngbjI6yRFKZrF7d85vGjleVkgVVbw879Y95EYDzIS3eel6YlaY4TGFw==", + "version": "11.1.29", + "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.1.29.tgz", + "integrity": "sha512-u+aEp90Ly3uQbLoicZs+ZOrJTyvaD0fbd6dpnSgFZz5Bf77gbKKfr+xFE2JxqZuIH15HlBrqDL7/OL2qL8oG+A==", "dev": true, "license": "MIT", "dependencies": { @@ -5598,6 +5590,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, "license": "MIT" }, "node_modules/busboy": { @@ -5995,51 +5988,6 @@ "dev": true, "license": "MIT" }, - "node_modules/concat-stream": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", - "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", - "engines": [ - "node >= 0.8" - ], - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^2.2.2", - "typedarray": "^0.0.6" - } - }, - "node_modules/concat-stream/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/concat-stream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/concat-stream/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, "node_modules/confbox": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", @@ -6101,16 +6049,10 @@ "dev": true, "license": "MIT" }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "license": "MIT" - }, "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", "license": "MIT", "dependencies": { "object-assign": "^4", @@ -6118,6 +6060,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/create-require": { @@ -7006,18 +6952,19 @@ } }, "node_modules/express": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", "dependencies": { "accepts": "^2.0.0", - "body-parser": "^2.2.0", + "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", + "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", @@ -8068,12 +8015,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -9197,6 +9138,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -9212,18 +9154,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -9231,22 +9161,21 @@ "license": "MIT" }, "node_modules/multer": { - "version": "1.4.5-lts.2", - "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", - "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", - "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.5.0.tgz", + "integrity": "sha512-BsqOzYGVq49JcW2IzoSAxeKYRevb+dGGPPWleOR8Lrlk4igwxu1/9pwSXlByhCb7MVZDrNJFC0IBP5/O5nLYRw==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", - "busboy": "^1.0.0", - "concat-stream": "^1.5.2", - "mkdirp": "^0.5.4", - "object-assign": "^4.1.1", - "type-is": "^1.6.4", - "xtend": "^4.0.0" + "busboy": "^1.6.0", + "type-is": "^1.6.18" }, "engines": { - "node": ">= 6.0.0" + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/multer/node_modules/media-typer": { @@ -10104,12 +10033,6 @@ } } }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "license": "MIT" - }, "node_modules/proper-lockfile": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", @@ -11660,12 +11583,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/typedarray": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", - "license": "MIT" - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -11848,6 +11765,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, "license": "MIT" }, "node_modules/v8-compile-cache-lib": { @@ -12286,6 +12204,15 @@ "grammex": "^3.1.11", "graphmatch": "^1.1.0" } + }, + "node_modules/zod": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 1f18bad..d4f64b7 100644 --- a/package.json +++ b/package.json @@ -22,10 +22,10 @@ }, "dependencies": { "@nestjs/axios": "^4.0.1", - "@nestjs/common": "^11.0.1", + "@nestjs/common": "^11.1.29", "@nestjs/config": "^12.0.0", - "@nestjs/core": "^11.0.1", - "@nestjs/platform-express": "^11.0.1", + "@nestjs/core": "^11.1.29", + "@nestjs/platform-express": "^11.1.29", "@nestjs/throttler": "^6.7.1", "@prisma/adapter-pg": "^7.10.0", "@prisma/client": "^7.10.0", @@ -35,14 +35,15 @@ "helmet": "^8.3.0", "pg": "^8.23.0", "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.1" + "rxjs": "^7.8.1", + "zod": "4.6.7" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", "@eslint/js": "^9.18.0", "@nestjs/cli": "^12.0.11", "@nestjs/schematics": "^11.0.0", - "@nestjs/testing": "^11.0.1", + "@nestjs/testing": "^11.1.29", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/node": "^24.0.0", @@ -101,5 +102,8 @@ "@parcel/watcher@2.6.0": true, "@prisma/engines@7.10.1": true, "prisma@7.10.1": true + }, + "overrides": { + "multer": "^2.5.0" } } diff --git a/prisma/prisma.service.ts b/prisma/prisma.service.ts index 347e9d8..e927fcf 100644 --- a/prisma/prisma.service.ts +++ b/prisma/prisma.service.ts @@ -1,20 +1,25 @@ -import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { Injectable, OnApplicationShutdown, OnModuleInit } from '@nestjs/common'; import { PrismaClient } from './generated/client'; import { PrismaPg } from '@prisma/adapter-pg'; /** Prisma client shared by the whole app (global PrismaModule), connected to `DATABASE_URL` through the pg driver adapter. */ @Injectable() -export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { +export class PrismaService extends PrismaClient implements OnModuleInit, OnApplicationShutdown { constructor() { const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }); - super({ adapter }); + // 'minimal': by default, error messages print the query arguments (emails, names, addresses...), which end up in the logs + super({ adapter, errorFormat: 'minimal' }); } async onModuleInit() { await this.$connect(); } - async onModuleDestroy() { + /** + * Last shutdown hook (after `onModuleDestroy` and `beforeApplicationShutdown`, once the HTTP server is closed): + * a synchronization still running is waited for in `beforeApplicationShutdown` and keeps the database until then. + */ + async onApplicationShutdown() { await this.$disconnect(); } } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 922e85e..efb2ecf 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -130,7 +130,7 @@ model Customer { updatedAt DateTime @updatedAt dateOfBirth DateTime? - /// Kept when removed in Hiboutik (an empty Hiboutik email does not erase it) + /// Null when the customer has no email in Hiboutik (erased when removed there) email String? firstName String hiboutikId Int @unique @@ -227,3 +227,19 @@ model OrderLine { @@map("order_lines") } + +/// Rate limiting counter shared by every instance (ThrottlerDatabaseStorage): one fixed window per throttler key +/// (route + client IP). Not synced from Hiboutik. Rows whose window and block are over are purged regularly. +model ThrottlerRecord { + /// Key generated by the ThrottlerGuard (hash of route, throttler name and client IP) + key String @id + /// Requests counted in the current window + hits Int + /// End of the current window + expiresAt DateTime @db.Timestamptz(3) + /// End of the block once the limit was exceeded, null when not blocked + blockedUntil DateTime? @db.Timestamptz(3) + + @@index([expiresAt]) + @@map("throttler_records") +} diff --git a/src/app.controller.ts b/src/app.controller.ts index 3dac09e..9c99c9f 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -7,7 +7,8 @@ export class AppController { constructor(private readonly hiboutikService: HiboutikService) {} /** - * Starts the synchronization in the background and answers immediately: 202, or 409 if one is already running. + * Starts the synchronization in the background and answers immediately: 202, 409 if one is already running, or 503 + * if this instance is shutting down (another instance can take the call). * Rate limited to 5 calls per minute: each one can trigger thousands of Hiboutik API calls. */ @Post('sync') diff --git a/src/app.module.ts b/src/app.module.ts index 289d076..3a8ac81 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -12,6 +12,8 @@ import { ProductHiboutikRepository } from './hiboutik/product.hiboutik.repositor import { HttpModule } from '@nestjs/axios'; import { CategoryDatabaseRepository } from './database/category.database.repository'; import { PrismaModule } from '../prisma/prisma.module'; +import { PrismaService } from '../prisma/prisma.service'; +import { ThrottlerDatabaseStorage } from './database/throttlerStorage.database.repository'; import { HiboutikClient } from './hiboutik/hiboutik.client'; import { BrandHiboutikRepository } from './hiboutik/brand.hiboutik.repository'; import { BrandDatabaseRepository } from './database/brand.database.repository'; @@ -44,9 +46,22 @@ import { SyncLockDatabaseRepository } from './database/syncLock.database.reposit isGlobal: true, validate: validateEnv, }), - // Default rate limit for every route: 60 requests per minute per IP (stricter limits via @Throttle) - ThrottlerModule.forRoot([{ ttl: 60_000, limit: 60 }]), - HttpModule.register({ timeout: 30_000 }), + // Default rate limit for every route: 60 requests per minute per IP (stricter limits via @Throttle). + // Counters live in PostgreSQL so that every instance shares them (see ThrottlerDatabaseStorage). + ThrottlerModule.forRootAsync({ + inject: [PrismaService], + useFactory: (prisma: PrismaService) => ({ + throttlers: [{ ttl: 60_000, limit: 60 }], + storage: new ThrottlerDatabaseStorage(prisma), + }), + }), + HttpModule.register({ + timeout: 30_000, + // Hiboutik never redirects: never follow a redirect with the Basic auth credentials + maxRedirects: 0, + // A runaway answer (bug, faulty proxy) must not be loaded whole in memory + maxContentLength: 50 * 1024 * 1024, + }), PrismaModule, ], controllers: [AppController], diff --git a/src/config/env.validation.spec.ts b/src/config/env.validation.spec.ts index d1a21cc..1cf6dc3 100644 --- a/src/config/env.validation.spec.ts +++ b/src/config/env.validation.spec.ts @@ -36,4 +36,35 @@ describe('validateEnv', () => { expect((error as Error).message).not.toContain('secret'); } }); + + describe('in production', () => { + const production = { ...valid, NODE_ENV: 'production' }; + + it('requires an encrypted database connection', () => { + expect(() => validateEnv(production)).toThrow(/DATABASE_URL: sslmode/); + expect(() => validateEnv({ ...production, DATABASE_URL: `${valid.DATABASE_URL}?sslmode=disable` })).toThrow(/DATABASE_URL/); + }); + + it.each(['require', 'verify-ca', 'verify-full'])('accepts sslmode=%s', (mode) => { + expect(() => validateEnv({ ...production, DATABASE_URL: `${valid.DATABASE_URL}?sslmode=${mode}` })).not.toThrow(); + }); + + it('never puts the connection string in the error message', () => { + expect.assertions(2); + try { + validateEnv(production); + } catch (error) { + expect((error as Error).message).toContain('DATABASE_URL'); + expect((error as Error).message).not.toContain('secret'); + } + }); + }); + + it('does not require TLS outside production', () => { + expect(() => validateEnv({ ...valid, NODE_ENV: 'development' })).not.toThrow(); + }); + + it('rejects an unknown NODE_ENV', () => { + expect(() => validateEnv({ ...valid, NODE_ENV: 'prod' })).toThrow(/NODE_ENV/); + }); }); diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index afc176e..87ecd55 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -1,8 +1,17 @@ import { plainToInstance } from 'class-transformer'; -import { IsInt, IsNotEmpty, IsOptional, IsString, IsUrl, Max, Min, validateSync } from 'class-validator'; +import { IsIn, IsInt, IsNotEmpty, IsOptional, IsString, IsUrl, Max, Min, validateSync } from 'class-validator'; + +/** `sslmode` values that encrypt the database connection (the pg driver treats them all as `verify-full`). */ +const SECURE_SSL_MODES = ['require', 'verify-ca', 'verify-full']; /** Environment variables read by the app. A missing or malformed required one stops the startup. */ export class EnvironmentVariables { + /** `production` enforces production-only rules (TLS to the database). */ + @IsOptional() + @IsIn(['development', 'production', 'test']) + NODE_ENV?: string; + + /** In production, `sslmode` must be require, verify-ca or verify-full: customer data must not travel in clear. */ @IsString() @IsNotEmpty() DATABASE_URL!: string; @@ -36,6 +45,25 @@ export class EnvironmentVariables { @Min(1) @Max(10) TRUST_PROXY?: number; + + /** + * Seconds a shutting down instance waits for its running synchronization (default 25). Keep it below the grace period + * of the orchestrator (Docker `stop_grace_period`, Kubernetes `terminationGracePeriodSeconds`), which kills afterwards. + */ + @IsOptional() + @IsInt() + @Min(0) + @Max(3600) + SYNC_SHUTDOWN_TIMEOUT?: number; +} + +/** Whether the connection string asks for an encrypted connection. An unparsable one does not. */ +function hasSecureSslMode(databaseUrl: string): boolean { + try { + return SECURE_SSL_MODES.includes(new URL(databaseUrl).searchParams.get('sslmode') ?? ''); + } catch { + return false; + } } /** `ConfigModule` validation hook: fails fast with the names of the faulty variables (never their values, they may be secrets). */ @@ -43,9 +71,13 @@ export function validateEnv(config: Record): Record 0) { - const details = errors.map((error) => `${error.property}: ${Object.values(error.constraints ?? {}).join(', ')}`).join('\n '); - throw new Error(`Invalid environment configuration:\n ${details}`); + const details = errors.map((error) => `${error.property}: ${Object.values(error.constraints ?? {}).join(', ')}`); + if (validated.NODE_ENV === 'production' && validated.DATABASE_URL && !hasSecureSslMode(validated.DATABASE_URL)) { + details.push(`DATABASE_URL: sslmode must be one of ${SECURE_SSL_MODES.join(', ')} in production`); + } + + if (details.length > 0) { + throw new Error(`Invalid environment configuration:\n ${details.join('\n ')}`); } return config; } diff --git a/src/database/customer.database.repository.ts b/src/database/customer.database.repository.ts index 8529b78..f96f512 100644 --- a/src/database/customer.database.repository.ts +++ b/src/database/customer.database.repository.ts @@ -23,13 +23,20 @@ export class CustomerDatabaseRepository { }); } + async getAllHiboutikIds(): Promise { + const customers = await this.prisma.customer.findMany({ select: { hiboutikId: true } }); + return customers.map((customer) => customer.hiboutikId); + } + /** - * Deletes the customers that no longer exist in Hiboutik. + * Deletes customers that no longer exist in Hiboutik. * Their addresses are deleted (onDelete: Cascade), their orders are kept with no customer (onDelete: SetNull). */ - async deleteMissing(keptHiboutikIds: number[]): Promise { + async deleteByHiboutikIds(hiboutikIds: number[]): Promise { + if (hiboutikIds.length === 0) return 0; + const result = await this.prisma.customer.deleteMany({ - where: { hiboutikId: { notIn: keptHiboutikIds } }, + where: { hiboutikId: { in: hiboutikIds } }, }); return result.count; } diff --git a/src/database/order.database.repository.ts b/src/database/order.database.repository.ts index 3222cc0..d215617 100644 --- a/src/database/order.database.repository.ts +++ b/src/database/order.database.repository.ts @@ -9,12 +9,19 @@ import { OrderStatus } from '../models/orderStatus'; export class OrderDatabaseRepository { constructor(private readonly prisma: PrismaService) {} - /** Among `hiboutikSaleIds`, the ones already stored as completed orders. */ - async getCompletedSaleIds(hiboutikSaleIds: number[]): Promise> { + /** + * Among `hiboutikSaleIds`, the ones already stored as completed orders of this customer. A completed order that lost + * its customer (customer deleted, then found again in Hiboutik) is not returned, so that the sync links it back. + */ + async getCompletedSaleIds(customerHiboutikId: number, hiboutikSaleIds: number[]): Promise> { if (hiboutikSaleIds.length === 0) return new Set(); const orders = await this.prisma.order.findMany({ - where: { hiboutikSaleId: { in: hiboutikSaleIds }, status: OrderStatus.COMPLETED }, + where: { + hiboutikSaleId: { in: hiboutikSaleIds }, + status: OrderStatus.COMPLETED, + customer: { is: { hiboutikId: customerHiboutikId } }, + }, select: { hiboutikSaleId: true }, }); return new Set(orders.map((order) => order.hiboutikSaleId).filter((id): id is number => id !== null)); diff --git a/src/database/product.database.repository.ts b/src/database/product.database.repository.ts index f0f6783..bd89dcf 100644 --- a/src/database/product.database.repository.ts +++ b/src/database/product.database.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { PrismaService } from '../../prisma/prisma.service'; -import { Product } from '../../prisma/generated/client'; +import { Prisma, Product } from '../../prisma/generated/client'; import { ProductCreateInput } from '../../prisma/generated/models/Product'; /** Database access to products. Rows are matched with Hiboutik by `hiboutikId`. */ @@ -14,9 +14,12 @@ export class ProductDatabaseRepository { }); } - /** Creates or updates the product with the same `hiboutikId`. On update, the relations missing from the input are unlinked. */ - async upsert(product: ProductCreateInput): Promise { - return this.prisma.product.upsert({ + /** + * Creates or updates the product with the same `hiboutikId`. On update, the relations missing from the input are unlinked. + * @param db transaction client, to run it inside a transaction. + */ + async upsert(product: ProductCreateInput, db: Prisma.TransactionClient = this.prisma): Promise { + return db.product.upsert({ where: { hiboutikId: product.hiboutikId }, create: product, update: { diff --git a/src/database/productVersion.database.repository.ts b/src/database/productVersion.database.repository.ts index c9efa52..3e7df96 100644 --- a/src/database/productVersion.database.repository.ts +++ b/src/database/productVersion.database.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { PrismaService } from '../../prisma/prisma.service'; -import { ProductVersion } from '../../prisma/generated/client'; +import { Prisma, ProductVersion } from '../../prisma/generated/client'; import { ProductVersionCreateInput } from '../../prisma/generated/models/ProductVersion'; /** Database access to product versions (one per Hiboutik size), identified by their product and `hiboutikSizeId`. */ @@ -14,9 +14,16 @@ export class ProductVersionDatabaseRepository { }); } - /** Creates or updates the version of `productId` with the same `hiboutikSizeId`. */ - async upsert(productId: string, productVersion: ProductVersionCreateInput): Promise { - return this.prisma.productVersion.upsert({ + /** + * Creates or updates the version of `productId` with the same `hiboutikSizeId`. + * @param db transaction client, to run it inside a transaction. + */ + async upsert( + productId: string, + productVersion: ProductVersionCreateInput, + db: Prisma.TransactionClient = this.prisma, + ): Promise { + return db.productVersion.upsert({ where: { productId_hiboutikSizeId: { productId: productId, hiboutikSizeId: productVersion.hiboutikSizeId } }, create: productVersion, update: productVersion, @@ -26,9 +33,10 @@ export class ProductVersionDatabaseRepository { /** * Deletes the versions of a product that no longer exist in Hiboutik. * Order lines keep their own snapshot of the product, their link is set to null. + * @param db transaction client, to run it inside a transaction. */ - async deleteMissing(productId: string, keptHiboutikSizeIds: number[]): Promise { - const result = await this.prisma.productVersion.deleteMany({ + async deleteMissing(productId: string, keptHiboutikSizeIds: number[], db: Prisma.TransactionClient = this.prisma): Promise { + const result = await db.productVersion.deleteMany({ where: { productId, hiboutikSizeId: { notIn: keptHiboutikSizeIds } }, }); return result.count; diff --git a/src/database/syncLock.database.repository.spec.ts b/src/database/syncLock.database.repository.spec.ts index 622163a..4f86822 100644 --- a/src/database/syncLock.database.repository.spec.ts +++ b/src/database/syncLock.database.repository.spec.ts @@ -27,7 +27,7 @@ describe('SyncLockDatabaseRepository', () => { const release = await repository.tryAcquire(); expect(release).toEqual(expect.any(Function)); - expect(Client).toHaveBeenCalledWith({ connectionString: 'postgresql://user:pass@localhost:5432/db' }); + expect(Client).toHaveBeenCalledWith({ connectionString: 'postgresql://user:pass@localhost:5432/db', connectionTimeoutMillis: 10_000 }); expect(client.query).toHaveBeenCalledWith('SELECT pg_try_advisory_lock($1) AS locked', [expect.any(Number)]); expect(client.end).not.toHaveBeenCalled(); }); diff --git a/src/database/syncLock.database.repository.ts b/src/database/syncLock.database.repository.ts index 61de25f..75d2ca1 100644 --- a/src/database/syncLock.database.repository.ts +++ b/src/database/syncLock.database.repository.ts @@ -22,7 +22,8 @@ export class SyncLockDatabaseRepository { /** @returns the function releasing the lock, or `null` if another instance (or process) already holds it. */ async tryAcquire(): Promise { - const client = new Client({ connectionString: this.configService.get('DATABASE_URL') }); + // Fails fast instead of holding the POST /sync request until its 30 s timeout when the database does not answer + const client = new Client({ connectionString: this.configService.get('DATABASE_URL'), connectionTimeoutMillis: 10_000 }); // A lost connection means Postgres released the lock: log it instead of crashing the process on an unhandled 'error' client.on('error', (error) => this.logger.error(`Synchronization lock connection error: ${error.message}`)); await client.connect(); diff --git a/src/database/throttlerStorage.database.repository.spec.ts b/src/database/throttlerStorage.database.repository.spec.ts new file mode 100644 index 0000000..370a363 --- /dev/null +++ b/src/database/throttlerStorage.database.repository.spec.ts @@ -0,0 +1,56 @@ +import { Logger } from '@nestjs/common'; +import { ThrottlerDatabaseStorage } from './throttlerStorage.database.repository'; + +// The SQL itself (window, block, reset) was checked on a real Postgres engine; these tests cover the TypeScript side. +describe('ThrottlerDatabaseStorage', () => { + const record = { totalHits: 2, timeToExpire: 40, isBlocked: false, timeToBlockExpire: 0 }; + const prisma = { $queryRaw: jest.fn(), throttlerRecord: { deleteMany: jest.fn() } }; + let logError: jest.SpyInstance; + let random: jest.Mock; + let storage: ThrottlerDatabaseStorage; + + beforeEach(() => { + jest.clearAllMocks(); + logError = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + prisma.$queryRaw.mockResolvedValue([record]); + prisma.throttlerRecord.deleteMany.mockResolvedValue({ count: 0 }); + random = jest.fn().mockReturnValue(0.5); + storage = new ThrottlerDatabaseStorage(prisma as never, random); + }); + + it('returns the record computed by the database, with parameters bound (never interpolated)', async () => { + await expect(storage.increment('key-hash', 60_000, 5, 30_000)).resolves.toEqual(record); + + const [strings, ...values] = prisma.$queryRaw.mock.calls[0] as [TemplateStringsArray, ...unknown[]]; + expect(values).toEqual(['key-hash', 60_000, 5, 30_000]); + expect(strings.join('?')).not.toContain('key-hash'); + }); + + it('purges the finished counters on about one request in 100', async () => { + await storage.increment('k', 60_000, 5, 60_000); + expect(prisma.throttlerRecord.deleteMany).not.toHaveBeenCalled(); + + random.mockReturnValue(0.001); + await storage.increment('k', 60_000, 5, 60_000); + expect(prisma.throttlerRecord.deleteMany).toHaveBeenCalledTimes(1); + }); + + it('lets the request through when the database fails, logging at most once a minute', async () => { + prisma.$queryRaw.mockRejectedValue(new Error('connection refused')); + + await expect(storage.increment('k', 60_000, 5, 60_000)).resolves.toMatchObject({ isBlocked: false }); + await storage.increment('k', 60_000, 5, 60_000); + + expect(logError).toHaveBeenCalledTimes(1); + expect(logError).toHaveBeenCalledWith(expect.stringContaining('connection refused')); + }); + + it('does not fail the request when the purge fails', async () => { + random.mockReturnValue(0); + prisma.throttlerRecord.deleteMany.mockRejectedValue(new Error('boom')); + + await expect(storage.increment('k', 60_000, 5, 60_000)).resolves.toEqual(record); + await new Promise((resolve) => setImmediate(resolve)); + expect(logError).toHaveBeenCalledWith(expect.stringContaining('boom')); + }); +}); diff --git a/src/database/throttlerStorage.database.repository.ts b/src/database/throttlerStorage.database.repository.ts new file mode 100644 index 0000000..2ee46d1 --- /dev/null +++ b/src/database/throttlerStorage.database.repository.ts @@ -0,0 +1,90 @@ +import { Logger } from '@nestjs/common'; +import { ThrottlerStorage } from '@nestjs/throttler'; +import { PrismaService } from '../../prisma/prisma.service'; + +/** Answer of {@link ThrottlerStorage.increment} (its type is not exported by the package root). */ +type ThrottlerStorageRecord = Awaited>; + +/** Share of the requests that also purge the finished counters (about one in 100). */ +const PURGE_PROBABILITY = 0.01; +/** At most one log line per minute while the database is unreachable (one request = one failure). */ +const ERROR_LOG_INTERVAL_MS = 60_000; + +/** + * Rate limiting counters stored in PostgreSQL (`throttler_records`), so that every instance shares them: with an + * in-memory storage, each instance would count apart and N instances would allow N times the limit. + * + * One fixed window per key: the first request opens a window of `ttl`; past `limit` requests in it, the key is blocked + * for `blockDuration`, then a new window starts. Each request is a single atomic upsert, computed with the database + * clock (`now()`), so the instances need neither a lock nor synchronized clocks. + * + * Fails open: if the database cannot be reached, the request is let through (and the failure logged) rather than + * answered with a 500. Every endpoint needs the database anyway. + */ +export class ThrottlerDatabaseStorage implements ThrottlerStorage { + private readonly logger = new Logger('ThrottlerDatabaseStorage'); + private lastErrorLogAt = 0; + + constructor( + private readonly prisma: PrismaService, + private readonly random: () => number = Math.random, + ) {} + + /** @param ttl window length (ms) @param blockDuration block length once the limit is exceeded (ms) */ + async increment(key: string, ttl: number, limit: number, blockDuration: number): Promise { + try { + const [record] = await this.prisma.$queryRaw` + INSERT INTO throttler_records AS r (key, hits, "expiresAt", "blockedUntil") + VALUES (${key}, 1, now() + ${ttl}::int * interval '1 millisecond', NULL) + ON CONFLICT (key) DO UPDATE SET + hits = CASE + WHEN r."blockedUntil" > now() THEN r.hits + WHEN r."expiresAt" <= now() OR r."blockedUntil" IS NOT NULL THEN 1 + ELSE r.hits + 1 + END, + "expiresAt" = CASE + WHEN r."blockedUntil" > now() THEN r."expiresAt" + WHEN r."expiresAt" <= now() OR r."blockedUntil" IS NOT NULL THEN EXCLUDED."expiresAt" + ELSE r."expiresAt" + END, + "blockedUntil" = CASE + WHEN r."blockedUntil" > now() THEN r."blockedUntil" + WHEN r."expiresAt" <= now() OR r."blockedUntil" IS NOT NULL THEN NULL + WHEN r.hits + 1 > ${limit}::int THEN now() + ${blockDuration}::int * interval '1 millisecond' + ELSE NULL + END + RETURNING + hits AS "totalHits", + GREATEST(0, CEIL(EXTRACT(EPOCH FROM ("expiresAt" - now()))))::int AS "timeToExpire", + COALESCE("blockedUntil" > now(), false) AS "isBlocked", + COALESCE(GREATEST(0, CEIL(EXTRACT(EPOCH FROM ("blockedUntil" - now())))), 0)::int AS "timeToBlockExpire"`; + + if (this.random() < PURGE_PROBABILITY) void this.purgeFinished(); + return record; + } catch (error) { + this.logFailure('Rate limiting storage unavailable, request let through', error); + return { totalHits: 0, timeToExpire: 0, isBlocked: false, timeToBlockExpire: 0 }; + } + } + + /** Deletes the counters whose window and block are both over (idempotent: every instance may run it). */ + private async purgeFinished(): Promise { + try { + await this.prisma.throttlerRecord.deleteMany({ + where: { + expiresAt: { lt: new Date() }, + OR: [{ blockedUntil: null }, { blockedUntil: { lt: new Date() } }], + }, + }); + } catch (error) { + this.logFailure('Failed to purge the finished rate limiting counters', error); + } + } + + private logFailure(message: string, error: unknown): void { + const now = Date.now(); + if (now - this.lastErrorLogAt < ERROR_LOG_INTERVAL_MS) return; + this.lastErrorLogAt = now; + this.logger.error(`${message}: ${(error as Error)?.message}`); + } +} diff --git a/src/hiboutik/brand.hiboutik.repository.ts b/src/hiboutik/brand.hiboutik.repository.ts index dca990f..083ba3e 100644 --- a/src/hiboutik/brand.hiboutik.repository.ts +++ b/src/hiboutik/brand.hiboutik.repository.ts @@ -1,14 +1,15 @@ import { Injectable } from '@nestjs/common'; import { HiboutikClient } from './hiboutik.client'; -import { BrandHiboutikDto } from '../models/hiboutikDTO/brand.hiboutik.dto'; +import { BrandHiboutikDto, brandHiboutikSchema } from '../models/hiboutikDTO/brand.hiboutik.dto'; /** Reads brands from the Hiboutik API. */ @Injectable() export class BrandHiboutikRepository { constructor(private readonly hiboutikClient: HiboutikClient) {} + /** Walked twice when it spans several pages: it drives the deletion of brands (see `getAllPagesForSync`). */ async getBrands(): Promise { - return this.hiboutikClient.getAllPages('/brands', { + return this.hiboutikClient.getAllPagesForSync('/brands', brandHiboutikSchema, (brand) => brand.brand_id, { order_by: 'brand_id', sort: 'ASC', }); diff --git a/src/hiboutik/category.hiboutik.repository.ts b/src/hiboutik/category.hiboutik.repository.ts index 43ef168..0acdefb 100644 --- a/src/hiboutik/category.hiboutik.repository.ts +++ b/src/hiboutik/category.hiboutik.repository.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { CategoryHiboutikDto } from '../models/hiboutikDTO/category.hiboutik.dto'; +import { CategoryHiboutikDto, categoryHiboutikSchema } from '../models/hiboutikDTO/category.hiboutik.dto'; import { HiboutikClient } from './hiboutik.client'; /** Reads categories from the Hiboutik API. */ @@ -7,10 +7,13 @@ import { HiboutikClient } from './hiboutik.client'; export class CategoryHiboutikRepository { constructor(private readonly hiboutikClient: HiboutikClient) {} - /** Sorted by parent id, which does not guarantee that a parent comes before its children. */ + /** + * Sorted by id (unique, so that the pages are stable), a parent may come after its children. Walked twice when it + * spans several pages: it drives the deletion of categories (see `getAllPagesForSync`). + */ async getCategories(): Promise { - return this.hiboutikClient.getAllPages('/categories', { - order_by: 'category_id_parent', + return this.hiboutikClient.getAllPagesForSync('/categories', categoryHiboutikSchema, (category) => category.category_id, { + order_by: 'category_id', sort: 'ASC', }); } diff --git a/src/hiboutik/customer.hiboutik.repository.ts b/src/hiboutik/customer.hiboutik.repository.ts index 50179dd..2c7f983 100644 --- a/src/hiboutik/customer.hiboutik.repository.ts +++ b/src/hiboutik/customer.hiboutik.repository.ts @@ -2,8 +2,11 @@ import { Injectable } from '@nestjs/common'; import { HiboutikClient } from './hiboutik.client'; import { CustomerAddressDetailsHiboutikDto, + customerAddressDetailsHiboutikSchema, CustomerHiboutikDto, + customerHiboutikSchema, FullCustomerHiboutikDto, + fullCustomerHiboutikSchema, } from '../models/hiboutikDTO/customer.hiboutik.dto'; /** Reads customers from the Hiboutik API. */ @@ -11,29 +14,39 @@ import { export class CustomerHiboutikRepository { constructor(private readonly hiboutikClient: HiboutikClient) {} - /** Customer summaries only: use {@link getCustomerById} for addresses and sales. */ + /** + * Customer summaries only: use {@link getCustomerById} for addresses and sales. Sorted by id so that the pages are + * stable, but a customer created or deleted while they are walked can still shift the following ones. + */ async getCustomers(): Promise { - return this.hiboutikClient.getAllPages('/customers'); + return this.hiboutikClient.getAllPages('/customers', customerHiboutikSchema, { + order_by: 'customers_id', + sort: 'ASC', + }); } /** - * Full customer, with its addresses and sales (Hiboutik answers with a one-element array). - * @throws if the customer does not exist. + * Full customer, with its addresses and sales. + * @returns `null` when Hiboutik says the customer does not exist (404 or empty answer). + * @throws on any other failure: an unknown state is never taken for a deleted customer. */ + async findCustomerById(id: number): Promise { + return this.hiboutikClient.getOne(`/customer/${id}`, fullCustomerHiboutikSchema); + } + + /** Same as {@link findCustomerById}, but a missing customer throws. */ async getCustomerById(id: number): Promise { - const data = await this.hiboutikClient.get('/customer/' + String(id)); - const customer = data[0]; + const customer = await this.findCustomerById(id); if (!customer) throw new Error(`Hiboutik customer ${id} not found`); return customer; } /** - * Address with its company details (Hiboutik answers with a one-element array). + * Address with its company details. * @throws if the address does not exist. */ async getAddressById(id: number): Promise { - const data = await this.hiboutikClient.get('/customers_addresses/' + String(id)); - const address = data[0]; + const address = await this.hiboutikClient.getOne(`/customers_addresses/${id}`, customerAddressDetailsHiboutikSchema); if (!address) throw new Error(`Hiboutik address ${id} not found`); return address; } diff --git a/src/hiboutik/hiboutik.client.spec.ts b/src/hiboutik/hiboutik.client.spec.ts index 2b204ce..28afdb0 100644 --- a/src/hiboutik/hiboutik.client.spec.ts +++ b/src/hiboutik/hiboutik.client.spec.ts @@ -1,12 +1,26 @@ +import { AxiosError, AxiosHeaders } from 'axios'; import { of, throwError } from 'rxjs'; -import { HiboutikClient } from './hiboutik.client'; +import { z } from 'zod'; +import { HiboutikClient, HiboutikRateLimitError } from './hiboutik.client'; describe('HiboutikClient', () => { const httpService = { get: jest.fn() }; const configService = { get: jest.fn((key: string) => (key === 'HIBOUTIK_BASE_URL' ? 'https://shop.hiboutik.test/api' : 'x')) }; + const schema = z.object({ id: z.coerce.number() }); let client: HiboutikClient; const answer = (data: unknown) => of({ data }); + const httpError = (status: number) => + throwError( + () => + new AxiosError(`Request failed with status code ${status}`, undefined, undefined, undefined, { + status, + statusText: '', + headers: {}, + config: { headers: new AxiosHeaders() }, + data: null, + }), + ); const page = (size: number) => Array.from({ length: size }, (_, index) => ({ id: index })); beforeEach(() => { @@ -18,30 +32,42 @@ describe('HiboutikClient', () => { it('returns the list of a 2xx answer, empty or not', async () => { httpService.get.mockReturnValueOnce(answer([{ id: 1 }])).mockReturnValueOnce(answer([])); - await expect(client.getList('/x')).resolves.toEqual([{ id: 1 }]); - await expect(client.getList('/x')).resolves.toEqual([]); + await expect(client.getList('/x', schema)).resolves.toEqual([{ id: 1 }]); + await expect(client.getList('/x', schema)).resolves.toEqual([]); }); it('treats a 2xx answer without body as an empty list', async () => { httpService.get.mockReturnValueOnce(answer('')).mockReturnValueOnce(answer(null)); - await expect(client.getList('/x')).resolves.toEqual([]); - await expect(client.getList('/x')).resolves.toEqual([]); + await expect(client.getList('/x', schema)).resolves.toEqual([]); + await expect(client.getList('/x', schema)).resolves.toEqual([]); }); it('rejects a 2xx answer that is not a list, without leaking its content', async () => { httpService.get.mockReturnValueOnce(answer({ error: 'secret details' })); - const result = client.getList('/x'); + const result = client.getList('/x', schema); await expect(result).rejects.toThrow('a list was expected'); await expect(result).rejects.not.toThrow('secret details'); }); - it('lets HTTP errors through', async () => { - httpService.get.mockReturnValueOnce(throwError(() => new Error('Request failed with status code 500'))); + it('validates and normalizes each item', async () => { + httpService.get.mockReturnValueOnce(answer([{ id: '1', extra: true }])); - await expect(client.getList('/x')).rejects.toThrow('500'); + await expect(client.getList('/x', schema)).resolves.toEqual([{ id: 1 }]); + }); + + it('rejects the whole list when an item is invalid, so that nothing is deleted', async () => { + httpService.get.mockReturnValueOnce(answer([{ id: 1 }, { id: 'abc' }])); + + await expect(client.getList('/x', schema)).rejects.toThrow('Unexpected answer from Hiboutik /x: invalid 1.id'); + }); + + it('lets HTTP errors through', async () => { + httpService.get.mockReturnValueOnce(httpError(500)); + + await expect(client.getList('/x', schema)).rejects.toThrow('500'); }); }); @@ -49,18 +75,140 @@ describe('HiboutikClient', () => { it('walks the pages until a short one', async () => { httpService.get.mockReturnValueOnce(answer(page(250))).mockReturnValueOnce(answer(page(10))); - const results = await client.getAllPages('/x'); + const results = await client.getAllPages('/x', schema); expect(results).toHaveLength(260); expect(httpService.get).toHaveBeenCalledTimes(2); }); it('fails as a whole when a later page fails, instead of returning a truncated list', async () => { + httpService.get.mockReturnValueOnce(answer(page(250))).mockReturnValueOnce(httpError(502)); + + await expect(client.getAllPages('/x', schema)).rejects.toThrow('502'); + }); + }); + + describe('getAllPagesForSync', () => { + const idOf = (item: { id: number }) => item.id; + const ids = (from: number, to: number) => Array.from({ length: to - from + 1 }, (_, index) => ({ id: from + index })); + + it('walks a list that fits in one page only once', async () => { + httpService.get.mockReturnValueOnce(answer(page(10))); + + await expect(client.getAllPagesForSync('/x', schema, idOf)).resolves.toHaveLength(10); + expect(httpService.get).toHaveBeenCalledTimes(1); + }); + + it('walks a multi-page list twice and keeps an item skipped by a page shift in one walk', async () => { + // First walk: item 10 is deleted right after page 1 is read, so item 250 moves to page 1 and page 2 starts at 251 + httpService.get.mockReturnValueOnce(answer(ids(0, 249))).mockReturnValueOnce(answer(ids(251, 260))); + // Second walk: the list without item 10 + const withoutTen = ids(0, 250).filter(({ id }) => id !== 10); + httpService.get.mockReturnValueOnce(answer(withoutTen)).mockReturnValueOnce(answer(ids(251, 260))); + + const results = await client.getAllPagesForSync('/x', schema, idOf); + + expect(httpService.get).toHaveBeenCalledTimes(4); + // Item 250 is kept (it would otherwise be deleted); item 10 is only deleted at the next sync + expect(results.map(idOf).sort((a, b) => a - b)).toEqual(ids(0, 260).map(idOf)); + }); + + it('fails as a whole when the second walk fails', async () => { httpService.get .mockReturnValueOnce(answer(page(250))) - .mockReturnValueOnce(throwError(() => new Error('Request failed with status code 502'))); + .mockReturnValueOnce(answer(page(10))) + .mockReturnValueOnce(httpError(502)); - await expect(client.getAllPages('/x')).rejects.toThrow('502'); + await expect(client.getAllPagesForSync('/x', schema, idOf)).rejects.toThrow('502'); + }); + }); + + describe('rate limit (429)', () => { + let sleep: jest.SpyInstance; + const tooManyRequests = (retryAfter?: string) => + throwError( + () => + new AxiosError('Request failed with status code 429', undefined, undefined, undefined, { + status: 429, + statusText: '', + headers: retryAfter === undefined ? {} : { 'retry-after': retryAfter }, + config: { headers: new AxiosHeaders() }, + data: null, + }), + ); + + beforeEach(() => { + sleep = jest.spyOn(client as unknown as { sleep: (ms: number) => Promise }, 'sleep').mockResolvedValue(undefined); + }); + + it('retries with exponential backoff, then succeeds', async () => { + httpService.get + .mockReturnValueOnce(tooManyRequests()) + .mockReturnValueOnce(tooManyRequests()) + .mockReturnValueOnce(answer([{ id: 1 }])); + + await expect(client.getList('/x', schema)).resolves.toEqual([{ id: 1 }]); + expect(sleep.mock.calls).toEqual([[2_000], [4_000]]); + }); + + it('waits as asked by Retry-After, capped at 60 s', async () => { + httpService.get + .mockReturnValueOnce(tooManyRequests('5')) + .mockReturnValueOnce(tooManyRequests('3600')) + .mockReturnValueOnce(answer([])); + + await client.getList('/x', schema); + + expect(sleep.mock.calls).toEqual([[5_000], [60_000]]); + }); + + it('gives up with a HiboutikRateLimitError after 3 retries', async () => { + httpService.get.mockReturnValue(tooManyRequests()); + + await expect(client.getList('/x', schema)).rejects.toThrow(HiboutikRateLimitError); + expect(httpService.get).toHaveBeenCalledTimes(4); + }); + + it('does not retry other errors', async () => { + httpService.get.mockReturnValueOnce(httpError(500)); + + await expect(client.getList('/x', schema)).rejects.toThrow('500'); + expect(sleep).not.toHaveBeenCalled(); + }); + + it('does not take a rate limited single resource for a missing one', async () => { + httpService.get.mockReturnValue(tooManyRequests()); + + await expect(client.getOne('/x/7', schema)).rejects.toThrow(HiboutikRateLimitError); + }); + }); + + describe('getOne', () => { + it('returns the single element of the answer', async () => { + httpService.get.mockReturnValueOnce(answer([{ id: '7' }])); + + await expect(client.getOne('/x/7', schema)).resolves.toEqual({ id: 7 }); + }); + + it('returns null when Hiboutik says the resource does not exist (404 or empty answer)', async () => { + httpService.get.mockReturnValueOnce(httpError(404)).mockReturnValueOnce(answer([])).mockReturnValueOnce(answer('')); + + await expect(client.getOne('/x/7', schema)).resolves.toBeNull(); + await expect(client.getOne('/x/7', schema)).resolves.toBeNull(); + await expect(client.getOne('/x/7', schema)).resolves.toBeNull(); + }); + + it('throws on any other failure: an unknown state is never taken for a missing resource', async () => { + httpService.get + .mockReturnValueOnce(httpError(500)) + .mockReturnValueOnce(httpError(401)) + .mockReturnValueOnce(throwError(() => new Error('timeout of 30000ms exceeded'))) + .mockReturnValueOnce(answer({ error: 'x' })); + + await expect(client.getOne('/x/7', schema)).rejects.toThrow('500'); + await expect(client.getOne('/x/7', schema)).rejects.toThrow('401'); + await expect(client.getOne('/x/7', schema)).rejects.toThrow('timeout'); + await expect(client.getOne('/x/7', schema)).rejects.toThrow('a list was expected'); }); }); }); diff --git a/src/hiboutik/hiboutik.client.ts b/src/hiboutik/hiboutik.client.ts index f0bb9d5..368acb6 100644 --- a/src/hiboutik/hiboutik.client.ts +++ b/src/hiboutik/hiboutik.client.ts @@ -1,12 +1,49 @@ import { Injectable } from '@nestjs/common'; import { HttpService } from '@nestjs/axios'; import { ConfigService } from '@nestjs/config'; +import { isAxiosError } from 'axios'; import { firstValueFrom } from 'rxjs'; +import { z } from 'zod'; +import { parseHiboutik } from '../models/hiboutikDTO/hiboutik.schema'; /** Results per page of every paginated Hiboutik endpoint (see "Results per page" in the API docs). */ const HIBOUTIK_PAGE_SIZE = 250; -/** Low-level HTTP client for the Hiboutik REST API: base URL and Basic auth credentials come from the environment. */ +/** Retries of a request answered 429 (too many requests), with exponential backoff: 2 s, 4 s, 8 s. */ +const RATE_LIMIT_RETRIES = 3; +const RATE_LIMIT_BASE_DELAY_MS = 2_000; +/** Cap of the wait asked by a `Retry-After` header: beyond, waiting holds the sync for nothing (monthly quota). */ +const RATE_LIMIT_MAX_DELAY_MS = 60_000; + +/** + * Hiboutik still answers 429 after the retries: the rate limit (30 concurrent calls) or the monthly quota (10,000 + * calls per shop) is reached. The synchronization must stop: every next call would fail the same way. + */ +export class HiboutikRateLimitError extends Error { + constructor(path: string) { + super(`Hiboutik rate limit reached on ${path} (still 429 after ${RATE_LIMIT_RETRIES} retries): monthly quota exhausted?`); + this.name = 'HiboutikRateLimitError'; + } +} + +/** Rethrows a {@link HiboutikRateLimitError}: for the per-item `catch` blocks of the sync, which must not swallow it. */ +export function rethrowIfRateLimited(error: unknown): void { + if (error instanceof HiboutikRateLimitError) throw error; +} + +/** Wait asked by a `Retry-After` header (seconds or HTTP date), if any. */ +function retryAfterMs(header: unknown): number | undefined { + if (typeof header !== 'string' || header.trim() === '') return undefined; + const seconds = Number(header); + if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000); + const date = Date.parse(header); + return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now()); +} + +/** + * Low-level HTTP client for the Hiboutik REST API: base URL and Basic auth credentials come from the environment. + * Every answer is validated against a zod schema (see `hiboutik.schema.ts`): a malformed one throws, like an HTTP error. + */ @Injectable() export class HiboutikClient { private readonly baseUrl: string; @@ -22,45 +59,109 @@ export class HiboutikClient { this.baseUrl = this.configService.get('HIBOUTIK_BASE_URL') ?? ''; } - /** GET on a Hiboutik path (relative to `HIBOUTIK_BASE_URL`), returns the response body. */ - async get(path: string, params?: Record): Promise { - const response = await firstValueFrom( - this.httpService.get(`${this.baseUrl}${path}`, { - headers: { Authorization: this.authHeader }, - params, - }), - ); - return response.data; + /** + * GET on a Hiboutik path (relative to `HIBOUTIK_BASE_URL`), returns the raw response body. A 429 is retried with + * backoff (or after the `Retry-After` delay), then turns into a {@link HiboutikRateLimitError}. + */ + private async get(path: string, params?: Record): Promise { + for (let attempt = 0; ; attempt++) { + try { + const response = await firstValueFrom( + this.httpService.get(`${this.baseUrl}${path}`, { + headers: { Authorization: this.authHeader }, + params, + }), + ); + return response.data; + } catch (error) { + if (!isAxiosError(error) || error.response?.status !== 429) throw error; + if (attempt >= RATE_LIMIT_RETRIES) throw new HiboutikRateLimitError(path); + + const delay = retryAfterMs(error.response.headers['retry-after']) ?? RATE_LIMIT_BASE_DELAY_MS * 2 ** attempt; + await this.sleep(Math.min(delay, RATE_LIMIT_MAX_DELAY_MS)); + } + } + } + + /** Separate method so that tests can skip the waits. */ + protected sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); } /** * GET on a list endpoint. HTTP errors (4xx/5xx) throw, a 2xx answer without body is an empty list, and any other - * non-list body throws: an empty list triggers deletions in the sync, so it must never be inferred from a malformed answer. + * non-list body or invalid item throws: an empty list triggers deletions in the sync, so it must never be inferred + * from a malformed answer. */ - async getList(path: string, params?: Record): Promise { - const data = await this.get(path, params); + async getList(path: string, itemSchema: T, params?: Record): Promise[]> { + const data = await this.get(path, params); if (data === '' || data == null) return []; if (!Array.isArray(data)) throw new Error(`Unexpected answer from Hiboutik ${path}: a list was expected`); - return data; + return parseHiboutik(z.array(itemSchema), data, path); } /** * Fetches every page of a paginated endpoint (`p` param, starting at 1) and concatenates the results. * Only for paginated endpoints: on a non-paginated one, a full first page would loop forever on the same results. */ - async getAllPages(path: string, baseParams: Record = {}): Promise { - const results: T[] = []; + async getAllPages(path: string, itemSchema: T, baseParams: Record = {}): Promise[]> { + return (await this.walkPages(path, itemSchema, baseParams)).items; + } + + /** + * Same as {@link getAllPages}, for a list whose content drives deletions. An item created or deleted in Hiboutik while + * the pages are walked shifts the following pages, so an item can be skipped. When the list spans several pages, it + * is walked a second time and both walks are merged by id (the second one wins): an item is missing from the result + * only if both walks missed it. A list that fits in one page costs no extra call. + */ + async getAllPagesForSync( + path: string, + itemSchema: T, + idOf: (item: z.output) => number, + baseParams: Record = {}, + ): Promise[]> { + const firstWalk = await this.walkPages(path, itemSchema, baseParams); + if (firstWalk.nbPages <= 1) return firstWalk.items; + + const secondWalk = await this.walkPages(path, itemSchema, baseParams); + const byId = new Map>(); + for (const item of [...firstWalk.items, ...secondWalk.items]) byId.set(idOf(item), item); + return [...byId.values()]; + } + + /** Walks the pages of a paginated endpoint. `nbPages` counts the pages holding results. */ + private async walkPages( + path: string, + itemSchema: T, + baseParams: Record, + ): Promise<{ items: z.output[]; nbPages: number }> { + const items: z.output[] = []; let page = 1; while (true) { - const pageData = await this.getList(path, { ...baseParams, p: page }); + const pageData = await this.getList(path, itemSchema, { ...baseParams, p: page }); if (pageData.length === 0) break; - results.push(...pageData); + items.push(...pageData); // A page smaller than the page size is the last one: no need to request an empty page if (pageData.length < HIBOUTIK_PAGE_SIZE) break; page++; } - return results; + return { items, nbPages: Math.ceil(items.length / HIBOUTIK_PAGE_SIZE) }; + } + + /** + * GET on a single-resource endpoint (Hiboutik answers with a one-element array). + * @returns `null` when Hiboutik says the resource does not exist: a 404, or a 2xx answer that is empty. + * @throws on any other HTTP error or malformed answer: an unknown state is never taken for a missing resource. + */ + async getOne(path: string, schema: T): Promise | null> { + try { + const [item] = await this.getList(path, schema); + return item ?? null; + } catch (error) { + if (isAxiosError(error) && error.response?.status === 404) return null; + throw error; + } } } diff --git a/src/hiboutik/product.hiboutik.repository.ts b/src/hiboutik/product.hiboutik.repository.ts index 25db422..5afd546 100644 --- a/src/hiboutik/product.hiboutik.repository.ts +++ b/src/hiboutik/product.hiboutik.repository.ts @@ -1,15 +1,18 @@ import { Injectable } from '@nestjs/common'; import { HiboutikClient } from './hiboutik.client'; -import { ProductHiboutikDto } from '../models/hiboutikDTO/product.hiboutik.dto'; +import { ProductHiboutikDto, productHiboutikSchema } from '../models/hiboutikDTO/product.hiboutik.dto'; /** Reads products from the Hiboutik API. */ @Injectable() export class ProductHiboutikRepository { constructor(private readonly hiboutikClient: HiboutikClient) {} - /** All products, archived ones included, with their sizes and per-size rules. */ + /** + * All products, archived ones included, with their sizes and per-size rules. Walked twice when it spans several + * pages: it drives the deletion of products (see `getAllPagesForSync`). + */ async getProducts(): Promise { - return this.hiboutikClient.getAllPages('/products', { + return this.hiboutikClient.getAllPagesForSync('/products', productHiboutikSchema, (product) => product.product_id, { order_by: 'product_id', sort: 'ASC', }); diff --git a/src/hiboutik/sale.hiboutik.repository.ts b/src/hiboutik/sale.hiboutik.repository.ts index 993a8c3..68d2df5 100644 --- a/src/hiboutik/sale.hiboutik.repository.ts +++ b/src/hiboutik/sale.hiboutik.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { HiboutikClient } from './hiboutik.client'; -import { SaleHiboutikDto } from '../models/hiboutikDTO/sale.hiboutik.dto'; +import { SaleHiboutikDto, saleHiboutikSchema } from '../models/hiboutikDTO/sale.hiboutik.dto'; /** Reads sales from the Hiboutik API. */ @Injectable() @@ -8,12 +8,11 @@ export class SaleHiboutikRepository { constructor(private readonly hiboutikClient: HiboutikClient) {} /** - * Full sale, with its lines (Hiboutik answers with a one-element array). + * Full sale, with its lines. * @throws if the sale does not exist. */ async getSaleById(id: number): Promise { - const data = await this.hiboutikClient.get('/sales/' + String(id)); - const sale = data[0]; + const sale = await this.hiboutikClient.getOne(`/sales/${id}`, saleHiboutikSchema); if (!sale) throw new Error(`Hiboutik sale ${id} not found`); return sale; } diff --git a/src/hiboutik/stock.hiboutik.repository.ts b/src/hiboutik/stock.hiboutik.repository.ts index a77a6fe..24ef013 100644 --- a/src/hiboutik/stock.hiboutik.repository.ts +++ b/src/hiboutik/stock.hiboutik.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { HiboutikClient } from './hiboutik.client'; -import { StockHiboutikDto } from '../models/hiboutikDTO/stock.hiboutik.dto'; +import { StockHiboutikDto, stockHiboutikSchema } from '../models/hiboutikDTO/stock.hiboutik.dto'; /** Reads available stocks from the Hiboutik API. */ @Injectable() @@ -9,12 +9,6 @@ export class StockHiboutikRepository { /** Stock of every product and size, all warehouses combined. Not paginated. */ async getStocks(): Promise { - const stocks = await this.hiboutikClient.getList('/stock_available/all_wh', {}); - // Hiboutik may return ids as strings: normalize them so strict comparisons work downstream - return stocks.map((stock) => ({ - ...stock, - product_id: Number(stock.product_id), - product_size: Number(stock.product_size), - })); + return this.hiboutikClient.getList('/stock_available/all_wh', stockHiboutikSchema); } } diff --git a/src/hiboutik/supplier.hiboutik.repository.ts b/src/hiboutik/supplier.hiboutik.repository.ts index 666c799..11cb261 100644 --- a/src/hiboutik/supplier.hiboutik.repository.ts +++ b/src/hiboutik/supplier.hiboutik.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { HiboutikClient } from './hiboutik.client'; -import { SupplierHiboutikDto } from '../models/hiboutikDTO/supplier.hiboutik.dto'; +import { SupplierHiboutikDto, supplierHiboutikSchema } from '../models/hiboutikDTO/supplier.hiboutik.dto'; /** Reads suppliers from the Hiboutik API. */ @Injectable() @@ -9,7 +9,7 @@ export class SupplierHiboutikRepository { /** `/suppliers` is not paginated: everything comes in one call. */ async getSuppliers(): Promise { - return this.hiboutikClient.getList('/suppliers', { + return this.hiboutikClient.getList('/suppliers', supplierHiboutikSchema, { order_by: 'supplier_id', sort: 'ASC', }); diff --git a/src/models/converter/brand.converter.ts b/src/models/converter/brand.converter.ts index d77270d..b97cd00 100644 --- a/src/models/converter/brand.converter.ts +++ b/src/models/converter/brand.converter.ts @@ -9,7 +9,7 @@ export class BrandConverter extends Converter = {}): FullCustomerHiboutikDto => - ({ - customers_id: 42, - first_name: 'Jane', - last_name: 'Doe', - email: 'jane@example.com', - phone: '0600000000', - date_of_birth: '1990-05-12', - updated_at: '2026-06-28 17:02:55', - ...overrides, - }) as FullCustomerHiboutikDto; +const hiboutikCustomer = (overrides: Partial = {}): FullCustomerHiboutikDto => ({ + customers_id: 42, + first_name: 'Jane', + last_name: 'Doe', + email: 'jane@example.com', + phone: '0600000000', + date_of_birth: '1990-05-12', + updated_at: '2026-06-28 17:02:55', + ...overrides, +}); describe('CustomerConverter', () => { const converter = new CustomerConverter(); @@ -22,7 +21,8 @@ describe('CustomerConverter', () => { email: 'jane@example.com', firstName: 'Jane', hiboutikId: 42, - hiboutikUpdatedAt: new Date('2026-06-28T17:02:55'), + // French summer time (UTC+2) + hiboutikUpdatedAt: new Date('2026-06-28T15:02:55Z'), lastName: 'Doe', phone: '0600000000', }); @@ -32,13 +32,13 @@ describe('CustomerConverter', () => { expect(converter.fromHiboutik(hiboutikCustomer({ date_of_birth: '0000-00-00' })).dateOfBirth).toBeUndefined(); }); - it('keeps the stored email when Hiboutik has none (undefined)', () => { - expect(converter.fromHiboutik(hiboutikCustomer({ email: '' })).email).toBeUndefined(); - expect(converter.fromHiboutik(hiboutikCustomer({ email: null as unknown as string })).email).toBeUndefined(); + it('erases the email when Hiboutik has none (null)', () => { + expect(converter.fromHiboutik(hiboutikCustomer({ email: '' })).email).toBeNull(); + expect(converter.fromHiboutik(hiboutikCustomer({ email: ' ' })).email).toBeNull(); }); it('erases the phone when Hiboutik has none (null)', () => { expect(converter.fromHiboutik(hiboutikCustomer({ phone: '' })).phone).toBeNull(); - expect(converter.fromHiboutik(hiboutikCustomer({ phone: null as unknown as string })).phone).toBeNull(); + expect(converter.fromHiboutik(hiboutikCustomer({ phone: ' ' })).phone).toBeNull(); }); }); diff --git a/src/models/converter/customer.converter.ts b/src/models/converter/customer.converter.ts index 1c6fb4a..5a696ad 100644 --- a/src/models/converter/customer.converter.ts +++ b/src/models/converter/customer.converter.ts @@ -9,14 +9,14 @@ export class CustomerConverter extends Converter = {}): CustomerAddressHiboutikDto => - ({ - address_id: 7, - customers_id: 42, - first_name: 'Jane', - last_name: 'Doe', - company: 'ACME', - phone: '0600000000', - address: ' 1 rue de la Paix ', - zip_code: '75002', - city: 'Paris', - state: '', - country: 'FRA', - other: 'Bât. B', - default: 1, - ...overrides, - }) as CustomerAddressHiboutikDto; +const hiboutikAddress = (overrides: Partial = {}): CustomerAddressHiboutikDto => ({ + address_id: 7, + customers_id: 42, + first_name: 'Jane', + last_name: 'Doe', + company: 'ACME', + phone: '0600000000', + address: ' 1 rue de la Paix ', + zip_code: '75002', + city: 'Paris', + state: '', + country: 'FRA', + other: 'Bât. B', + default: 1, + ...overrides, +}); describe('CustomerAddressConverter', () => { const converter = new CustomerAddressConverter(); @@ -40,17 +39,8 @@ describe('CustomerAddressConverter', () => { }); }); - it('accepts ids and flags sent as strings', () => { - const address = converter.fromHiboutik(hiboutikAddress({ address_id: '8', customers_id: '43', default: '0' } as never)); - expect(address.hiboutikId).toBe(8); - expect(address.isDefault).toBe(false); - expect(address.customer).toEqual({ connect: { hiboutikId: 43 } }); - }); - it('erases empty optional fields (null) and defaults empty required ones to ""', () => { - const address = converter.fromHiboutik( - hiboutikAddress({ company: ' ', phone: null as unknown as string, other: '', first_name: null as unknown as string }), - ); + const address = converter.fromHiboutik(hiboutikAddress({ company: ' ', phone: '', other: '', first_name: '' })); expect(address.company).toBeNull(); expect(address.phone).toBeNull(); expect(address.other).toBeNull(); diff --git a/src/models/converter/customerAddress.converter.ts b/src/models/converter/customerAddress.converter.ts index ab50c37..bb1201c 100644 --- a/src/models/converter/customerAddress.converter.ts +++ b/src/models/converter/customerAddress.converter.ts @@ -31,8 +31,8 @@ export class CustomerAddressConverter extends Converter< companyNumber: companyDetail(details.company_number), country: required(dto.country), firstName: required(dto.first_name), - hiboutikId: Number(dto.address_id), - isDefault: Number(dto.default) === 1, + hiboutikId: dto.address_id, + isDefault: dto.default === 1, lastName: required(dto.last_name), legalStatus: companyDetail(details.legal_status), other: optional(dto.other), @@ -40,7 +40,7 @@ export class CustomerAddressConverter extends Converter< state: optional(dto.state), taxNumber: companyDetail(details.tax_number), zipCode: required(dto.zip_code), - customer: { connect: { hiboutikId: Number(dto.customers_id) } }, + customer: { connect: { hiboutikId: dto.customers_id } }, }; } toHiboutik(): CustomerAddressHiboutikDto { diff --git a/src/models/converter/order.converter.spec.ts b/src/models/converter/order.converter.spec.ts index c38bfcf..f191d1e 100644 --- a/src/models/converter/order.converter.spec.ts +++ b/src/models/converter/order.converter.spec.ts @@ -2,29 +2,26 @@ import { OrderConverter } from './order.converter'; import { SaleHiboutikDto, SaleLineHiboutikDto } from '../hiboutikDTO/sale.hiboutik.dto'; const hiboutikLine = (overrides: Partial = {}): SaleLineHiboutikDto => ({ - line_item_id: 3, product_id: 746, product_size: 1, quantity: 2, product_model: 'KATSEYE - WILD', product_barcode: '2430010007467', size_name: 'Daniela', - item_unit_gross_wo_discount: '19.00', item_unit_gross: '17.10', ...overrides, }); -const hiboutikSale = (overrides: Partial = {}): SaleHiboutikDto => - ({ - sale_id: 5, - created_at: '2026-10-18 11:00:21', - completed_at: '2026-10-18 11:01:04', - customer_id: 1, - total: '34.20', - quotation: 0, - line_items: [hiboutikLine()], - ...overrides, - }) as SaleHiboutikDto; +const hiboutikSale = (overrides: Partial = {}): SaleHiboutikDto => ({ + sale_id: 5, + created_at: '2026-10-18 11:00:21', + completed_at: '2026-10-18 11:01:04', + customer_id: 1, + total: '34.20', + quotation: 0, + line_items: [hiboutikLine()], + ...overrides, +}); describe('OrderConverter', () => { const converter = new OrderConverter(); @@ -32,7 +29,8 @@ describe('OrderConverter', () => { it('maps a completed Hiboutik sale with its lines', () => { expect(converter.fromHiboutik(hiboutikSale())).toEqual({ hiboutikSaleId: 5, - orderedAt: new Date('2026-10-18T11:01:04'), + // French summer time (UTC+2) + orderedAt: new Date('2026-10-18T09:01:04Z'), source: 'STORE', status: 'COMPLETED', total: '34.20', @@ -54,7 +52,7 @@ describe('OrderConverter', () => { it('maps a sale not completed yet as PENDING, ordered at its creation date', () => { const order = converter.fromHiboutik(hiboutikSale({ completed_at: '0000-00-00 00:00:00' })); expect(order.status).toBe('PENDING'); - expect(order.orderedAt).toEqual(new Date('2026-10-18T11:00:21')); + expect(order.orderedAt).toEqual(new Date('2026-10-18T09:00:21Z')); }); it('throws when the sale has no valid date', () => { @@ -75,6 +73,6 @@ describe('OrderConverter', () => { }); it('accepts a sale without lines', () => { - expect(converter.fromHiboutik(hiboutikSale({ line_items: undefined as never })).lines).toEqual([]); + expect(converter.fromHiboutik(hiboutikSale({ line_items: [] })).lines).toEqual([]); }); }); diff --git a/src/models/converter/order.converter.ts b/src/models/converter/order.converter.ts index 9d8e3af..095f19b 100644 --- a/src/models/converter/order.converter.ts +++ b/src/models/converter/order.converter.ts @@ -17,29 +17,28 @@ export class OrderConverter extends Converter 0 ? { connect: { hiboutikId: customerId } } : undefined, - lines: (dto.line_items ?? []).map((line) => this.lineFromHiboutik(line)), + customer: dto.customer_id > 0 ? { connect: { hiboutikId: dto.customer_id } } : undefined, + lines: dto.line_items.map((line) => this.lineFromHiboutik(line)), }; } private lineFromHiboutik(line: SaleLineHiboutikDto): OrderWithLinesInput['lines'][number] { return { - barcode: line.product_barcode?.trim() || null, - hiboutikProductId: Number(line.product_id), - hiboutikSizeId: Number(line.product_size), + barcode: line.product_barcode.trim() || null, + hiboutikProductId: line.product_id, + hiboutikSizeId: line.product_size, productName: line.product_model, - quantity: Number(line.quantity), + quantity: line.quantity, unitPrice: line.item_unit_gross, // same rule as ProductVersion: the product name for a product without sizes - versionName: line.size_name?.trim() || line.product_model, + versionName: line.size_name.trim() || line.product_model, }; } diff --git a/src/models/converter/product.converter.spec.ts b/src/models/converter/product.converter.spec.ts index 24ebd64..3966f06 100644 --- a/src/models/converter/product.converter.spec.ts +++ b/src/models/converter/product.converter.spec.ts @@ -9,13 +9,13 @@ const hiboutikProduct = (overrides: Partial = {}): ProductHi product_supplier: 2, product_price: '13.99', product_discount_price: '0.00', - product_supply_price: '7.49', product_category: 19, product_display_www: 0, product_arch: 0, products_desc: '', updated_at: '2026-06-28 17:02:55', product_specific_rules: [], + product_size_details: [], ...overrides, }); @@ -31,7 +31,7 @@ describe('ProductConverter', () => { description: '', discountPrice: null, hiboutikId: 1, - hiboutikUpdatedAt: new Date('2026-06-28T17:02:55'), + hiboutikUpdatedAt: new Date('2026-06-28T15:02:55Z'), name: 'BOUTEILLE MÉTAL ISOTHERME 260ML BT21', price: '13.99', enabled: false, diff --git a/src/models/converter/product.converter.ts b/src/models/converter/product.converter.ts index 650d649..751b257 100644 --- a/src/models/converter/product.converter.ts +++ b/src/models/converter/product.converter.ts @@ -10,7 +10,7 @@ export class ProductConverter extends Converter; diff --git a/src/models/hiboutikDTO/category.hiboutik.dto.ts b/src/models/hiboutikDTO/category.hiboutik.dto.ts index 30732a1..79c9b58 100644 --- a/src/models/hiboutikDTO/category.hiboutik.dto.ts +++ b/src/models/hiboutikDTO/category.hiboutik.dto.ts @@ -1,18 +1,18 @@ -/** Category as returned by Hiboutik `GET /categories`. */ -export interface CategoryHiboutikDto { - category_id: number; - category_name: string; +import { z } from 'zod'; +import { hiboutikId, hiboutikInt, hiboutikRef, hiboutikText } from './hiboutik.schema'; + +/** Category as returned by Hiboutik `GET /categories` (only the fields used by the sync). */ +export const categoryHiboutikSchema = z.object({ + category_id: hiboutikId, + category_name: hiboutikText, /** Id of the parent category, 0 for a root category. */ - category_id_parent: number; + category_id_parent: hiboutikRef, /** Enabled in the POS: 1 = yes, 0 = no. */ - category_enabled: number; + category_enabled: hiboutikInt, /** Displayed on the website: 1 = yes, 0 = no. */ - category_enabled_www: number; + category_enabled_www: hiboutikInt, /** Display order among its siblings. */ - category_position: number; - category_bck_color: string; - category_color: string; - category_desc: string; - accounting_account: string; - category_ref_ext: string; -} + category_position: hiboutikInt, +}); + +export type CategoryHiboutikDto = z.output; diff --git a/src/models/hiboutikDTO/customer.hiboutik.dto.ts b/src/models/hiboutikDTO/customer.hiboutik.dto.ts index ccb42b6..bee0355 100644 --- a/src/models/hiboutikDTO/customer.hiboutik.dto.ts +++ b/src/models/hiboutikDTO/customer.hiboutik.dto.ts @@ -1,91 +1,65 @@ -/** Customer summary as returned by Hiboutik `GET /customers` (no addresses nor sales). */ -export interface CustomerHiboutikDto { - customers_id: number; - last_name: string; - first_name: string; - company: string; - email: string; - phone: string; - vat: string; - country: string; - /** "YYYY-MM-DD", "0000-00-00" when unknown. */ - date_of_birth: string; - customers_code: string; - loyalty_points: number; - intial_loyalty_points: number; - validity: string; - prepaid_purchases: string; - store_credit: string; - turnover: string; - orders: number; - customer_ref_ext: string; - last_order_date: string; - /** Local time, "YYYY-MM-DD HH:mm:ss". */ - updated_at: string; -} +import { z } from 'zod'; +import { hiboutikId, hiboutikInt, hiboutikOptionalList, hiboutikRef, hiboutikText } from './hiboutik.schema'; -/** Full customer as returned by Hiboutik `GET /customer/:customers_id`, with its addresses and sales. */ -export interface FullCustomerHiboutikDto extends CustomerHiboutikDto { - gender: number; - comments: string; - customers_id_referrer: number; - customers_misc: string; - accounting_account: string; - addresses: CustomerAddressHiboutikDto[]; - sales: CustomerSaleHiboutikDto[]; - tags: unknown[]; - customers_referee: unknown[]; -} +/** Customer summary as returned by Hiboutik `GET /customers`: only its id is used (no addresses nor sales). */ +export const customerHiboutikSchema = z.object({ + customers_id: hiboutikId, +}); -/** Address of a customer, embedded in {@link FullCustomerHiboutikDto}. Name and phone can differ from the customer's. */ -export interface CustomerAddressHiboutikDto { - address_id: number; - gender: string; - first_name: string; - last_name: string; - creation_date: string; - email: string; - phone: string; - company: string; - address: string; - zip_code: string; - city: string; - state: string; - country: string; - customers_id: number; - display: number; - editable: number; +/** Address of a customer, embedded in the full customer. Name and phone can differ from the customer's. */ +export const customerAddressHiboutikSchema = z.object({ + address_id: hiboutikId, + first_name: hiboutikText, + last_name: hiboutikText, + phone: hiboutikText, + company: hiboutikText, + address: hiboutikText, + zip_code: hiboutikText, + city: hiboutikText, + state: hiboutikText, + country: hiboutikText, + customers_id: hiboutikRef, /** 1 for the customer's default address. */ - default: number; - other: string; -} + default: hiboutikInt, + other: hiboutikText, +}); /** * Address as returned by Hiboutik `GET /customers_addresses/:address_id`: the embedded address plus the company - * details and its last update. One API call per address, so only fetched for addresses with a company. + * details. One API call per address, so only fetched for addresses with a company. */ -export interface CustomerAddressDetailsHiboutikDto extends CustomerAddressHiboutikDto { - tax_number: string; - company_number: string; - legal_status: string; - /** Local time, "YYYY-MM-DD HH:mm:ss". */ - updated_at: string; -} +export const customerAddressDetailsHiboutikSchema = customerAddressHiboutikSchema.extend({ + tax_number: hiboutikText, + company_number: hiboutikText, + legal_status: hiboutikText, + /** French local time, "YYYY-MM-DD HH:mm:ss". */ + updated_at: hiboutikText, +}); -/** Sale header embedded in {@link FullCustomerHiboutikDto}: no sale lines, they come from `GET /sales/:sale_id`. */ -export interface CustomerSaleHiboutikDto { - sale_id: number; - created_at: string; - completed_at: string; - date_z: number; - store_id: number; - vendor_id: number; - unique_sale_id: string; - customer_id: number; - currency: string; - /** Decimal as a string. */ - total: string; - points: number; - billing_address: number; - shipping_address: number; -} +/** Sale header embedded in the full customer: no sale lines, they come from `GET /sales/:sale_id`. */ +export const customerSaleHiboutikSchema = z.object({ + sale_id: hiboutikId, +}); + +/** Full customer as returned by Hiboutik `GET /customer/:customers_id`, with its addresses and sales. */ +export const fullCustomerHiboutikSchema = z.object({ + customers_id: hiboutikId, + last_name: hiboutikText, + first_name: hiboutikText, + email: hiboutikText, + phone: hiboutikText, + /** "YYYY-MM-DD", "0000-00-00" when unknown. */ + date_of_birth: hiboutikText, + /** French local time, "YYYY-MM-DD HH:mm:ss". */ + updated_at: hiboutikText, + /** `undefined` when not an array: the stored addresses are then kept. */ + addresses: hiboutikOptionalList(customerAddressHiboutikSchema), + /** `undefined` when not an array: the orders sync is then skipped. */ + sales: hiboutikOptionalList(customerSaleHiboutikSchema), +}); + +export type CustomerHiboutikDto = z.output; +export type CustomerAddressHiboutikDto = z.output; +export type CustomerAddressDetailsHiboutikDto = z.output; +export type CustomerSaleHiboutikDto = z.output; +export type FullCustomerHiboutikDto = z.output; diff --git a/src/models/hiboutikDTO/hiboutik.schema.spec.ts b/src/models/hiboutikDTO/hiboutik.schema.spec.ts new file mode 100644 index 0000000..3f53220 --- /dev/null +++ b/src/models/hiboutikDTO/hiboutik.schema.spec.ts @@ -0,0 +1,87 @@ +import { parseHiboutik } from './hiboutik.schema'; +import { customerAddressHiboutikSchema, fullCustomerHiboutikSchema } from './customer.hiboutik.dto'; +import { productHiboutikSchema } from './product.hiboutik.dto'; +import { saleHiboutikSchema } from './sale.hiboutik.dto'; +import { stockHiboutikSchema } from './stock.hiboutik.dto'; + +const parse = [0]>(schema: T, data: unknown) => parseHiboutik(schema, data, '/x'); + +describe('Hiboutik schemas', () => { + const product = { + product_id: 1, + product_model: 'BT21 bottle', + product_barcode: '8435497280048', + product_brand: 1, + product_supplier: 2, + product_price: '13.99', + product_discount_price: '0.00', + product_category: 19, + product_display_www: 1, + product_arch: 0, + products_desc: '', + updated_at: '2026-06-28 17:02:55', + product_specific_rules: [], + }; + + it('accepts ids, references and flags sent as strings', () => { + const parsed = parse(customerAddressHiboutikSchema, { address_id: ' 8 ', customers_id: '43', default: '0' }); + expect(parsed.address_id).toBe(8); + expect(parsed.customers_id).toBe(43); + expect(parsed.default).toBe(0); + }); + + it('turns empty references into 0 and empty text into ""', () => { + const parsed = parse(productHiboutikSchema, { ...product, product_brand: '', product_category: null, products_desc: null }); + expect(parsed.product_brand).toBe(0); + expect(parsed.product_category).toBe(0); + expect(parsed.products_desc).toBe(''); + }); + + it('keeps numeric text (zip code) and prices as strings', () => { + expect(parse(customerAddressHiboutikSchema, { address_id: 1, zip_code: 75002 }).zip_code).toBe('75002'); + expect(parse(productHiboutikSchema, { ...product, product_price: 13.5 }).product_price).toBe('13.5'); + }); + + it('defaults missing lists to [] and strips unknown fields', () => { + const parsed = parse(productHiboutikSchema, { ...product, product_specific_rules: '', product_supply_price: '7.49' }); + expect(parsed.product_specific_rules).toEqual([]); + expect(parsed.product_size_details).toEqual([]); + expect(parsed).not.toHaveProperty('product_supply_price'); + expect(parse(saleHiboutikSchema, { sale_id: 5, total: '0.00' }).line_items).toEqual([]); + }); + + it('leaves addresses and sales undefined when they are not arrays, so that stored data is kept', () => { + const parsed = parse(fullCustomerHiboutikSchema, { customers_id: 1, addresses: '', sales: { error: 'x' } }); + expect(parsed.addresses).toBeUndefined(); + expect(parsed.sales).toBeUndefined(); + }); + + it('parses stocks sent as decimal strings', () => { + expect(parse(stockHiboutikSchema, { product_id: '1', product_size: '0', stock_available: '3.000' })).toEqual({ + product_id: 1, + product_size: 0, + stock_available: 3, + }); + }); + + it.each([ + ['a missing id', { ...product, product_id: undefined }], + ['a zero id', { ...product, product_id: 0 }], + ['a non-numeric id', { ...product, product_id: 'abc' }], + ['a negative reference', { ...product, product_brand: -1 }], + ['an invalid price', { ...product, product_price: 'free' }], + ['a list that is not an array', { ...product, product_size_details: { size_id: 1 } }], + ])('rejects %s', (_, data) => { + expect(() => parse(productHiboutikSchema, data)).toThrow('Unexpected answer from Hiboutik /x'); + }); + + it('names the faulty fields, never their values', () => { + expect.assertions(2); + try { + parse(fullCustomerHiboutikSchema, { customers_id: 'jane@example.com', addresses: [{ address_id: 'Jane Doe' }] }); + } catch (error) { + expect((error as Error).message).toMatch(/customers_id.*addresses\.0\.address_id/); + expect((error as Error).message).not.toMatch(/jane|Doe/i); + } + }); +}); diff --git a/src/models/hiboutikDTO/hiboutik.schema.ts b/src/models/hiboutikDTO/hiboutik.schema.ts new file mode 100644 index 0000000..2d0af2a --- /dev/null +++ b/src/models/hiboutikDTO/hiboutik.schema.ts @@ -0,0 +1,58 @@ +import { z } from 'zod'; + +/** + * Field types shared by the Hiboutik payload schemas. Hiboutik sends numbers either as JSON numbers or as numeric + * strings, and empty values as "", null or a missing key: the schemas normalize all that, so that the rest of the + * code gets real numbers and strings. Strict on what the sync relies on (identifiers, relations, prices), + * lenient (empty string) on free text. + */ + +const trimmed = (value: unknown) => (typeof value === 'string' ? value.trim() : value); +const emptyAsZero = (value: unknown) => { + const v = trimmed(value); + return v == null || v === '' ? 0 : v; +}; + +/** Identifier of a Hiboutik entity (join key): a positive integer. */ +export const hiboutikId = z.preprocess(trimmed, z.coerce.number().int().positive()); + +/** Reference to another entity: its id, or 0 (also sent as "" or null) when none. */ +export const hiboutikRef = z.preprocess(emptyAsZero, z.coerce.number().int().nonnegative()); + +/** Integer, e.g. a 1/0 flag, a position or a quantity (0 when empty). */ +export const hiboutikInt = z.preprocess(emptyAsZero, z.coerce.number().int()); + +/** Number, e.g. a stock that may be sent as "3.000" (0 when empty). */ +export const hiboutikNumber = z.preprocess(emptyAsZero, z.coerce.number()); + +/** Free text, "" when empty. */ +export const hiboutikText = z.preprocess((value) => (value == null ? '' : typeof value === 'number' ? String(value) : value), z.string()); + +/** Required decimal kept as a string for Prisma `Decimal`, e.g. "13.99". */ +export const hiboutikDecimal = z.preprocess( + (value) => (typeof value === 'number' ? String(value) : trimmed(value)), + z.string().regex(/^-?\d+(\.\d+)?$/, 'decimal expected'), +); + +/** List that is empty when missing ("", null or no key). */ +export const hiboutikList = (item: T) => + z.preprocess((value) => (value == null || value === '' ? [] : value), z.array(item)); + +/** List that is `undefined` when it is not an array: the caller then keeps the stored data instead of emptying it. */ +export const hiboutikOptionalList = (item: T) => + z.preprocess((value) => (Array.isArray(value) ? value : undefined), z.array(item).optional()); + +/** + * Validates a Hiboutik payload. The error names the faulty fields (path and issue code) but never their values, + * which may be personal data. + */ +export function parseHiboutik(schema: T, data: unknown, path: string): z.output { + const result = schema.safeParse(data); + if (result.success) return result.data; + + const issues = result.error.issues + .slice(0, 5) + .map((issue) => `${issue.path.join('.') || '(root)'} (${issue.code})`) + .join(', '); + throw new Error(`Unexpected answer from Hiboutik ${path}: invalid ${issues}`); +} diff --git a/src/models/hiboutikDTO/product.hiboutik.dto.ts b/src/models/hiboutikDTO/product.hiboutik.dto.ts index 6080276..783c808 100644 --- a/src/models/hiboutikDTO/product.hiboutik.dto.ts +++ b/src/models/hiboutikDTO/product.hiboutik.dto.ts @@ -1,46 +1,51 @@ -/** Product as returned by Hiboutik `GET /products` (archived products included). */ -export interface ProductHiboutikDto { - product_id: number; - /** Product name. */ - product_model: string; - /** Empty string when the product has no barcode. */ - product_barcode: string; - /** Brand id, 0 when none. */ - product_brand: number; - /** Supplier id, 0 when none. */ - product_supplier: number; - /** Decimal as a string, e.g. "13.99". */ - product_price: string; - /** "0.00" when the product has no discount. */ - product_discount_price: string; - product_supply_price: string; - /** Category id, 0 when none. */ - product_category: number; - /** Displayed on the website: 1 = yes, 0 = no. */ - product_display_www: number; - /** Archived: 1 = yes, 0 = no. */ - product_arch: number; - products_desc: string; - /** Local time, "YYYY-MM-DD HH:mm:ss". */ - updated_at: string; - /** Per-size overrides of the product's price, discount and barcode. */ - product_specific_rules: ProductSpecificRulesHiboutikDto[]; - /** Sizes of the product; missing or empty for a product without sizes. */ - product_size_details?: ProductSizeDetailsHiboutikDto[]; -} +import { z } from 'zod'; +import { hiboutikDecimal, hiboutikId, hiboutikInt, hiboutikList, hiboutikRef, hiboutikText } from './hiboutik.schema'; /** Override of a product's values for one size. Prices are "0.00" (or missing) when not overridden. */ -export interface ProductSpecificRulesHiboutikDto { - size_id: number; - barcode: string; - product_price?: string; - product_discount_price?: string; -} +export const productSpecificRulesHiboutikSchema = z.object({ + size_id: hiboutikRef, + product_price: hiboutikText, + product_discount_price: hiboutikText, +}); /** One size of a product (e.g. "S", "M"). */ -export interface ProductSizeDetailsHiboutikDto { - size_id: number; - size_name: string; - /** Empty string when the size has no barcode. */ - barcode: string; -} +export const productSizeDetailsHiboutikSchema = z.object({ + size_id: hiboutikId, + size_name: hiboutikText, + /** Empty when the size has no barcode. */ + barcode: hiboutikText, +}); + +/** Product as returned by Hiboutik `GET /products`, archived products included (only the fields used by the sync). */ +export const productHiboutikSchema = z.object({ + product_id: hiboutikId, + /** Product name. */ + product_model: hiboutikText, + /** Empty when the product has no barcode. */ + product_barcode: hiboutikText, + /** Brand id, 0 when none. */ + product_brand: hiboutikRef, + /** Supplier id, 0 when none. */ + product_supplier: hiboutikRef, + /** e.g. "13.99". */ + product_price: hiboutikDecimal, + /** "0.00" when the product has no discount. */ + product_discount_price: hiboutikText, + /** Category id, 0 when none. */ + product_category: hiboutikRef, + /** Displayed on the website: 1 = yes, 0 = no. */ + product_display_www: hiboutikInt, + /** Archived: 1 = yes, 0 = no. */ + product_arch: hiboutikInt, + products_desc: hiboutikText, + /** French local time, "YYYY-MM-DD HH:mm:ss". */ + updated_at: hiboutikText, + /** Per-size overrides of the product's price, discount and barcode. */ + product_specific_rules: hiboutikList(productSpecificRulesHiboutikSchema), + /** Sizes of the product; empty for a product without sizes. */ + product_size_details: hiboutikList(productSizeDetailsHiboutikSchema), +}); + +export type ProductSpecificRulesHiboutikDto = z.output; +export type ProductSizeDetailsHiboutikDto = z.output; +export type ProductHiboutikDto = z.output; diff --git a/src/models/hiboutikDTO/sale.hiboutik.dto.ts b/src/models/hiboutikDTO/sale.hiboutik.dto.ts index 4d3486c..ffd97eb 100644 --- a/src/models/hiboutikDTO/sale.hiboutik.dto.ts +++ b/src/models/hiboutikDTO/sale.hiboutik.dto.ts @@ -1,40 +1,39 @@ -/** - * Sale as returned by Hiboutik `GET /sales/:sale_id`, with its lines. - * Only the fields used by the sync are typed: the payload also carries many computed copies (dates split in parts, - * totals by tax, customer and store details...). - */ -export interface SaleHiboutikDto { - sale_id: number; - /** Local time, "YYYY-MM-DD HH:mm:ss". */ - created_at: string; - /** Local time, "0000-00-00 00:00:00" while the sale is not completed. */ - completed_at: string; - store_id: number; - customer_id: number; - currency: string; - /** Decimal as a string, taxes included. */ - total: string; - /** Payment type code, e.g. "CB", "ESP", "WEB". */ - payment: string; - /** 1 for a quotation. */ - quotation: number; - line_items: SaleLineHiboutikDto[]; -} +import { z } from 'zod'; +import { hiboutikDecimal, hiboutikId, hiboutikInt, hiboutikList, hiboutikRef, hiboutikText } from './hiboutik.schema'; -/** Line of a {@link SaleHiboutikDto}: a snapshot of the product at sale time. */ -export interface SaleLineHiboutikDto { - line_item_id: number; - product_id: number; +/** Line of a sale: a snapshot of the product at sale time. */ +export const saleLineHiboutikSchema = z.object({ + product_id: hiboutikRef, /** Hiboutik size id, 0 for a product without sizes. */ - product_size: number; + product_size: hiboutikRef, /** Negative for a returned item. */ - quantity: number; - product_model: string; - product_barcode: string; + quantity: hiboutikInt, + product_model: hiboutikText, + product_barcode: hiboutikText, /** Size name, empty for a product without sizes. */ - size_name: string; - /** Unit price before discount, taxes included (decimal as a string). */ - item_unit_gross_wo_discount: string; - /** Unit price actually paid, discount deducted, taxes included (decimal as a string). */ - item_unit_gross: string; -} + size_name: hiboutikText, + /** Unit price actually paid, discount deducted, taxes included. */ + item_unit_gross: hiboutikDecimal, +}); + +/** + * Sale as returned by Hiboutik `GET /sales/:sale_id`, with its lines. Only the fields used by the sync: the payload + * also carries many computed copies (dates split in parts, totals by tax, customer and store details...). + */ +export const saleHiboutikSchema = z.object({ + sale_id: hiboutikId, + /** French local time, "YYYY-MM-DD HH:mm:ss". */ + created_at: hiboutikText, + /** French local time, "0000-00-00 00:00:00" while the sale is not completed. */ + completed_at: hiboutikText, + /** 0 for an anonymous sale. */ + customer_id: hiboutikRef, + /** Taxes included. */ + total: hiboutikDecimal, + /** 1 for a quotation. */ + quotation: hiboutikInt, + line_items: hiboutikList(saleLineHiboutikSchema), +}); + +export type SaleLineHiboutikDto = z.output; +export type SaleHiboutikDto = z.output; diff --git a/src/models/hiboutikDTO/stock.hiboutik.dto.ts b/src/models/hiboutikDTO/stock.hiboutik.dto.ts index 45d7bea..325dc9c 100644 --- a/src/models/hiboutikDTO/stock.hiboutik.dto.ts +++ b/src/models/hiboutikDTO/stock.hiboutik.dto.ts @@ -1,10 +1,13 @@ +import { z } from 'zod'; +import { hiboutikId, hiboutikNumber, hiboutikRef } from './hiboutik.schema'; + /** Available stock of one product size, as returned by Hiboutik `GET /stock_available/all_wh` (all warehouses). */ -export interface StockHiboutikDto { - product_id: number; +export const stockHiboutikSchema = z.object({ + product_id: hiboutikId, /** Size id, 0 for a product without sizes. */ - product_size: number; - /** Decimal as a string, e.g. "3". Stays at 0 while stock management is disabled in Hiboutik. */ - stock_available: string; - product_barcode: string; - product_supplier_reference: string; -} + product_size: hiboutikRef, + /** Sent as a decimal string, e.g. "3". Stays at 0 while stock management is disabled in Hiboutik. */ + stock_available: hiboutikNumber, +}); + +export type StockHiboutikDto = z.output; diff --git a/src/models/hiboutikDTO/supplier.hiboutik.dto.ts b/src/models/hiboutikDTO/supplier.hiboutik.dto.ts index 10dac3d..30b3feb 100644 --- a/src/models/hiboutikDTO/supplier.hiboutik.dto.ts +++ b/src/models/hiboutikDTO/supplier.hiboutik.dto.ts @@ -1,7 +1,12 @@ -/** Supplier as returned by Hiboutik `GET /suppliers`. */ -export interface SupplierHiboutikDto { - supplier_id: number; - supplier_name: string; +import { z } from 'zod'; +import { hiboutikId, hiboutikInt, hiboutikText } from './hiboutik.schema'; + +/** Supplier as returned by Hiboutik `GET /suppliers` (only the fields used by the sync). */ +export const supplierHiboutikSchema = z.object({ + supplier_id: hiboutikId, + supplier_name: hiboutikText, /** 1 = enabled, 0 = disabled. */ - supplier_enabled: number; -} + supplier_enabled: hiboutikInt, +}); + +export type SupplierHiboutikDto = z.output; diff --git a/src/services/hiboutik/brand.hiboutik.service.ts b/src/services/hiboutik/brand.hiboutik.service.ts index b3a1409..7bc896b 100644 --- a/src/services/hiboutik/brand.hiboutik.service.ts +++ b/src/services/hiboutik/brand.hiboutik.service.ts @@ -26,7 +26,7 @@ export class BrandHiboutikService { ); // Hiboutik is trusted: a successful (2xx) answer, even an empty list, is the truth. An HTTP error throws before this point. - const nbDeleted = await this.brandDatabaseRepository.deleteMissing(hiboutikBrands.map((dto) => Number(dto.brand_id))); + const nbDeleted = await this.brandDatabaseRepository.deleteMissing(hiboutikBrands.map((dto) => dto.brand_id)); this.logger.log( `Brands sync ended: ${hiboutikBrands.length - nbFailed}/${hiboutikBrands.length} synced, ${nbFailed} failed, ${nbDeleted} deleted.`, ); diff --git a/src/services/hiboutik/category.hiboutik.service.spec.ts b/src/services/hiboutik/category.hiboutik.service.spec.ts index 871adbf..e60b69e 100644 --- a/src/services/hiboutik/category.hiboutik.service.spec.ts +++ b/src/services/hiboutik/category.hiboutik.service.spec.ts @@ -3,16 +3,15 @@ import { CategoryHiboutikService } from './category.hiboutik.service'; import { CategoryConverter } from '../../models/converter/category.converter'; import { CategoryHiboutikDto } from '../../models/hiboutikDTO/category.hiboutik.dto'; -const hiboutikCategory = (id: number, parentId: number, overrides: Partial = {}): CategoryHiboutikDto => - ({ - category_id: id, - category_name: `Category ${id}`, - category_id_parent: parentId, - category_enabled: 1, - category_enabled_www: 0, - category_position: 3, - ...overrides, - }) as CategoryHiboutikDto; +const hiboutikCategory = (id: number, parentId: number, overrides: Partial = {}): CategoryHiboutikDto => ({ + category_id: id, + category_name: `Category ${id}`, + category_id_parent: parentId, + category_enabled: 1, + category_enabled_www: 0, + category_position: 3, + ...overrides, +}); describe('CategoryHiboutikService', () => { const categoryHiboutikRepository = { getCategories: jest.fn() }; diff --git a/src/services/hiboutik/category.hiboutik.service.ts b/src/services/hiboutik/category.hiboutik.service.ts index c50ec64..a9c9585 100644 --- a/src/services/hiboutik/category.hiboutik.service.ts +++ b/src/services/hiboutik/category.hiboutik.service.ts @@ -32,13 +32,13 @@ export class CategoryHiboutikService { // Pass 2: parent links, once every category exists (a child can come before its parent in Hiboutik's list) const nbParentsFailed = await forEachSafely( hiboutikCategories, - (dto) => this.categoryDatabaseRepository.setParent(Number(dto.category_id), Number(dto.category_id_parent) || null), + (dto) => this.categoryDatabaseRepository.setParent(dto.category_id, dto.category_id_parent || null), this.logger, (dto) => `parent of Hiboutik category ${dto.category_id}`, ); // Hiboutik is trusted: a successful (2xx) answer, even an empty list, is the truth. An HTTP error throws before this point. - const nbDeleted = await this.categoryDatabaseRepository.deleteMissing(hiboutikCategories.map((dto) => Number(dto.category_id))); + const nbDeleted = await this.categoryDatabaseRepository.deleteMissing(hiboutikCategories.map((dto) => dto.category_id)); this.logger.log( `Categories sync ended: ${hiboutikCategories.length - nbFailed}/${hiboutikCategories.length} synced, ${nbFailed} failed, ` + `${nbParentsFailed} parent links failed, ${nbDeleted} deleted.`, diff --git a/src/services/hiboutik/customer.hiboutik.service.spec.ts b/src/services/hiboutik/customer.hiboutik.service.spec.ts index e59443c..628a693 100644 --- a/src/services/hiboutik/customer.hiboutik.service.spec.ts +++ b/src/services/hiboutik/customer.hiboutik.service.spec.ts @@ -2,8 +2,13 @@ import { Logger } from '@nestjs/common'; import { CustomerHiboutikService } from './customer.hiboutik.service'; describe('CustomerHiboutikService', () => { - const customerHiboutikRepository = { getCustomers: jest.fn(), getCustomerById: jest.fn(), getAddressById: jest.fn() }; - const customerDatabaseRepository = { upsert: jest.fn(), deleteMissing: jest.fn() }; + const customerHiboutikRepository = { + getCustomers: jest.fn(), + getCustomerById: jest.fn(), + findCustomerById: jest.fn(), + getAddressById: jest.fn(), + }; + const customerDatabaseRepository = { upsert: jest.fn(), getAllHiboutikIds: jest.fn(), deleteByHiboutikIds: jest.fn() }; const customerConverter = { fromHiboutik: jest.fn() }; const customerAddressDatabaseRepository = { upsert: jest.fn(), deleteMissing: jest.fn() }; const customerAddressConverter = { fromHiboutik: jest.fn((dto: unknown) => dto) }; @@ -23,7 +28,9 @@ describe('CustomerHiboutikService', () => { customerHiboutikRepository.getCustomers.mockResolvedValue([]); customerHiboutikRepository.getCustomerById.mockResolvedValue({ customers_id: 1, addresses: [] }); customerDatabaseRepository.upsert.mockResolvedValue({ id: 'customer-uuid' }); - customerDatabaseRepository.deleteMissing.mockResolvedValue(0); + customerDatabaseRepository.getAllHiboutikIds.mockResolvedValue([]); + customerDatabaseRepository.deleteByHiboutikIds.mockImplementation((ids: number[]) => Promise.resolve(ids.length)); + customerHiboutikRepository.findCustomerById.mockResolvedValue(null); customerAddressDatabaseRepository.deleteMissing.mockResolvedValue(0); orderDatabaseRepository.getCompletedSaleIds.mockResolvedValue(new Set()); saleHiboutikRepository.getSaleById.mockImplementation((id: number) => Promise.resolve({ sale_id: id, quotation: 0 })); @@ -39,27 +46,61 @@ describe('CustomerHiboutikService', () => { ); }); - it('deletes customers missing from Hiboutik', async () => { - customerHiboutikRepository.getCustomers.mockResolvedValue([{ customers_id: 1 }, { customers_id: '2' }]); + describe('deletions', () => { + it('deletes the stored customers missing from the list once Hiboutik confirms they no longer exist', async () => { + customerHiboutikRepository.getCustomers.mockResolvedValue([{ customers_id: 1 }, { customers_id: 2 }]); + customerDatabaseRepository.getAllHiboutikIds.mockResolvedValue([1, 2, 3, 4]); - await service.syncCustomers(); + await service.syncCustomers(); - expect(customerDatabaseRepository.deleteMissing).toHaveBeenCalledWith([1, 2]); - expect(customerDatabaseRepository.upsert).toHaveBeenCalledTimes(2); - }); + expect(customerHiboutikRepository.findCustomerById.mock.calls).toEqual([[3], [4]]); + expect(customerDatabaseRepository.deleteByHiboutikIds).toHaveBeenCalledWith([3, 4]); + expect(customerDatabaseRepository.upsert).toHaveBeenCalledTimes(2); + }); - it('deletes every customer when Hiboutik answers with an empty list', async () => { - await service.syncCustomers(); + it('keeps and syncs a customer missing from the list (page shift) that still exists in Hiboutik', async () => { + customerHiboutikRepository.getCustomers.mockResolvedValue([{ customers_id: 1 }]); + customerDatabaseRepository.getAllHiboutikIds.mockResolvedValue([1, 2]); + customerHiboutikRepository.findCustomerById.mockResolvedValue({ customers_id: 2 }); - expect(customerDatabaseRepository.deleteMissing).toHaveBeenCalledWith([]); - }); + await service.syncCustomers(); - it('deletes nothing when the Hiboutik call fails', async () => { - customerHiboutikRepository.getCustomers.mockRejectedValue(new Error('Request failed with status code 500')); + expect(customerDatabaseRepository.deleteByHiboutikIds).toHaveBeenCalledWith([]); + expect(customerHiboutikRepository.getCustomerById.mock.calls).toEqual([[1], [2]]); + }); - await expect(service.syncCustomers()).rejects.toThrow('500'); + it('keeps a customer whose check fails', async () => { + customerDatabaseRepository.getAllHiboutikIds.mockResolvedValue([3, 4]); + customerHiboutikRepository.findCustomerById.mockRejectedValueOnce(new Error('Request failed with status code 500')); - expect(customerDatabaseRepository.deleteMissing).not.toHaveBeenCalled(); + await service.syncCustomers(); + + expect(customerDatabaseRepository.deleteByHiboutikIds).toHaveBeenCalledWith([4]); + }); + + it('deletes every confirmed customer when Hiboutik answers with an empty list', async () => { + customerDatabaseRepository.getAllHiboutikIds.mockResolvedValue([1, 2]); + + await service.syncCustomers(); + + expect(customerDatabaseRepository.deleteByHiboutikIds).toHaveBeenCalledWith([1, 2]); + }); + + it('deletes nothing when the Hiboutik list call fails', async () => { + customerHiboutikRepository.getCustomers.mockRejectedValue(new Error('Request failed with status code 500')); + + await expect(service.syncCustomers()).rejects.toThrow('500'); + + expect(customerDatabaseRepository.deleteByHiboutikIds).not.toHaveBeenCalled(); + }); + + it('syncs once a customer listed twice', async () => { + customerHiboutikRepository.getCustomers.mockResolvedValue([{ customers_id: 1 }, { customers_id: 1 }]); + + await service.syncCustomers(); + + expect(customerHiboutikRepository.getCustomerById).toHaveBeenCalledTimes(1); + }); }); it('keeps syncing the other customers when one fails', async () => { @@ -80,8 +121,8 @@ describe('CustomerHiboutikService', () => { customerHiboutikRepository.getCustomerById.mockResolvedValue({ customers_id: 1, addresses: [ - { address_id: 10, customers_id: 1 }, - { address_id: '11', customers_id: 999 }, + { address_id: 10, customers_id: 1, company: '' }, + { address_id: 11, customers_id: 999, company: '' }, ], }); @@ -89,7 +130,7 @@ describe('CustomerHiboutikService', () => { expect(customerAddressDatabaseRepository.deleteMissing).toHaveBeenCalledWith('customer-uuid', [10, 11]); expect(customerAddressDatabaseRepository.upsert).toHaveBeenCalledTimes(2); - expect(customerAddressConverter.fromHiboutik).toHaveBeenLastCalledWith({ address_id: '11', customers_id: 1 }); + expect(customerAddressConverter.fromHiboutik).toHaveBeenLastCalledWith({ address_id: 11, customers_id: 1, company: '' }); expect(customerAddressDatabaseRepository.deleteMissing.mock.invocationCallOrder[0]).toBeLessThan( customerAddressDatabaseRepository.upsert.mock.invocationCallOrder[0], ); @@ -155,7 +196,10 @@ describe('CustomerHiboutikService', () => { it('keeps syncing the other addresses when one fails', async () => { customerHiboutikRepository.getCustomerById.mockResolvedValue({ customers_id: 1, - addresses: [{ address_id: 10 }, { address_id: 11 }], + addresses: [ + { address_id: 10, company: '' }, + { address_id: 11, company: '' }, + ], }); customerAddressDatabaseRepository.upsert.mockRejectedValueOnce(new Error('boom')); @@ -171,7 +215,7 @@ describe('CustomerHiboutikService', () => { customerHiboutikRepository.getCustomerById.mockResolvedValue({ customers_id: 1, addresses: [], - sales: [{ sale_id: 5 }, { sale_id: '6' }, { sale_id: 7 }], + sales: [{ sale_id: 5 }, { sale_id: 6 }, { sale_id: 7 }], }); }); @@ -180,7 +224,7 @@ describe('CustomerHiboutikService', () => { await service.syncCustomers(); - expect(orderDatabaseRepository.getCompletedSaleIds).toHaveBeenCalledWith([5, 6, 7]); + expect(orderDatabaseRepository.getCompletedSaleIds).toHaveBeenCalledWith(1, [5, 6, 7]); expect(saleHiboutikRepository.getSaleById.mock.calls.map((call: unknown[]) => call[0])).toEqual([5, 7]); expect(orderDatabaseRepository.upsert).toHaveBeenCalledTimes(2); expect(orderConverter.fromHiboutik).toHaveBeenCalledWith({ sale_id: 5, quotation: 0, customer_id: 1 }); diff --git a/src/services/hiboutik/customer.hiboutik.service.ts b/src/services/hiboutik/customer.hiboutik.service.ts index a8d959d..e56f919 100644 --- a/src/services/hiboutik/customer.hiboutik.service.ts +++ b/src/services/hiboutik/customer.hiboutik.service.ts @@ -13,6 +13,7 @@ import { forEachSafely } from '../../utils/sync'; import { SaleHiboutikRepository } from '../../hiboutik/sale.hiboutik.repository'; import { OrderDatabaseRepository } from '../../database/order.database.repository'; import { OrderConverter } from '../../models/converter/order.converter'; +import { rethrowIfRateLimited } from '../../hiboutik/hiboutik.client'; @Injectable() export class CustomerHiboutikService { @@ -30,35 +31,66 @@ export class CustomerHiboutikService { ) {} /** - * Mirrors Hiboutik customers: deletes the customers missing from Hiboutik, then fetches and upserts each of them - * with its addresses and orders (one API call per customer, as the list endpoint lacks addresses and sales, plus one - * per sale not yet stored as completed). + * Mirrors Hiboutik customers: deletes the customers Hiboutik confirms as deleted, then fetches and upserts each of + * them with its addresses and orders (one API call per customer, as the list endpoint lacks addresses and sales, plus + * one per sale not yet stored as completed). */ async syncCustomers() { const hiboutikCustomers = await this.customerHiboutikRepository.getCustomers(); + // A page shift can list a customer twice: sync it once + const listedIds = [...new Set(hiboutikCustomers.map((dto) => dto.customers_id))]; - // Hiboutik is trusted: a successful (2xx) answer, even an empty list, is the truth. An HTTP error throws before this point. - const nbDeleted = await this.customerDatabaseRepository.deleteMissing(hiboutikCustomers.map((dto) => Number(dto.customers_id))); + const { nbDeleted, unlistedIds } = await this.deleteMissingCustomers(listedIds); + const customerIds = [...listedIds, ...unlistedIds]; let nbFailed = 0; let nbFailedAddresses = 0; let nbFailedOrders = 0; - for (const hiboutikCustomer of hiboutikCustomers) { + for (const customerId of customerIds) { try { - const hiboutikFullCustomer = await this.customerHiboutikRepository.getCustomerById(hiboutikCustomer.customers_id); + const hiboutikFullCustomer = await this.customerHiboutikRepository.getCustomerById(customerId); const customer = await this.customerDatabaseRepository.upsert(this.customerConverter.fromHiboutik(hiboutikFullCustomer)); nbFailedAddresses += await this.syncAddresses(customer.id, hiboutikFullCustomer); nbFailedOrders += await this.syncOrders(hiboutikFullCustomer); } catch (error) { + rethrowIfRateLimited(error); nbFailed++; - this.logger.error(`Failed to sync Hiboutik customer ${hiboutikCustomer.customers_id}`, (error as Error)?.stack); + this.logger.error(`Failed to sync Hiboutik customer ${customerId}`, (error as Error)?.stack); } } this.logger.log( - `Customers sync ended: ${hiboutikCustomers.length - nbFailed}/${hiboutikCustomers.length} synced, ${nbFailed} failed, ${nbDeleted} deleted, ${nbFailedAddresses} addresses failed, ${nbFailedOrders} orders failed.`, + `Customers sync ended: ${customerIds.length - nbFailed}/${customerIds.length} synced, ${nbFailed} failed, ${nbDeleted} deleted, ` + + `${unlistedIds.length} missing from the list but still in Hiboutik, ${nbFailedAddresses} addresses failed, ${nbFailedOrders} orders failed.`, ); } + /** + * Deletes the stored customers missing from the Hiboutik list, once Hiboutik confirms one by one that they no longer + * exist. The list is paginated: a customer created or deleted in Hiboutik while it is walked shifts the following + * pages, so a customer can be missing from it without being deleted. Hiboutik is trusted: a 404 or an empty 2xx + * answer is a confirmation. A customer whose check fails is kept. + * @returns the number of deleted customers, and the ids of the customers that still exist although not listed. + */ + private async deleteMissingCustomers(listedIds: number[]): Promise<{ nbDeleted: number; unlistedIds: number[] }> { + const listed = new Set(listedIds); + const missingIds = (await this.customerDatabaseRepository.getAllHiboutikIds()).filter((id) => !listed.has(id)); + + const deletedIds: number[] = []; + const unlistedIds: number[] = []; + for (const id of missingIds) { + try { + const customer = await this.customerHiboutikRepository.findCustomerById(id); + (customer ? unlistedIds : deletedIds).push(id); + } catch (error) { + rethrowIfRateLimited(error); + this.logger.warn(`Failed to check whether Hiboutik customer ${id} still exists, it is kept: ${(error as Error)?.message}`); + } + } + + const nbDeleted = await this.customerDatabaseRepository.deleteByHiboutikIds(deletedIds); + return { nbDeleted, unlistedIds }; + } + /** * Mirrors the addresses embedded in a full Hiboutik customer: deletes the ones missing from Hiboutik, then upserts * each of them (a failing address is logged and skipped). @@ -73,13 +105,13 @@ export class CustomerHiboutikService { await this.customerAddressDatabaseRepository.deleteMissing( customerId, - hiboutikCustomer.addresses.map((dto) => Number(dto.address_id)), + hiboutikCustomer.addresses.map((dto) => dto.address_id), ); return forEachSafely( hiboutikCustomer.addresses, // Linked to the parent customer, whatever the embedded customers_id says async (dto) => { - const address = dto.company?.trim() ? await this.withCompanyDetails(dto) : dto; + const address = dto.company.trim() ? await this.withCompanyDetails(dto) : dto; await this.customerAddressDatabaseRepository.upsert( this.customerAddressConverter.fromHiboutik({ ...address, customers_id: hiboutikCustomer.customers_id }), ); @@ -106,6 +138,7 @@ export class CustomerHiboutikService { updated_at: details.updated_at, }; } catch (error) { + rethrowIfRateLimited(error); this.logger.warn(`Failed to fetch company details of Hiboutik address ${dto.address_id}: ${(error as Error)?.message}`); return dto; } @@ -124,15 +157,16 @@ export class CustomerHiboutikService { // A completed sale no longer changes in Hiboutik: no need to fetch it again const completedSaleIds = await this.orderDatabaseRepository.getCompletedSaleIds( - hiboutikCustomer.sales.map((dto) => Number(dto.sale_id)), + hiboutikCustomer.customers_id, + hiboutikCustomer.sales.map((dto) => dto.sale_id), ); - const salesToSync = hiboutikCustomer.sales.filter((dto) => !completedSaleIds.has(Number(dto.sale_id))); + const salesToSync = hiboutikCustomer.sales.filter((dto) => !completedSaleIds.has(dto.sale_id)); return forEachSafely( salesToSync, async (dto) => { const sale = await this.saleHiboutikRepository.getSaleById(dto.sale_id); - if (Number(sale.quotation) === 1) return; + if (sale.quotation === 1) return; // Linked to the parent customer, whatever the sale says await this.orderDatabaseRepository.upsert( this.orderConverter.fromHiboutik({ ...sale, customer_id: hiboutikCustomer.customers_id }), diff --git a/src/services/hiboutik/hiboutik.service.spec.ts b/src/services/hiboutik/hiboutik.service.spec.ts index 87ef133..bc650a4 100644 --- a/src/services/hiboutik/hiboutik.service.spec.ts +++ b/src/services/hiboutik/hiboutik.service.spec.ts @@ -1,5 +1,6 @@ -import { Logger } from '@nestjs/common'; +import { Logger, ServiceUnavailableException } from '@nestjs/common'; import { HiboutikService } from './hiboutik.service'; +import { HiboutikRateLimitError } from '../../hiboutik/hiboutik.client'; describe('HiboutikService', () => { const brandService = { syncBrands: jest.fn() }; @@ -9,12 +10,16 @@ describe('HiboutikService', () => { const customerService = { syncCustomers: jest.fn() }; const syncLock = { tryAcquire: jest.fn() }; const release = jest.fn(); + // SYNC_SHUTDOWN_TIMEOUT of 1 s + const configService = { get: jest.fn().mockReturnValue(1) }; let service: HiboutikService; const flush = () => new Promise((resolve) => setImmediate(resolve)); + let logWarn: jest.SpyInstance; + beforeAll(() => { jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); - jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + logWarn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); }); @@ -34,6 +39,7 @@ describe('HiboutikService', () => { productService as never, supplierService as never, syncLock as never, + configService as never, ); }); @@ -45,7 +51,8 @@ describe('HiboutikService', () => { productService.syncProducts.mockImplementation(() => Promise.resolve(order.push('products'))); customerService.syncCustomers.mockImplementation(() => Promise.resolve(order.push('customers'))); - await service.syncAll(); + // private method, reached through an index access on purpose + await service['syncAll'](); expect(order).toEqual(['brands', 'categories', 'suppliers', 'products', 'customers']); }); @@ -53,7 +60,8 @@ describe('HiboutikService', () => { it('continues with the next steps when one fails', async () => { brandService.syncBrands.mockRejectedValue(new Error('boom')); - await service.syncAll(); + // private method, reached through an index access on purpose + await service['syncAll'](); expect(productService.syncProducts).toHaveBeenCalled(); expect(customerService.syncCustomers).toHaveBeenCalled(); @@ -95,11 +103,68 @@ describe('HiboutikService', () => { }); it('releases the lock even when the synchronization crashes', async () => { - jest.spyOn(service as never, 'syncAll').mockRejectedValue(new Error('crash')); + jest.spyOn(service as unknown as { syncAll: () => Promise }, 'syncAll').mockRejectedValue(new Error('crash')); await expect(service.startSync()).resolves.toBe(true); await flush(); expect(release).toHaveBeenCalledTimes(1); }); + + it('stops the synchronization when the Hiboutik rate limit is reached', async () => { + productService.syncProducts.mockRejectedValue(new HiboutikRateLimitError('/products')); + + await service['syncAll'](); + + expect(customerService.syncCustomers).not.toHaveBeenCalled(); + }); + + describe('shutdown', () => { + afterEach(() => { + jest.useRealTimers(); + }); + + it('waits for the running synchronization to end, and refuses new ones meanwhile', async () => { + let finishBrands: () => void = () => undefined; + brandService.syncBrands.mockReturnValue(new Promise((resolve) => (finishBrands = resolve))); + await service.startSync(); + + let shutDown = false; + const shutdown = service.beforeApplicationShutdown().then(() => (shutDown = true)); + await flush(); + expect(shutDown).toBe(false); + await expect(service.startSync()).rejects.toThrow(ServiceUnavailableException); + + finishBrands(); + await shutdown; + expect(release).toHaveBeenCalledTimes(1); + }); + + it('gives up waiting after the timeout', async () => { + jest.useFakeTimers(); + brandService.syncBrands.mockReturnValue(new Promise(() => undefined)); + await service.startSync(); + + const shutdown = service.beforeApplicationShutdown(); + await jest.advanceTimersByTimeAsync(1_000); + + await expect(shutdown).resolves.toBeUndefined(); + expect(logWarn).toHaveBeenCalledWith(expect.stringContaining('did not end in time')); + }); + + it('returns at once when no synchronization is running', async () => { + await expect(service.beforeApplicationShutdown()).resolves.toBeUndefined(); + }); + + it('releases the lock and refuses when the shutdown began while the lock was requested', async () => { + syncLock.tryAcquire.mockImplementation(async () => { + await service.beforeApplicationShutdown(); + return release; + }); + + await expect(service.startSync()).rejects.toThrow(ServiceUnavailableException); + expect(release).toHaveBeenCalledTimes(1); + expect(brandService.syncBrands).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/services/hiboutik/hiboutik.service.ts b/src/services/hiboutik/hiboutik.service.ts index 54375ec..2f29bf1 100644 --- a/src/services/hiboutik/hiboutik.service.ts +++ b/src/services/hiboutik/hiboutik.service.ts @@ -1,15 +1,24 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { BeforeApplicationShutdown, Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { BrandHiboutikService } from './brand.hiboutik.service'; import { CategoryHiboutikService } from './category.hiboutik.service'; import { CustomerHiboutikService } from './customer.hiboutik.service'; import { SupplierHiboutikService } from './supplier.hiboutik.service'; import { ProductHiboutikService } from './product.hiboutik.service'; import { ReleaseLock, SyncLockDatabaseRepository } from '../../database/syncLock.database.repository'; +import { HiboutikRateLimitError } from '../../hiboutik/hiboutik.client'; + +/** Default of `SYNC_SHUTDOWN_TIMEOUT`: below the 30 s grace period that Kubernetes gives before killing a container. */ +const DEFAULT_SHUTDOWN_TIMEOUT_SECONDS = 25; /** Entry point of the Hiboutik → database synchronization: runs each entity sync in dependency order. */ @Injectable() -export class HiboutikService { +export class HiboutikService implements BeforeApplicationShutdown { private readonly logger = new Logger('HiboutikService'); + private readonly shutdownTimeoutMs: number; + /** Synchronization running on this instance, if any. */ + private running: Promise | null = null; + private stopping = false; constructor( private readonly brandHiboutikService: BrandHiboutikService, @@ -18,23 +27,60 @@ export class HiboutikService { private readonly productHiboutikService: ProductHiboutikService, private readonly supplierHiboutikService: SupplierHiboutikService, private readonly syncLock: SyncLockDatabaseRepository, - ) {} + configService: ConfigService, + ) { + this.shutdownTimeoutMs = (configService.get('SYNC_SHUTDOWN_TIMEOUT') ?? DEFAULT_SHUTDOWN_TIMEOUT_SECONDS) * 1000; + } /** * Starts a full synchronization in the background, if no other one is running on this instance or any other. * The Postgres advisory lock is the only guard: each request takes it on its own connection (its own session), * so it also refuses concurrent calls on this very instance. It is held until the synchronization ends. * @returns false if a synchronization is already running (nothing is started). + * @throws ServiceUnavailableException if this instance is shutting down (another instance can take it). * @throws if the lock cannot be requested (e.g. database unreachable). */ async startSync(): Promise { + this.refuseIfStopping(); const release = await this.syncLock.tryAcquire(); if (!release) return false; - void this.runLocked(release); + // The shutdown may have begun while the lock was requested: it would not wait for this synchronization + if (this.stopping) { + await release(); + this.refuseIfStopping(); + } + + this.running = this.runLocked(release).finally(() => (this.running = null)); return true; } + /** + * Shutdown hook: refuses new synchronizations and waits for the running one to end, up to `SYNC_SHUTDOWN_TIMEOUT` + * seconds. Runs before Prisma disconnects (`PrismaService.onApplicationShutdown`). Past the timeout, the shutdown goes + * on: the synchronization then fails on its next database call, without corrupting anything (one transaction per + * product, the lock is released by Postgres with the connection). + */ + async beforeApplicationShutdown(): Promise { + this.stopping = true; + const running = this.running; + if (!running) return; + + this.logger.log(`Shutdown requested: waiting for the running Hiboutik synchronization (up to ${this.shutdownTimeoutMs / 1000} s)`); + let timer: NodeJS.Timeout | undefined; + const timedOut = new Promise((resolve) => (timer = setTimeout(() => resolve(true), this.shutdownTimeoutMs))); + const result = await Promise.race([running.then(() => false as const), timedOut]); + clearTimeout(timer); + + if (result) { + this.logger.warn('The Hiboutik synchronization did not end in time: shutting down anyway, it will be interrupted'); + } + } + + private refuseIfStopping(): void { + if (this.stopping) throw new ServiceUnavailableException('This instance is shutting down, retry in a moment'); + } + /** Runs the synchronization, then always releases the lock. */ private async runLocked(release: ReleaseLock): Promise { try { @@ -46,7 +92,10 @@ export class HiboutikService { } } - /** Runs every sync step in order. A failing step is logged and does not stop the next ones. */ + /** + * Runs every sync step in order. A failing step is logged and does not stop the next ones, except when the Hiboutik + * rate limit is reached: the next steps would only fail too, so the synchronization stops there. + */ private async syncAll() { this.logger.log('Starting global Hiboutik synchronization'); // Order matters: products connect to brands, categories and suppliers by hiboutikId. @@ -64,6 +113,10 @@ export class HiboutikService { await step(); } catch (error) { failedSteps.push(name); + if (error instanceof HiboutikRateLimitError) { + this.logger.error(`Hiboutik synchronization stopped during the ${name} step: ${error.message}`); + return; + } this.logger.error(`Hiboutik ${name} sync failed`, (error as Error)?.stack); } } diff --git a/src/services/hiboutik/product.hiboutik.service.spec.ts b/src/services/hiboutik/product.hiboutik.service.spec.ts index 5a0068d..a4c2229 100644 --- a/src/services/hiboutik/product.hiboutik.service.spec.ts +++ b/src/services/hiboutik/product.hiboutik.service.spec.ts @@ -12,22 +12,20 @@ const hiboutikProduct = (id: number, overrides: Partial = {} product_supplier: 0, product_price: '10.00', product_discount_price: '0.00', - product_supply_price: '5.00', product_category: 0, product_display_www: 1, product_arch: 0, products_desc: '', updated_at: '2026-06-28 17:02:55', product_specific_rules: [], + product_size_details: [], ...overrides, }); -const stock = (productId: number, size: number, available: string): StockHiboutikDto => ({ +const stock = (productId: number, size: number, available: number): StockHiboutikDto => ({ product_id: productId, product_size: size, stock_available: available, - product_barcode: '', - product_supplier_reference: '', }); describe('ProductHiboutikService', () => { @@ -37,6 +35,9 @@ describe('ProductHiboutikService', () => { const stockHiboutikRepository = { getStocks: jest.fn() }; const productDatabaseRepository = { getAll: jest.fn(), upsert: jest.fn(), deleteAll: jest.fn() }; const productVersionDatabaseRepository = { upsert: jest.fn(), deleteMissing: jest.fn() }; + // The transaction client handed to the repositories inside $transaction + const tx = { transaction: true }; + const prisma = { $transaction: jest.fn() }; beforeAll(() => { jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); @@ -47,6 +48,7 @@ describe('ProductHiboutikService', () => { beforeEach(() => { jest.clearAllMocks(); calls = []; + prisma.$transaction.mockImplementation((run: (client: unknown) => Promise) => run(tx)); stockHiboutikRepository.getStocks.mockResolvedValue([]); productDatabaseRepository.getAll.mockResolvedValue([]); productDatabaseRepository.deleteAll.mockImplementation((ids: string[]) => { @@ -73,6 +75,7 @@ describe('ProductHiboutikService', () => { productVersionDatabaseRepository as never, new ProductConverter(), stockHiboutikRepository as never, + prisma as never, ); }); @@ -109,16 +112,17 @@ describe('ProductHiboutikService', () => { it('creates a single version for a product without sizes', async () => { productHiboutikRepository.getProducts.mockResolvedValue([hiboutikProduct(1)]); - stockHiboutikRepository.getStocks.mockResolvedValue([stock(1, 0, '4'), stock(2, 0, '99')]); + stockHiboutikRepository.getStocks.mockResolvedValue([stock(1, 0, 4), stock(2, 0, 99)]); await service.syncProducts(); - expect(productDatabaseRepository.upsert).toHaveBeenCalledWith(expect.objectContaining({ hiboutikId: 1, globalStock: 4 })); - expect(productVersionDatabaseRepository.deleteMissing).toHaveBeenCalledWith('uuid-1', [0]); + expect(productDatabaseRepository.upsert).toHaveBeenCalledWith(expect.objectContaining({ hiboutikId: 1, globalStock: 4 }), tx); + expect(productVersionDatabaseRepository.deleteMissing).toHaveBeenCalledWith('uuid-1', [0], tx); expect(productVersionDatabaseRepository.upsert).toHaveBeenCalledTimes(1); expect(productVersionDatabaseRepository.upsert).toHaveBeenCalledWith( 'uuid-1', expect.objectContaining({ hiboutikSizeId: 0, barcode: 'barcode-1', stock: 4, versionName: 'Product 1', discountPrice: null }), + tx, ); }); @@ -129,34 +133,44 @@ describe('ProductHiboutikService', () => { { size_id: 10, size_name: 'S', barcode: 'b-s' }, { size_id: 11, size_name: 'M', barcode: '' }, ], - product_specific_rules: [{ size_id: 10, barcode: 'b-s', product_price: '12.00', product_discount_price: '0.00' }], + product_specific_rules: [{ size_id: 10, product_price: '12.00', product_discount_price: '0.00' }], }), ]); - stockHiboutikRepository.getStocks.mockResolvedValue([stock(1, 10, '3'), stock(1, 11, '2')]); + stockHiboutikRepository.getStocks.mockResolvedValue([stock(1, 10, 3), stock(1, 11, 2)]); await service.syncProducts(); - expect(productDatabaseRepository.upsert).toHaveBeenCalledWith(expect.objectContaining({ globalStock: 5 })); - expect(productVersionDatabaseRepository.deleteMissing).toHaveBeenCalledWith('uuid-1', [10, 11]); + expect(productDatabaseRepository.upsert).toHaveBeenCalledWith(expect.objectContaining({ globalStock: 5 }), tx); + expect(productVersionDatabaseRepository.deleteMissing).toHaveBeenCalledWith('uuid-1', [10, 11], tx); expect(calls).toEqual(['deleteProducts', 'upsertProduct', 'deleteVersions', 'upsertVersion', 'upsertVersion']); - expect(productVersionDatabaseRepository.upsert).toHaveBeenNthCalledWith(1, 'uuid-1', { - barcode: 'b-s', - discountPrice: null, - hiboutikSizeId: 10, - price: '12.00', - product: { connect: { id: 'uuid-1' } }, - stock: 3, - versionName: 'S', - }); - expect(productVersionDatabaseRepository.upsert).toHaveBeenNthCalledWith(2, 'uuid-1', { - barcode: null, - discountPrice: null, - hiboutikSizeId: 11, - price: null, - product: { connect: { id: 'uuid-1' } }, - stock: 2, - versionName: 'M', - }); + expect(productVersionDatabaseRepository.upsert).toHaveBeenNthCalledWith( + 1, + 'uuid-1', + { + barcode: 'b-s', + discountPrice: null, + hiboutikSizeId: 10, + price: '12.00', + product: { connect: { id: 'uuid-1' } }, + stock: 3, + versionName: 'S', + }, + tx, + ); + expect(productVersionDatabaseRepository.upsert).toHaveBeenNthCalledWith( + 2, + 'uuid-1', + { + barcode: null, + discountPrice: null, + hiboutikSizeId: 11, + price: null, + product: { connect: { id: 'uuid-1' } }, + stock: 2, + versionName: 'M', + }, + tx, + ); }); it('keeps syncing the other products when one fails', async () => { @@ -168,4 +182,16 @@ describe('ProductHiboutikService', () => { expect(productDatabaseRepository.upsert).toHaveBeenCalledTimes(2); expect(productVersionDatabaseRepository.upsert).toHaveBeenCalledTimes(1); }); + + it('writes a product and its versions in one transaction per product, and counts a failed one', async () => { + productHiboutikRepository.getProducts.mockResolvedValue([hiboutikProduct(1), hiboutikProduct(2)]); + productVersionDatabaseRepository.upsert.mockRejectedValueOnce(new Error('boom')); + + await expect(service.syncProducts()).resolves.toBeUndefined(); + + expect(prisma.$transaction).toHaveBeenCalledTimes(2); + // the failure inside the transaction is what makes Prisma roll the product back + await expect(prisma.$transaction.mock.results[0].value).rejects.toThrow('boom'); + await expect(prisma.$transaction.mock.results[1].value).resolves.toBeUndefined(); + }); }); diff --git a/src/services/hiboutik/product.hiboutik.service.ts b/src/services/hiboutik/product.hiboutik.service.ts index e0172f7..a9d2ad0 100644 --- a/src/services/hiboutik/product.hiboutik.service.ts +++ b/src/services/hiboutik/product.hiboutik.service.ts @@ -9,6 +9,8 @@ import { StockHiboutikDto } from '../../models/hiboutikDTO/stock.hiboutik.dto'; import { Product } from '../../../prisma/generated/client'; import { ProductVersionCreateInput } from '../../../prisma/generated/models/ProductVersion'; import { parseOptionalHiboutikPrice } from '../../utils/price'; +import { PrismaService } from '../../../prisma/prisma.service'; +import { rethrowIfRateLimited } from '../../hiboutik/hiboutik.client'; /** Version id used for products that have no sizes in Hiboutik. */ const NO_SIZE_ID = 0; @@ -23,6 +25,7 @@ export class ProductHiboutikService { private readonly productVersionDatabaseRepository: ProductVersionDatabaseRepository, private readonly productConverter: ProductConverter, private readonly stockHiboutikRepository: StockHiboutikRepository, + private readonly prisma: PrismaService, ) {} /** Mirrors Hiboutik products, their versions (sizes) and stocks. Products missing from Hiboutik are deleted. */ @@ -33,16 +36,24 @@ export class ProductHiboutikService { this.productDatabaseRepository.getAll(), ]); - // Deletion happens before upserts so that a product recreated in Hiboutik (new id, same barcode) - // does not collide with the old one on the unique barcode. + // Deletion happens before the upserts, so that order lines of a deleted product are unlinked before anything else changes const nbDeleted = await this.deleteMissingProducts(productsHiboutik, existingProducts); + // Grouped once by product: one pass over the stocks instead of one per product + const stocksByProduct = new Map(); + for (const stock of hiboutikStocks) { + const productStocks = stocksByProduct.get(stock.product_id); + if (productStocks) productStocks.push(stock); + else stocksByProduct.set(stock.product_id, [stock]); + } + let nbFailed = 0; for (const productHiboutik of productsHiboutik) { - const hiboutikProductStocks = hiboutikStocks.filter((stock) => stock.product_id === Number(productHiboutik.product_id)); + const hiboutikProductStocks = stocksByProduct.get(productHiboutik.product_id) ?? []; try { await this.updateProduct(productHiboutik, hiboutikProductStocks); } catch (error) { + rethrowIfRateLimited(error); nbFailed++; this.logger.error(`Failed to sync Hiboutik product ${productHiboutik.product_id}`, (error as Error)?.stack); } @@ -56,27 +67,35 @@ export class ProductHiboutikService { /** @returns the number of deleted products. */ private async deleteMissingProducts(productsHiboutik: ProductHiboutikDto[], existingProducts: Product[]): Promise { // Hiboutik is trusted: a successful (2xx) answer, even an empty list, is the truth. An HTTP error throws before this point. - const hiboutikIds = new Set(productsHiboutik.map((product) => Number(product.product_id))); + const hiboutikIds = new Set(productsHiboutik.map((product) => product.product_id)); const missingProductIds = existingProducts.filter((product) => !hiboutikIds.has(product.hiboutikId)).map((product) => product.id); return this.productDatabaseRepository.deleteAll(missingProductIds); } - /** Upserts a product with its global stock, then replaces its versions. */ + /** + * Upserts a product with its global stock, then replaces its versions, in a single transaction: on failure, nothing + * is changed (no global stock out of step with the versions). + */ private async updateProduct(productHiboutik: ProductHiboutikDto, productStocks: StockHiboutikDto[]): Promise { const newProduct = this.productConverter.fromHiboutik(productHiboutik); - newProduct.globalStock = productStocks.map((stock) => Number(stock.stock_available)).reduce((sum, stock) => sum + stock, 0); + newProduct.globalStock = productStocks.reduce((sum, stock) => sum + stock.stock_available, 0); - const savedProduct: Product = await this.productDatabaseRepository.upsert(newProduct); - const versions = this.buildVersions(productHiboutik, productStocks, savedProduct); + await this.prisma.$transaction( + async (tx) => { + const savedProduct: Product = await this.productDatabaseRepository.upsert(newProduct, tx); + const versions = this.buildVersions(productHiboutik, productStocks, savedProduct); - // Same reason as for products: delete removed versions first to free their barcodes - await this.productVersionDatabaseRepository.deleteMissing( - savedProduct.id, - versions.map((version) => version.hiboutikSizeId), + await this.productVersionDatabaseRepository.deleteMissing( + savedProduct.id, + versions.map((version) => version.hiboutikSizeId), + tx, + ); + for (const version of versions) { + await this.productVersionDatabaseRepository.upsert(savedProduct.id, version, tx); + } + }, + { timeout: 15_000 }, ); - for (const version of versions) { - await this.productVersionDatabaseRepository.upsert(savedProduct.id, version); - } } /** @@ -88,8 +107,8 @@ export class ProductHiboutikService { productStocks: StockHiboutikDto[], savedProduct: Product, ): ProductVersionCreateInput[] { - const specificRules = productHiboutik.product_specific_rules ?? []; - const sizesDetails = productHiboutik.product_size_details ?? []; + const specificRules = productHiboutik.product_specific_rules; + const sizesDetails = productHiboutik.product_size_details; if (sizesDetails.length === 0) { return [ @@ -106,8 +125,8 @@ export class ProductHiboutikService { } return sizesDetails.map((sizeDetails) => { - const sizeId = Number(sizeDetails.size_id); - const specificRule = specificRules.find((rule) => Number(rule.size_id) === sizeId); + const sizeId = sizeDetails.size_id; + const specificRule = specificRules.find((rule) => rule.size_id === sizeId); const stock = productStocks.find((stock) => stock.product_size === sizeId); // Values are always set explicitly (null / 0 rather than undefined), otherwise Prisma would keep // the previous value on update when a rule or a stock is removed in Hiboutik. @@ -117,7 +136,7 @@ export class ProductHiboutikService { hiboutikSizeId: sizeId, price: parseOptionalHiboutikPrice(specificRule?.product_price), product: { connect: { id: savedProduct.id } }, - stock: stock ? Number(stock.stock_available) : 0, + stock: stock?.stock_available ?? 0, versionName: sizeDetails.size_name, }; }); diff --git a/src/services/hiboutik/supplier.hiboutik.service.ts b/src/services/hiboutik/supplier.hiboutik.service.ts index 9353aae..0bf6f01 100644 --- a/src/services/hiboutik/supplier.hiboutik.service.ts +++ b/src/services/hiboutik/supplier.hiboutik.service.ts @@ -26,7 +26,7 @@ export class SupplierHiboutikService { ); // Hiboutik is trusted: a successful (2xx) answer, even an empty list, is the truth. An HTTP error throws before this point. - const nbDeleted = await this.supplierDatabaseRepository.deleteMissing(hiboutikSuppliers.map((dto) => Number(dto.supplier_id))); + const nbDeleted = await this.supplierDatabaseRepository.deleteMissing(hiboutikSuppliers.map((dto) => dto.supplier_id)); this.logger.log( `Suppliers sync ended: ${hiboutikSuppliers.length - nbFailed}/${hiboutikSuppliers.length} synced, ${nbFailed} failed, ${nbDeleted} deleted.`, ); diff --git a/src/utils/date.spec.ts b/src/utils/date.spec.ts index 58f4198..d724d93 100644 --- a/src/utils/date.spec.ts +++ b/src/utils/date.spec.ts @@ -1,15 +1,35 @@ import { parseHiboutikDate } from './date'; describe('parseHiboutikDate', () => { - it('parses a Hiboutik datetime', () => { - expect(parseHiboutikDate('2026-06-28 17:02:55')).toEqual(new Date('2026-06-28T17:02:55')); + it('parses a Hiboutik datetime as French summer time (UTC+2)', () => { + expect(parseHiboutikDate('2026-06-28 17:02:55')).toEqual(new Date('2026-06-28T15:02:55Z')); }); - it('parses a date without time', () => { - expect(parseHiboutikDate('1990-05-12')).toEqual(new Date('1990-05-12')); + it('parses a Hiboutik datetime as French winter time (UTC+1)', () => { + expect(parseHiboutikDate('2026-01-15 10:00:00')).toEqual(new Date('2026-01-15T09:00:00Z')); }); - it.each(['', '0000-00-00', '0000-00-00 00:00:00', 'not a date', null, undefined])('returns undefined for %p', (value) => { + it('switches offset at the DST changes', () => { + expect(parseHiboutikDate('2026-03-29 01:59:59')).toEqual(new Date('2026-03-29T00:59:59Z')); + expect(parseHiboutikDate('2026-03-29 03:00:00')).toEqual(new Date('2026-03-29T01:00:00Z')); + expect(parseHiboutikDate('2026-10-25 03:00:00')).toEqual(new Date('2026-10-25T02:00:00Z')); + }); + + it('parses a date without time as midnight UTC (calendar date)', () => { + expect(parseHiboutikDate('1990-05-12')).toEqual(new Date('1990-05-12T00:00:00Z')); + }); + + it.each([ + '', + '0000-00-00', + '0000-00-00 00:00:00', + '2026-02-30', + '2026-13-01 10:00:00', + '2026-06-28 25:00:00', + 'not a date', + null, + undefined, + ])('returns undefined for %p', (value) => { expect(parseHiboutikDate(value)).toBeUndefined(); }); }); diff --git a/src/utils/date.ts b/src/utils/date.ts index 34bf91a..eeb64ae 100644 --- a/src/utils/date.ts +++ b/src/utils/date.ts @@ -1,10 +1,53 @@ +/** Time zone of the dates sent by Hiboutik: the shop's local (French) time, without any offset. */ +export const HIBOUTIK_TIME_ZONE = 'Europe/Paris'; + +const HIBOUTIK_DATE = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?$/; + +const timeZoneFormatter = new Intl.DateTimeFormat('en-US', { + timeZone: HIBOUTIK_TIME_ZONE, + hourCycle: 'h23', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', +}); + +/** Offset (ms) of {@link HIBOUTIK_TIME_ZONE} from UTC at the given instant: +1 h in winter, +2 h in summer. */ +function timeZoneOffset(instant: number): number { + const parts = Object.fromEntries(timeZoneFormatter.formatToParts(new Date(instant)).map((part) => [part.type, Number(part.value)])); + const wallClockAsUtc = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second); + return wallClockAsUtc - instant; +} + /** - * Parses a Hiboutik date ("YYYY-MM-DD HH:mm:ss" or "YYYY-MM-DD"). - * Returns undefined for empty or invalid dates (e.g. "0000-00-00"). + * Parses a Hiboutik date. Returns undefined for empty or invalid dates (e.g. "0000-00-00", "2026-02-30"). + * - "YYYY-MM-DD HH:mm:ss": French local time ({@link HIBOUTIK_TIME_ZONE}), whatever the time zone of the server. + * A time that is ambiguous or skipped at a DST change (02:00-03:00) resolves to a nearby valid instant. + * - "YYYY-MM-DD" (calendar date, e.g. a date of birth): midnight UTC, so that the stored date is the same day. */ export function parseHiboutikDate(value: string | null | undefined): Date | undefined { - if (!value) return undefined; + const match = HIBOUTIK_DATE.exec(value?.trim() ?? ''); + if (!match) return undefined; - const date = new Date(value.trim().replace(' ', 'T')); - return isNaN(date.getTime()) ? undefined : date; + const [year, month, day, hour, minute, second] = match.slice(1).map((part) => Number(part ?? 0)); + const wallClockAsUtc = Date.UTC(year, month - 1, day, hour, minute, second); + + // Rejects out-of-range values (month 0 or 13, February 30, 25:00...), which Date.UTC would silently roll over + const check = new Date(wallClockAsUtc); + const isValid = + check.getUTCFullYear() === year && + check.getUTCMonth() === month - 1 && + check.getUTCDate() === day && + check.getUTCHours() === hour && + check.getUTCMinutes() === minute && + check.getUTCSeconds() === second; + if (!isValid) return undefined; + + if (match[4] === undefined) return check; + + // The offset depends on the instant itself (DST): guess it from the wall clock, then correct it once + const firstGuess = wallClockAsUtc - timeZoneOffset(wallClockAsUtc); + return new Date(wallClockAsUtc - timeZoneOffset(firstGuess)); } diff --git a/src/utils/sync.ts b/src/utils/sync.ts index d35a6d1..454bc70 100644 --- a/src/utils/sync.ts +++ b/src/utils/sync.ts @@ -1,7 +1,9 @@ import { Logger } from '@nestjs/common'; +import { rethrowIfRateLimited } from '../hiboutik/hiboutik.client'; /** * Runs `action` on each item, one at a time: a failing item is logged and skipped, the others are still processed. + * A Hiboutik rate limit error stops the loop (rethrown): the next items would fail the same way. * @param items * @param action * @param logger @@ -19,6 +21,7 @@ export async function forEachSafely( try { await action(item); } catch (error) { + rethrowIfRateLimited(error); nbFailed++; logger.error(`Failed to sync ${describe(item)}`, (error as Error)?.stack); } diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 8051759..8fe3687 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -10,14 +10,18 @@ import { PrismaService } from './../prisma/prisma.service'; describe('AppController (e2e)', () => { let app: INestApplication; const hiboutikService = { startSync: jest.fn() }; + // Rate limiting counters (ThrottlerDatabaseStorage): the database answers "not blocked" unless a test says otherwise + const notBlocked = { totalHits: 1, timeToExpire: 60, isBlocked: false, timeToBlockExpire: 0 }; + const prisma = { $queryRaw: jest.fn(), throttlerRecord: { deleteMany: jest.fn().mockResolvedValue({ count: 0 }) } }; beforeEach(async () => { hiboutikService.startSync.mockReset(); + prisma.$queryRaw.mockReset().mockResolvedValue([notBlocked]); const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], }) .overrideProvider(PrismaService) - .useValue({}) + .useValue(prisma) .overrideProvider(HiboutikService) .useValue(hiboutikService) .compile(); @@ -36,6 +40,13 @@ describe('AppController (e2e)', () => { return request(app.getHttpServer()).post('/sync').expect(409); }); + it('POST /sync answers 429 when the shared rate limiting counter is blocked', async () => { + prisma.$queryRaw.mockResolvedValue([{ totalHits: 6, timeToExpire: 30, isBlocked: true, timeToBlockExpire: 30 }]); + + await request(app.getHttpServer()).post('/sync').expect(429).expect('Retry-After', '30'); + expect(hiboutikService.startSync).not.toHaveBeenCalled(); + }); + afterEach(async () => { await app.close(); }); diff --git a/tsconfig.json b/tsconfig.json index aba29b0..f5d7532 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,10 +16,8 @@ "baseUrl": "./", "incremental": true, "skipLibCheck": true, - "strictNullChecks": true, + "strict": true, "forceConsistentCasingInFileNames": true, - "noImplicitAny": false, - "strictBindCallApply": false, "noFallthroughCasesInSwitch": false } }