feat(sync): synchronize Hiboutik data through POST /sync

This commit is contained in:
MastersGame
2026-11-05 23:00:49 +01:00
parent 60cd4cc1d6
commit 358e99c1ba
17 changed files with 878 additions and 34 deletions

View File

@@ -1,22 +1,29 @@
import { ConflictException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { HiboutikService } from './services/hiboutik/hiboutik.service';
describe('AppController', () => {
let appController: AppController;
let controller: AppController;
const hiboutikService = { startSync: jest.fn() };
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
hiboutikService.startSync.mockReset();
const module: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
providers: [{ provide: HiboutikService, useValue: hiboutikService }],
}).compile();
appController = app.get<AppController>(AppController);
controller = module.get(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
it('starts a synchronization', () => {
hiboutikService.startSync.mockReturnValue(true);
expect(controller.hiboutikSync()).toEqual({ message: 'Hiboutik synchronization started' });
expect(hiboutikService.startSync).toHaveBeenCalledTimes(1);
});
it('rejects a synchronization when one is already running', () => {
hiboutikService.startSync.mockReturnValue(false);
expect(() => controller.hiboutikSync()).toThrow(ConflictException);
});
});

View File

@@ -1,12 +1,17 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
import { ConflictException, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { HiboutikService } from './services/hiboutik/hiboutik.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
constructor(private readonly hiboutikService: HiboutikService) {}
@Get()
getHello(): string {
return this.appService.getHello();
/** Starts the synchronization in the background and answers immediately: 202, or 409 if one is already running. */
@Post('sync')
@HttpCode(HttpStatus.ACCEPTED)
hiboutikSync() {
if (!this.hiboutikService.startSync()) {
throw new ConflictException('A Hiboutik synchronization is already in progress');
}
return { message: 'Hiboutik synchronization started' };
}
}

View File

@@ -1,10 +1,67 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ConfigModule } from '@nestjs/config';
import { HiboutikService } from './services/hiboutik/hiboutik.service';
import { CategoryHiboutikRepository } from './hiboutik/category.hiboutik.repository';
import { CategoryConverter } from './models/converter/category.converter';
import { ProductHiboutikRepository } from './hiboutik/product.hiboutik.repository';
import { HttpModule } from '@nestjs/axios';
import { CategoryDatabaseRepository } from './database/category.database.repository';
import { PrismaModule } from '../prisma/prisma.module';
import { HiboutikClient } from './hiboutik/hiboutik.client';
import { BrandHiboutikRepository } from './hiboutik/brand.hiboutik.repository';
import { BrandDatabaseRepository } from './database/brand.database.repository';
import { BrandConverter } from './models/converter/brand.converter';
import { SupplierHiboutikRepository } from './hiboutik/supplier.hiboutik.repository';
import { SupplierDatabaseRepository } from './database/supplier.database.repository';
import { SupplierConverter } from './models/converter/supplier.converter';
import { StockHiboutikRepository } from './hiboutik/stock.hiboutik.repository';
import { ProductConverter } from './models/converter/product.converter';
import { ProductDatabaseRepository } from './database/product.database.repository';
import { ProductVersionDatabaseRepository } from './database/productVersion.database.repository';
import { CustomerHiboutikRepository } from './hiboutik/customer.hiboutik.repository';
import { CustomerDatabaseRepository } from './database/customer.database.repository';
import { CustomerConverter } from './models/converter/customer.converter';
import { BrandHiboutikService } from './services/hiboutik/brand.hiboutik.service';
import { CategoryHiboutikService } from './services/hiboutik/category.hiboutik.service';
import { CustomerHiboutikService } from './services/hiboutik/customer.hiboutik.service';
import { SupplierHiboutikService } from './services/hiboutik/supplier.hiboutik.service';
import { ProductHiboutikService } from './services/hiboutik/product.hiboutik.service';
@Module({
imports: [],
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
HttpModule.register({ timeout: 30_000 }),
PrismaModule,
],
controllers: [AppController],
providers: [AppService],
providers: [
BrandConverter,
BrandDatabaseRepository,
BrandHiboutikRepository,
BrandHiboutikService,
CategoryConverter,
CategoryDatabaseRepository,
CategoryHiboutikRepository,
CategoryHiboutikService,
CustomerHiboutikRepository,
CustomerDatabaseRepository,
CustomerConverter,
CustomerHiboutikService,
HiboutikClient,
HiboutikService,
ProductConverter,
ProductDatabaseRepository,
ProductHiboutikRepository,
ProductHiboutikService,
ProductVersionDatabaseRepository,
StockHiboutikRepository,
SupplierConverter,
SupplierDatabaseRepository,
SupplierHiboutikRepository,
SupplierHiboutikService,
],
})
export class AppModule {}

View File

@@ -1,8 +0,0 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

View File

@@ -5,4 +5,4 @@ async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
void bootstrap();

View File

@@ -0,0 +1,38 @@
import { Injectable, Logger } from '@nestjs/common';
import { BrandHiboutikRepository } from '../../hiboutik/brand.hiboutik.repository';
import { BrandDatabaseRepository } from '../../database/brand.database.repository';
import { BrandConverter } from '../../models/converter/brand.converter';
import { forEachSafely } from '../../utils/sync';
@Injectable()
export class BrandHiboutikService {
private readonly logger = new Logger('BrandHiboutikService');
constructor(
private readonly brandHiboutikRepository: BrandHiboutikRepository,
private readonly brandDatabaseRepository: BrandDatabaseRepository,
private readonly brandConverter: BrandConverter,
) {}
/** Mirrors Hiboutik brands: upserts each of them, then deletes the brands missing from Hiboutik. */
async syncBrands() {
const hiboutikBrands = await this.brandHiboutikRepository.getBrands();
// One upsert per brand: an invalid brand must not prevent the others from being saved
const nbFailed = await forEachSafely(
this.brandConverter.fromHiboutikList(hiboutikBrands),
(brand) => this.brandDatabaseRepository.upsert(brand),
this.logger,
(brand) => `Hiboutik brand ${brand.hiboutikId}`,
);
// Safety net: an empty answer from Hiboutik most likely means an API issue
if (hiboutikBrands.length === 0) {
this.logger.warn('Hiboutik returned no brands: deletion skipped.');
return;
}
const nbDeleted = await this.brandDatabaseRepository.deleteMissing(hiboutikBrands.map((dto) => Number(dto.brand_id)));
this.logger.log(
`Brands sync ended: ${hiboutikBrands.length - nbFailed}/${hiboutikBrands.length} synced, ${nbFailed} failed, ${nbDeleted} deleted.`,
);
}
}

View File

@@ -0,0 +1,89 @@
import { Logger } from '@nestjs/common';
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> = {}): 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;
describe('CategoryHiboutikService', () => {
const categoryHiboutikRepository = { getCategories: jest.fn() };
const categoryDatabaseRepository = { upsert: jest.fn(), setParent: jest.fn(), deleteMissing: jest.fn() };
let service: CategoryHiboutikService;
let calls: string[];
beforeAll(() => {
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
});
beforeEach(() => {
jest.clearAllMocks();
calls = [];
categoryDatabaseRepository.upsert.mockImplementation((category: { hiboutikId: number }) => {
calls.push(`upsert ${category.hiboutikId}`);
return Promise.resolve({});
});
categoryDatabaseRepository.setParent.mockImplementation((id: number) => {
calls.push(`parent ${id}`);
return Promise.resolve({});
});
categoryDatabaseRepository.deleteMissing.mockResolvedValue(0);
service = new CategoryHiboutikService(
categoryHiboutikRepository as never,
categoryDatabaseRepository as never,
new CategoryConverter(),
);
});
it('stores position and web visibility', async () => {
categoryHiboutikRepository.getCategories.mockResolvedValue([hiboutikCategory(1, 0)]);
await service.syncCategories();
expect(categoryDatabaseRepository.upsert).toHaveBeenCalledWith({
hiboutikId: 1,
name: 'Category 1',
enabled: true,
position: 3,
webEnabled: false,
});
});
it('links parents once every category is saved, a child being listed before its parent', async () => {
categoryHiboutikRepository.getCategories.mockResolvedValue([hiboutikCategory(2, 3), hiboutikCategory(3, 0)]);
await service.syncCategories();
expect(calls).toEqual(['upsert 2', 'upsert 3', 'parent 2', 'parent 3']);
expect(categoryDatabaseRepository.setParent).toHaveBeenCalledWith(2, 3);
expect(categoryDatabaseRepository.setParent).toHaveBeenCalledWith(3, null);
});
it('keeps syncing the other categories when one fails, then deletes the missing ones', async () => {
categoryHiboutikRepository.getCategories.mockResolvedValue([hiboutikCategory(1, 0), hiboutikCategory(2, 0)]);
categoryDatabaseRepository.upsert.mockRejectedValueOnce(new Error('boom'));
await expect(service.syncCategories()).resolves.toBeUndefined();
expect(categoryDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
expect(categoryDatabaseRepository.deleteMissing).toHaveBeenCalledWith([1, 2]);
});
it('does not delete anything when Hiboutik returns no categories', async () => {
categoryHiboutikRepository.getCategories.mockResolvedValue([]);
await service.syncCategories();
expect(categoryDatabaseRepository.deleteMissing).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,51 @@
import { Injectable, Logger } from '@nestjs/common';
import { CategoryHiboutikRepository } from '../../hiboutik/category.hiboutik.repository';
import { CategoryDatabaseRepository } from '../../database/category.database.repository';
import { CategoryConverter } from '../../models/converter/category.converter';
import { forEachSafely } from '../../utils/sync';
@Injectable()
export class CategoryHiboutikService {
private readonly logger = new Logger('CategoryHiboutikService');
constructor(
private readonly categoryHiboutikRepository: CategoryHiboutikRepository,
private readonly categoryDatabaseRepository: CategoryDatabaseRepository,
private readonly categoryConverter: CategoryConverter,
) {}
/**
* Mirrors Hiboutik categories and their tree: upserts each of them, links them to their parent,
* then deletes the categories missing from Hiboutik.
*/
async syncCategories() {
const hiboutikCategories = await this.categoryHiboutikRepository.getCategories();
// Pass 1: categories themselves. One upsert per category: an invalid category must not prevent the others from being saved
const nbFailed = await forEachSafely(
this.categoryConverter.fromHiboutikList(hiboutikCategories),
(category) => this.categoryDatabaseRepository.upsert(category),
this.logger,
(category) => `Hiboutik category ${category.hiboutikId}`,
);
// 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),
this.logger,
(dto) => `parent of Hiboutik category ${dto.category_id}`,
);
// Safety net: an empty answer from Hiboutik most likely means an API issue
if (hiboutikCategories.length === 0) {
this.logger.warn('Hiboutik returned no categories: deletion skipped.');
return;
}
const nbDeleted = await this.categoryDatabaseRepository.deleteMissing(hiboutikCategories.map((dto) => Number(dto.category_id)));
this.logger.log(
`Categories sync ended: ${hiboutikCategories.length - nbFailed}/${hiboutikCategories.length} synced, ${nbFailed} failed, ` +
`${nbParentsFailed} parent links failed, ${nbDeleted} deleted.`,
);
}
}

View File

@@ -0,0 +1,51 @@
import { Logger } from '@nestjs/common';
import { CustomerHiboutikService } from './customer.hiboutik.service';
describe('CustomerHiboutikService', () => {
const customerHiboutikRepository = { getCustomers: jest.fn(), getCustomerById: jest.fn() };
const customerDatabaseRepository = { upsert: jest.fn(), deleteMissing: jest.fn() };
const customerConverter = { fromHiboutik: jest.fn() };
let service: CustomerHiboutikService;
beforeAll(() => {
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
});
beforeEach(() => {
jest.clearAllMocks();
customerHiboutikRepository.getCustomers.mockResolvedValue([]);
customerHiboutikRepository.getCustomerById.mockResolvedValue({});
customerDatabaseRepository.deleteMissing.mockResolvedValue(0);
service = new CustomerHiboutikService(
customerHiboutikRepository as never,
customerDatabaseRepository as never,
customerConverter as never,
);
});
it('deletes customers missing from Hiboutik', async () => {
customerHiboutikRepository.getCustomers.mockResolvedValue([{ customers_id: 1 }, { customers_id: '2' }]);
await service.syncCustomers();
expect(customerDatabaseRepository.deleteMissing).toHaveBeenCalledWith([1, 2]);
expect(customerDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
});
it('does not delete customers when Hiboutik returns none', async () => {
await service.syncCustomers();
expect(customerDatabaseRepository.deleteMissing).not.toHaveBeenCalled();
});
it('keeps syncing the other customers when one fails', async () => {
customerHiboutikRepository.getCustomers.mockResolvedValue([{ customers_id: 1 }, { customers_id: 2 }]);
customerHiboutikRepository.getCustomerById.mockRejectedValueOnce(new Error('boom'));
await expect(service.syncCustomers()).resolves.toBeUndefined();
expect(customerDatabaseRepository.upsert).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,49 @@
import { Injectable, Logger } from '@nestjs/common';
import { CustomerHiboutikRepository } from '../../hiboutik/customer.hiboutik.repository';
import { CustomerDatabaseRepository } from '../../database/customer.database.repository';
import { CustomerConverter } from '../../models/converter/customer.converter';
@Injectable()
export class CustomerHiboutikService {
private readonly logger = new Logger('CustomerHiboutikService');
constructor(
private readonly customerHiboutikRepository: CustomerHiboutikRepository,
private readonly customerDatabaseRepository: CustomerDatabaseRepository,
private readonly customerConverter: CustomerConverter,
) {}
/**
* Mirrors Hiboutik customers: deletes the customers missing from Hiboutik, then fetches and upserts each of them
* (one API call per customer, as the list endpoint lacks addresses and sales).
*/
async syncCustomers() {
const hiboutikCustomers = await this.customerHiboutikRepository.getCustomers();
// Safety net: an empty answer from Hiboutik most likely means an API issue
let nbDeleted = 0;
if (hiboutikCustomers.length === 0) {
this.logger.warn('Hiboutik returned no customers: deletion skipped.');
} else {
nbDeleted = await this.customerDatabaseRepository.deleteMissing(hiboutikCustomers.map((dto) => Number(dto.customers_id)));
}
let nbFailed = 0;
for (const hiboutikCustomer of hiboutikCustomers) {
try {
const hiboutikFullCustomer = await this.customerHiboutikRepository.getCustomerById(hiboutikCustomer.customers_id);
await this.customerDatabaseRepository.upsert(this.customerConverter.fromHiboutik(hiboutikFullCustomer));
// retreive addresses DTOs
// save addresses
// retreive orders DTOs
// save orders
} catch (error) {
nbFailed++;
this.logger.error(`Failed to sync Hiboutik customer ${hiboutikCustomer.customers_id}`, (error as Error)?.stack);
}
}
this.logger.log(
`Customers sync ended: ${hiboutikCustomers.length - nbFailed}/${hiboutikCustomers.length} synced, ${nbFailed} failed, ${nbDeleted} deleted.`,
);
}
}

View File

@@ -0,0 +1,68 @@
import { Logger } from '@nestjs/common';
import { HiboutikService } from './hiboutik.service';
describe('HiboutikService', () => {
const brandService = { syncBrands: jest.fn() };
const categoryService = { syncCategories: jest.fn() };
const supplierService = { syncSuppliers: jest.fn() };
const productService = { syncProducts: jest.fn() };
const customerService = { syncCustomers: jest.fn() };
let service: HiboutikService;
beforeAll(() => {
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
});
beforeEach(() => {
jest.clearAllMocks();
brandService.syncBrands.mockResolvedValue(undefined);
categoryService.syncCategories.mockResolvedValue(undefined);
supplierService.syncSuppliers.mockResolvedValue(undefined);
productService.syncProducts.mockResolvedValue(undefined);
customerService.syncCustomers.mockResolvedValue(undefined);
service = new HiboutikService(
brandService as never,
categoryService as never,
customerService as never,
productService as never,
supplierService as never,
);
});
it('runs the steps in dependency order', async () => {
const order: string[] = [];
brandService.syncBrands.mockImplementation(() => Promise.resolve(order.push('brands')));
categoryService.syncCategories.mockImplementation(() => Promise.resolve(order.push('categories')));
supplierService.syncSuppliers.mockImplementation(() => Promise.resolve(order.push('suppliers')));
productService.syncProducts.mockImplementation(() => Promise.resolve(order.push('products')));
customerService.syncCustomers.mockImplementation(() => Promise.resolve(order.push('customers')));
await service.syncAll();
expect(order).toEqual(['brands', 'categories', 'suppliers', 'products', 'customers']);
});
it('continues with the next steps when one fails', async () => {
brandService.syncBrands.mockRejectedValue(new Error('boom'));
await service.syncAll();
expect(productService.syncProducts).toHaveBeenCalled();
expect(customerService.syncCustomers).toHaveBeenCalled();
});
it('refuses a second synchronization while one is running, and unlocks at the end', async () => {
let finishBrands: () => void = () => undefined;
brandService.syncBrands.mockReturnValue(new Promise<void>((resolve) => (finishBrands = resolve)));
expect(service.startSync()).toBe(true);
expect(service.isSyncInProgress).toBe(true);
expect(service.startSync()).toBe(false);
finishBrands();
await new Promise((resolve) => setImmediate(resolve));
expect(service.isSyncInProgress).toBe(false);
});
});

View File

@@ -0,0 +1,69 @@
import { Injectable, Logger } from '@nestjs/common';
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';
/** Entry point of the Hiboutik → database synchronization: runs each entity sync in dependency order. */
@Injectable()
export class HiboutikService {
private readonly logger = new Logger('HiboutikService');
/** In-memory lock: only valid while the app runs as a single instance. */
private syncInProgress = false;
constructor(
private readonly brandHiboutikService: BrandHiboutikService,
private readonly categoryHiboutikService: CategoryHiboutikService,
private readonly customerHiboutikService: CustomerHiboutikService,
private readonly productHiboutikService: ProductHiboutikService,
private readonly supplierHiboutikService: SupplierHiboutikService,
) {}
get isSyncInProgress(): boolean {
return this.syncInProgress;
}
/**
* Starts a full synchronization in the background.
* @returns false if a synchronization is already running (nothing is started).
*/
startSync(): boolean {
if (this.syncInProgress) return false;
this.syncInProgress = true;
void this.syncAll()
.catch((error) => this.logger.error('Global Hiboutik synchronization crashed', (error as Error)?.stack))
.finally(() => (this.syncInProgress = false));
return true;
}
/** Runs every sync step in order. A failing step is logged and does not stop the next ones. */
async syncAll() {
this.logger.log('Starting global Hiboutik synchronization');
// Order matters: products connect to brands, categories and suppliers by hiboutikId.
const steps: [string, () => Promise<void>][] = [
['brands', () => this.brandHiboutikService.syncBrands()],
['categories', () => this.categoryHiboutikService.syncCategories()],
['suppliers', () => this.supplierHiboutikService.syncSuppliers()],
['products', () => this.productHiboutikService.syncProducts()],
['customers', () => this.customerHiboutikService.syncCustomers()],
];
const failedSteps: string[] = [];
for (const [name, step] of steps) {
try {
await step();
} catch (error) {
failedSteps.push(name);
this.logger.error(`Hiboutik ${name} sync failed`, (error as Error)?.stack);
}
}
if (failedSteps.length > 0) {
this.logger.warn(`Global Hiboutik synchronization ended with errors (failed steps: ${failedSteps.join(', ')})`);
} else {
this.logger.log('Global Hiboutik synchronization ended successfully');
}
}
}

View File

@@ -0,0 +1,162 @@
import { Logger } from '@nestjs/common';
import { ProductHiboutikService } from './product.hiboutik.service';
import { ProductConverter } from '../../models/converter/product.converter';
import { ProductHiboutikDto } from '../../models/hiboutikDTO/product.hiboutik.dto';
import { StockHiboutikDto } from '../../models/hiboutikDTO/stock.hiboutik.dto';
const hiboutikProduct = (id: number, overrides: Partial<ProductHiboutikDto> = {}): ProductHiboutikDto => ({
product_id: id,
product_model: `Product ${id}`,
product_barcode: `barcode-${id}`,
product_brand: 0,
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: [],
...overrides,
});
const stock = (productId: number, size: number, available: string): StockHiboutikDto => ({
product_id: productId,
product_size: size,
stock_available: available,
product_barcode: '',
product_supplier_reference: '',
});
describe('ProductHiboutikService', () => {
let service: ProductHiboutikService;
let calls: string[];
const productHiboutikRepository = { getProducts: jest.fn() };
const stockHiboutikRepository = { getStocks: jest.fn() };
const productDatabaseRepository = { getAll: jest.fn(), upsert: jest.fn(), deleteAll: jest.fn() };
const productVersionDatabaseRepository = { upsert: jest.fn(), deleteMissing: jest.fn() };
beforeAll(() => {
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
});
beforeEach(() => {
jest.clearAllMocks();
calls = [];
stockHiboutikRepository.getStocks.mockResolvedValue([]);
productDatabaseRepository.getAll.mockResolvedValue([]);
productDatabaseRepository.deleteAll.mockImplementation((ids: string[]) => {
calls.push('deleteProducts');
return Promise.resolve(ids.length);
});
// the saved product mirrors what was upserted, like the database would
productDatabaseRepository.upsert.mockImplementation((product: { hiboutikId: number }) => {
calls.push('upsertProduct');
return Promise.resolve({ ...product, id: `uuid-${product.hiboutikId}` });
});
productVersionDatabaseRepository.deleteMissing.mockImplementation(() => {
calls.push('deleteVersions');
return Promise.resolve(0);
});
productVersionDatabaseRepository.upsert.mockImplementation(() => {
calls.push('upsertVersion');
return Promise.resolve({});
});
service = new ProductHiboutikService(
productHiboutikRepository as never,
productDatabaseRepository as never,
productVersionDatabaseRepository as never,
new ProductConverter(),
stockHiboutikRepository as never,
);
});
it('deletes products missing from Hiboutik, before upserting', async () => {
productHiboutikRepository.getProducts.mockResolvedValue([hiboutikProduct(1)]);
productDatabaseRepository.getAll.mockResolvedValue([
{ id: 'uuid-1', hiboutikId: 1 },
{ id: 'uuid-2', hiboutikId: 2 },
]);
await service.syncProducts();
expect(productDatabaseRepository.deleteAll).toHaveBeenCalledWith(['uuid-2']);
expect(calls[0]).toBe('deleteProducts');
});
it('does not delete anything when Hiboutik returns no products', async () => {
productHiboutikRepository.getProducts.mockResolvedValue([]);
productDatabaseRepository.getAll.mockResolvedValue([{ id: 'uuid-1', hiboutikId: 1 }]);
await service.syncProducts();
expect(productDatabaseRepository.deleteAll).not.toHaveBeenCalled();
});
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')]);
await service.syncProducts();
expect(productDatabaseRepository.upsert).toHaveBeenCalledWith(expect.objectContaining({ hiboutikId: 1, globalStock: 4 }));
expect(productVersionDatabaseRepository.deleteMissing).toHaveBeenCalledWith('uuid-1', [0]);
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 }),
);
});
it('creates one version per size, with specific rules and stocks, after deleting removed sizes', async () => {
productHiboutikRepository.getProducts.mockResolvedValue([
hiboutikProduct(1, {
product_size_details: [
{ 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' }],
}),
]);
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(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',
});
});
it('keeps syncing the other products when one fails', async () => {
productHiboutikRepository.getProducts.mockResolvedValue([hiboutikProduct(1), hiboutikProduct(2)]);
productDatabaseRepository.upsert.mockRejectedValueOnce(new Error('boom'));
await expect(service.syncProducts()).resolves.toBeUndefined();
expect(productDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
expect(productVersionDatabaseRepository.upsert).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,131 @@
import { Injectable, Logger } from '@nestjs/common';
import { ProductHiboutikRepository } from '../../hiboutik/product.hiboutik.repository';
import { ProductDatabaseRepository } from '../../database/product.database.repository';
import { ProductVersionDatabaseRepository } from '../../database/productVersion.database.repository';
import { ProductConverter } from '../../models/converter/product.converter';
import { StockHiboutikRepository } from '../../hiboutik/stock.hiboutik.repository';
import { ProductHiboutikDto } from '../../models/hiboutikDTO/product.hiboutik.dto';
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';
/** Version id used for products that have no sizes in Hiboutik. */
const NO_SIZE_ID = 0;
@Injectable()
export class ProductHiboutikService {
private readonly logger = new Logger('ProductHiboutikService');
constructor(
private readonly productHiboutikRepository: ProductHiboutikRepository,
private readonly productDatabaseRepository: ProductDatabaseRepository,
private readonly productVersionDatabaseRepository: ProductVersionDatabaseRepository,
private readonly productConverter: ProductConverter,
private readonly stockHiboutikRepository: StockHiboutikRepository,
) {}
/** Mirrors Hiboutik products, their versions (sizes) and stocks. Products missing from Hiboutik are deleted. */
async syncProducts() {
this.logger.log('Starting products sync');
const [productsHiboutik, hiboutikStocks, existingProducts] = await Promise.all([
this.productHiboutikRepository.getProducts(),
this.stockHiboutikRepository.getStocks(),
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.
const nbDeleted = await this.deleteMissingProducts(productsHiboutik, existingProducts);
let nbFailed = 0;
for (const productHiboutik of productsHiboutik) {
const hiboutikProductStocks = hiboutikStocks.filter((stock) => stock.product_id === Number(productHiboutik.product_id));
try {
await this.updateProduct(productHiboutik, hiboutikProductStocks);
} catch (error) {
nbFailed++;
this.logger.error(`Failed to sync Hiboutik product ${productHiboutik.product_id}`, (error as Error)?.stack);
}
}
this.logger.log(
`Products sync ended: ${productsHiboutik.length - nbFailed}/${productsHiboutik.length} synced, ${nbFailed} failed, ${nbDeleted} deleted.`,
);
}
/** @returns the number of deleted products. */
private async deleteMissingProducts(productsHiboutik: ProductHiboutikDto[], existingProducts: Product[]): Promise<number> {
// Safety net: an empty answer from Hiboutik most likely means an API issue, not an empty catalog
if (productsHiboutik.length === 0 && existingProducts.length > 0) {
this.logger.warn(`Hiboutik returned no products while ${existingProducts.length} exist in database: deletion skipped.`);
return 0;
}
const hiboutikIds = new Set(productsHiboutik.map((product) => Number(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. */
private async updateProduct(productHiboutik: ProductHiboutikDto, productStocks: StockHiboutikDto[]): Promise<void> {
const newProduct = this.productConverter.fromHiboutik(productHiboutik);
newProduct.globalStock = productStocks.map((stock) => Number(stock.stock_available)).reduce((sum, stock) => sum + stock, 0);
const savedProduct: Product = await this.productDatabaseRepository.upsert(newProduct);
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),
);
for (const version of versions) {
await this.productVersionDatabaseRepository.upsert(savedProduct.id, version);
}
}
/**
* Builds the versions of a product: one per Hiboutik size, or a single {@link NO_SIZE_ID} version
* copying the product's barcode, prices and stock when it has no sizes.
*/
private buildVersions(
productHiboutik: ProductHiboutikDto,
productStocks: StockHiboutikDto[],
savedProduct: Product,
): ProductVersionCreateInput[] {
const specificRules = productHiboutik.product_specific_rules ?? [];
const sizesDetails = productHiboutik.product_size_details ?? [];
if (sizesDetails.length === 0) {
return [
{
barcode: savedProduct.barcode,
discountPrice: savedProduct.discountPrice,
hiboutikSizeId: NO_SIZE_ID,
price: savedProduct.price,
product: { connect: { id: savedProduct.id } },
stock: savedProduct.globalStock,
versionName: savedProduct.name,
},
];
}
return sizesDetails.map((sizeDetails) => {
const sizeId = Number(sizeDetails.size_id);
const specificRule = specificRules.find((rule) => Number(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.
return {
barcode: sizeDetails.barcode || null,
discountPrice: parseOptionalHiboutikPrice(specificRule?.product_discount_price),
hiboutikSizeId: sizeId,
price: parseOptionalHiboutikPrice(specificRule?.product_price),
product: { connect: { id: savedProduct.id } },
stock: stock ? Number(stock.stock_available) : 0,
versionName: sizeDetails.size_name,
};
});
}
}

View File

@@ -0,0 +1,38 @@
import { Injectable, Logger } from '@nestjs/common';
import { SupplierHiboutikRepository } from '../../hiboutik/supplier.hiboutik.repository';
import { SupplierDatabaseRepository } from '../../database/supplier.database.repository';
import { SupplierConverter } from '../../models/converter/supplier.converter';
import { forEachSafely } from '../../utils/sync';
@Injectable()
export class SupplierHiboutikService {
private readonly logger = new Logger('SupplierHiboutikService');
constructor(
private readonly supplierHiboutikRepository: SupplierHiboutikRepository,
private readonly supplierDatabaseRepository: SupplierDatabaseRepository,
private readonly supplierConverter: SupplierConverter,
) {}
/** Mirrors Hiboutik suppliers: upserts each of them, then deletes the suppliers missing from Hiboutik. */
async syncSuppliers() {
const hiboutikSuppliers = await this.supplierHiboutikRepository.getSuppliers();
// One upsert per supplier: an invalid supplier must not prevent the others from being saved
const nbFailed = await forEachSafely(
this.supplierConverter.fromHiboutikList(hiboutikSuppliers),
(supplier) => this.supplierDatabaseRepository.upsert(supplier),
this.logger,
(supplier) => `Hiboutik supplier ${supplier.hiboutikId}`,
);
// Safety net: an empty answer from Hiboutik most likely means an API issue
if (hiboutikSuppliers.length === 0) {
this.logger.warn('Hiboutik returned no suppliers: deletion skipped.');
return;
}
const nbDeleted = await this.supplierDatabaseRepository.deleteMissing(hiboutikSuppliers.map((dto) => Number(dto.supplier_id)));
this.logger.log(
`Suppliers sync ended: ${hiboutikSuppliers.length - nbFailed}/${hiboutikSuppliers.length} synced, ${nbFailed} failed, ${nbDeleted} deleted.`,
);
}
}

24
src/utils/sync.ts Normal file
View File

@@ -0,0 +1,24 @@
import { Logger } from '@nestjs/common';
/**
* Runs `action` on each item, one at a time: a failing item is logged and skipped, the others are still processed.
* @param describe label of an item in the error log, e.g. "Hiboutik brand 12".
* @returns the number of failed items.
*/
export async function forEachSafely<T>(
items: T[],
action: (item: T) => Promise<unknown>,
logger: Logger,
describe: (item: T) => string,
): Promise<number> {
let nbFailed = 0;
for (const item of items) {
try {
await action(item);
} catch (error) {
nbFailed++;
logger.error(`Failed to sync ${describe(item)}`, (error as Error)?.stack);
}
}
return nbFailed;
}

View File

@@ -3,24 +3,37 @@ import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
import { HiboutikService } from './../src/services/hiboutik/hiboutik.service';
import { PrismaService } from './../prisma/prisma.service';
// Boots the real AppModule wiring, without database nor Hiboutik calls
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
const hiboutikService = { startSync: jest.fn() };
beforeEach(async () => {
hiboutikService.startSync.mockReset();
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
})
.overrideProvider(PrismaService)
.useValue({})
.overrideProvider(HiboutikService)
.useValue(hiboutikService)
.compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
it('POST /sync starts a synchronization (202)', () => {
hiboutikService.startSync.mockReturnValue(true);
return request(app.getHttpServer()).post('/sync').expect(202).expect({ message: 'Hiboutik synchronization started' });
});
it('POST /sync answers 409 when a synchronization is already running', () => {
hiboutikService.startSync.mockReturnValue(false);
return request(app.getHttpServer()).post('/sync').expect(409);
});
afterEach(async () => {