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_category: 0, product_display_www: 1, product_arch: 0, products_desc: '', updated_at: '2026-06-28 17:02:55', product_specific_rules: [], product_size_details: [], ...overrides, }); const stock = (productId: number, size: number, available: number): StockHiboutikDto => ({ product_id: productId, product_size: size, stock_available: available, }); 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() }; // The transaction client handed to the repositories inside $transaction const tx = { transaction: true }; const prisma = { $transaction: jest.fn() }; const slugService = { assignMissingSlugs: 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 = []; prisma.$transaction.mockImplementation((run: (client: unknown) => Promise) => run(tx)); 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, prisma as never, slugService 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('deletes every product when Hiboutik answers with an empty list', async () => { productHiboutikRepository.getProducts.mockResolvedValue([]); productDatabaseRepository.getAll.mockResolvedValue([{ id: 'uuid-1', hiboutikId: 1 }]); await service.syncProducts(); expect(productDatabaseRepository.deleteAll).toHaveBeenCalledWith(['uuid-1']); }); it('deletes nothing when the Hiboutik call fails', async () => { productHiboutikRepository.getProducts.mockRejectedValue(new Error('Request failed with status code 500')); productDatabaseRepository.getAll.mockResolvedValue([{ id: 'uuid-1', hiboutikId: 1 }]); await expect(service.syncProducts()).rejects.toThrow('500'); 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 }), tx); expect(productVersionDatabaseRepository.deleteMissing).toHaveBeenCalledWith('uuid-1', [0], tx); 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 }), tx, ); }); 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, 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 }), tx); expect(productVersionDatabaseRepository.deleteMissing).toHaveBeenCalledWith('uuid-1', [10, 11], tx); 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', }, tx, ); expect(productVersionDatabaseRepository.upsert).toHaveBeenNthCalledWith( 2, 'uuid-1', { barcode: null, discountPrice: null, hiboutikSizeId: 11, price: null, product: { connect: { id: 'uuid-1' } }, stock: 2, versionName: 'M', }, tx, ); }); 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); }); it('writes a product and its versions in one transaction per product, and counts a failed one', async () => { productHiboutikRepository.getProducts.mockResolvedValue([hiboutikProduct(1), hiboutikProduct(2)]); productVersionDatabaseRepository.upsert.mockRejectedValueOnce(new Error('boom')); await expect(service.syncProducts()).resolves.toBeUndefined(); expect(prisma.$transaction).toHaveBeenCalledTimes(2); // the failure inside the transaction is what makes Prisma roll the product back await expect(prisma.$transaction.mock.results[0].value).rejects.toThrow('boom'); await expect(prisma.$transaction.mock.results[1].value).resolves.toBeUndefined(); }); it('generates the slugs of new products once they are saved', async () => { productHiboutikRepository.getProducts.mockResolvedValue([hiboutikProduct(1)]); slugService.assignMissingSlugs.mockImplementation(() => { calls.push('slugs'); return Promise.resolve(); }); await service.syncProducts(); expect(slugService.assignMissingSlugs).toHaveBeenCalledWith('PRODUCT'); expect(calls[calls.length - 1]).toBe('slugs'); }); });