feat(sync): validate Hiboutik payloads, read French dates and guard deletions
- Hiboutik payloads are zod schemas, validated by HiboutikClient (getOne added for single resources: null on 404 or empty answer). - Hiboutik dates are read as Europe/Paris whatever the server time zone. - Paginated lists driving deletions are walked twice when they span several pages (brands, categories, products); categories are sorted by id. - A customer missing from the list is deleted only once Hiboutik confirms it; completed orders that lost their customer are linked back. - Each product and its versions are written in one transaction. - An email removed in Hiboutik is erased in database. - A 429 is retried with backoff, then stops the synchronization. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,8 +2,13 @@ import { Logger } from '@nestjs/common';
|
||||
import { CustomerHiboutikService } from './customer.hiboutik.service';
|
||||
|
||||
describe('CustomerHiboutikService', () => {
|
||||
const customerHiboutikRepository = { getCustomers: jest.fn(), getCustomerById: jest.fn(), getAddressById: jest.fn() };
|
||||
const customerDatabaseRepository = { upsert: jest.fn(), deleteMissing: jest.fn() };
|
||||
const customerHiboutikRepository = {
|
||||
getCustomers: jest.fn(),
|
||||
getCustomerById: jest.fn(),
|
||||
findCustomerById: jest.fn(),
|
||||
getAddressById: jest.fn(),
|
||||
};
|
||||
const customerDatabaseRepository = { upsert: jest.fn(), getAllHiboutikIds: jest.fn(), deleteByHiboutikIds: jest.fn() };
|
||||
const customerConverter = { fromHiboutik: jest.fn() };
|
||||
const customerAddressDatabaseRepository = { upsert: jest.fn(), deleteMissing: jest.fn() };
|
||||
const customerAddressConverter = { fromHiboutik: jest.fn((dto: unknown) => dto) };
|
||||
@@ -23,7 +28,9 @@ describe('CustomerHiboutikService', () => {
|
||||
customerHiboutikRepository.getCustomers.mockResolvedValue([]);
|
||||
customerHiboutikRepository.getCustomerById.mockResolvedValue({ customers_id: 1, addresses: [] });
|
||||
customerDatabaseRepository.upsert.mockResolvedValue({ id: 'customer-uuid' });
|
||||
customerDatabaseRepository.deleteMissing.mockResolvedValue(0);
|
||||
customerDatabaseRepository.getAllHiboutikIds.mockResolvedValue([]);
|
||||
customerDatabaseRepository.deleteByHiboutikIds.mockImplementation((ids: number[]) => Promise.resolve(ids.length));
|
||||
customerHiboutikRepository.findCustomerById.mockResolvedValue(null);
|
||||
customerAddressDatabaseRepository.deleteMissing.mockResolvedValue(0);
|
||||
orderDatabaseRepository.getCompletedSaleIds.mockResolvedValue(new Set());
|
||||
saleHiboutikRepository.getSaleById.mockImplementation((id: number) => Promise.resolve({ sale_id: id, quotation: 0 }));
|
||||
@@ -39,27 +46,61 @@ describe('CustomerHiboutikService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes customers missing from Hiboutik', async () => {
|
||||
customerHiboutikRepository.getCustomers.mockResolvedValue([{ customers_id: 1 }, { customers_id: '2' }]);
|
||||
describe('deletions', () => {
|
||||
it('deletes the stored customers missing from the list once Hiboutik confirms they no longer exist', async () => {
|
||||
customerHiboutikRepository.getCustomers.mockResolvedValue([{ customers_id: 1 }, { customers_id: 2 }]);
|
||||
customerDatabaseRepository.getAllHiboutikIds.mockResolvedValue([1, 2, 3, 4]);
|
||||
|
||||
await service.syncCustomers();
|
||||
await service.syncCustomers();
|
||||
|
||||
expect(customerDatabaseRepository.deleteMissing).toHaveBeenCalledWith([1, 2]);
|
||||
expect(customerDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(customerHiboutikRepository.findCustomerById.mock.calls).toEqual([[3], [4]]);
|
||||
expect(customerDatabaseRepository.deleteByHiboutikIds).toHaveBeenCalledWith([3, 4]);
|
||||
expect(customerDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('deletes every customer when Hiboutik answers with an empty list', async () => {
|
||||
await service.syncCustomers();
|
||||
it('keeps and syncs a customer missing from the list (page shift) that still exists in Hiboutik', async () => {
|
||||
customerHiboutikRepository.getCustomers.mockResolvedValue([{ customers_id: 1 }]);
|
||||
customerDatabaseRepository.getAllHiboutikIds.mockResolvedValue([1, 2]);
|
||||
customerHiboutikRepository.findCustomerById.mockResolvedValue({ customers_id: 2 });
|
||||
|
||||
expect(customerDatabaseRepository.deleteMissing).toHaveBeenCalledWith([]);
|
||||
});
|
||||
await service.syncCustomers();
|
||||
|
||||
it('deletes nothing when the Hiboutik call fails', async () => {
|
||||
customerHiboutikRepository.getCustomers.mockRejectedValue(new Error('Request failed with status code 500'));
|
||||
expect(customerDatabaseRepository.deleteByHiboutikIds).toHaveBeenCalledWith([]);
|
||||
expect(customerHiboutikRepository.getCustomerById.mock.calls).toEqual([[1], [2]]);
|
||||
});
|
||||
|
||||
await expect(service.syncCustomers()).rejects.toThrow('500');
|
||||
it('keeps a customer whose check fails', async () => {
|
||||
customerDatabaseRepository.getAllHiboutikIds.mockResolvedValue([3, 4]);
|
||||
customerHiboutikRepository.findCustomerById.mockRejectedValueOnce(new Error('Request failed with status code 500'));
|
||||
|
||||
expect(customerDatabaseRepository.deleteMissing).not.toHaveBeenCalled();
|
||||
await service.syncCustomers();
|
||||
|
||||
expect(customerDatabaseRepository.deleteByHiboutikIds).toHaveBeenCalledWith([4]);
|
||||
});
|
||||
|
||||
it('deletes every confirmed customer when Hiboutik answers with an empty list', async () => {
|
||||
customerDatabaseRepository.getAllHiboutikIds.mockResolvedValue([1, 2]);
|
||||
|
||||
await service.syncCustomers();
|
||||
|
||||
expect(customerDatabaseRepository.deleteByHiboutikIds).toHaveBeenCalledWith([1, 2]);
|
||||
});
|
||||
|
||||
it('deletes nothing when the Hiboutik list call fails', async () => {
|
||||
customerHiboutikRepository.getCustomers.mockRejectedValue(new Error('Request failed with status code 500'));
|
||||
|
||||
await expect(service.syncCustomers()).rejects.toThrow('500');
|
||||
|
||||
expect(customerDatabaseRepository.deleteByHiboutikIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('syncs once a customer listed twice', async () => {
|
||||
customerHiboutikRepository.getCustomers.mockResolvedValue([{ customers_id: 1 }, { customers_id: 1 }]);
|
||||
|
||||
await service.syncCustomers();
|
||||
|
||||
expect(customerHiboutikRepository.getCustomerById).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps syncing the other customers when one fails', async () => {
|
||||
@@ -80,8 +121,8 @@ describe('CustomerHiboutikService', () => {
|
||||
customerHiboutikRepository.getCustomerById.mockResolvedValue({
|
||||
customers_id: 1,
|
||||
addresses: [
|
||||
{ address_id: 10, customers_id: 1 },
|
||||
{ address_id: '11', customers_id: 999 },
|
||||
{ address_id: 10, customers_id: 1, company: '' },
|
||||
{ address_id: 11, customers_id: 999, company: '' },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -89,7 +130,7 @@ describe('CustomerHiboutikService', () => {
|
||||
|
||||
expect(customerAddressDatabaseRepository.deleteMissing).toHaveBeenCalledWith('customer-uuid', [10, 11]);
|
||||
expect(customerAddressDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
|
||||
expect(customerAddressConverter.fromHiboutik).toHaveBeenLastCalledWith({ address_id: '11', customers_id: 1 });
|
||||
expect(customerAddressConverter.fromHiboutik).toHaveBeenLastCalledWith({ address_id: 11, customers_id: 1, company: '' });
|
||||
expect(customerAddressDatabaseRepository.deleteMissing.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
customerAddressDatabaseRepository.upsert.mock.invocationCallOrder[0],
|
||||
);
|
||||
@@ -155,7 +196,10 @@ describe('CustomerHiboutikService', () => {
|
||||
it('keeps syncing the other addresses when one fails', async () => {
|
||||
customerHiboutikRepository.getCustomerById.mockResolvedValue({
|
||||
customers_id: 1,
|
||||
addresses: [{ address_id: 10 }, { address_id: 11 }],
|
||||
addresses: [
|
||||
{ address_id: 10, company: '' },
|
||||
{ address_id: 11, company: '' },
|
||||
],
|
||||
});
|
||||
customerAddressDatabaseRepository.upsert.mockRejectedValueOnce(new Error('boom'));
|
||||
|
||||
@@ -171,7 +215,7 @@ describe('CustomerHiboutikService', () => {
|
||||
customerHiboutikRepository.getCustomerById.mockResolvedValue({
|
||||
customers_id: 1,
|
||||
addresses: [],
|
||||
sales: [{ sale_id: 5 }, { sale_id: '6' }, { sale_id: 7 }],
|
||||
sales: [{ sale_id: 5 }, { sale_id: 6 }, { sale_id: 7 }],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -180,7 +224,7 @@ describe('CustomerHiboutikService', () => {
|
||||
|
||||
await service.syncCustomers();
|
||||
|
||||
expect(orderDatabaseRepository.getCompletedSaleIds).toHaveBeenCalledWith([5, 6, 7]);
|
||||
expect(orderDatabaseRepository.getCompletedSaleIds).toHaveBeenCalledWith(1, [5, 6, 7]);
|
||||
expect(saleHiboutikRepository.getSaleById.mock.calls.map((call: unknown[]) => call[0])).toEqual([5, 7]);
|
||||
expect(orderDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
|
||||
expect(orderConverter.fromHiboutik).toHaveBeenCalledWith({ sale_id: 5, quotation: 0, customer_id: 1 });
|
||||
|
||||
Reference in New Issue
Block a user