local-app.test.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. import assert from 'node:assert/strict';
  2. import type { AddressInfo } from 'node:net';
  3. import test from 'node:test';
  4. import { createLocalDemoApp } from '../src/local-app.js';
  5. import type { DomesticDataset, DomesticMetricSummary } from '../src/types/domestic-dataset.js';
  6. const emptyMetrics: DomesticMetricSummary = {
  7. gmv: 0,
  8. soldUnits: 0,
  9. transactionOrders: 0,
  10. transactionCustomers: 0,
  11. impressions: 0,
  12. clicks: 0,
  13. views: 0,
  14. visitors: 0,
  15. cartUnits: 0,
  16. orderAmount: 0,
  17. orderUnits: 0,
  18. orderCount: 0,
  19. refundAmount: 0,
  20. refundUnits: 0,
  21. refundOrders: 0,
  22. conversionRate: 0,
  23. clickThroughRate: 0,
  24. averageUnitPrice: 0,
  25. refundToGmvRate: 0,
  26. };
  27. const dataset: DomesticDataset = {
  28. schemaVersion: 1,
  29. generatedAt: '2026-07-23T00:00:00.000Z',
  30. caseName: 'Demashi',
  31. platform: 'jd',
  32. source: {
  33. sourceFile: 'demashi-summary.json',
  34. sourceHash: 'test',
  35. sheets: [],
  36. dateRange: { start: '2026-07-01', end: '2026-07-23' },
  37. },
  38. summary: {
  39. metricRows: 0,
  40. metricProducts: 1,
  41. mappingRows: 0,
  42. relations: 0,
  43. uniqueCompetitorProducts: 0,
  44. category2Count: 0,
  45. category3Count: 0,
  46. reviewCount: 0,
  47. },
  48. dailyTotals: [],
  49. products: [{
  50. platform: 'jd',
  51. productId: '11266507445',
  52. productKey: 'jd:11266507445',
  53. asin: '11266507445',
  54. role: 'own',
  55. brand: 'Demashi',
  56. title: 'Local demo product',
  57. model: '',
  58. category1: '',
  59. category2: '',
  60. category3: '',
  61. source: 'excel',
  62. relationCount: 0,
  63. summary: emptyMetrics,
  64. trend: [],
  65. }],
  66. mappingGroups: [],
  67. relations: [],
  68. reviews: [],
  69. quality: {
  70. orphanMappings: [],
  71. mappingsWithoutCompetitor: [],
  72. brandWithoutProductId: [],
  73. },
  74. };
  75. test('local demo serves a snapshot and queryable completed sync jobs without a database', async () => {
  76. const app = createLocalDemoApp({ dataset, corsOrigins: ['http://localhost:4200'] });
  77. const server = await new Promise<ReturnType<typeof app.listen>>((resolve) => {
  78. const listening = app.listen(0, '127.0.0.1', () => resolve(listening));
  79. });
  80. try {
  81. const address = server.address() as AddressInfo;
  82. const baseUrl = `http://127.0.0.1:${address.port}`;
  83. const health = await fetch(`${baseUrl}/health`);
  84. assert.equal(health.status, 200);
  85. const healthBody = await health.json() as {
  86. mode: string;
  87. database: string;
  88. dataset: { products: number; reviews: number };
  89. };
  90. assert.equal(healthBody.mode, 'local-demo');
  91. assert.equal(healthBody.database, 'deferred');
  92. assert.deepEqual(healthBody.dataset, { caseName: 'Demashi', platform: 'jd', products: 1, reviews: 0 });
  93. const snapshot = await fetch(`${baseUrl}/api/domestic-voc/snapshot?workspaceId=demashi&platform=jd`);
  94. assert.equal(snapshot.status, 200);
  95. assert.equal((await snapshot.json() as DomesticDataset).products[0]?.productId, '11266507445');
  96. const knowledge = await fetch(`${baseUrl}/api/knowledge/products?workspaceId=demashi&status=active`);
  97. assert.equal(knowledge.status, 200);
  98. assert.equal((await knowledge.json() as { items: Array<{ productId: string }> }).items[0]?.productId, '11266507445');
  99. const savedKnowledge = await fetch(`${baseUrl}/api/knowledge/products`, {
  100. method: 'PUT',
  101. headers: { 'Content-Type': 'application/json' },
  102. body: JSON.stringify({
  103. workspaceId: 'demashi',
  104. productKey: 'jd:11266507445',
  105. productId: '11266507445',
  106. productRole: 'own',
  107. featured: true,
  108. status: 'active',
  109. tags: ['经营TOP', '演示重点'],
  110. note: '本地后端知识库写入验证',
  111. ownerUserId: 'local-admin',
  112. }),
  113. });
  114. assert.equal(savedKnowledge.status, 200);
  115. const savedItem = (await savedKnowledge.json() as { item: { tags: string[]; note: string } }).item;
  116. assert.deepEqual(savedItem.tags, ['经营TOP', '演示重点']);
  117. assert.equal(savedItem.note, '本地后端知识库写入验证');
  118. const syncResponse = await fetch(`${baseUrl}/api/domestic-voc/sync`, {
  119. method: 'POST',
  120. headers: { 'Content-Type': 'application/json', 'Idempotency-Key': 'local-demo-sync-11266507445' },
  121. body: JSON.stringify({ productIds: ['11266507445'] }),
  122. });
  123. assert.equal(syncResponse.status, 202);
  124. const syncBody = await syncResponse.json() as { job: { id: string; status: string; progress: number } };
  125. assert.equal(syncBody.job.status, 'completed');
  126. assert.equal(syncBody.job.progress, 100);
  127. const jobResponse = await fetch(`${baseUrl}/api/domestic-voc/jobs/${syncBody.job.id}`);
  128. assert.equal(jobResponse.status, 200);
  129. assert.equal((await jobResponse.json() as { job: { status: string } }).job.status, 'completed');
  130. const partialResponse = await fetch(`${baseUrl}/api/domestic-voc/sync`, {
  131. method: 'POST',
  132. headers: { 'Content-Type': 'application/json', 'Idempotency-Key': 'local-demo-sync-missing-product' },
  133. body: JSON.stringify({ productIds: ['missing-product'] }),
  134. });
  135. assert.equal(partialResponse.status, 202);
  136. const partial = await partialResponse.json() as { job: { id: string; status: string } };
  137. assert.equal(partial.job.status, 'partial');
  138. const retryResponse = await fetch(`${baseUrl}/api/domestic-voc/jobs/${partial.job.id}/retry`, { method: 'POST' });
  139. assert.equal(retryResponse.status, 202);
  140. assert.equal((await retryResponse.json() as { job: { status: string } }).job.status, 'partial');
  141. const eventsResponse = await fetch(`${baseUrl}/api/domestic-voc/jobs/${partial.job.id}/events`);
  142. assert.equal(eventsResponse.status, 200);
  143. const eventTypes = (await eventsResponse.json() as { items: Array<{ type: string }> }).items.map((item) => item.type);
  144. assert.deepEqual(eventTypes, ['sync_finished', 'sync_manually_retried']);
  145. const invalidCancel = await fetch(`${baseUrl}/api/domestic-voc/jobs/${syncBody.job.id}/cancel`, { method: 'POST' });
  146. assert.equal(invalidCancel.status, 409);
  147. } finally {
  148. await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
  149. }
  150. });
  151. test('local demo workspace id is configurable and becomes the request default', async () => {
  152. const app = createLocalDemoApp({
  153. dataset,
  154. workspaceId: 'case-two',
  155. corsOrigins: ['http://localhost:4200'],
  156. });
  157. const server = await new Promise<ReturnType<typeof app.listen>>((resolve) => {
  158. const listening = app.listen(0, '127.0.0.1', () => resolve(listening));
  159. });
  160. try {
  161. const address = server.address() as AddressInfo;
  162. const baseUrl = `http://127.0.0.1:${address.port}`;
  163. const context = await fetch(`${baseUrl}/api/saas/context`);
  164. assert.equal(context.status, 200);
  165. assert.equal((await context.json() as { workspaces: Array<{ id: string }> }).workspaces[0]?.id, 'case-two');
  166. const snapshot = await fetch(`${baseUrl}/api/domestic-voc/snapshot?platform=jd`);
  167. assert.equal(snapshot.status, 200);
  168. assert.equal((await snapshot.json() as DomesticDataset).caseName, 'Demashi');
  169. const sync = await fetch(`${baseUrl}/api/domestic-voc/sync`, {
  170. method: 'POST',
  171. headers: { 'Content-Type': 'application/json', 'Idempotency-Key': 'case-two-default-workspace' },
  172. body: JSON.stringify({ productIds: ['11266507445'] }),
  173. });
  174. assert.equal(sync.status, 202);
  175. assert.equal((await sync.json() as { job: { workspaceId: string } }).job.workspaceId, 'case-two');
  176. } finally {
  177. await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
  178. }
  179. });