import assert from 'node:assert/strict'; import test from 'node:test'; import type { ClaimedSyncJob } from '../src/modules/domestic-voc/jobs/sync-worker.js'; import type { SyncJobFinalStatus, SyncPersistence, } from '../src/modules/domestic-voc/repositories/voc-ingestion.repository.js'; import { JdSyncService, type GatewayRequestClient } from '../src/modules/domestic-voc/services/jd-sync.service.js'; import { FmodeRequestError } from '../src/modules/domestic-voc/upstream/fmode-client.js'; class FakePersistence implements SyncPersistence { products = 0; reviews = 0; progress: number[] = []; finished: Array<{ status: SyncJobFinalStatus; errorSummary?: string }> = []; requeued: string[] = []; events: Array<{ eventType: string; message: string }> = []; async upsertProduct() { this.products += 1; } async ensureProductStub() {} async upsertReviews(_workspaceId: string, _platform: string, _productId: string, reviews: unknown[]) { this.reviews += reviews.length; return reviews.length; } async setJobProgress(_jobInternalId: string, progress: number) { this.progress.push(progress); } async finishJob(_jobInternalId: string, status: SyncJobFinalStatus, errorSummary?: string) { this.finished.push({ status, ...(errorSummary ? { errorSummary } : {}) }); } async requeueJob(_jobInternalId: string, errorSummary: string) { this.requeued.push(errorSummary); } async addJobEvent(input: { eventType: string; message: string }) { this.events.push({ eventType: input.eventType, message: input.message }); } } function job(overrides: Partial = {}): ClaimedSyncJob { return { internalId: '1', publicId: '11111111-1111-4111-8111-111111111111', workspaceId: 'demashi', platform: 'jd', scopes: ['product', 'reviews'], productIds: ['11266507445'], attempts: 1, maxAttempts: 3, ...overrides, }; } test('JD sync calls only registered company gateway paths and completes', async () => { const calls: Array<{ path: string; params: Record | undefined }> = []; const gateway: GatewayRequestClient = { async request(path, init) { calls.push({ path, params: init?.params }); if (path === 'jd/get-item-detail/v1') { return { code: 200, data: { code: 200, data: { item: { itemId: '11266507445', itemName: 'Product' } } } } as never; } return { code: 200, data: { code: 200, data: { comments: [{ commentId: 'c1', score: 5, content: 'Useful evidence.' }] } }, } as never; }, }; const persistence = new FakePersistence(); await new JdSyncService(gateway, persistence, 1).process(job()); assert.deepEqual(calls, [ { path: 'jd/get-item-detail/v1', params: { itemId: '11266507445' } }, { path: 'jd/get-item-comments/v1', params: { itemId: '11266507445', page: 1 } }, ]); assert.equal(persistence.products, 1); assert.equal(persistence.reviews, 1); assert.deepEqual(persistence.finished, [{ status: 'completed' }]); assert.equal(persistence.progress.at(-1), 100); }); test('JD sync returns a safe partial status when reviews are not permitted', async () => { const gateway: GatewayRequestClient = { async request(path) { if (path === 'jd/get-item-detail/v1') { return { code: 200, data: { item: { itemId: '11266507445', itemName: 'Product' } } } as never; } throw new FmodeRequestError('Bearer secret-value raw 403 response', 403, false); }, }; const persistence = new FakePersistence(); await new JdSyncService(gateway, persistence).process(job()); assert.deepEqual(persistence.finished, [{ status: 'partial', errorSummary: 'Data gateway permission is unavailable', }]); assert.doesNotMatch(JSON.stringify(persistence.events), /secret-value|Bearer|raw 403/); }); test('JD sync requeues temporary gateway failures while attempts remain', async () => { const gateway: GatewayRequestClient = { async request() { throw new FmodeRequestError('network internals', undefined, true); }, }; const persistence = new FakePersistence(); await new JdSyncService(gateway, persistence).process(job({ scopes: ['product'] })); assert.deepEqual(persistence.finished, []); assert.deepEqual(persistence.requeued, ['Data gateway is temporarily unavailable']); });