fix(api): add sync safety guards against degenerate SDS responses
Add SYNC_GUARDS thresholds so a partial/degenerate upstream response never triggers a destructive operation: - skip stale category deletion when the fetched tree is suspiciously small vs the existing SDS category count - skip delist detection unless both leaf-category and seen-product counts are healthy Verified: 77 tests pass; live SDS returns 226 categories (guard off), incident-case ratios (2/226, 2/2) are correctly blocked.
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { SyncService } from './sync.service';
|
||||
import {
|
||||
SyncService,
|
||||
shouldRunDelistDetection,
|
||||
shouldSkipStaleDeletion,
|
||||
} from './sync.service';
|
||||
import { SdsClientService } from './sds-client.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@@ -168,6 +172,98 @@ describe('SyncService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('sync guard thresholds', () => {
|
||||
describe('shouldSkipStaleDeletion', () => {
|
||||
it('skips stale deletion when the fetched count is below the hard floor', () => {
|
||||
expect(shouldSkipStaleDeletion(2, 226)).toBe(true);
|
||||
expect(shouldSkipStaleDeletion(9, 226)).toBe(true);
|
||||
});
|
||||
|
||||
it('skips stale deletion when fetched is far smaller than existing (ratio guard)', () => {
|
||||
expect(shouldSkipStaleDeletion(100, 250)).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT skip when fetched count is healthy', () => {
|
||||
expect(shouldSkipStaleDeletion(226, 226)).toBe(false);
|
||||
expect(shouldSkipStaleDeletion(200, 226)).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT skip when there are no existing SDS categories', () => {
|
||||
expect(shouldSkipStaleDeletion(0, 0)).toBe(false);
|
||||
expect(shouldSkipStaleDeletion(2, 0)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldRunDelistDetection', () => {
|
||||
it('skips delist detection when leaf categories are too few', () => {
|
||||
expect(shouldRunDelistDetection(2, 500)).toBe(false);
|
||||
expect(shouldRunDelistDetection(9, 500)).toBe(false);
|
||||
});
|
||||
|
||||
it('skips delist detection when the seen product count is too small', () => {
|
||||
expect(shouldRunDelistDetection(148, 2)).toBe(false);
|
||||
expect(shouldRunDelistDetection(148, 49)).toBe(false);
|
||||
});
|
||||
|
||||
it('runs delist detection only when both metrics are healthy', () => {
|
||||
expect(shouldRunDelistDetection(148, 500)).toBe(true);
|
||||
expect(shouldRunDelistDetection(10, 50)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('category sync keeps existing SDS categories when upstream returns a degenerate tree', async () => {
|
||||
const stamp = Date.now();
|
||||
const keep = await prisma.category.create({
|
||||
data: { sdsCategoryId: `keep-${stamp}`, categoryName: `Keep ${stamp}` },
|
||||
});
|
||||
createdSdsCategoryIds.push(`keep-${stamp}`);
|
||||
|
||||
sds.fetchCategoryTree.mockResolvedValueOnce([
|
||||
{ id: `g-${stamp}-1`, name: 'Tiny 1' },
|
||||
{ id: `g-${stamp}-2`, name: 'Tiny 2' },
|
||||
]);
|
||||
createdSdsCategoryIds.push(`g-${stamp}-1`, `g-${stamp}-2`);
|
||||
|
||||
const result = await service.syncCategories();
|
||||
expect(result.deletedStale).toBe(0);
|
||||
|
||||
const still = await prisma.category.findUnique({ where: { id: keep.id } });
|
||||
expect(still).not.toBeNull();
|
||||
});
|
||||
|
||||
it('product sync does NOT delist origin goods when it sees too few products', async () => {
|
||||
const stamp = Date.now();
|
||||
const leaf = await prisma.category.create({
|
||||
data: { sdsCategoryId: `leafguard-${stamp}`, categoryName: `LeafGuard ${stamp}` },
|
||||
});
|
||||
createdSdsCategoryIds.push(`leafguard-${stamp}`);
|
||||
|
||||
const active = await prisma.originGood.create({
|
||||
data: { sdsGoodId: `active-${stamp}`, delisted: false, goodName: 'Active' },
|
||||
});
|
||||
createdSdsGoodIds.push(`active-${stamp}`);
|
||||
|
||||
sds.fetchProductsPage.mockImplementation(async (categoryId) => {
|
||||
if (categoryId === `leafguard-${stamp}`) {
|
||||
return {
|
||||
content: [
|
||||
{ id: `guardp-${stamp}-1`, name: 'P1' },
|
||||
{ id: `guardp-${stamp}-2`, name: 'P2' },
|
||||
],
|
||||
};
|
||||
}
|
||||
return { content: [] };
|
||||
});
|
||||
|
||||
const result = await service.syncProducts();
|
||||
expect(result.delisted).toBe(0);
|
||||
|
||||
const still = await prisma.originGood.findUnique({ where: { id: active.id } });
|
||||
expect(still?.delisted).toBe(false);
|
||||
createdSdsGoodIds.push(`guardp-${stamp}-1`, `guardp-${stamp}-2`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatus', () => {
|
||||
it('returns recent logs ordered by startedAt desc', async () => {
|
||||
const logs = await service.getStatus(5);
|
||||
|
||||
Reference in New Issue
Block a user