listing-ai.pagination.test.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. import assert from 'node:assert/strict';
  2. import test from 'node:test';
  3. import type { ParseRestClient } from '../src/db/parse-rest.client.js';
  4. import { VOC_PARSE_CLASSES } from '../src/db/parse-rest.schema.js';
  5. import { ApiError } from '../src/http/api-error.js';
  6. import type { ListingAiRepository, ListingProductQuery, ListingScoreResult, ListingSourceSnapshot } from '../src/modules/listing-ai/domain.js';
  7. import { InMemoryListingAiRepository } from '../src/modules/listing-ai/repositories/in-memory-listing-ai.repository.js';
  8. import { ParseRestListingAiRepository } from '../src/modules/listing-ai/repositories/parse-rest-listing-ai.repository.js';
  9. import { LISTING_RUBRIC_VERSION } from '../src/modules/listing-ai/scoring/rule-engine.js';
  10. const WORKSPACE_ID = 'listing-pagination-625';
  11. function productId(index: number): string {
  12. return `JD${String(index).padStart(4, '0')}`;
  13. }
  14. function sourceAt(index: number): ListingSourceSnapshot {
  15. const id = productId(index);
  16. const day = String(index % 25 + 1).padStart(2, '0');
  17. return {
  18. id: `source-${id}`,
  19. workspaceId: WORKSPACE_ID,
  20. platform: 'jd',
  21. shopId: 'shop-1',
  22. productId: id,
  23. sourceHash: index.toString(16).padStart(64, '0'),
  24. title: `商品 ${id}`,
  25. titleBrandName: '测试品牌',
  26. brand: { id: 'brand-1', name: '测试品牌' },
  27. categoryIds: [`category-${index % 5}`],
  28. itemStatus: '1',
  29. price: { jd: 100 + index, cost: null },
  30. descriptions: { desktopHtml: '<p>完整详情</p>', mobileHtml: '<p>完整详情</p>' },
  31. features: [{ key: 'feature', value: '可靠卖点' }],
  32. attributes: [{ id: 'attribute-1', name: '规格', values: ['标准'] }],
  33. images: [{ url: `https://img.test/${id}.jpg`, order: 1, isPrimary: true, gptFlag: null }],
  34. skus: [{ skuId: `sku-${id}`, name: '标准', price: 100 + index, stock: 10, status: '1', attributes: [] }],
  35. dimensions: { length: 1, width: 1, height: 1, weight: 1 },
  36. logistics: {},
  37. afterService: {},
  38. sourceModifiedAt: null,
  39. syncedAt: `2026-08-${day}T12:00:00.000Z`,
  40. detailStatus: 'available',
  41. };
  42. }
  43. function scoreAt(source: ListingSourceSnapshot, index: number): ListingScoreResult {
  44. return {
  45. id: `score-${source.productId}`,
  46. workspaceId: source.workspaceId,
  47. productId: source.productId,
  48. sourceHash: source.sourceHash,
  49. rubricVersion: LISTING_RUBRIC_VERSION,
  50. overallScore: index * 37 % 101,
  51. coverage: { percent: 100, missing: [], status: 'eligible' },
  52. dimensions: [],
  53. aiStatus: 'not_requested',
  54. aiSuggestions: [],
  55. aiCandidate: null,
  56. model: null,
  57. promptVersion: null,
  58. scoreKind: 'rules',
  59. createdAt: `2026-08-26T${String(index % 24).padStart(2, '0')}:00:00.000Z`,
  60. };
  61. }
  62. const sources = Array.from({ length: 625 }, (_, offset) => sourceAt(offset + 1)).reverse();
  63. const scores = sources.map((source) => scoreAt(source, Number(source.productId.slice(2))));
  64. async function seededMemory(): Promise<InMemoryListingAiRepository> {
  65. const repository = new InMemoryListingAiRepository(sources);
  66. for (const score of scores) await repository.upsertCurrentScore(score);
  67. return repository;
  68. }
  69. async function traverse(repository: ListingAiRepository, sort: NonNullable<ListingProductQuery['sort']>, limit: number) {
  70. const items = [];
  71. let cursor: string | null = null;
  72. let pages = 0;
  73. do {
  74. const page = await repository.listProducts({ workspaceId: WORKSPACE_ID, platform: 'jd', sort, limit, cursor });
  75. assert.ok(page.items.length <= limit);
  76. items.push(...page.items);
  77. cursor = page.nextCursor;
  78. pages += 1;
  79. assert.ok(pages <= 30, 'cursor traversal must terminate');
  80. } while (cursor);
  81. return { items, pages };
  82. }
  83. function expectedIds(sort: NonNullable<ListingProductQuery['sort']>): string[] {
  84. return sources.map((source) => ({
  85. productId: source.productId,
  86. score: Number(source.productId.slice(2)) * 37 % 101,
  87. syncedAt: source.syncedAt,
  88. })).sort((left, right) => {
  89. if (sort === 'score_asc') return left.score - right.score || left.productId.localeCompare(right.productId);
  90. if (sort === 'score_desc') return right.score - left.score || left.productId.localeCompare(right.productId);
  91. if (sort === 'updated_desc') return right.syncedAt.localeCompare(left.syncedAt) || left.productId.localeCompare(right.productId);
  92. return left.productId.localeCompare(right.productId);
  93. }).map((item) => item.productId);
  94. }
  95. test('625 listings traverse every existing sort without duplicates or omissions at 25 and 100 item boundaries', async () => {
  96. const repository = await seededMemory();
  97. for (const sort of ['productId', 'score_asc', 'score_desc', 'updated_desc'] as const) {
  98. for (const limit of [25, 100]) {
  99. const result = await traverse(repository, sort, limit);
  100. const ids = result.items.map((item) => item.productId);
  101. assert.equal(ids.length, 625, `${sort}/${limit} returns the complete cohort`);
  102. assert.equal(new Set(ids).size, 625, `${sort}/${limit} has no duplicates`);
  103. assert.deepEqual(ids, expectedIds(sort), `${sort}/${limit} is globally sorted`);
  104. assert.equal(result.pages, limit === 25 ? 25 : 7);
  105. assert.equal(result.items.length % limit, limit === 25 ? 0 : 25);
  106. }
  107. }
  108. });
  109. test('Parse REST product traversal loads the complete cohort before score sorting', async () => {
  110. let boundedFindCalled = false;
  111. const sourceRows = sources.map((payload) => ({
  112. objectId: payload.id,
  113. naturalKey: payload.id,
  114. workspaceId: payload.workspaceId,
  115. productId: payload.productId,
  116. platform: payload.platform,
  117. payload,
  118. }));
  119. const scoreRows = scores.map((payload) => ({
  120. objectId: payload.id,
  121. naturalKey: payload.id,
  122. workspaceId: payload.workspaceId,
  123. productId: payload.productId,
  124. payload,
  125. }));
  126. const client = {
  127. count: async (className: string) => className === VOC_PARSE_CLASSES.listingSourceSnapshot ? 625 : 0,
  128. find: async () => { boundedFindCalled = true; throw new Error('bounded Parse query must not be used for product pagination'); },
  129. findAll: async (className: string) => className === VOC_PARSE_CLASSES.listingSourceSnapshot ? sourceRows : scoreRows,
  130. } as unknown as ParseRestClient;
  131. const repository = new ParseRestListingAiRepository(client);
  132. const result = await traverse(repository, 'score_desc', 100);
  133. assert.equal(boundedFindCalled, false);
  134. assert.equal(result.pages, 7);
  135. assert.deepEqual(result.items.map((item) => item.productId), expectedIds('score_desc'));
  136. });
  137. test('stable product cursors reject query changes and changed snapshots instead of mixing pages', async () => {
  138. const repository = await seededMemory();
  139. const first = await repository.listProducts({ workspaceId: WORKSPACE_ID, platform: 'jd', sort: 'score_desc', limit: 25, cursor: null });
  140. assert.ok(first.nextCursor);
  141. const cursorPayload = JSON.parse(Buffer.from(first.nextCursor, 'base64url').toString('utf8')) as Record<string, unknown>;
  142. assert.deepEqual(Object.keys(cursorPayload).sort(), ['direction', 'productId', 'queryHash', 'snapshotId', 'sortKey', 'sortValue', 'version']);
  143. assert.equal(cursorPayload['sortKey'], 'overallScore');
  144. assert.equal(cursorPayload['direction'], 'desc');
  145. assert.equal(typeof cursorPayload['sortValue'], 'number');
  146. assert.equal(typeof cursorPayload['productId'], 'string');
  147. await assert.rejects(
  148. repository.listProducts({ workspaceId: WORKSPACE_ID, platform: 'jd', sort: 'score_asc', limit: 25, cursor: first.nextCursor }),
  149. (error: unknown) => error instanceof ApiError && error.status === 409 && error.code === 'listing_overview_cursor_stale',
  150. );
  151. const changed = { ...sources[0]!, syncedAt: '2026-08-26T13:00:00.000Z' };
  152. await repository.upsertSources([changed]);
  153. await assert.rejects(
  154. repository.listProducts({ workspaceId: WORKSPACE_ID, platform: 'jd', sort: 'score_desc', limit: 25, cursor: first.nextCursor }),
  155. (error: unknown) => error instanceof ApiError && error.status === 409 && error.code === 'listing_overview_cursor_stale',
  156. );
  157. });
  158. test('null scores sort last in both directions with productId as the stable tie-breaker', async () => {
  159. const selectedSources = [sourceAt(1), sourceAt(2), sourceAt(3), sourceAt(4)];
  160. const repository = new InMemoryListingAiRepository(selectedSources);
  161. await repository.upsertCurrentScore(scoreAt(selectedSources[0]!, 1));
  162. await repository.upsertCurrentScore(scoreAt(selectedSources[2]!, 1));
  163. for (const sort of ['score_asc', 'score_desc'] as const) {
  164. const page = await repository.listProducts({ workspaceId: WORKSPACE_ID, platform: 'jd', sort, limit: 25, cursor: null });
  165. assert.deepEqual(page.items.map((item) => item.productId), [productId(1), productId(3), productId(2), productId(4)]);
  166. assert.equal(page.nextCursor, null);
  167. }
  168. });