feat(sync): synchronize Hiboutik data through POST /sync
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
||||
@@ -5,4 +5,4 @@ async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
}
|
||||
bootstrap();
|
||||
void bootstrap();
|
||||
|
||||
38
src/services/hiboutik/brand.hiboutik.service.ts
Normal file
38
src/services/hiboutik/brand.hiboutik.service.ts
Normal 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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
89
src/services/hiboutik/category.hiboutik.service.spec.ts
Normal file
89
src/services/hiboutik/category.hiboutik.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
51
src/services/hiboutik/category.hiboutik.service.ts
Normal file
51
src/services/hiboutik/category.hiboutik.service.ts
Normal 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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
51
src/services/hiboutik/customer.hiboutik.service.spec.ts
Normal file
51
src/services/hiboutik/customer.hiboutik.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
49
src/services/hiboutik/customer.hiboutik.service.ts
Normal file
49
src/services/hiboutik/customer.hiboutik.service.ts
Normal 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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
68
src/services/hiboutik/hiboutik.service.spec.ts
Normal file
68
src/services/hiboutik/hiboutik.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
69
src/services/hiboutik/hiboutik.service.ts
Normal file
69
src/services/hiboutik/hiboutik.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
162
src/services/hiboutik/product.hiboutik.service.spec.ts
Normal file
162
src/services/hiboutik/product.hiboutik.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
131
src/services/hiboutik/product.hiboutik.service.ts
Normal file
131
src/services/hiboutik/product.hiboutik.service.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
38
src/services/hiboutik/supplier.hiboutik.service.ts
Normal file
38
src/services/hiboutik/supplier.hiboutik.service.ts
Normal 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
24
src/utils/sync.ts
Normal 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;
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user