import { Test } from '@nestjs/testing'; import { ConfigModule } from '@nestjs/config'; import { SyncService, shouldRunDelistDetection, shouldSkipStaleDeletion, } from './sync.service'; import { SdsClientService } from './sds-client.service'; import { PrismaService } from '../prisma/prisma.service'; import { FamilyRecomputeService } from '../product-families/family-recompute.service'; describe('SyncService', () => { let service: SyncService; let sds: jest.Mocked; let prisma: PrismaService; const createdSdsCategoryIds: string[] = []; const createdSdsGoodIds: string[] = []; beforeAll(async () => { const sdsMock: Partial = { fetchCategoryTree: jest.fn(), fetchProductsPage: jest.fn(), fetchProductDetail: jest.fn(async (goodId: string | number) => ({ id: goodId })), }; const moduleRef = await Test.createTestingModule({ imports: [ConfigModule.forRoot({ isGlobal: true })], providers: [ SyncService, { provide: SdsClientService, useValue: sdsMock }, { provide: FamilyRecomputeService, useValue: { enqueue: jest.fn() } }, PrismaService, ], }).compile(); service = moduleRef.get(SyncService); jest .spyOn(service, 'syncConfiguredProductDetails') .mockResolvedValue({ synced: 0, failed: 0 }); sds = moduleRef.get(SdsClientService) as jest.Mocked; prisma = moduleRef.get(PrismaService); await prisma.onModuleInit(); }); afterAll(async () => { if (createdSdsCategoryIds.length) { await prisma.category.deleteMany({ where: { sdsCategoryId: { in: createdSdsCategoryIds } }, }); } if (createdSdsGoodIds.length) { await prisma.originGood.deleteMany({ where: { sdsGoodId: { in: createdSdsGoodIds } }, }); } await prisma.onModuleDestroy(); }); it('should be defined', () => { expect(service).toBeDefined(); }); describe('flattenCategoryTree', () => { it('flattens a nested SDS tree and preserves parent linkage', () => { const tree = [ { id: 1, name: 'Root', children: [ { id: 11, name: 'Child A' }, { id: 12, name: 'Child B', children: [{ id: 121, name: 'Leaf' }] }, ], }, ]; const flat = service.flattenCategoryTree(tree); expect(flat).toHaveLength(4); const byId = Object.fromEntries(flat.map((n) => [n.sdsId, n])); expect(byId['1'].name).toBe('Root'); expect(byId['1'].parentSdsId).toBeUndefined(); expect(byId['11'].parentSdsId).toBe('1'); expect(byId['12'].parentSdsId).toBe('1'); expect(byId['121'].parentSdsId).toBe('12'); }); it('skips nodes without a usable id', () => { const flat = service.flattenCategoryTree([{ id: null, name: 'no-id' }]); expect(flat).toHaveLength(0); }); }); describe('syncCategories', () => { it('inserts + updates rows and links parents', async () => { const stamp = Date.now(); sds.fetchCategoryTree.mockResolvedValueOnce([ { id: `r-${stamp}`, name: `Root ${stamp}`, children: [{ id: `c-${stamp}`, name: `Child ${stamp}` }], }, ]); const result = await service.syncCategories(); expect(result.total).toBe(2); expect(result.inserted).toBe(2); expect(result.updated).toBe(0); createdSdsCategoryIds.push(`r-${stamp}`, `c-${stamp}`); const child = await prisma.category.findUnique({ where: { sdsCategoryId: `c-${stamp}` }, }); const root = await prisma.category.findUnique({ where: { sdsCategoryId: `r-${stamp}` }, }); expect(child?.parentCategoryId).toBe(root?.id); }); it('marks SyncLog SUCCESS', async () => { const logs = await prisma.syncLog.findMany({ where: { type: 'CATEGORIES' }, orderBy: { startedAt: 'desc' }, take: 1, }); expect(logs[0]?.status).toBe('SUCCESS'); }); }); describe('syncProducts', () => { beforeEach(() => { jest.clearAllMocks(); }); it('upserts origin goods by sdsGoodId and updates on re-run', async () => { // Seed a leaf category with sdsCategoryId so the sync has work. const stamp = Date.now(); const leaf = await prisma.category.create({ data: { sdsCategoryId: `leaf-${stamp}`, categoryName: `Leaf ${stamp}`, }, }); createdSdsCategoryIds.push(`leaf-${stamp}`); // Default mock returns a single page with 2 products, then // breaks the loop because content.length < 50. sds.fetchProductsPage.mockImplementation(async (categoryId) => { if (categoryId === `leaf-${stamp}`) { return { content: [ { id: `p-${stamp}-1`, name: 'Product 1', price: 12.5, pic: 'http://x' }, { id: `p-${stamp}-2`, name: 'Product 2', price: '99.00' }, ], }; } // For any other (already-existing) category, return empty // so the loop terminates immediately. return { content: [] }; }); const result1 = await service.syncProducts(); expect(result1.inserted).toBeGreaterThanOrEqual(2); createdSdsGoodIds.push(`p-${stamp}-1`, `p-${stamp}-2`); // Re-run with updated name -> should be `updated`, not `inserted`. sds.fetchProductsPage.mockImplementation(async (categoryId) => { if (categoryId === `leaf-${stamp}`) { return { content: [ { id: `p-${stamp}-1`, name: 'Product 1 renamed' }, ], }; } return { content: [] }; }); const result2 = await service.syncProducts(); expect(result2.updated).toBeGreaterThanOrEqual(1); const row = await prisma.originGood.findUnique({ where: { sdsGoodId: `p-${stamp}-1` }, }); expect(row?.goodName).toBe('Product 1 renamed'); }); }); 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); expect(Array.isArray(logs)).toBe(true); expect(logs.length).toBeGreaterThan(0); }); }); }); describe('SyncService product detail scopes', () => { const originGoods = [ { id: 1n, sdsGoodId: 'all-1' }, { id: 2n, sdsGoodId: 'all-2' }, ]; function createService() { const prisma = { originGood: { findMany: jest.fn().mockResolvedValue(originGoods) }, } as unknown as PrismaService; const sds = { fetchProductDetail: jest.fn(async (goodId: string) => ({ id: goodId })), } as unknown as SdsClientService; const familyRecompute = { enqueue: jest.fn(), } as unknown as FamilyRecomputeService; const scopedService = new SyncService(prisma, sds, familyRecompute); jest .spyOn(scopedService as any, 'persistProductDetail') .mockResolvedValue(undefined); return { scopedService, prisma, sds }; } it('manual detail sync selects every active origin product', async () => { const { scopedService, prisma, sds } = createService(); const result = await scopedService.syncAllProductDetails(); expect(prisma.originGood.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { delisted: false, source: 'SDS' } }), ); expect(sds.fetchProductDetail).toHaveBeenCalledTimes(2); expect(result).toEqual({ total: 2, synced: 2, failed: 0 }); }); it('hourly detail refresh remains limited to configured products', async () => { const { scopedService, prisma } = createService(); await scopedService.syncConfiguredProductDetails(); expect(prisma.originGood.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { delisted: false, source: 'SDS', goods: { some: {} } }, }), ); }); it('keeps hourly category/product sync separate from the daily detail sync', async () => { const { scopedService } = createService(); const categories = jest.spyOn(scopedService, 'syncCategories').mockResolvedValue({ inserted: 0, updated: 0, total: 0, deletedStale: 0, }); const products = jest.spyOn(scopedService, 'syncProducts').mockResolvedValue({ inserted: 0, updated: 0, total: 0, leafCategories: 0, delisted: 0, }); const details = jest.spyOn(scopedService, 'syncProductDetails').mockResolvedValue({ total: 0, synced: 0, failed: 0, }); await scopedService.hourlyCron(); expect(categories).toHaveBeenCalledTimes(1); expect(products).toHaveBeenCalledTimes(1); expect(details).not.toHaveBeenCalled(); await scopedService.dailyProductDetailCron(); expect(details).toHaveBeenCalledTimes(1); }); });