competitor-listing-monitor.test.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  1. import assert from 'node:assert/strict';
  2. import test from 'node:test';
  3. import { VOC_PARSE_CLASSES, VOC_PARSE_SCHEMAS } from '../src/db/parse-rest.schema.js';
  4. import type {
  5. CompetitorListingCollectionResult,
  6. CompetitorListingObservation,
  7. CompetitorListingSnapshot,
  8. } from '../src/modules/competitor-listing-monitor/domain.js';
  9. import {
  10. decideCompetitorListingSnapshot,
  11. normalizeCompetitorListingImageUrl,
  12. normalizeCompetitorListingText,
  13. } from '../src/modules/competitor-listing-monitor/snapshot-diff.js';
  14. function observation(overrides: Partial<CompetitorListingObservation> = {}): CompetitorListingObservation {
  15. return {
  16. workspaceId: 'demashi',
  17. platform: 'jd',
  18. productId: '1001',
  19. observedAt: '2026-08-26T01:00:00.000Z',
  20. title: '德玛仕 商用冰箱',
  21. priceCents: 199_900,
  22. availability: 'online',
  23. mainImageUrl: 'https://img.example.test/product.jpg',
  24. keySpecifications: {
  25. model: 'DM-100',
  26. color: '银色',
  27. specification: '100 L',
  28. origin: '广东',
  29. weightKg: '40',
  30. lengthMm: '600',
  31. widthMm: '650',
  32. heightMm: '1800',
  33. },
  34. observedFields: ['title', 'price', 'availability', 'main_image', 'key_specifications'],
  35. collectionStatus: 'succeeded',
  36. ...overrides,
  37. };
  38. }
  39. function baseline(overrides: Partial<CompetitorListingObservation> = {}): CompetitorListingSnapshot {
  40. const decision = decideCompetitorListingSnapshot(null, observation(overrides), { snapshotId: 'snapshot-1' });
  41. assert.equal(decision.status, 'baseline');
  42. return decision.snapshot;
  43. }
  44. test('first successful collection creates only an immutable baseline', () => {
  45. const result = decideCompetitorListingSnapshot(null, observation(), { snapshotId: 'snapshot-1' });
  46. assert.equal(result.status, 'baseline');
  47. assert.equal(result.snapshot.previousSnapshotId, null);
  48. assert.equal(result.snapshot.collectionStatus, 'succeeded');
  49. assert.equal(result.change, null);
  50. });
  51. test('equivalent normalized content creates neither a duplicate snapshot nor an event', () => {
  52. const previous = baseline();
  53. const result = decideCompetitorListingSnapshot(previous, observation({
  54. observedAt: '2026-08-27T01:00:00.000Z',
  55. title: ' 德玛仕\u3000商用冰箱 ',
  56. mainImageUrl: 'http://img.example.test/product.jpg?width=800&utm_source=test',
  57. observedFields: ['key_specifications', 'main_image', 'availability', 'price', 'title'],
  58. }), { snapshotId: 'snapshot-2' });
  59. assert.equal(result.status, 'unchanged');
  60. assert.equal(result.snapshot, null);
  61. assert.equal(result.change, null);
  62. assert.equal(normalizeCompetitorListingText('ABC\u3000 商品'), 'ABC 商品');
  63. assert.equal(normalizeCompetitorListingImageUrl('//img.example.test/product.jpg?t=123'), 'https://img.example.test/product.jpg');
  64. });
  65. test('price, title, main image, availability and individual specifications are detected', () => {
  66. const previous = baseline();
  67. const result = decideCompetitorListingSnapshot(previous, observation({
  68. observedAt: '2026-08-27T01:00:00.000Z',
  69. title: '德玛仕 商用冷柜',
  70. priceCents: 189_900,
  71. availability: 'offline',
  72. mainImageUrl: 'https://img.example.test/new-product.jpg',
  73. keySpecifications: {
  74. ...previous.keySpecifications,
  75. model: 'DM-200',
  76. widthMm: '700',
  77. },
  78. }), { snapshotId: 'snapshot-2', changeId: 'change-1' });
  79. assert.equal(result.status, 'changed');
  80. assert.deepEqual(result.change.changeTypes, [
  81. 'price', 'availability', 'title', 'main_image', 'key_specifications',
  82. ]);
  83. assert.deepEqual(result.change.changes.map((item) => item.field), [
  84. 'priceCents',
  85. 'availability',
  86. 'title',
  87. 'mainImageUrl',
  88. 'keySpecifications.model',
  89. 'keySpecifications.widthMm',
  90. ]);
  91. assert.equal(result.snapshot.previousSnapshotId, 'snapshot-1');
  92. assert.equal(result.change.naturalKey, 'snapshot-1:snapshot-2');
  93. });
  94. test('missing and unknown fields change coverage without producing false changes', () => {
  95. const previous = baseline();
  96. const result = decideCompetitorListingSnapshot(previous, observation({
  97. observedAt: '2026-08-27T01:00:00.000Z',
  98. title: null,
  99. priceCents: null,
  100. availability: 'unknown',
  101. mainImageUrl: null,
  102. keySpecifications: {
  103. ...previous.keySpecifications,
  104. model: null,
  105. },
  106. collectionStatus: 'partial',
  107. }), { snapshotId: 'snapshot-2' });
  108. assert.equal(result.status, 'unchanged');
  109. assert.ok(result.snapshot, 'a changed partial observation remains available for persistence');
  110. assert.deepEqual(result.snapshot.observedFields, ['key_specifications']);
  111. assert.equal(result.change, null);
  112. });
  113. test('unmonitored upstream fields do not participate in hashing or comparison', () => {
  114. const previous = baseline();
  115. const withUnknownFields = {
  116. ...observation({ observedAt: '2026-08-27T01:00:00.000Z' }),
  117. sellerName: 'a newly returned upstream field',
  118. observedFields: [
  119. 'title', 'price', 'availability', 'main_image', 'key_specifications', 'seller_name',
  120. ],
  121. } as unknown as CompetitorListingObservation;
  122. const result = decideCompetitorListingSnapshot(previous, withUnknownFields, { snapshotId: 'snapshot-2' });
  123. assert.equal(result.status, 'unchanged');
  124. assert.equal(result.snapshot, null);
  125. assert.equal(result.change, null);
  126. });
  127. test('an unknown specification sub-field is not interpreted as a deletion', () => {
  128. const previous = baseline();
  129. const result = decideCompetitorListingSnapshot(previous, observation({
  130. keySpecifications: { ...previous.keySpecifications, model: null, color: '黑色' },
  131. }), { snapshotId: 'snapshot-2' });
  132. assert.equal(result.status, 'changed');
  133. assert.deepEqual(result.change.changeTypes, ['key_specifications']);
  134. assert.deepEqual(result.change.changes, [{
  135. field: 'keySpecifications.color', before: '银色', after: '黑色',
  136. }]);
  137. });
  138. test('collection failure never creates an empty snapshot or an offline event', () => {
  139. const previous = baseline();
  140. const failure: CompetitorListingCollectionResult = {
  141. workspaceId: 'demashi', platform: 'jd', productId: '1001',
  142. observedAt: '2026-08-27T01:00:00.000Z', collectionStatus: 'failed', errorCode: 'jd_detail_failed',
  143. };
  144. const result = decideCompetitorListingSnapshot(previous, failure, { snapshotId: 'snapshot-2' });
  145. assert.deepEqual(result, {
  146. status: 'failed', snapshot: null, change: null, errorCode: 'jd_detail_failed',
  147. });
  148. });
  149. test('the three Parse classes required by phase 1 are registered', () => {
  150. const classNames = new Set(VOC_PARSE_SCHEMAS.map((schema) => schema.className));
  151. assert.equal(VOC_PARSE_CLASSES.competitorListingSnapshot, 'VocCompetitorListingSnapshot');
  152. assert.equal(VOC_PARSE_CLASSES.competitorListingChange, 'VocCompetitorListingChange');
  153. assert.equal(VOC_PARSE_CLASSES.competitorListingRefreshRun, 'VocCompetitorListingRefreshRun');
  154. assert.equal(classNames.has(VOC_PARSE_CLASSES.competitorListingSnapshot), true);
  155. assert.equal(classNames.has(VOC_PARSE_CLASSES.competitorListingChange), true);
  156. assert.equal(classNames.has(VOC_PARSE_CLASSES.competitorListingRefreshRun), true);
  157. });