- Rate limiting counters live in PostgreSQL (throttler_records), shared by every instance, one atomic upsert per request; fails open. - On shutdown, new syncs are refused (503) and the running one is waited for up to SYNC_SHUTDOWN_TIMEOUT; Prisma disconnects last. - Hiboutik calls never follow redirects and are capped at 50 MB. - Prisma errors no longer print query arguments (errorFormat minimal). - In production, DATABASE_URL must require TLS. - The sync lock connection times out after 10 s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
171 lines
6.7 KiB
TypeScript
171 lines
6.7 KiB
TypeScript
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<void>((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<void> }, '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<void>((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<void>(() => 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();
|
|
});
|
|
});
|
|
});
|