insight-decision.repository.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. import assert from 'node:assert/strict';
  2. import { readFile } from 'node:fs/promises';
  3. import test from 'node:test';
  4. import type { ParseRestClient } from '../src/db/parse-rest.client.js';
  5. import { VOC_PARSE_CLASSES, VOC_PARSE_SCHEMAS } from '../src/db/parse-rest.schema.js';
  6. import type { Queryable } from '../src/db/types.js';
  7. import { ParseRestVocRepository } from '../src/modules/saas-platform/parse-rest-voc.repository.js';
  8. import { PostgresPlatformRepository } from '../src/modules/saas-platform/postgres-platform.repository.js';
  9. const timestamp = '2026-08-07T08:00:00.000Z';
  10. const decisionInput = {
  11. id: 'decision-2',
  12. workspaceId: 'workspace-1',
  13. sourceAnalysisId: 'analysis-1',
  14. sourceInsightId: 'insight-1',
  15. decision: 'confirmed' as const,
  16. reviewedEvidenceIds: ['evidence-1', 'evidence-1', 'evidence-2'],
  17. comment: 'Evidence reviewed by the product owner.',
  18. decidedBy: 'user-1',
  19. };
  20. const previousParseDecision = {
  21. objectId: 'parse-decision-1',
  22. publicId: 'decision-1',
  23. workspaceId: 'workspace-1',
  24. sourceAnalysisId: 'analysis-1',
  25. sourceInsightId: 'insight-1',
  26. decision: 'needs_more_evidence' as const,
  27. reviewedEvidenceIds: ['evidence-1'],
  28. comment: 'Review another sample.',
  29. decidedBy: 'user-1',
  30. decidedAt: { __type: 'Date', iso: timestamp },
  31. version: 1,
  32. supersedesId: null,
  33. isCurrent: true,
  34. createdAt: timestamp,
  35. updatedAt: timestamp,
  36. };
  37. test('Parse REST insight decisions append a version and retire the prior current record', async () => {
  38. let createdBody: Record<string, unknown> | null = null;
  39. const updates: Array<{ objectId: string; patch: Record<string, unknown> }> = [];
  40. const client = {
  41. async findOne(className: string) {
  42. assert.equal(className, VOC_PARSE_CLASSES.insightDecision);
  43. return null;
  44. },
  45. async findAll(className: string, where: Record<string, unknown>) {
  46. assert.equal(className, VOC_PARSE_CLASSES.insightDecision);
  47. assert.deepEqual(where, {
  48. workspaceId: 'workspace-1',
  49. sourceAnalysisId: 'analysis-1',
  50. sourceInsightId: 'insight-1',
  51. });
  52. return [previousParseDecision];
  53. },
  54. async create(className: string, body: Record<string, unknown>) {
  55. assert.equal(className, VOC_PARSE_CLASSES.insightDecision);
  56. createdBody = body;
  57. return { objectId: 'parse-decision-2', createdAt: timestamp, updatedAt: timestamp };
  58. },
  59. async update(className: string, objectId: string, patch: Record<string, unknown>) {
  60. assert.equal(className, VOC_PARSE_CLASSES.insightDecision);
  61. updates.push({ objectId, patch });
  62. return { updatedAt: timestamp };
  63. },
  64. } as unknown as ParseRestClient;
  65. const decision = await new ParseRestVocRepository(client).createInsightDecision(decisionInput);
  66. assert.equal((createdBody as unknown as { version: number }).version, 2);
  67. assert.equal((createdBody as unknown as { supersedesId: string }).supersedesId, 'decision-1');
  68. assert.deepEqual(
  69. (createdBody as unknown as { reviewedEvidenceIds: string[] }).reviewedEvidenceIds,
  70. ['evidence-1', 'evidence-2'],
  71. );
  72. assert.deepEqual(updates, [{ objectId: 'parse-decision-1', patch: { isCurrent: false } }]);
  73. assert.equal(decision.version, 2);
  74. assert.equal(decision.supersedesId, 'decision-1');
  75. assert.equal(decision.isCurrent, true);
  76. });
  77. test('Parse REST insight decision create returns an existing public ID without appending', async () => {
  78. let writes = 0;
  79. const client = {
  80. async findOne() {
  81. return previousParseDecision;
  82. },
  83. async findAll() {
  84. throw new Error('findAll should not run for an existing decision ID');
  85. },
  86. async create() {
  87. writes += 1;
  88. throw new Error('create should not run for an existing decision ID');
  89. },
  90. } as unknown as ParseRestClient;
  91. const decision = await new ParseRestVocRepository(client).createInsightDecision({
  92. ...decisionInput,
  93. id: 'decision-1',
  94. });
  95. assert.equal(decision.id, 'decision-1');
  96. assert.equal(writes, 0);
  97. });
  98. test('Parse REST insight decisions support filtered list and workspace-scoped get', async () => {
  99. const client = {
  100. async findAll(className: string, where: Record<string, unknown>) {
  101. assert.equal(className, VOC_PARSE_CLASSES.insightDecision);
  102. assert.deepEqual(where, { workspaceId: 'workspace-1' });
  103. return [previousParseDecision, { ...previousParseDecision, publicId: 'decision-retired', isCurrent: false }];
  104. },
  105. async findOne(className: string, where: Record<string, unknown>) {
  106. assert.equal(className, VOC_PARSE_CLASSES.insightDecision);
  107. assert.deepEqual(where, { workspaceId: 'workspace-1', publicId: 'decision-1' });
  108. return previousParseDecision;
  109. },
  110. } as unknown as ParseRestClient;
  111. const repository = new ParseRestVocRepository(client);
  112. const page = await repository.listInsightDecisions({
  113. workspaceId: 'workspace-1',
  114. limit: 10,
  115. cursor: null,
  116. sourceAnalysisId: 'analysis-1',
  117. sourceInsightId: 'insight-1',
  118. currentOnly: true,
  119. });
  120. const decision = await repository.getInsightDecision('workspace-1', 'decision-1');
  121. assert.deepEqual(page.items.map((item) => item.id), ['decision-1']);
  122. assert.equal(page.nextCursor, null);
  123. assert.equal(decision?.decision, 'needs_more_evidence');
  124. });
  125. test('Postgres insight decision create uses one append-and-retire statement and maps the version chain', async () => {
  126. const calls: Array<{ text: string; values: readonly unknown[] }> = [];
  127. const database = {
  128. async query(text: string, values: readonly unknown[] = []) {
  129. calls.push({ text, values });
  130. assert.match(text, /WITH target AS/);
  131. assert.match(text, /FOR UPDATE OF analysis/);
  132. assert.match(text, /SET is_current = false/);
  133. assert.match(text, /CROSS JOIN \(SELECT count\(\*\) FROM retired\)/);
  134. return {
  135. rows: [{
  136. public_id: 'decision-2',
  137. workspace_public_id: 'workspace-1',
  138. source_analysis_public_id: 'analysis-1',
  139. source_insight_id: 'insight-1',
  140. decision: 'confirmed',
  141. reviewed_evidence_ids: ['evidence-1', 'evidence-2'],
  142. comment: 'Evidence reviewed by the product owner.',
  143. decided_by_external_id: 'user-1',
  144. decided_at: timestamp,
  145. version: 2,
  146. supersedes_public_id: 'decision-1',
  147. is_current: true,
  148. created_at: timestamp,
  149. updated_at: timestamp,
  150. }],
  151. rowCount: 1,
  152. };
  153. },
  154. } as unknown as Queryable;
  155. const decision = await new PostgresPlatformRepository(database).createInsightDecision(decisionInput);
  156. assert.equal(calls.length, 1);
  157. assert.deepEqual(calls[0]!.values, [
  158. 'workspace-1',
  159. 'decision-2',
  160. 'analysis-1',
  161. 'insight-1',
  162. 'confirmed',
  163. JSON.stringify(['evidence-1', 'evidence-2']),
  164. 'Evidence reviewed by the product owner.',
  165. 'user-1',
  166. ]);
  167. assert.equal(decision.version, 2);
  168. assert.equal(decision.supersedesId, 'decision-1');
  169. assert.equal(decision.decidedAt, timestamp);
  170. });
  171. test('Postgres insight decisions support filtered list and workspace-scoped get', async () => {
  172. const calls: Array<{ text: string; values: readonly unknown[] }> = [];
  173. const row = {
  174. cursor_id: '42',
  175. public_id: 'decision-1',
  176. workspace_public_id: 'workspace-1',
  177. source_analysis_public_id: 'analysis-1',
  178. source_insight_id: 'insight-1',
  179. decision: 'needs_more_evidence',
  180. reviewed_evidence_ids: ['evidence-1'],
  181. comment: 'Review another sample.',
  182. decided_by_external_id: 'user-1',
  183. decided_at: timestamp,
  184. version: 1,
  185. supersedes_public_id: null,
  186. is_current: true,
  187. created_at: timestamp,
  188. updated_at: timestamp,
  189. };
  190. const database = {
  191. async query(text: string, values: readonly unknown[] = []) {
  192. calls.push({ text, values });
  193. return { rows: [row], rowCount: 1 };
  194. },
  195. } as unknown as Queryable;
  196. const repository = new PostgresPlatformRepository(database);
  197. const page = await repository.listInsightDecisions({
  198. workspaceId: 'workspace-1',
  199. limit: 10,
  200. cursor: null,
  201. sourceAnalysisId: 'analysis-1',
  202. sourceInsightId: 'insight-1',
  203. currentOnly: true,
  204. });
  205. const decision = await repository.getInsightDecision('workspace-1', 'decision-1');
  206. assert.deepEqual(calls[0]!.values, [
  207. 'workspace-1',
  208. '9223372036854775807',
  209. 'analysis-1',
  210. 'insight-1',
  211. true,
  212. 11,
  213. ]);
  214. assert.deepEqual(calls[1]!.values, ['workspace-1', 'decision-1']);
  215. assert.equal(page.items[0]?.id, 'decision-1');
  216. assert.equal(decision?.sourceAnalysisId, 'analysis-1');
  217. });
  218. test('Parse schema and PostgreSQL migrations declare decision, idempotency, and source guards', async () => {
  219. const decisionSchema = VOC_PARSE_SCHEMAS.find((schema) => schema.className === VOC_PARSE_CLASSES.insightDecision);
  220. const actionSchema = VOC_PARSE_SCHEMAS.find((schema) => schema.className === VOC_PARSE_CLASSES.actionItem);
  221. assert.ok(decisionSchema);
  222. assert.equal(decisionSchema.fields.isCurrent?.type, 'Boolean');
  223. assert.deepEqual(decisionSchema.indexes?.voc_insight_decision_source_current_idx, {
  224. workspaceId: 1,
  225. sourceAnalysisId: 1,
  226. sourceInsightId: 1,
  227. isCurrent: 1,
  228. });
  229. assert.equal(actionSchema?.fields.creationKey?.type, 'String');
  230. assert.deepEqual(actionSchema?.indexes?.voc_action_workspace_creation_key_idx, {
  231. workspaceId: 1,
  232. creationKey: 1,
  233. });
  234. const migration = await readFile(
  235. new URL('../migrations/005_insight_decision_action_idempotency.sql', import.meta.url),
  236. 'utf8',
  237. );
  238. assert.match(migration, /CREATE UNIQUE INDEX IF NOT EXISTS insight_decision_source_current_unique/);
  239. assert.match(migration, /CREATE UNIQUE INDEX IF NOT EXISTS action_item_workspace_creation_key_unique/);
  240. assert.match(migration, /CREATE TRIGGER insight_decision_append_only_guard/);
  241. assert.match(migration, /CREATE TRIGGER action_item_source_decision_guard/);
  242. const hardeningMigration = await readFile(
  243. new URL('../migrations/006_insight_action_guard_hardening.sql', import.meta.url),
  244. 'utf8',
  245. );
  246. assert.match(hardeningMigration, /reviewed evidence must cover every evidence ID/);
  247. assert.match(hardeningMigration, /non-insight actions cannot carry insight analysis, insight, or decision sources/);
  248. assert.match(hardeningMigration, /insight actions require analysis, insight, and decision sources/);
  249. assert.match(hardeningMigration, /UPDATE OF[\s\S]*action_type,[\s\S]*validation_metric,[\s\S]*evidence_ids/);
  250. assert.match(hardeningMigration, /ADD CONSTRAINT action_item_source_decision_kind_check[\s\S]*NOT VALID/);
  251. });