import { Logger, ServiceUnavailableException } from '@nestjs/common'; import { HiboutikService } from './hiboutik.service'; import { HiboutikRateLimitError } from '../../hiboutik/hiboutik.client'; describe('HiboutikService', () => { const brandService = { syncBrands: jest.fn() }; const categoryService = { syncCategories: jest.fn() }; const supplierService = { syncSuppliers: jest.fn() }; const productService = { syncProducts: jest.fn() }; const customerService = { syncCustomers: jest.fn() }; const syncLock = { tryAcquire: jest.fn() }; const release = jest.fn(); // SYNC_SHUTDOWN_TIMEOUT of 1 s const configService = { get: jest.fn().mockReturnValue(1) }; let service: HiboutikService; const flush = () => new Promise((resolve) => setImmediate(resolve)); let logWarn: jest.SpyInstance; beforeAll(() => { jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); logWarn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); }); beforeEach(() => { jest.clearAllMocks(); brandService.syncBrands.mockResolvedValue(undefined); categoryService.syncCategories.mockResolvedValue(undefined); supplierService.syncSuppliers.mockResolvedValue(undefined); productService.syncProducts.mockResolvedValue(undefined); customerService.syncCustomers.mockResolvedValue(undefined); release.mockResolvedValue(undefined); syncLock.tryAcquire.mockResolvedValue(release); service = new HiboutikService( brandService as never, categoryService as never, customerService as never, productService as never, supplierService as never, syncLock as never, configService as never, ); }); it('runs the steps in dependency order', async () => { const order: string[] = []; brandService.syncBrands.mockImplementation(() => Promise.resolve(order.push('brands'))); categoryService.syncCategories.mockImplementation(() => Promise.resolve(order.push('categories'))); supplierService.syncSuppliers.mockImplementation(() => Promise.resolve(order.push('suppliers'))); productService.syncProducts.mockImplementation(() => Promise.resolve(order.push('products'))); customerService.syncCustomers.mockImplementation(() => Promise.resolve(order.push('customers'))); // private method, reached through an index access on purpose await service['syncAll'](); expect(order).toEqual(['brands', 'categories', 'suppliers', 'products', 'customers']); }); it('continues with the next steps when one fails', async () => { brandService.syncBrands.mockRejectedValue(new Error('boom')); // private method, reached through an index access on purpose await service['syncAll'](); expect(productService.syncProducts).toHaveBeenCalled(); expect(customerService.syncCustomers).toHaveBeenCalled(); }); it('refuses a second synchronization while one is running, and unlocks at the end', async () => { let finishBrands: () => void = () => undefined; brandService.syncBrands.mockReturnValue(new Promise((resolve) => (finishBrands = resolve))); // The lock is the only guard: the second request does not get it, even from the same instance syncLock.tryAcquire.mockResolvedValueOnce(release).mockResolvedValueOnce(null); await expect(service.startSync()).resolves.toBe(true); await expect(service.startSync()).resolves.toBe(false); expect(brandService.syncBrands).toHaveBeenCalledTimes(1); expect(release).not.toHaveBeenCalled(); finishBrands(); await flush(); expect(release).toHaveBeenCalledTimes(1); }); it('starts nothing when another instance holds the lock', async () => { syncLock.tryAcquire.mockResolvedValue(null); await expect(service.startSync()).resolves.toBe(false); expect(brandService.syncBrands).not.toHaveBeenCalled(); expect(release).not.toHaveBeenCalled(); }); it('starts nothing and can be retried when the lock cannot be requested', async () => { syncLock.tryAcquire.mockRejectedValueOnce(new Error('database unreachable')); await expect(service.startSync()).rejects.toThrow('database unreachable'); expect(brandService.syncBrands).not.toHaveBeenCalled(); await expect(service.startSync()).resolves.toBe(true); await flush(); }); it('releases the lock even when the synchronization crashes', async () => { jest.spyOn(service as unknown as { syncAll: () => Promise }, 'syncAll').mockRejectedValue(new Error('crash')); await expect(service.startSync()).resolves.toBe(true); await flush(); expect(release).toHaveBeenCalledTimes(1); }); it('stops the synchronization when the Hiboutik rate limit is reached', async () => { productService.syncProducts.mockRejectedValue(new HiboutikRateLimitError('/products')); await service['syncAll'](); expect(customerService.syncCustomers).not.toHaveBeenCalled(); }); describe('shutdown', () => { afterEach(() => { jest.useRealTimers(); }); it('waits for the running synchronization to end, and refuses new ones meanwhile', async () => { let finishBrands: () => void = () => undefined; brandService.syncBrands.mockReturnValue(new Promise((resolve) => (finishBrands = resolve))); await service.startSync(); let shutDown = false; const shutdown = service.beforeApplicationShutdown().then(() => (shutDown = true)); await flush(); expect(shutDown).toBe(false); await expect(service.startSync()).rejects.toThrow(ServiceUnavailableException); finishBrands(); await shutdown; expect(release).toHaveBeenCalledTimes(1); }); it('gives up waiting after the timeout', async () => { jest.useFakeTimers(); brandService.syncBrands.mockReturnValue(new Promise(() => undefined)); await service.startSync(); const shutdown = service.beforeApplicationShutdown(); await jest.advanceTimersByTimeAsync(1_000); await expect(shutdown).resolves.toBeUndefined(); expect(logWarn).toHaveBeenCalledWith(expect.stringContaining('did not end in time')); }); it('returns at once when no synchronization is running', async () => { await expect(service.beforeApplicationShutdown()).resolves.toBeUndefined(); }); it('releases the lock and refuses when the shutdown began while the lock was requested', async () => { syncLock.tryAcquire.mockImplementation(async () => { await service.beforeApplicationShutdown(); return release; }); await expect(service.startSync()).rejects.toThrow(ServiceUnavailableException); expect(release).toHaveBeenCalledTimes(1); expect(brandService.syncBrands).not.toHaveBeenCalled(); }); }); });