| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572 |
- 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<string, unknown>;
- },
- ): Promise<AnalysisRun> {
- 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> = {}): 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<typeof express>) {
- const server = await new Promise<ReturnType<typeof app.listen>>((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<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve())),
- };
- }
- async function json(response: Response): Promise<Record<string, any>> {
- return await response.json() as Record<string, any>;
- }
- 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();
- }
- });
|