action-item-source.repository.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  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 type { Queryable } from '../src/db/types.js';
  5. import { ApiError } from '../src/http/api-error.js';
  6. import type { ActionItem } from '../src/modules/saas-platform/domain.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-06T08:00:00.000Z';
  10. const actionInput: Omit<ActionItem, 'completedAt' | 'createdAt' | 'updatedAt'> & {
  11. sourceDecisionId: string;
  12. sourceKind: 'insight';
  13. creationKey: string;
  14. } = {
  15. id: 'action-1',
  16. workspaceId: 'workspace-1',
  17. sourceAnalysisId: 'analysis-1',
  18. sourceInsightId: 'insight-1',
  19. sourceDecisionId: 'decision-1',
  20. sourceKind: 'insight',
  21. creationKey: 'workspace-1|analysis-1|insight-1|decision-1',
  22. evidenceIds: ['evidence-1', 'evidence-1', 'evidence-2'],
  23. validationMetric: 'Evidence coverage >= 80%',
  24. actionType: 'experience',
  25. title: 'Improve evidence traceability',
  26. description: 'Keep insight evidence attached to delivery.',
  27. priority: 'high',
  28. status: 'open',
  29. productKey: null,
  30. assigneeUserId: null,
  31. dueAt: null,
  32. createdBy: 'user-1',
  33. };
  34. const analysisRow = {
  35. public_id: 'analysis-1',
  36. workspace_public_id: 'workspace-1',
  37. analysis_type: 'voc_insight',
  38. target_kind: 'workspace',
  39. target_key: '',
  40. status: 'completed',
  41. input: {},
  42. result: { insights: [{ id: 'insight-1', evidenceIds: ['evidence-1', 'evidence-2'] }] },
  43. evidence_count: 2,
  44. requested_by_external_id: 'user-1',
  45. error_summary: null,
  46. requested_at: timestamp,
  47. started_at: timestamp,
  48. completed_at: timestamp,
  49. };
  50. const decisionRow = {
  51. public_id: 'decision-1',
  52. workspace_public_id: 'workspace-1',
  53. source_analysis_public_id: 'analysis-1',
  54. source_insight_id: 'insight-1',
  55. decision: 'confirmed',
  56. reviewed_evidence_ids: ['evidence-1', 'evidence-2'],
  57. comment: 'Evidence reviewed.',
  58. decided_by_external_id: 'user-1',
  59. decided_at: timestamp,
  60. version: 1,
  61. supersedes_public_id: null,
  62. is_current: true,
  63. created_at: timestamp,
  64. updated_at: timestamp,
  65. };
  66. test('Parse REST action repository writes and maps insight provenance', async () => {
  67. let createdBody: Record<string, unknown> | null = null;
  68. const client = {
  69. async findOne(className: string, where: Record<string, unknown>) {
  70. if (className === 'VocActionItem') {
  71. assert.equal(where.workspaceId, 'workspace-1');
  72. return null;
  73. }
  74. if (className === 'VocInsightDecision') {
  75. assert.deepEqual(where, { workspaceId: 'workspace-1', publicId: 'decision-1' });
  76. return {
  77. objectId: 'parse-decision-1',
  78. publicId: 'decision-1',
  79. workspaceId: 'workspace-1',
  80. sourceAnalysisId: 'analysis-1',
  81. sourceInsightId: 'insight-1',
  82. decision: 'confirmed',
  83. reviewedEvidenceIds: ['evidence-1', 'evidence-2'],
  84. comment: 'Evidence reviewed.',
  85. decidedBy: 'user-1',
  86. decidedAt: timestamp,
  87. version: 1,
  88. supersedesId: null,
  89. isCurrent: true,
  90. createdAt: timestamp,
  91. updatedAt: timestamp,
  92. };
  93. }
  94. assert.equal(className, 'VocAnalysisRun');
  95. assert.deepEqual(where, { workspaceId: 'workspace-1', publicId: 'analysis-1' });
  96. return {
  97. objectId: 'parse-analysis-1',
  98. createdAt: timestamp,
  99. publicId: 'analysis-1',
  100. workspaceId: 'workspace-1',
  101. analysisType: 'voc_insight',
  102. targetKind: 'workspace',
  103. targetKey: '',
  104. status: 'completed',
  105. input: {},
  106. result: { insights: [{ id: 'insight-1', evidenceIds: ['evidence-1', 'evidence-2'] }] },
  107. evidenceCount: 2,
  108. requestedBy: 'user-1',
  109. requestedAt: timestamp,
  110. startedAt: timestamp,
  111. completedAt: timestamp,
  112. };
  113. },
  114. async create(className: string, body: Record<string, unknown>) {
  115. assert.equal(className, 'VocActionItem');
  116. createdBody = body;
  117. return { objectId: 'parse-action-1', createdAt: timestamp };
  118. },
  119. } as unknown as ParseRestClient;
  120. const repository = new ParseRestVocRepository(client);
  121. const action = await repository.createAction(actionInput);
  122. assert.deepEqual((createdBody as unknown as { evidenceIds: string[] }).evidenceIds, ['evidence-1', 'evidence-2']);
  123. assert.equal((createdBody as unknown as { sourceAnalysisId: string }).sourceAnalysisId, 'analysis-1');
  124. assert.equal((createdBody as unknown as { sourceDecisionId: string }).sourceDecisionId, 'decision-1');
  125. assert.equal((createdBody as unknown as { sourceKind: string }).sourceKind, 'insight');
  126. assert.equal(
  127. (createdBody as unknown as { creationKey: string }).creationKey,
  128. 'workspace-1|analysis-1|insight-1|decision-1',
  129. );
  130. assert.equal(action.sourceInsightId, 'insight-1');
  131. assert.equal(action.sourceDecisionId, 'decision-1');
  132. assert.deepEqual(action.evidenceIds, ['evidence-1', 'evidence-2']);
  133. assert.equal(action.validationMetric, 'Evidence coverage >= 80%');
  134. await assert.rejects(
  135. repository.createAction({ ...actionInput, sourceInsightId: 'insight-unknown' }),
  136. (error) => error instanceof ApiError && error.code === 'source_insight_not_found',
  137. );
  138. await assert.rejects(
  139. repository.createAction({ ...actionInput, sourceAnalysisId: null }),
  140. (error) => error instanceof ApiError && error.code === 'source_insight_orphan',
  141. );
  142. });
  143. test('Postgres action repository converts the source public ID and maps provenance', async () => {
  144. const calls: Array<{ text: string; values: readonly unknown[] }> = [];
  145. const database = {
  146. async query(text: string, values: readonly unknown[] = []) {
  147. calls.push({ text, values });
  148. if (text.includes('action.creation_key = $2')) {
  149. return { rows: [], rowCount: 0 };
  150. }
  151. if (text.includes('FROM voc.analysis_run run')) {
  152. return { rows: [analysisRow], rowCount: 1 };
  153. }
  154. if (text.includes('FROM voc.insight_decision decision')) {
  155. return { rows: [decisionRow], rowCount: 1 };
  156. }
  157. assert.match(text, /INSERT INTO voc\.action_item/);
  158. assert.match(text, /source_analysis_id/);
  159. assert.match(text, /source_insight_id/);
  160. assert.match(text, /evidence_ids/);
  161. assert.match(text, /validation_metric/);
  162. return {
  163. rows: [{
  164. public_id: 'action-1',
  165. workspace_public_id: 'workspace-1',
  166. source_analysis_public_id: 'analysis-1',
  167. source_insight_id: 'insight-1',
  168. source_decision_id: 12,
  169. source_decision_public_id: 'decision-1',
  170. source_kind: 'insight',
  171. creation_key: 'workspace-1|analysis-1|insight-1|decision-1',
  172. evidence_ids: ['evidence-1', 'evidence-2'],
  173. validation_metric: 'Evidence coverage >= 80%',
  174. action_type: 'experience',
  175. title: 'Improve evidence traceability',
  176. description: 'Keep insight evidence attached to delivery.',
  177. priority: 'high',
  178. status: 'open',
  179. product_key: null,
  180. assignee_external_id: null,
  181. due_at: null,
  182. created_by_external_id: 'user-1',
  183. completed_at: null,
  184. created_at: timestamp,
  185. updated_at: timestamp,
  186. }],
  187. rowCount: 1,
  188. };
  189. },
  190. } as unknown as Queryable;
  191. const repository = new PostgresPlatformRepository(database);
  192. const action = await repository.createAction(actionInput);
  193. assert.equal(calls.length, 4);
  194. assert.equal(calls[3]!.values[11], 'analysis-1');
  195. assert.equal(calls[3]!.values[12], 'insight-1');
  196. assert.equal(calls[3]!.values[13], JSON.stringify(['evidence-1', 'evidence-2']));
  197. assert.equal(calls[3]!.values[14], 'Evidence coverage >= 80%');
  198. assert.equal(calls[3]!.values[15], 'decision-1');
  199. assert.equal(calls[3]!.values[16], 'insight');
  200. assert.equal(calls[3]!.values[17], 'workspace-1|analysis-1|insight-1|decision-1');
  201. assert.equal(action.sourceAnalysisId, 'analysis-1');
  202. assert.equal(action.sourceDecisionId, 'decision-1');
  203. assert.equal(action.sourceKind, 'insight');
  204. assert.equal(action.creationKey, 'workspace-1|analysis-1|insight-1|decision-1');
  205. assert.deepEqual(action.evidenceIds, ['evidence-1', 'evidence-2']);
  206. await assert.rejects(
  207. repository.createAction({ ...actionInput, evidenceIds: ['evidence-from-other-insight'] }),
  208. (error) => error instanceof ApiError && error.code === 'source_evidence_not_in_insight',
  209. );
  210. await assert.rejects(
  211. repository.createAction({ ...actionInput, sourceInsightId: null, evidenceIds: [] }),
  212. (error) => error instanceof ApiError && error.code === 'source_insight_required',
  213. );
  214. });
  215. test('action repositories return the existing creation key without creating another row', async () => {
  216. let parseCreates = 0;
  217. const parseClient = {
  218. async findOne(className: string, where: Record<string, unknown>) {
  219. if (className === 'VocAnalysisRun') {
  220. assert.deepEqual(where, { workspaceId: 'workspace-1', publicId: 'analysis-1' });
  221. return {
  222. objectId: 'parse-analysis-1',
  223. createdAt: timestamp,
  224. publicId: 'analysis-1',
  225. workspaceId: 'workspace-1',
  226. analysisType: 'voc_insight',
  227. targetKind: 'workspace',
  228. targetKey: '',
  229. status: 'completed',
  230. input: {},
  231. result: { insights: [{ id: 'insight-1', evidenceIds: ['evidence-1', 'evidence-2'] }] },
  232. evidenceCount: 2,
  233. requestedBy: 'user-1',
  234. requestedAt: timestamp,
  235. startedAt: timestamp,
  236. completedAt: timestamp,
  237. };
  238. }
  239. if (className === 'VocInsightDecision') {
  240. assert.deepEqual(where, { workspaceId: 'workspace-1', publicId: 'decision-1' });
  241. return {
  242. objectId: 'parse-decision-1',
  243. publicId: 'decision-1',
  244. workspaceId: 'workspace-1',
  245. sourceAnalysisId: 'analysis-1',
  246. sourceInsightId: 'insight-1',
  247. decision: 'confirmed',
  248. reviewedEvidenceIds: ['evidence-1', 'evidence-2'],
  249. comment: 'Evidence reviewed.',
  250. decidedBy: 'user-1',
  251. decidedAt: timestamp,
  252. version: 1,
  253. supersedesId: null,
  254. isCurrent: true,
  255. createdAt: timestamp,
  256. updatedAt: timestamp,
  257. };
  258. }
  259. assert.equal(className, 'VocActionItem');
  260. assert.deepEqual(where, {
  261. workspaceId: 'workspace-1',
  262. creationKey: 'workspace-1|analysis-1|insight-1|decision-1',
  263. });
  264. return {
  265. objectId: 'parse-action-1',
  266. publicId: 'action-existing',
  267. workspaceId: 'workspace-1',
  268. sourceAnalysisId: 'analysis-1',
  269. sourceInsightId: 'insight-1',
  270. sourceDecisionId: 'decision-1',
  271. sourceKind: 'insight',
  272. creationKey: 'workspace-1|analysis-1|insight-1|decision-1',
  273. evidenceIds: ['evidence-1', 'evidence-2'],
  274. validationMetric: 'Evidence coverage >= 80%',
  275. actionType: 'experience',
  276. title: 'Existing action',
  277. description: '',
  278. priority: 'high',
  279. status: 'open',
  280. createdBy: 'user-1',
  281. createdAt: timestamp,
  282. updatedAt: timestamp,
  283. };
  284. },
  285. async create() {
  286. parseCreates += 1;
  287. throw new Error('Parse create should not run for an existing creation key');
  288. },
  289. } as unknown as ParseRestClient;
  290. const parseAction = await new ParseRestVocRepository(parseClient).createAction(actionInput);
  291. assert.equal(parseAction.id, 'action-existing');
  292. assert.equal(parseCreates, 0);
  293. let postgresQueries = 0;
  294. const database = {
  295. async query(text: string, values: readonly unknown[] = []) {
  296. postgresQueries += 1;
  297. if (text.includes('FROM voc.analysis_run run')) {
  298. return { rows: [analysisRow], rowCount: 1 };
  299. }
  300. if (text.includes('FROM voc.insight_decision decision')) {
  301. return { rows: [decisionRow], rowCount: 1 };
  302. }
  303. assert.match(text, /action\.creation_key = \$2/);
  304. assert.deepEqual(values, ['workspace-1', 'workspace-1|analysis-1|insight-1|decision-1']);
  305. return {
  306. rows: [{
  307. public_id: 'action-existing',
  308. workspace_public_id: 'workspace-1',
  309. source_analysis_id: 10,
  310. source_analysis_public_id: 'analysis-1',
  311. source_insight_id: 'insight-1',
  312. source_decision_id: 11,
  313. source_decision_public_id: 'decision-1',
  314. source_kind: 'insight',
  315. creation_key: 'workspace-1|analysis-1|insight-1|decision-1',
  316. evidence_ids: ['evidence-1', 'evidence-2'],
  317. validation_metric: 'Evidence coverage >= 80%',
  318. action_type: 'experience',
  319. title: 'Existing action',
  320. description: '',
  321. priority: 'high',
  322. status: 'open',
  323. product_key: null,
  324. assignee_external_id: null,
  325. due_at: null,
  326. created_by_external_id: 'user-1',
  327. completed_at: null,
  328. created_at: timestamp,
  329. updated_at: timestamp,
  330. }],
  331. rowCount: 1,
  332. };
  333. },
  334. } as unknown as Queryable;
  335. const postgresAction = await new PostgresPlatformRepository(database).createAction(actionInput);
  336. assert.equal(postgresAction.id, 'action-existing');
  337. assert.equal(postgresQueries, 3);
  338. });