|
@@ -0,0 +1,572 @@
|
|
|
|
|
+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();
|
|
|
|
|
+ }
|
|
|
|
|
+});
|