feat(sync): synchronize Hiboutik data through POST /sync

POST /sync starts the full synchronization in the background (202, or 409 when one is already running). Entities are synced in dependency order: brands, categories, suppliers, products (with versions and stocks), customers. Each row is synced independently so that a failure is logged without stopping the rest. Replaces the default AppService.
This commit is contained in:
MastersGame
2026-11-05 23:00:49 +01:00
parent 27d0e0149d
commit c5f65dcb62
17 changed files with 878 additions and 34 deletions

View File

@@ -0,0 +1,89 @@
import { Logger } from '@nestjs/common';
import { CategoryHiboutikService } from './category.hiboutik.service';
import { CategoryConverter } from '../../models/converter/category.converter';
import { CategoryHiboutikDto } from '../../models/hiboutikDTO/category.hiboutik.dto';
const hiboutikCategory = (id: number, parentId: number, overrides: Partial<CategoryHiboutikDto> = {}): CategoryHiboutikDto =>
({
category_id: id,
category_name: `Category ${id}`,
category_id_parent: parentId,
category_enabled: 1,
category_enabled_www: 0,
category_position: 3,
...overrides,
}) as CategoryHiboutikDto;
describe('CategoryHiboutikService', () => {
const categoryHiboutikRepository = { getCategories: jest.fn() };
const categoryDatabaseRepository = { upsert: jest.fn(), setParent: jest.fn(), deleteMissing: jest.fn() };
let service: CategoryHiboutikService;
let calls: string[];
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 = [];
categoryDatabaseRepository.upsert.mockImplementation((category: { hiboutikId: number }) => {
calls.push(`upsert ${category.hiboutikId}`);
return Promise.resolve({});
});
categoryDatabaseRepository.setParent.mockImplementation((id: number) => {
calls.push(`parent ${id}`);
return Promise.resolve({});
});
categoryDatabaseRepository.deleteMissing.mockResolvedValue(0);
service = new CategoryHiboutikService(
categoryHiboutikRepository as never,
categoryDatabaseRepository as never,
new CategoryConverter(),
);
});
it('stores position and web visibility', async () => {
categoryHiboutikRepository.getCategories.mockResolvedValue([hiboutikCategory(1, 0)]);
await service.syncCategories();
expect(categoryDatabaseRepository.upsert).toHaveBeenCalledWith({
hiboutikId: 1,
name: 'Category 1',
enabled: true,
position: 3,
webEnabled: false,
});
});
it('links parents once every category is saved, a child being listed before its parent', async () => {
categoryHiboutikRepository.getCategories.mockResolvedValue([hiboutikCategory(2, 3), hiboutikCategory(3, 0)]);
await service.syncCategories();
expect(calls).toEqual(['upsert 2', 'upsert 3', 'parent 2', 'parent 3']);
expect(categoryDatabaseRepository.setParent).toHaveBeenCalledWith(2, 3);
expect(categoryDatabaseRepository.setParent).toHaveBeenCalledWith(3, null);
});
it('keeps syncing the other categories when one fails, then deletes the missing ones', async () => {
categoryHiboutikRepository.getCategories.mockResolvedValue([hiboutikCategory(1, 0), hiboutikCategory(2, 0)]);
categoryDatabaseRepository.upsert.mockRejectedValueOnce(new Error('boom'));
await expect(service.syncCategories()).resolves.toBeUndefined();
expect(categoryDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
expect(categoryDatabaseRepository.deleteMissing).toHaveBeenCalledWith([1, 2]);
});
it('does not delete anything when Hiboutik returns no categories', async () => {
categoryHiboutikRepository.getCategories.mockResolvedValue([]);
await service.syncCategories();
expect(categoryDatabaseRepository.deleteMissing).not.toHaveBeenCalled();
});
});