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