feat(sync): harden the Hiboutik sync, share rate limits across instances, update dependencies (#4)
This commit is contained in:
@@ -12,22 +12,20 @@ const hiboutikProduct = (id: number, overrides: Partial<ProductHiboutikDto> = {}
|
||||
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: [],
|
||||
product_size_details: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const stock = (productId: number, size: number, available: string): StockHiboutikDto => ({
|
||||
const stock = (productId: number, size: number, available: number): StockHiboutikDto => ({
|
||||
product_id: productId,
|
||||
product_size: size,
|
||||
stock_available: available,
|
||||
product_barcode: '',
|
||||
product_supplier_reference: '',
|
||||
});
|
||||
|
||||
describe('ProductHiboutikService', () => {
|
||||
@@ -37,6 +35,9 @@ describe('ProductHiboutikService', () => {
|
||||
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() };
|
||||
|
||||
beforeAll(() => {
|
||||
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||
@@ -47,6 +48,7 @@ describe('ProductHiboutikService', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
calls = [];
|
||||
prisma.$transaction.mockImplementation((run: (client: unknown) => Promise<unknown>) => run(tx));
|
||||
stockHiboutikRepository.getStocks.mockResolvedValue([]);
|
||||
productDatabaseRepository.getAll.mockResolvedValue([]);
|
||||
productDatabaseRepository.deleteAll.mockImplementation((ids: string[]) => {
|
||||
@@ -73,6 +75,7 @@ describe('ProductHiboutikService', () => {
|
||||
productVersionDatabaseRepository as never,
|
||||
new ProductConverter(),
|
||||
stockHiboutikRepository as never,
|
||||
prisma as never,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -109,16 +112,17 @@ describe('ProductHiboutikService', () => {
|
||||
|
||||
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')]);
|
||||
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(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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -129,34 +133,44 @@ describe('ProductHiboutikService', () => {
|
||||
{ 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' }],
|
||||
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')]);
|
||||
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(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',
|
||||
});
|
||||
expect(productVersionDatabaseRepository.upsert).toHaveBeenNthCalledWith(2, 'uuid-1', {
|
||||
barcode: null,
|
||||
discountPrice: null,
|
||||
hiboutikSizeId: 11,
|
||||
price: null,
|
||||
product: { connect: { id: 'uuid-1' } },
|
||||
stock: 2,
|
||||
versionName: 'M',
|
||||
});
|
||||
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 () => {
|
||||
@@ -168,4 +182,16 @@ describe('ProductHiboutikService', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user