feat(sync): harden the Hiboutik sync, share rate limits across instances, update dependencies #4
20
CLAUDE.md
20
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<Model, Dto>`; `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.
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>): Record<string, unk
|
||||
const validated = plainToInstance(EnvironmentVariables, config, { enableImplicitConversion: true });
|
||||
const errors = validateSync(validated, { whitelist: false });
|
||||
|
||||
if (errors.length > 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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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<ReleaseLock | null> {
|
||||
const client = new Client({ connectionString: this.configService.get<string>('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<string>('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();
|
||||
|
||||
56
src/database/throttlerStorage.database.repository.spec.ts
Normal file
56
src/database/throttlerStorage.database.repository.spec.ts
Normal file
@@ -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'));
|
||||
});
|
||||
});
|
||||
90
src/database/throttlerStorage.database.repository.ts
Normal file
90
src/database/throttlerStorage.database.repository.ts
Normal file
@@ -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<ReturnType<ThrottlerStorage['increment']>>;
|
||||
|
||||
/** 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<ThrottlerStorageRecord> {
|
||||
try {
|
||||
const [record] = await this.prisma.$queryRaw<ThrottlerStorageRecord[]>`
|
||||
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<void> {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
@@ -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<void> }, '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<void>((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<void>(() => 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void> | 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<number>('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<boolean> {
|
||||
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<void> {
|
||||
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<true>((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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,14 +10,18 @@ import { PrismaService } from './../prisma/prisma.service';
|
||||
describe('AppController (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user