diff --git a/src/app.controller.spec.ts b/src/app.controller.spec.ts index d22f389..c46c13f 100644 --- a/src/app.controller.spec.ts +++ b/src/app.controller.spec.ts @@ -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); + 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); }); }); diff --git a/src/app.controller.ts b/src/app.controller.ts index cce879e..345b0ed 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -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' }; } } diff --git a/src/app.module.ts b/src/app.module.ts index 8662803..bb44aee 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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 {} diff --git a/src/app.service.ts b/src/app.service.ts deleted file mode 100644 index 927d7cc..0000000 --- a/src/app.service.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -@Injectable() -export class AppService { - getHello(): string { - return 'Hello World!'; - } -} diff --git a/src/main.ts b/src/main.ts index f76bc8d..ff7d2ee 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,4 +5,4 @@ async function bootstrap() { const app = await NestFactory.create(AppModule); await app.listen(process.env.PORT ?? 3000); } -bootstrap(); +void bootstrap(); diff --git a/src/services/hiboutik/brand.hiboutik.service.ts b/src/services/hiboutik/brand.hiboutik.service.ts new file mode 100644 index 0000000..ce55ee2 --- /dev/null +++ b/src/services/hiboutik/brand.hiboutik.service.ts @@ -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.`, + ); + } +} diff --git a/src/services/hiboutik/category.hiboutik.service.spec.ts b/src/services/hiboutik/category.hiboutik.service.spec.ts new file mode 100644 index 0000000..7d1dcdb --- /dev/null +++ b/src/services/hiboutik/category.hiboutik.service.spec.ts @@ -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 => + ({ + 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(); + }); +}); diff --git a/src/services/hiboutik/category.hiboutik.service.ts b/src/services/hiboutik/category.hiboutik.service.ts new file mode 100644 index 0000000..e1454d5 --- /dev/null +++ b/src/services/hiboutik/category.hiboutik.service.ts @@ -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.`, + ); + } +} diff --git a/src/services/hiboutik/customer.hiboutik.service.spec.ts b/src/services/hiboutik/customer.hiboutik.service.spec.ts new file mode 100644 index 0000000..b15648a --- /dev/null +++ b/src/services/hiboutik/customer.hiboutik.service.spec.ts @@ -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); + }); +}); diff --git a/src/services/hiboutik/customer.hiboutik.service.ts b/src/services/hiboutik/customer.hiboutik.service.ts new file mode 100644 index 0000000..90c19a9 --- /dev/null +++ b/src/services/hiboutik/customer.hiboutik.service.ts @@ -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.`, + ); + } +} diff --git a/src/services/hiboutik/hiboutik.service.spec.ts b/src/services/hiboutik/hiboutik.service.spec.ts new file mode 100644 index 0000000..55eed97 --- /dev/null +++ b/src/services/hiboutik/hiboutik.service.spec.ts @@ -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((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); + }); +}); diff --git a/src/services/hiboutik/hiboutik.service.ts b/src/services/hiboutik/hiboutik.service.ts new file mode 100644 index 0000000..5578c48 --- /dev/null +++ b/src/services/hiboutik/hiboutik.service.ts @@ -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][] = [ + ['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'); + } + } +} diff --git a/src/services/hiboutik/product.hiboutik.service.spec.ts b/src/services/hiboutik/product.hiboutik.service.spec.ts new file mode 100644 index 0000000..ecbf373 --- /dev/null +++ b/src/services/hiboutik/product.hiboutik.service.spec.ts @@ -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 => ({ + 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); + }); +}); diff --git a/src/services/hiboutik/product.hiboutik.service.ts b/src/services/hiboutik/product.hiboutik.service.ts new file mode 100644 index 0000000..2a64ca2 --- /dev/null +++ b/src/services/hiboutik/product.hiboutik.service.ts @@ -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 { + // 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 { + 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, + }; + }); + } +} diff --git a/src/services/hiboutik/supplier.hiboutik.service.ts b/src/services/hiboutik/supplier.hiboutik.service.ts new file mode 100644 index 0000000..5a5673b --- /dev/null +++ b/src/services/hiboutik/supplier.hiboutik.service.ts @@ -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.`, + ); + } +} diff --git a/src/utils/sync.ts b/src/utils/sync.ts new file mode 100644 index 0000000..4477887 --- /dev/null +++ b/src/utils/sync.ts @@ -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( + items: T[], + action: (item: T) => Promise, + logger: Logger, + describe: (item: T) => string, +): Promise { + 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; +} diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index a767839..07f5664 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -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; + 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 () => {