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()));
}
});