Files
dinggadingga-backend/src/services/hiboutik/customer.hiboutik.service.spec.ts
MastersGame 35a7c27cb7 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>
2026-11-08 12:20:58 +01:00

259 lines
11 KiB
TypeScript

import { Logger } from '@nestjs/common';
import { CustomerHiboutikService } from './customer.hiboutik.service';
describe('CustomerHiboutikService', () => {
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) };
const saleHiboutikRepository = { getSaleById: jest.fn() };
const orderDatabaseRepository = { upsert: jest.fn(), getCompletedSaleIds: jest.fn() };
const orderConverter = { fromHiboutik: jest.fn((dto: unknown) => dto) };
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({ customers_id: 1, addresses: [] });
customerDatabaseRepository.upsert.mockResolvedValue({ id: 'customer-uuid' });
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 }));
service = new CustomerHiboutikService(
customerHiboutikRepository as never,
customerDatabaseRepository as never,
customerConverter as never,
customerAddressDatabaseRepository as never,
customerAddressConverter as never,
saleHiboutikRepository as never,
orderDatabaseRepository as never,
orderConverter as never,
);
});
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();
expect(customerHiboutikRepository.findCustomerById.mock.calls).toEqual([[3], [4]]);
expect(customerDatabaseRepository.deleteByHiboutikIds).toHaveBeenCalledWith([3, 4]);
expect(customerDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
});
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 });
await service.syncCustomers();
expect(customerDatabaseRepository.deleteByHiboutikIds).toHaveBeenCalledWith([]);
expect(customerHiboutikRepository.getCustomerById.mock.calls).toEqual([[1], [2]]);
});
it('keeps a customer whose check fails', async () => {
customerDatabaseRepository.getAllHiboutikIds.mockResolvedValue([3, 4]);
customerHiboutikRepository.findCustomerById.mockRejectedValueOnce(new Error('Request failed with status code 500'));
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 () => {
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);
});
describe('addresses', () => {
beforeEach(() => {
customerHiboutikRepository.getCustomers.mockResolvedValue([{ customers_id: 1 }]);
});
it('deletes the missing addresses of the customer, then upserts the others linked to it', async () => {
customerHiboutikRepository.getCustomerById.mockResolvedValue({
customers_id: 1,
addresses: [
{ address_id: 10, customers_id: 1, company: '' },
{ address_id: 11, customers_id: 999, company: '' },
],
});
await service.syncCustomers();
expect(customerAddressDatabaseRepository.deleteMissing).toHaveBeenCalledWith('customer-uuid', [10, 11]);
expect(customerAddressDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
expect(customerAddressConverter.fromHiboutik).toHaveBeenLastCalledWith({ address_id: 11, customers_id: 1, company: '' });
expect(customerAddressDatabaseRepository.deleteMissing.mock.invocationCallOrder[0]).toBeLessThan(
customerAddressDatabaseRepository.upsert.mock.invocationCallOrder[0],
);
});
it('deletes all the addresses of a customer that has none left', async () => {
await service.syncCustomers();
expect(customerAddressDatabaseRepository.deleteMissing).toHaveBeenCalledWith('customer-uuid', []);
expect(customerAddressDatabaseRepository.upsert).not.toHaveBeenCalled();
});
it('keeps the stored addresses when the payload has no addresses array', async () => {
customerHiboutikRepository.getCustomerById.mockResolvedValue({ customers_id: 1 });
await service.syncCustomers();
expect(customerAddressDatabaseRepository.deleteMissing).not.toHaveBeenCalled();
expect(customerAddressDatabaseRepository.upsert).not.toHaveBeenCalled();
});
it('fetches the company details of addresses with a company only', async () => {
customerHiboutikRepository.getCustomerById.mockResolvedValue({
customers_id: 1,
addresses: [
{ address_id: 10, company: 'ACME' },
{ address_id: 11, company: ' ' },
],
});
customerHiboutikRepository.getAddressById.mockResolvedValue({
address_id: 10,
tax_number: 'FR123',
company_number: '123',
legal_status: 'SAS',
updated_at: '2026-11-01 10:00:00',
});
await service.syncCustomers();
expect(customerHiboutikRepository.getAddressById).toHaveBeenCalledTimes(1);
expect(customerHiboutikRepository.getAddressById).toHaveBeenCalledWith(10);
expect(customerAddressConverter.fromHiboutik).toHaveBeenCalledWith({
address_id: 10,
company: 'ACME',
customers_id: 1,
tax_number: 'FR123',
company_number: '123',
legal_status: 'SAS',
updated_at: '2026-11-01 10:00:00',
});
});
it('still syncs an address whose company details cannot be fetched', async () => {
customerHiboutikRepository.getCustomerById.mockResolvedValue({ customers_id: 1, addresses: [{ address_id: 10, company: 'ACME' }] });
customerHiboutikRepository.getAddressById.mockRejectedValue(new Error('boom'));
await service.syncCustomers();
expect(customerAddressConverter.fromHiboutik).toHaveBeenCalledWith({ address_id: 10, company: 'ACME', customers_id: 1 });
expect(customerAddressDatabaseRepository.upsert).toHaveBeenCalledTimes(1);
});
it('keeps syncing the other addresses when one fails', async () => {
customerHiboutikRepository.getCustomerById.mockResolvedValue({
customers_id: 1,
addresses: [
{ address_id: 10, company: '' },
{ address_id: 11, company: '' },
],
});
customerAddressDatabaseRepository.upsert.mockRejectedValueOnce(new Error('boom'));
await service.syncCustomers();
expect(customerAddressDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
});
});
describe('orders', () => {
beforeEach(() => {
customerHiboutikRepository.getCustomers.mockResolvedValue([{ customers_id: 1 }]);
customerHiboutikRepository.getCustomerById.mockResolvedValue({
customers_id: 1,
addresses: [],
sales: [{ sale_id: 5 }, { sale_id: 6 }, { sale_id: 7 }],
});
});
it('fetches and upserts the sales not yet stored as completed, linked to the customer', async () => {
orderDatabaseRepository.getCompletedSaleIds.mockResolvedValue(new Set([6]));
await service.syncCustomers();
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 });
});
it('ignores quotations', async () => {
saleHiboutikRepository.getSaleById.mockResolvedValueOnce({ sale_id: 5, quotation: 1 });
await service.syncCustomers();
expect(orderDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
});
it('keeps syncing the other sales when one fails', async () => {
saleHiboutikRepository.getSaleById.mockRejectedValueOnce(new Error('boom'));
await service.syncCustomers();
expect(orderDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
});
it('skips orders when the payload has no sales array', async () => {
customerHiboutikRepository.getCustomerById.mockResolvedValue({ customers_id: 1, addresses: [] });
await service.syncCustomers();
expect(saleHiboutikRepository.getSaleById).not.toHaveBeenCalled();
expect(orderDatabaseRepository.upsert).not.toHaveBeenCalled();
});
});
});