import assert from 'node:assert/strict'; import type { AddressInfo } from 'node:net'; import test from 'node:test'; import { createLocalDemoApp } from '../src/local-app.js'; import { InMemoryListingAiRepository } from '../src/modules/listing-ai/repositories/in-memory-listing-ai.repository.js'; import { ListingAiService } from '../src/modules/listing-ai/listing-ai.service.js'; import { ApiError } from '../src/http/api-error.js'; import type { ListingScoreResult, ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js'; import { LISTING_AI_RUBRIC_VERSION } from '../src/modules/listing-ai/scoring/ai-rubric.js'; import { scoreListing } from '../src/modules/listing-ai/scoring/rule-engine.js'; import { scoreJdVocRules } from '../src/modules/listing-ai/scoring/jd-voc-rule-engine.js'; import type { DomesticDataset, DomesticMetricSummary } from '../src/types/domestic-dataset.js'; const metrics: DomesticMetricSummary = { gmv: 0, soldUnits: 0, transactionOrders: 0, transactionCustomers: 0, impressions: 0, clicks: 0, views: 0, visitors: 0, cartUnits: 0, orderAmount: 0, orderUnits: 0, orderCount: 0, refundAmount: 0, refundUnits: 0, refundOrders: 0, conversionRate: 0, clickThroughRate: 0, averageUnitPrice: 0, refundToGmvRate: 0 }; const dataset: DomesticDataset = { schemaVersion: 1, generatedAt: '2026-08-21T00:00:00.000Z', caseName: 'Listing test', platform: 'jd', source: { sourceFile: 'test.json', sourceHash: 'test', dateRange: { start: '2026-08-21', end: '2026-08-21' } }, summary: { metricRows: 0, metricProducts: 1, mappingRows: 0, relations: 0, uniqueCompetitorProducts: 0, category2Count: 0, category3Count: 0, reviewCount: 0 }, dailyTotals: [], products: [{ platform: 'jd', productId: '1001', productKey: 'jd:1001', asin: '1001', role: 'own', brand: '星星', title: '测试商品', model: '', category1: '', category2: '', category3: '', source: 'test', relationCount: 0, summary: metrics, trend: [] }], mappingGroups: [], relations: [], reviews: [], quality: { orphanMappings: [], mappingsWithoutCompetitor: [], brandWithoutProductId: [] }, }; const listingSource: ListingSourceSnapshot = { id: 'source-1', workspaceId: 'demashi', platform: 'jd', shopId: 'shop', productId: '1001', sourceHash: 'b'.repeat(64), title: '星星 商用冷藏展示柜 299L 一级能效风冷无霜', titleBrandName: '星星', brand: { id: '1', name: '星星' }, categoryIds: ['20'], categoryContext: { names: ['商用冷藏展示柜'], coreTerms: ['商用冷藏展示柜'], requiredSpecificationNames: ['容量'], qualificationNames: [], ruleVersion: 'test-category-v1' }, itemStatus: '1', price: { jd: 1049, cost: null }, descriptions: { desktopHtml: '

安全详情

', mobileHtml: '

移动详情

' }, descriptionStructure: { observed: true, imageCount: 5, videoCount: 0, headingCount: 1, faqCandidateCount: 0 }, features: [{ key: 'one', value: '299L 大容量' }, { key: 'two', value: '一级能效' }, { key: 'three', value: '风冷无霜' }], attributes: [{ id: '1', name: '容量', values: ['299L'] }, { id: '2', name: '能效', values: ['一级'] }, { id: '3', name: '制冷', values: ['风冷'] }], images: Array.from({ length: 5 }, (_, i) => ({ url: `https://img.test/${i}.jpg`, order: i + 1, isPrimary: i === 0, gptFlag: null })), skus: [{ skuId: 'sku1', name: '299L', price: 1049, stock: 1, status: '1', attributes: [{ id: '1', name: '容量', values: ['299L'] }] }], dimensions: { length: 1, width: 1, height: 1, weight: 1 }, logistics: {}, afterService: { return7Days: true }, marketing: { adword: '299L 大容量 一级能效', skuShortTitles: [{ skuId: 'sku1', value: '299L 高效冷藏' }], sellingPoints: [{ value: '299L 大容量 一级能效', source: 'product_adword', fieldPath: 'productInfo.adword', skuId: null }, { value: '299L 高效冷藏', source: 'sku_short_title', fieldPath: 'skuList[].features[key=shortTitle]', skuId: 'sku1' }] }, sourceModifiedAt: null, syncedAt: '2026-08-21T00:00:00.000Z', detailStatus: 'available', }; test('listing API scores a frozen source, sanitizes HTML, and adopts an internal version', async () => { const repository = new InMemoryListingAiRepository([listingSource]); const app = createLocalDemoApp({ dataset, corsOrigins: ['http://localhost:4200'], listingAiRepository: repository }); const server = await new Promise>((resolve) => { const listener = app.listen(0, '127.0.0.1', () => resolve(listener)); }); try { const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}/api/listing-ai`; const products = await fetch(`${base}/products`); assert.equal(products.status, 200); const catalog = await products.json() as { items: Array & { productId: string }>; summary: { sourceTotal: number } }; assert.equal(catalog.summary.sourceTotal, 1); assert.equal(catalog.items[0]?.productId, '1001'); assert.equal(catalog.items[0]?.['sourceHash'], undefined); assert.equal(catalog.items[0]?.['rubricVersion'], undefined); assert.equal(catalog.items[0]?.['latestScore'], undefined); assert.equal(catalog.items[0]?.['scoreText'], '等待智能评分'); const detail = await fetch(`${base}/products/1001`); const detailBody = await detail.json() as { source: { descriptions: { desktopHtml: string } } }; assert.equal(detail.status, 200); assert.doesNotMatch(detailBody.source.descriptions.desktopHtml, /script|onclick/i); const jobResponse = await fetch(`${base}/score-jobs`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': 'listing-route-test-1' }, body: JSON.stringify({ scope: { mode: 'filter', filter: {} }, includeAiSuggestions: false }), }); assert.equal(jobResponse.status, 202); const createdJob = (await jobResponse.json() as { job: { id: string; statusLabel: string; rubricVersion?: string } }).job; const jobId = createdJob.id; assert.equal(createdJob.statusLabel, '等待处理'); let status = ''; for (let index = 0; index < 30; index += 1) { const response = await fetch(`${base}/score-jobs/${jobId}`); status = (await response.json() as { job: { status: string } }).job.status; if (['completed', 'partial', 'failed'].includes(status)) break; await new Promise((resolve) => setTimeout(resolve, 10)); } assert.equal(status, 'partial'); const scored = await fetch(`${base}/products/1001`); const scoredBody = await scored.json() as { currentScore: { score: number | null; scoreText: string; standardLabel: string } }; assert.equal(scoredBody.currentScore.score, null); assert.equal(scoredBody.currentScore.standardLabel, '京东五维评分 V7'); const versionResponse = await fetch(`${base}/products/1001/versions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ baseSourceHash: listingSource.sourceHash, content: { title: `${listingSource.title} 优化稿`, sellingPoints: ['299L 大容量', '一级能效', '风冷无霜'], descriptionHtml: '

优化详情

', specifications: listingSource.attributes, imageUrls: listingSource.images.map((item) => item.url), }, }), }); assert.equal(versionResponse.status, 201); const versionId = (await versionResponse.json() as { version: { id: string } }).version.id; const adopted = await fetch(`${base}/versions/${versionId}/adopt`, { method: 'POST' }); assert.equal(adopted.status, 200); assert.equal((await adopted.json() as { version: { status: string } }).version.status, 'adopted'); } finally { await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); } }); test('score-job scoringMode uses JD-VOC v0.5 and the approved display selector keeps legacy results queryable', async () => { const repository = new InMemoryListingAiRepository([listingSource]); const service = new ListingAiService(repository, undefined, () => new Date('2026-09-05T00:00:00.000Z'), 1, 10, undefined, undefined, true); const app = createLocalDemoApp({ dataset, corsOrigins: ['http://localhost:4200'], listingAiRepository: repository, listingAiService: service }); const server = await new Promise>((resolve) => { const listener = app.listen(0, '127.0.0.1', () => resolve(listener)); }); try { const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}/api/listing-ai`; const response = await fetch(`${base}/score-jobs`, { method: 'POST', headers: { 'content-type': 'application/json', 'idempotency-key': 'jd-voc-route-job-1' }, body: JSON.stringify({ scope: { mode: 'selected', productIds: [listingSource.productId] }, scoringMode: 'rules', rescorePolicy: 'reuse' }), }); assert.equal(response.status, 202); const job = (await response.json() as { job: { id: string; rubricVersion: string } }).job; assert.equal(job.rubricVersion, 'jd-voc-v0.5'); for (let index = 0; index < 50; index += 1) { const current = await repository.getJob(listingSource.workspaceId, job.id); if (current && ['completed', 'partial', 'failed'].includes(current.status)) break; await new Promise((resolve) => setTimeout(resolve, 5)); } const jdVoc = await repository.getJdVocCurrentScore(listingSource.workspaceId, listingSource.productId, 'jd_voc_rules'); assert.equal(jdVoc?.rubricVersion, 'jd-voc-v0.5'); assert.equal(jdVoc?.dimensions.length, 6); const detail = await fetch(`${base}/products/${listingSource.productId}`); const detailBody = await detail.json() as { displayScoreKind: string; jdVocScore: { sourceHash: string; inputFingerprint: string }; currentScore: unknown }; assert.equal(detail.status, 200); assert.equal(detailBody.displayScoreKind, 'jd_voc'); assert.equal(detailBody.jdVocScore.sourceHash, listingSource.sourceHash); assert.ok(detailBody.jdVocScore.inputFingerprint); const defaultScore = await fetch(`${base}/products/${listingSource.productId}/score`); const defaultBody = await defaultScore.json() as { displayScoreKind: string; score: { rubricVersion: string } }; assert.equal(defaultBody.displayScoreKind, 'jd_voc'); assert.equal(defaultBody.score.rubricVersion, 'jd-voc-v0.5'); } finally { await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); } }); test('JD_VOC_DISPLAY_DEFAULT=false immediately restores the legacy selector without deleting JD-VOC slots', async () => { const repository = new InMemoryListingAiRepository([listingSource]); await repository.upsertCurrentScore(scoreListing(listingSource, { now:'2026-09-05T00:00:00.000Z' })); await repository.upsertJdVocCurrentScore(scoreJdVocRules(listingSource, {}, { now:'2026-09-05T00:00:00.000Z' })); const service = new ListingAiService(repository, undefined, () => new Date('2026-09-05T00:00:00.000Z'), 1, 10, undefined, undefined, false, true); const app = createLocalDemoApp({ dataset, corsOrigins:['http://localhost:4200'], listingAiRepository:repository, listingAiService:service }); const server = await new Promise>((resolve)=>{const listener=app.listen(0,'127.0.0.1',()=>resolve(listener));}); try{ const base=`http://127.0.0.1:${(server.address() as AddressInfo).port}/api/listing-ai/products/${listingSource.productId}`; const detail=await (await fetch(base)).json() as {displayScoreKind:string;jdVocScore:unknown;currentScore:unknown}; assert.equal(detail.displayScoreKind,'legacy'); assert.ok(detail.currentScore); assert.ok(detail.jdVocScore,'new slot remains queryable during rollback'); const score=await (await fetch(`${base}/score`)).json() as {displayScoreKind?:string;score:{standardLabel?:string}}; assert.equal(score.displayScoreKind,undefined); assert.equal(score.score.standardLabel,'京东五维评分 V7'); }finally{await new Promise((resolve,reject)=>server.close((error)=>error?reject(error):resolve()));} }); test('a changed source hash hides stale scores and marks prior versions stale', async () => { const repository = new InMemoryListingAiRepository([listingSource]); const now = '2026-08-21T01:00:00.000Z'; await repository.upsertCurrentScore({ id: 'score-old', workspaceId: listingSource.workspaceId, productId: listingSource.productId, sourceHash: listingSource.sourceHash, rubricVersion: 'listing-jd-v1', overallScore: 88, coverage: { percent: 100, missing: [], status: 'eligible' }, dimensions: [], aiStatus: 'not_requested', aiSuggestions: [], aiCandidate: null, model: null, promptVersion: null, createdAt: now, }); const version = await repository.createVersion({ id: 'version-old', workspaceId: listingSource.workspaceId, productId: listingSource.productId, versionNo: 0, baseSourceHash: listingSource.sourceHash, content: { title: listingSource.title, sellingPoints: [], descriptionHtml: null, specifications: [], imageUrls: [] }, status: 'draft', createdBy: 'test', createdAt: now, adoptedAt: null, }); const changed = { ...listingSource, id: 'source-2', sourceHash: 'c'.repeat(64), syncedAt: '2026-08-21T02:00:00.000Z' }; await repository.upsertSources([changed]); assert.equal((await repository.listProducts({ workspaceId: changed.workspaceId, platform: 'jd', limit: 10, cursor: null })).items[0]?.latestScore, null); assert.equal((await repository.getVersion(changed.workspaceId, version.id))?.status, 'stale'); }); test('AI jobs fail closed before enqueueing beyond the configured item budget', async () => { const second = { ...listingSource, id: 'source-budget-2', productId: '1002', sourceHash: 'd'.repeat(64) }; const service = new ListingAiService( new InMemoryListingAiRepository([listingSource, second]), undefined, () => new Date('2026-08-21T03:00:00.000Z'), 1, 1, ); await assert.rejects( service.enqueueScoreJob({ workspaceId: listingSource.workspaceId, platform: 'jd', scope: { mode: 'filter', filter: {} }, includeAiSuggestions: true, idempotencyKey: 'budget-guard-test', requestedBy: 'test', }), (error: unknown) => error instanceof ApiError && error.status === 429 && error.code === 'listing_ai_budget_exceeded', ); }); test('critical compliance findings prevent adopting an internal version', async () => { const blockedSource = { ...listingSource, productId: 'blocked-1', sourceHash: '9'.repeat(64), title: '星星 商用冷藏展示柜 联系电话:13800138000' }; const repository = new InMemoryListingAiRepository([blockedSource]); const service = new ListingAiService(repository, undefined, () => new Date('2026-08-24T02:00:00.000Z')); const job = await service.enqueueScoreJob({ workspaceId: blockedSource.workspaceId, platform: 'jd', scope: { mode: 'selected', productIds: [blockedSource.productId] }, includeAiSuggestions: false, idempotencyKey: 'blocked-compliance-score', requestedBy: 'test' }); for (let index = 0; index < 30; index += 1) { if (['completed', 'partial', 'failed'].includes((await repository.getJob(blockedSource.workspaceId, job.id))?.status ?? '')) break; await new Promise((resolve) => setTimeout(resolve, 5)); } const version = await service.createVersion({ workspaceId: blockedSource.workspaceId, productId: blockedSource.productId, platform: 'jd', baseSourceHash: blockedSource.sourceHash, content: { title: blockedSource.title, sellingPoints: [], descriptionHtml: null, specifications: blockedSource.attributes, imageUrls: blockedSource.images.map((item) => item.url) }, createdBy: 'test' }); await assert.rejects(service.adoptVersion(blockedSource.workspaceId, 'jd', version.id), (error: unknown) => error instanceof ApiError && error.code === 'listing_compliance_blocked'); }); test('JD-VOC compliance BLOCK prevents adoption even when the legacy selector has no block', async () => { const blockedSource = { ...listingSource, productId: 'jd-blocked-1', sourceHash: '8'.repeat(64), title: '星星 商用冷藏展示柜 加微信购买' }; const repository = new InMemoryListingAiRepository([blockedSource]); const service = new ListingAiService(repository, undefined, () => new Date('2026-09-05T00:00:00.000Z')); const score = await service.scoreJdVocRules({ workspaceId: blockedSource.workspaceId, platform:'jd', productId:blockedSource.productId }); assert.equal(score.compliance?.gate, 'BLOCK'); const version = await service.createVersion({ workspaceId:blockedSource.workspaceId,platform:'jd',productId:blockedSource.productId,baseSourceHash:blockedSource.sourceHash,content:{title:blockedSource.title,sellingPoints:[],descriptionHtml:null,specifications:blockedSource.attributes,imageUrls:blockedSource.images.map((item)=>item.url)},createdBy:'test' }); await assert.rejects(service.adoptVersion(blockedSource.workspaceId,'jd',version.id),(error:unknown)=>error instanceof ApiError&&error.code==='listing_compliance_blocked'); }); test('score history routes are physically removed', async () => { const repository = new InMemoryListingAiRepository([listingSource]); const app = createLocalDemoApp({ dataset, corsOrigins: ['http://localhost:4200'], listingAiRepository: repository }); const server = await new Promise>((resolve) => { const listener = app.listen(0, '127.0.0.1', () => resolve(listener)); }); try { const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}/api/listing-ai/products/1001`; assert.equal((await fetch(`${base}/scores`)).status, 404); assert.equal((await fetch(`${base}/scores/latest`)).status, 404); } finally { await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); } }); test('overview route returns the current five-dimension read model, aggregates, facets, and validates ranges', async () => { const repository = new InMemoryListingAiRepository([listingSource]); const overviewScore: ListingScoreResult = { id: 'overview-simulation-score', workspaceId: listingSource.workspaceId, productId: listingSource.productId, sourceHash: listingSource.sourceHash, rubricVersion: LISTING_AI_RUBRIC_VERSION, overallScore: 82, coverage: { percent: 100, missing: [], status: 'eligible' }, dimensions: [ { dimension: 'title', score: 24, maxScore: 30, coverage: 100, status: 'scored', evidence: [], suggestions: [] }, { dimension: 'selling_points', score: 21, maxScore: 25, coverage: 100, status: 'scored', evidence: [], suggestions: [] }, { dimension: 'images', score: 15, maxScore: 20, coverage: 100, status: 'scored', evidence: [], suggestions: [] }, { dimension: 'description', score: 13, maxScore: 15, coverage: 100, status: 'scored', evidence: [], suggestions: [] }, { dimension: 'specifications', score: 9, maxScore: 10, coverage: 100, status: 'scored', evidence: [], suggestions: [] }, ], aiStatus: 'completed', aiSuggestions: [], aiCandidate: null, model: 'listing-v7-demo-simulation', promptVersion: 'test', scoreKind: 'hybrid_ai', createdAt: '2026-08-26T12:00:00.000Z', }; await repository.upsertCurrentScore(overviewScore); const app = createLocalDemoApp({ dataset, corsOrigins: ['http://localhost:4200'], listingAiRepository: repository }); const server = await new Promise>((resolve) => { const listener = app.listen(0, '127.0.0.1', () => resolve(listener)); }); try { const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}/api/listing-ai`; const response = await fetch(`${base}/overview?categoryIds=20&sort=images&direction=asc&limit=25`); assert.equal(response.status, 200); const body = await response.json() as { items: Array & { productId: string; scoreNature: string; overallScore: number; weakestDimension: string; dimensions: { images: { rate: number; gap: number } } }>; nextCursor: string | null; summary: { sourceTotal: number; matchedTotal: number; scoredTotal: number; simulationTotal: number; snapshotId: string; scoreDistribution: unknown[]; dimensionStats: Record; categoryFacets: unknown[]; scoreNatureFacets: unknown[] }; }; assert.equal(body.items[0]?.productId, listingSource.productId); assert.equal(body.items[0]?.scoreNature, 'simulation'); assert.equal(body.items[0]?.overallScore, 82); assert.equal(body.items[0]?.weakestDimension, 'images'); assert.deepEqual(body.items[0]?.dimensions.images, { score: 15, maxScore: 20, rate: 0.75, gap: 5 }); assert.equal(body.items[0]?.['sourceHash'], undefined); assert.equal(body.items[0]?.['model'], undefined); assert.equal(body.summary.sourceTotal, 1); assert.equal(body.summary.matchedTotal, 1); assert.equal(body.summary.scoredTotal, 1); assert.equal(body.summary.simulationTotal, 1); assert.ok(body.summary.snapshotId); assert.equal(body.summary.scoreDistribution.length, 5); assert.ok(body.summary.dimensionStats['images']); assert.equal(body.summary.categoryFacets.length, 1); assert.equal(body.summary.scoreNatureFacets.length, 4); assert.equal(body.nextCursor, null); const invalid = await fetch(`${base}/overview?minScore=90&maxScore=80`); assert.equal(invalid.status, 400); assert.equal((await invalid.json() as { error: string }).error, 'invalid_request'); } finally { await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); } });