app.test.ts 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
  1. import assert from 'node:assert/strict';
  2. import type { AddressInfo } from 'node:net';
  3. import test from 'node:test';
  4. import type { Pool } from 'pg';
  5. import { createApp } from '../src/app.js';
  6. import { loadConfig } from '../src/config/env.js';
  7. const config = loadConfig({
  8. NODE_ENV: 'test',
  9. HOST: '127.0.0.1',
  10. PORT: '4400',
  11. DATABASE_URL: 'postgres://runtime:password@127.0.0.1:5432/saas_voc',
  12. PARSE_APP_ID: 'test-app-id',
  13. PARSE_MASTER_KEY: 'a'.repeat(32),
  14. PARSE_MAINTENANCE_KEY: 'b'.repeat(32),
  15. PARSE_SERVER_URL: 'http://127.0.0.1:4400/parse',
  16. API_AUTH_MODE: 'disabled',
  17. FMODE_BASE_URL: 'http://127.0.0.1:3000/api/voc-e-commerce',
  18. FMODE_API_KEY: 'test-key',
  19. CORS_ORIGINS: 'http://127.0.0.1:4300',
  20. });
  21. function createMockPool(missingTable = ''): Pool {
  22. return {
  23. async query(text: string) {
  24. if (text.includes('to_regclass')) {
  25. const readiness: Record<string, string | null> = {
  26. workspace: 'voc.workspace',
  27. product: 'voc.product',
  28. review: 'voc.review',
  29. sync_job: 'voc.sync_job',
  30. workspace_member: 'voc.workspace_member',
  31. analysis_run: 'voc.analysis_run',
  32. action_item: 'voc.action_item',
  33. alert: 'voc.alert',
  34. audit_log: 'voc.audit_log',
  35. };
  36. if (missingTable) readiness[missingTable] = null;
  37. return {
  38. rows: [readiness],
  39. rowCount: 1,
  40. };
  41. }
  42. if (text.includes('WITH target_workspace')) {
  43. return {
  44. rows: [{
  45. public_id: '11111111-1111-4111-8111-111111111111',
  46. workspace_public_id: 'demashi',
  47. platform: 'jd',
  48. status: 'pending',
  49. scopes: ['product', 'reviews'],
  50. product_ids: ['11266507445'],
  51. progress: 0,
  52. attempts: 0,
  53. max_attempts: 3,
  54. error_summary: null,
  55. requested_at: '2026-07-23T00:00:00.000Z',
  56. started_at: null,
  57. completed_at: null,
  58. }],
  59. rowCount: 1,
  60. };
  61. }
  62. if (text.includes('FROM voc.workspace_member')) {
  63. return {
  64. rows: [{
  65. id: '1',
  66. public_id: 'demashi',
  67. user_external_id: 'local-admin',
  68. email: 'local-admin@localhost',
  69. display_name: 'Local Admin',
  70. role: 'owner',
  71. status: 'active',
  72. created_at: '2026-07-23T00:00:00.000Z',
  73. updated_at: '2026-07-23T00:00:00.000Z',
  74. }],
  75. rowCount: 1,
  76. };
  77. }
  78. if (text.includes('FROM voc.workspace')) {
  79. return { rows: [{ id: '1', public_id: 'demashi', case_name: 'Demashi' }], rowCount: 1 };
  80. }
  81. return { rows: [], rowCount: 0 };
  82. },
  83. } as unknown as Pool;
  84. }
  85. test('HTTP surface exposes health, empty snapshot, and sync queue contract', async () => {
  86. const app = createApp({ config, pool: createMockPool() });
  87. const server = await new Promise<ReturnType<typeof app.listen>>((resolve) => {
  88. const listening = app.listen(0, '127.0.0.1', () => resolve(listening));
  89. });
  90. try {
  91. const address = server.address() as AddressInfo;
  92. const baseUrl = `http://127.0.0.1:${address.port}`;
  93. const health = await fetch(`${baseUrl}/health`);
  94. assert.equal(health.status, 200);
  95. assert.equal((await health.json() as { database: string }).database, 'ready');
  96. const snapshot = await fetch(`${baseUrl}/api/domestic-voc/snapshot?workspaceId=demashi&platform=jd`);
  97. assert.equal(snapshot.status, 200);
  98. assert.deepEqual((await snapshot.json() as { products: unknown[] }).products, []);
  99. const sync = await fetch(`${baseUrl}/api/domestic-voc/sync`, {
  100. method: 'POST',
  101. headers: { 'Content-Type': 'application/json' },
  102. body: JSON.stringify({ productIds: ['11266507445'] }),
  103. });
  104. assert.equal(sync.status, 202);
  105. assert.equal((await sync.json() as { job: { status: string } }).job.status, 'pending');
  106. } finally {
  107. await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
  108. }
  109. });
  110. test('health reports a required platform migration instead of false readiness', async () => {
  111. const app = createApp({ config, pool: createMockPool('action_item') });
  112. const server = await new Promise<ReturnType<typeof app.listen>>((resolve) => {
  113. const listening = app.listen(0, '127.0.0.1', () => resolve(listening));
  114. });
  115. try {
  116. const address = server.address() as AddressInfo;
  117. const response = await fetch(`http://127.0.0.1:${address.port}/health`);
  118. assert.equal(response.status, 503);
  119. const body = await response.json() as { database: string; missingTables: string[] };
  120. assert.equal(body.database, 'migration_required');
  121. assert.deepEqual(body.missingTables, ['voc.action_item']);
  122. } finally {
  123. await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
  124. }
  125. });