import assert from 'node:assert/strict';
import test from 'node:test';
import type { ParseRestClient } from '../src/db/parse-rest.client.js';
import { VOC_PARSE_CLASSES } from '../src/db/parse-rest.schema.js';
import { ApiError } from '../src/http/api-error.js';
import type { ListingAiRepository, ListingProductQuery, ListingScoreResult, ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js';
import { InMemoryListingAiRepository } from '../src/modules/listing-ai/repositories/in-memory-listing-ai.repository.js';
import { ParseRestListingAiRepository } from '../src/modules/listing-ai/repositories/parse-rest-listing-ai.repository.js';
import { LISTING_RUBRIC_VERSION } from '../src/modules/listing-ai/scoring/rule-engine.js';
const WORKSPACE_ID = 'listing-pagination-625';
function productId(index: number): string {
return `JD${String(index).padStart(4, '0')}`;
}
function sourceAt(index: number): ListingSourceSnapshot {
const id = productId(index);
const day = String(index % 25 + 1).padStart(2, '0');
return {
id: `source-${id}`,
workspaceId: WORKSPACE_ID,
platform: 'jd',
shopId: 'shop-1',
productId: id,
sourceHash: index.toString(16).padStart(64, '0'),
title: `商品 ${id}`,
titleBrandName: '测试品牌',
brand: { id: 'brand-1', name: '测试品牌' },
categoryIds: [`category-${index % 5}`],
itemStatus: '1',
price: { jd: 100 + index, cost: null },
descriptions: { desktopHtml: '
完整详情
', mobileHtml: '完整详情
' },
features: [{ key: 'feature', value: '可靠卖点' }],
attributes: [{ id: 'attribute-1', name: '规格', values: ['标准'] }],
images: [{ url: `https://img.test/${id}.jpg`, order: 1, isPrimary: true, gptFlag: null }],
skus: [{ skuId: `sku-${id}`, name: '标准', price: 100 + index, stock: 10, status: '1', attributes: [] }],
dimensions: { length: 1, width: 1, height: 1, weight: 1 },
logistics: {},
afterService: {},
sourceModifiedAt: null,
syncedAt: `2026-08-${day}T12:00:00.000Z`,
detailStatus: 'available',
};
}
function scoreAt(source: ListingSourceSnapshot, index: number): ListingScoreResult {
return {
id: `score-${source.productId}`,
workspaceId: source.workspaceId,
productId: source.productId,
sourceHash: source.sourceHash,
rubricVersion: LISTING_RUBRIC_VERSION,
overallScore: index * 37 % 101,
coverage: { percent: 100, missing: [], status: 'eligible' },
dimensions: [],
aiStatus: 'not_requested',
aiSuggestions: [],
aiCandidate: null,
model: null,
promptVersion: null,
scoreKind: 'rules',
createdAt: `2026-08-26T${String(index % 24).padStart(2, '0')}:00:00.000Z`,
};
}
const sources = Array.from({ length: 625 }, (_, offset) => sourceAt(offset + 1)).reverse();
const scores = sources.map((source) => scoreAt(source, Number(source.productId.slice(2))));
async function seededMemory(): Promise {
const repository = new InMemoryListingAiRepository(sources);
for (const score of scores) await repository.upsertCurrentScore(score);
return repository;
}
async function traverse(repository: ListingAiRepository, sort: NonNullable, limit: number) {
const items = [];
let cursor: string | null = null;
let pages = 0;
do {
const page = await repository.listProducts({ workspaceId: WORKSPACE_ID, platform: 'jd', sort, limit, cursor });
assert.ok(page.items.length <= limit);
items.push(...page.items);
cursor = page.nextCursor;
pages += 1;
assert.ok(pages <= 30, 'cursor traversal must terminate');
} while (cursor);
return { items, pages };
}
function expectedIds(sort: NonNullable): string[] {
return sources.map((source) => ({
productId: source.productId,
score: Number(source.productId.slice(2)) * 37 % 101,
syncedAt: source.syncedAt,
})).sort((left, right) => {
if (sort === 'score_asc') return left.score - right.score || left.productId.localeCompare(right.productId);
if (sort === 'score_desc') return right.score - left.score || left.productId.localeCompare(right.productId);
if (sort === 'updated_desc') return right.syncedAt.localeCompare(left.syncedAt) || left.productId.localeCompare(right.productId);
return left.productId.localeCompare(right.productId);
}).map((item) => item.productId);
}
test('625 listings traverse every existing sort without duplicates or omissions at 25 and 100 item boundaries', async () => {
const repository = await seededMemory();
for (const sort of ['productId', 'score_asc', 'score_desc', 'updated_desc'] as const) {
for (const limit of [25, 100]) {
const result = await traverse(repository, sort, limit);
const ids = result.items.map((item) => item.productId);
assert.equal(ids.length, 625, `${sort}/${limit} returns the complete cohort`);
assert.equal(new Set(ids).size, 625, `${sort}/${limit} has no duplicates`);
assert.deepEqual(ids, expectedIds(sort), `${sort}/${limit} is globally sorted`);
assert.equal(result.pages, limit === 25 ? 25 : 7);
assert.equal(result.items.length % limit, limit === 25 ? 0 : 25);
}
}
});
test('Parse REST product traversal loads the complete cohort before score sorting', async () => {
let boundedFindCalled = false;
const sourceRows = sources.map((payload) => ({
objectId: payload.id,
naturalKey: payload.id,
workspaceId: payload.workspaceId,
productId: payload.productId,
platform: payload.platform,
payload,
}));
const scoreRows = scores.map((payload) => ({
objectId: payload.id,
naturalKey: payload.id,
workspaceId: payload.workspaceId,
productId: payload.productId,
payload,
}));
const client = {
count: async (className: string) => className === VOC_PARSE_CLASSES.listingSourceSnapshot ? 625 : 0,
find: async () => { boundedFindCalled = true; throw new Error('bounded Parse query must not be used for product pagination'); },
findAll: async (className: string) => className === VOC_PARSE_CLASSES.listingSourceSnapshot ? sourceRows : scoreRows,
} as unknown as ParseRestClient;
const repository = new ParseRestListingAiRepository(client);
const result = await traverse(repository, 'score_desc', 100);
assert.equal(boundedFindCalled, false);
assert.equal(result.pages, 7);
assert.deepEqual(result.items.map((item) => item.productId), expectedIds('score_desc'));
});
test('stable product cursors reject query changes and changed snapshots instead of mixing pages', async () => {
const repository = await seededMemory();
const first = await repository.listProducts({ workspaceId: WORKSPACE_ID, platform: 'jd', sort: 'score_desc', limit: 25, cursor: null });
assert.ok(first.nextCursor);
const cursorPayload = JSON.parse(Buffer.from(first.nextCursor, 'base64url').toString('utf8')) as Record;
assert.deepEqual(Object.keys(cursorPayload).sort(), ['direction', 'productId', 'queryHash', 'snapshotId', 'sortKey', 'sortValue', 'version']);
assert.equal(cursorPayload['sortKey'], 'overallScore');
assert.equal(cursorPayload['direction'], 'desc');
assert.equal(typeof cursorPayload['sortValue'], 'number');
assert.equal(typeof cursorPayload['productId'], 'string');
await assert.rejects(
repository.listProducts({ workspaceId: WORKSPACE_ID, platform: 'jd', sort: 'score_asc', limit: 25, cursor: first.nextCursor }),
(error: unknown) => error instanceof ApiError && error.status === 409 && error.code === 'listing_overview_cursor_stale',
);
const changed = { ...sources[0]!, syncedAt: '2026-08-26T13:00:00.000Z' };
await repository.upsertSources([changed]);
await assert.rejects(
repository.listProducts({ workspaceId: WORKSPACE_ID, platform: 'jd', sort: 'score_desc', limit: 25, cursor: first.nextCursor }),
(error: unknown) => error instanceof ApiError && error.status === 409 && error.code === 'listing_overview_cursor_stale',
);
});
test('null scores sort last in both directions with productId as the stable tie-breaker', async () => {
const selectedSources = [sourceAt(1), sourceAt(2), sourceAt(3), sourceAt(4)];
const repository = new InMemoryListingAiRepository(selectedSources);
await repository.upsertCurrentScore(scoreAt(selectedSources[0]!, 1));
await repository.upsertCurrentScore(scoreAt(selectedSources[2]!, 1));
for (const sort of ['score_asc', 'score_desc'] as const) {
const page = await repository.listProducts({ workspaceId: WORKSPACE_ID, platform: 'jd', sort, limit: 25, cursor: null });
assert.deepEqual(page.items.map((item) => item.productId), [productId(1), productId(3), productId(2), productId(4)]);
assert.equal(page.nextCursor, null);
}
});