feat(sync): harden the Hiboutik sync, share rate limits across instances, update dependencies (#4)

This commit is contained in:
2026-11-08 13:45:08 +00:00
parent 04960379e8
commit d07c4df52d
62 changed files with 1546 additions and 639 deletions

View File

@@ -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<BrandHiboutikDto[]> {
return this.hiboutikClient.getAllPages<BrandHiboutikDto>('/brands', {
return this.hiboutikClient.getAllPagesForSync('/brands', brandHiboutikSchema, (brand) => brand.brand_id, {
order_by: 'brand_id',
sort: 'ASC',
});

View File

@@ -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<CategoryHiboutikDto[]> {
return this.hiboutikClient.getAllPages<CategoryHiboutikDto>('/categories', {
order_by: 'category_id_parent',
return this.hiboutikClient.getAllPagesForSync('/categories', categoryHiboutikSchema, (category) => category.category_id, {
order_by: 'category_id',
sort: 'ASC',
});
}

View File

@@ -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<CustomerHiboutikDto[]> {
return this.hiboutikClient.getAllPages<CustomerHiboutikDto>('/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<FullCustomerHiboutikDto | null> {
return this.hiboutikClient.getOne(`/customer/${id}`, fullCustomerHiboutikSchema);
}
/** Same as {@link findCustomerById}, but a missing customer throws. */
async getCustomerById(id: number): Promise<FullCustomerHiboutikDto> {
const data = await this.hiboutikClient.get<FullCustomerHiboutikDto[]>('/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<CustomerAddressDetailsHiboutikDto> {
const data = await this.hiboutikClient.get<CustomerAddressDetailsHiboutikDto[]>('/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;
}

View File

@@ -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<void> }, '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');
});
});
});

View File

@@ -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<string>('HIBOUTIK_BASE_URL') ?? '';
}
/** GET on a Hiboutik path (relative to `HIBOUTIK_BASE_URL`), returns the response body. */
async get<T>(path: string, params?: Record<string, unknown>): Promise<T> {
const response = await firstValueFrom(
this.httpService.get<T>(`${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<string, unknown>): Promise<unknown> {
for (let attempt = 0; ; attempt++) {
try {
const response = await firstValueFrom(
this.httpService.get<unknown>(`${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<void> {
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<T>(path: string, params?: Record<string, unknown>): Promise<T[]> {
const data = await this.get<T[] | '' | null | undefined>(path, params);
async getList<T extends z.ZodType>(path: string, itemSchema: T, params?: Record<string, unknown>): Promise<z.output<T>[]> {
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<T>(path: string, baseParams: Record<string, unknown> = {}): Promise<T[]> {
const results: T[] = [];
async getAllPages<T extends z.ZodType>(path: string, itemSchema: T, baseParams: Record<string, unknown> = {}): Promise<z.output<T>[]> {
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<T extends z.ZodType>(
path: string,
itemSchema: T,
idOf: (item: z.output<T>) => number,
baseParams: Record<string, unknown> = {},
): Promise<z.output<T>[]> {
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<number, z.output<T>>();
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<T extends z.ZodType>(
path: string,
itemSchema: T,
baseParams: Record<string, unknown>,
): Promise<{ items: z.output<T>[]; nbPages: number }> {
const items: z.output<T>[] = [];
let page = 1;
while (true) {
const pageData = await this.getList<T>(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<T extends z.ZodType>(path: string, schema: T): Promise<z.output<T> | 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;
}
}
}

View File

@@ -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<ProductHiboutikDto[]> {
return this.hiboutikClient.getAllPages<ProductHiboutikDto>('/products', {
return this.hiboutikClient.getAllPagesForSync('/products', productHiboutikSchema, (product) => product.product_id, {
order_by: 'product_id',
sort: 'ASC',
});

View File

@@ -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<SaleHiboutikDto> {
const data = await this.hiboutikClient.get<SaleHiboutikDto[]>('/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;
}

View File

@@ -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<StockHiboutikDto[]> {
const stocks = await this.hiboutikClient.getList<StockHiboutikDto>('/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);
}
}

View File

@@ -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<SupplierHiboutikDto[]> {
return this.hiboutikClient.getList<SupplierHiboutikDto>('/suppliers', {
return this.hiboutikClient.getList('/suppliers', supplierHiboutikSchema, {
order_by: 'supplier_id',
sort: 'ASC',
});