| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184 |
- 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: '<p>完整详情</p>', mobileHtml: '<p>完整详情</p>' },
- 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<InMemoryListingAiRepository> {
- const repository = new InMemoryListingAiRepository(sources);
- for (const score of scores) await repository.upsertCurrentScore(score);
- return repository;
- }
- async function traverse(repository: ListingAiRepository, sort: NonNullable<ListingProductQuery['sort']>, 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<ListingProductQuery['sort']>): 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<string, unknown>;
- 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);
- }
- });
|