import assert from 'node:assert/strict'; import type { AddressInfo } from 'node:net'; import test from 'node:test'; import express, { type ErrorRequestHandler } from 'express'; import { ZodError } from 'zod'; import { ApiError } from '../src/http/api-error.js'; import { LocalSyncJobStore } from '../src/modules/domestic-voc/local/local-sync-job.store.js'; import { createAuthenticationMiddleware, type RequestAuthenticator, WorkspaceAccessService, } from '../src/modules/saas-platform/auth.js'; import type { ActionItemCreateInput, AnalysisRun, InsightDecisionCreateInput, } from '../src/modules/saas-platform/domain.js'; import { assertValidActionDecisionSource } from '../src/modules/saas-platform/domain.js'; import { LocalPlatformRepository } from '../src/modules/saas-platform/local-platform.repository.js'; import { createSaasPlatformRouter } from '../src/modules/saas-platform/routes.js'; import type { DomesticDataset } from '../src/types/domestic-dataset.js'; const dataset: DomesticDataset = { schemaVersion: 1, generatedAt: '2026-08-07T00:00:00.000Z', caseName: 'Insight decision test', platform: 'jd', source: { sourceFile: 'insight-decision.json', sourceHash: 'test', sheets: [], dateRange: { start: '2026-08-01', end: '2026-08-07' }, }, summary: { metricRows: 0, metricProducts: 0, mappingRows: 0, relations: 0, uniqueCompetitorProducts: 0, category2Count: 0, category3Count: 0, reviewCount: 0, }, dailyTotals: [], products: [], mappingGroups: [], relations: [], reviews: [], quality: { orphanMappings: [], mappingsWithoutCompetitor: [], brandWithoutProductId: [] }, }; function createRepository(): LocalPlatformRepository { let tick = 0; return new LocalPlatformRepository( dataset, new LocalSyncJobStore(dataset), { userId: 'local-admin', email: 'local-admin@localhost', displayName: 'Local Admin' }, 'demashi', () => new Date(Date.parse('2026-08-07T00:00:00.000Z') + tick++ * 1_000), ); } async function createAnalysis( repository: LocalPlatformRepository, input: { id: string; analysisType?: AnalysisRun['analysisType']; status?: 'pending' | 'completed' | 'partial'; result?: Record; }, ): Promise { const analysis = await repository.createAnalysis({ id: input.id, workspaceId: 'demashi', analysisType: input.analysisType ?? 'voc_insight', targetKind: 'workspace', targetKey: '', input: {}, requestedBy: 'local-admin', }); if (!input.status || input.status === 'pending') return analysis; await repository.updateAnalysis('demashi', input.id, { status: 'processing' }); return (await repository.updateAnalysis('demashi', input.id, { status: input.status, result: input.result ?? { mode: 'ai', insights: [{ id: 'insight-1', evidenceIds: ['evidence-1', 'evidence-2'] }], }, evidenceCount: 2, }))!; } function decisionInput(overrides: Partial = {}): InsightDecisionCreateInput { return { id: 'decision-1', workspaceId: 'demashi', sourceAnalysisId: 'analysis-ai', sourceInsightId: 'insight-1', decision: 'confirmed', reviewedEvidenceIds: ['evidence-1', 'evidence-2'], comment: '', decidedBy: 'local-admin', ...overrides, }; } function rejectsWith(code: string): (error: unknown) => boolean { return (error) => error instanceof ApiError && error.status === 400 && error.code === code; } function rejectsWithGate(...codes: string[]): (error: unknown) => boolean { return (error) => error instanceof ApiError && (error.status === 400 || error.status === 409) && codes.includes(error.code); } test('local insight decisions validate sources and retain an append-only current version chain', async () => { const repository = createRepository(); await createAnalysis(repository, { id: 'analysis-pending' }); await assert.rejects( repository.createInsightDecision(decisionInput({ sourceAnalysisId: 'analysis-pending' })), rejectsWith('insight_decision_analysis_not_ready'), ); await createAnalysis(repository, { id: 'analysis-voice', analysisType: 'voice', status: 'completed' }); await assert.rejects( repository.createInsightDecision(decisionInput({ sourceAnalysisId: 'analysis-voice' })), rejectsWith('insight_decision_analysis_not_voc_insight'), ); await createAnalysis(repository, { id: 'analysis-ai', status: 'completed' }); await assert.rejects( repository.createInsightDecision(decisionInput({ sourceInsightId: 'missing-insight' })), rejectsWith('insight_decision_insight_not_found'), ); await assert.rejects( repository.createInsightDecision(decisionInput({ reviewedEvidenceIds: [] })), rejectsWith('insight_decision_evidence_required'), ); await assert.rejects( repository.createInsightDecision(decisionInput({ reviewedEvidenceIds: ['evidence-outside-insight'] })), rejectsWith('insight_decision_evidence_not_in_insight'), ); await assert.rejects( repository.createInsightDecision(decisionInput({ reviewedEvidenceIds: ['evidence-1'] })), rejectsWith('insight_decision_evidence_incomplete'), ); await assert.rejects( repository.createInsightDecision(decisionInput({ decision: 'rejected', comment: ' ' })), rejectsWith('insight_decision_comment_required'), ); const first = await repository.createInsightDecision(decisionInput({ reviewedEvidenceIds: ['evidence-1', 'evidence-1', 'evidence-2'], comment: ' confirmed by reviewer ', })); assert.equal(first.version, 1); assert.equal(first.supersedesId, null); assert.equal(first.isCurrent, true); assert.deepEqual(first.reviewedEvidenceIds, ['evidence-1', 'evidence-2']); assert.equal(first.comment, 'confirmed by reviewer'); const second = await repository.createInsightDecision(decisionInput({ id: 'decision-2', decision: 'needs_more_evidence', reviewedEvidenceIds: ['evidence-1', 'evidence-2'], comment: 'Collect another review cycle.', })); assert.equal(second.version, 2); assert.equal(second.supersedesId, first.id); assert.equal(second.isCurrent, true); const storedFirst = await repository.getInsightDecision('demashi', first.id); assert.equal(storedFirst?.isCurrent, false); assert.equal(storedFirst?.decision, 'confirmed'); assert.deepEqual(storedFirst?.reviewedEvidenceIds, ['evidence-1', 'evidence-2']); assert.equal(storedFirst?.createdAt, first.createdAt); const current = await repository.listInsightDecisions({ workspaceId: 'demashi', limit: 10, cursor: null, sourceAnalysisId: 'analysis-ai', sourceInsightId: 'insight-1', currentOnly: true, }); assert.deepEqual(current.items.map((item) => item.id), [second.id]); const history = await repository.listInsightDecisions({ workspaceId: 'demashi', limit: 10, cursor: null, sourceAnalysisId: 'analysis-ai', sourceInsightId: '', currentOnly: false, }); assert.deepEqual(history.items.map((item) => item.version), [2, 1]); await createAnalysis(repository, { id: 'analysis-deterministic', status: 'partial', result: { mode: 'deterministic', insights: [{ id: 'insight-rule', evidenceIds: ['evidence-rule'] }], }, }); await assert.rejects( repository.createInsightDecision(decisionInput({ id: 'decision-rule-confirmed', sourceAnalysisId: 'analysis-deterministic', sourceInsightId: 'insight-rule', reviewedEvidenceIds: ['evidence-rule'], })), rejectsWith('insight_decision_deterministic_requires_more_evidence'), ); const deterministic = await repository.createInsightDecision(decisionInput({ id: 'decision-rule-more-evidence', sourceAnalysisId: 'analysis-deterministic', sourceInsightId: 'insight-rule', decision: 'needs_more_evidence', reviewedEvidenceIds: ['evidence-rule'], comment: 'Rule output needs human evidence.', })); assert.equal(deterministic.decision, 'needs_more_evidence'); }); test('actions require the current matching decision and use its reviewed evidence', async () => { const repository = createRepository(); await createAnalysis(repository, { id: 'analysis-ai', status: 'completed' }); const confirmed = await repository.createInsightDecision(decisionInput({ reviewedEvidenceIds: ['evidence-1', 'evidence-2'], comment: 'All evidence supports a formal action.', })); const actionInput: ActionItemCreateInput = { id: 'action-1', workspaceId: 'demashi', sourceAnalysisId: 'analysis-ai', sourceInsightId: 'insight-1', sourceDecisionId: confirmed.id, sourceKind: 'insight', creationKey: 'client-value-is-normalized', evidenceIds: ['evidence-1'], validationMetric: 'Issue rate decreases within 30 days.', actionType: 'experience', title: 'Address the confirmed issue', description: '', priority: 'high', status: 'open', productKey: null, assigneeUserId: null, dueAt: null, createdBy: 'local-admin', }; const created = await repository.createAction(actionInput); assert.equal(created.creationKey, 'demashi|analysis-ai|insight-1|decision-1'); const duplicate = await repository.createAction({ ...actionInput, id: 'action-duplicate' }); assert.equal(duplicate.id, created.id); const rejected = await repository.createInsightDecision(decisionInput({ id: 'decision-rejected', decision: 'rejected', reviewedEvidenceIds: ['evidence-1', 'evidence-2'], comment: 'The issue is not supported.', })); assert.throws( () => assertValidActionDecisionSource({ workspaceId: 'demashi', sourceAnalysisId: 'analysis-ai', sourceInsightId: 'insight-1', sourceDecisionId: confirmed.id, sourceKind: 'insight', creationKey: created.creationKey, evidenceIds: ['evidence-1'], actionType: 'experience', validationMetric: actionInput.validationMetric, }, { ...confirmed, isCurrent: false }), (error) => error instanceof ApiError && error.code === 'source_decision_superseded', ); await assert.rejects( repository.createAction({ ...actionInput, id: 'action-stale-decision-replay' }), (error) => error instanceof ApiError && error.status === 409 && error.code === 'source_decision_superseded', ); await assert.rejects( repository.createAction({ ...actionInput, id: 'action-rejected', sourceDecisionId: rejected.id }), (error) => error instanceof ApiError && error.code === 'source_decision_rejected', ); await createAnalysis(repository, { id: 'analysis-rule', status: 'partial', result: { mode: 'deterministic', insights: [{ id: 'insight-rule', evidenceIds: ['evidence-rule'] }], }, }); const moreEvidence = await repository.createInsightDecision(decisionInput({ id: 'decision-more-evidence', sourceAnalysisId: 'analysis-rule', sourceInsightId: 'insight-rule', decision: 'needs_more_evidence', reviewedEvidenceIds: ['evidence-rule'], comment: 'Collect another review cycle.', })); const dataQualityInput: ActionItemCreateInput = { ...actionInput, id: 'action-data-quality', sourceAnalysisId: 'analysis-rule', sourceInsightId: 'insight-rule', sourceDecisionId: moreEvidence.id, evidenceIds: ['evidence-rule'], actionType: 'data_quality', }; await assert.rejects( repository.createAction({ ...dataQualityInput, id: 'action-formal', actionType: 'general' }), (error) => error instanceof ApiError && error.code === 'source_decision_requires_data_quality_action', ); const dataQualityAction = await repository.createAction(dataQualityInput); assert.equal(dataQualityAction.actionType, 'data_quality'); }); test('insight-backed actions cannot disguise their source kind to bypass a decision', async () => { for (const sourceKind of ['raw_feedback', 'rule_action'] as const) { const repository = createRepository(); await createAnalysis(repository, { id: 'analysis-ai', status: 'completed' }); await assert.rejects( repository.createAction({ id: `action-disguised-${sourceKind}`, workspaceId: 'demashi', sourceAnalysisId: 'analysis-ai', sourceInsightId: 'insight-1', sourceDecisionId: null, sourceKind, creationKey: `disguised-${sourceKind}`, evidenceIds: ['evidence-1', 'evidence-2'], validationMetric: 'Issue rate decreases within 30 days.', actionType: 'experience', title: `Disguised ${sourceKind} action`, description: '', priority: 'high', status: 'open', productKey: null, assigneeUserId: null, dueAt: null, createdBy: 'local-admin', }), rejectsWithGate('insight_source_requires_insight_kind'), ); } }); test('idempotent action replay rejects changes to its decision-bound payload', async () => { const repository = createRepository(); await createAnalysis(repository, { id: 'analysis-rule', status: 'partial', result: { mode: 'deterministic', insights: [{ id: 'insight-rule', evidenceIds: ['evidence-rule-1', 'evidence-rule-2'] }], }, }); const decision = await repository.createInsightDecision(decisionInput({ id: 'decision-rule', sourceAnalysisId: 'analysis-rule', sourceInsightId: 'insight-rule', decision: 'needs_more_evidence', reviewedEvidenceIds: ['evidence-rule-1', 'evidence-rule-2'], comment: 'Collect enough evidence to verify the rule output.', })); const actionInput: ActionItemCreateInput = { id: 'action-rule', workspaceId: 'demashi', sourceAnalysisId: 'analysis-rule', sourceInsightId: 'insight-rule', sourceDecisionId: decision.id, sourceKind: 'insight', creationKey: 'same-client-creation-key', evidenceIds: ['evidence-rule-1', 'evidence-rule-2'], validationMetric: 'Collect 20 additional verified reviews.', actionType: 'data_quality', title: 'Collect additional evidence', description: '', priority: 'high', status: 'open', productKey: null, assigneeUserId: null, dueAt: null, createdBy: 'local-admin', }; await repository.createAction(actionInput); await assert.rejects( repository.createAction({ ...actionInput, id: 'action-rule-evidence-tampered', evidenceIds: ['evidence-rule-1'], }), rejectsWithGate('action_creation_key_conflict'), ); await assert.rejects( repository.createAction({ ...actionInput, id: 'action-rule-type-tampered', actionType: 'experience', }), rejectsWithGate( 'action_creation_key_conflict', 'source_decision_requires_data_quality_action', ), ); await assert.rejects( repository.createAction({ ...actionInput, id: 'action-rule-metric-tampered', validationMetric: 'Collect only 3 additional verified reviews.', }), rejectsWithGate('action_creation_key_conflict'), ); }); async function listen(app: ReturnType) { const server = await new Promise>((resolve) => { const listening = app.listen(0, '127.0.0.1', () => resolve(listening)); }); const address = server.address() as AddressInfo; return { baseUrl: `http://127.0.0.1:${address.port}`, close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())), }; } async function json(response: Response): Promise> { return await response.json() as Record; } test('insight decision routes create versions, filter current records, return details, audit, and enforce permissions', async () => { const repository = createRepository(); await repository.upsertMember({ workspaceId: 'demashi', userId: 'viewer-user', email: 'viewer@example.test', displayName: 'Viewer', role: 'viewer', status: 'active', }); await createAnalysis(repository, { id: 'analysis-http', status: 'completed' }); const authenticator: RequestAuthenticator = { async authenticate(request) { const userId = request.header('X-Test-User') || 'local-admin'; return { userId, email: `${userId}@example.test`, displayName: userId, authMode: 'disabled' }; }, }; const app = express(); app.use(express.json()); app.use('/api', createAuthenticationMiddleware(authenticator)); app.use('/api/saas', createSaasPlatformRouter({ repository, access: new WorkspaceAccessService(repository), })); const errorHandler: ErrorRequestHandler = (error, _request, response, _next) => { if (error instanceof ApiError) { response.status(error.status).json({ error: error.code }); return; } if (error instanceof ZodError) { response.status(400).json({ error: 'invalid_request' }); return; } response.status(500).json({ error: 'internal_error' }); }; app.use(errorHandler); const server = await listen(app); const basePath = `${server.baseUrl}/api/saas/workspaces/demashi/insight-decisions`; const ownerHeaders = { 'Content-Type': 'application/json', 'X-Test-User': 'local-admin' }; const viewerHeaders = { 'Content-Type': 'application/json', 'X-Test-User': 'viewer-user' }; try { const firstResponse = await fetch(basePath, { method: 'POST', headers: ownerHeaders, body: JSON.stringify({ sourceAnalysisId: 'analysis-http', sourceInsightId: 'insight-1', decision: 'confirmed', reviewedEvidenceIds: ['evidence-1', 'evidence-1', 'evidence-2'], }), }); assert.equal(firstResponse.status, 201); const first = (await json(firstResponse)).decision; assert.equal(first.version, 1); assert.equal(first.decidedBy, 'local-admin'); assert.deepEqual(first.reviewedEvidenceIds, ['evidence-1', 'evidence-2']); const missingCommentResponse = await fetch(basePath, { method: 'POST', headers: ownerHeaders, body: JSON.stringify({ sourceAnalysisId: 'analysis-http', sourceInsightId: 'insight-1', decision: 'rejected', reviewedEvidenceIds: ['evidence-1', 'evidence-2'], }), }); assert.equal(missingCommentResponse.status, 400); assert.equal((await json(missingCommentResponse)).error, 'insight_decision_comment_required'); const secondResponse = await fetch(basePath, { method: 'POST', headers: ownerHeaders, body: JSON.stringify({ sourceAnalysisId: 'analysis-http', sourceInsightId: 'insight-1', decision: 'rejected', reviewedEvidenceIds: ['evidence-1', 'evidence-2'], comment: 'The evidence does not support the proposed opportunity.', }), }); assert.equal(secondResponse.status, 201); const second = (await json(secondResponse)).decision; assert.equal(second.version, 2); assert.equal(second.supersedesId, first.id); const currentResponse = await fetch( `${basePath}?analysisId=analysis-http&insightId=insight-1¤tOnly=true`, { headers: viewerHeaders }, ); assert.equal(currentResponse.status, 200); const current = await json(currentResponse); assert.equal(current.items.length, 1); assert.equal(current.items[0].id, second.id); const historyResponse = await fetch(`${basePath}?analysisId=analysis-http`, { headers: ownerHeaders }); assert.equal(historyResponse.status, 200); const history = await json(historyResponse); assert.equal(history.items.length, 2); const detailResponse = await fetch(`${basePath}/${first.id}`, { headers: viewerHeaders }); assert.equal(detailResponse.status, 200); const detail = (await json(detailResponse)).decision; assert.equal(detail.id, first.id); assert.equal(detail.isCurrent, false); const viewerWrite = await fetch(basePath, { method: 'POST', headers: viewerHeaders, body: JSON.stringify({ sourceAnalysisId: 'analysis-http', sourceInsightId: 'insight-1', decision: 'confirmed', reviewedEvidenceIds: ['evidence-1', 'evidence-2'], }), }); assert.equal(viewerWrite.status, 403); assert.equal((await json(viewerWrite)).error, 'workspace_permission_denied'); const missingDetail = await fetch(`${basePath}/00000000-0000-4000-8000-000000000000`, { headers: ownerHeaders }); assert.equal(missingDetail.status, 404); assert.equal((await json(missingDetail)).error, 'insight_decision_not_found'); const auditResponse = await fetch( `${server.baseUrl}/api/saas/workspaces/demashi/audit?limit=20`, { headers: ownerHeaders }, ); assert.equal(auditResponse.status, 200); const audit = await json(auditResponse); const decisionEntries = audit.items.filter((item: { action: string }) => item.action === 'decision.created'); assert.equal(decisionEntries.length, 2); const secondEntry = decisionEntries.find((item: { entityId: string }) => item.entityId === second.id); assert.equal(secondEntry.entityType, 'insight_decision'); assert.equal(secondEntry.metadata.sourceAnalysisId, 'analysis-http'); assert.deepEqual(secondEntry.metadata.reviewedEvidenceIds, ['evidence-1', 'evidence-2']); assert.equal(secondEntry.metadata.version, 2); assert.equal(secondEntry.metadata.supersedesId, first.id); } finally { await server.close(); } });