Files
dinggadingga-backend/src/services/hiboutik/hiboutik.service.spec.ts
MastersGame 3dd5369175 feat(security): share rate limits across instances, drain syncs on shutdown
- 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>
2026-11-08 12:21:15 +01:00

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();
});
});
});