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:
yeuimu
2026-08-20 16:37:44 +08:00
parent 79fabd85f7
commit aed9afef92
6 changed files with 342 additions and 45 deletions
+97 -1
View File
@@ -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);