feat(sync): synchronize customer addresses

Mirror the addresses embedded in GET /customer/🆔 per customer, delete
the ones missing from Hiboutik, then upsert each of them (failures logged
and skipped). CustomerAddress gains its own recipient (firstName,
lastName, company, phone) plus state and other.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MastersGame
2026-11-07 10:37:00 +01:00
parent c5f65dcb62
commit 9e6e5162ad
7 changed files with 229 additions and 7 deletions

View File

@@ -5,6 +5,8 @@ describe('CustomerHiboutikService', () => {
const customerHiboutikRepository = { getCustomers: jest.fn(), getCustomerById: jest.fn() };
const customerDatabaseRepository = { upsert: jest.fn(), deleteMissing: jest.fn() };
const customerConverter = { fromHiboutik: jest.fn() };
const customerAddressDatabaseRepository = { upsert: jest.fn(), deleteMissing: jest.fn() };
const customerAddressConverter = { fromHiboutik: jest.fn((dto: unknown) => dto) };
let service: CustomerHiboutikService;
beforeAll(() => {
@@ -16,12 +18,16 @@ describe('CustomerHiboutikService', () => {
beforeEach(() => {
jest.clearAllMocks();
customerHiboutikRepository.getCustomers.mockResolvedValue([]);
customerHiboutikRepository.getCustomerById.mockResolvedValue({});
customerHiboutikRepository.getCustomerById.mockResolvedValue({ customers_id: 1, addresses: [] });
customerDatabaseRepository.upsert.mockResolvedValue({ id: 'customer-uuid' });
customerDatabaseRepository.deleteMissing.mockResolvedValue(0);
customerAddressDatabaseRepository.deleteMissing.mockResolvedValue(0);
service = new CustomerHiboutikService(
customerHiboutikRepository as never,
customerDatabaseRepository as never,
customerConverter as never,
customerAddressDatabaseRepository as never,
customerAddressConverter as never,
);
});
@@ -48,4 +54,57 @@ describe('CustomerHiboutikService', () => {
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 },
{ address_id: '11', customers_id: 999 },
],
});
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 });
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('keeps syncing the other addresses when one fails', async () => {
customerHiboutikRepository.getCustomerById.mockResolvedValue({
customers_id: 1,
addresses: [{ address_id: 10 }, { address_id: 11 }],
});
customerAddressDatabaseRepository.upsert.mockRejectedValueOnce(new Error('boom'));
await service.syncCustomers();
expect(customerAddressDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -2,6 +2,10 @@ 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';
import { CustomerAddressDatabaseRepository } from '../../database/customerAddress.database.repository';
import { CustomerAddressConverter } from '../../models/converter/customerAddress.converter';
import { FullCustomerHiboutikDto } from '../../models/hiboutikDTO/customer.hiboutik.dto';
import { forEachSafely } from '../../utils/sync';
@Injectable()
export class CustomerHiboutikService {
@@ -11,11 +15,13 @@ export class CustomerHiboutikService {
private readonly customerHiboutikRepository: CustomerHiboutikRepository,
private readonly customerDatabaseRepository: CustomerDatabaseRepository,
private readonly customerConverter: CustomerConverter,
private readonly customerAddressDatabaseRepository: CustomerAddressDatabaseRepository,
private readonly customerAddressConverter: CustomerAddressConverter,
) {}
/**
* 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).
* with its addresses (one API call per customer, as the list endpoint lacks addresses and sales).
*/
async syncCustomers() {
const hiboutikCustomers = await this.customerHiboutikRepository.getCustomers();
@@ -29,12 +35,12 @@ export class CustomerHiboutikService {
}
let nbFailed = 0;
let nbFailedAddresses = 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
const customer = await this.customerDatabaseRepository.upsert(this.customerConverter.fromHiboutik(hiboutikFullCustomer));
nbFailedAddresses += await this.syncAddresses(customer.id, hiboutikFullCustomer);
// retreive orders DTOs
// save orders
} catch (error) {
@@ -43,7 +49,35 @@ export class CustomerHiboutikService {
}
}
this.logger.log(
`Customers sync ended: ${hiboutikCustomers.length - nbFailed}/${hiboutikCustomers.length} synced, ${nbFailed} failed, ${nbDeleted} deleted.`,
`Customers sync ended: ${hiboutikCustomers.length - nbFailed}/${hiboutikCustomers.length} synced, ${nbFailed} failed, ${nbDeleted} deleted, ${nbFailedAddresses} addresses failed.`,
);
}
/**
* Mirrors the addresses embedded in a full Hiboutik customer: deletes the ones missing from Hiboutik, then upserts
* each of them (a failing address is logged and skipped).
* @returns the number of failed addresses.
*/
private async syncAddresses(customerId: string, hiboutikCustomer: FullCustomerHiboutikDto): Promise<number> {
// A missing array means an unexpected payload, not a customer without addresses: keep the stored ones
if (!Array.isArray(hiboutikCustomer.addresses)) {
this.logger.warn(`Hiboutik customer ${hiboutikCustomer.customers_id} has no addresses array: addresses sync skipped.`);
return 0;
}
await this.customerAddressDatabaseRepository.deleteMissing(
customerId,
hiboutikCustomer.addresses.map((dto) => Number(dto.address_id)),
);
return forEachSafely(
hiboutikCustomer.addresses,
// Linked to the parent customer, whatever the embedded customers_id says
(dto) =>
this.customerAddressDatabaseRepository.upsert(
this.customerAddressConverter.fromHiboutik({ ...dto, customers_id: hiboutikCustomer.customers_id }),
),
this.logger,
(dto) => `Hiboutik address ${dto.address_id} of customer ${hiboutikCustomer.customers_id}`,
);
}
}