import { describe, it, expect } from 'vitest' import { complianceScopeEngine } from '../compliance-scope-engine' // Helper: create an answer object (engine reads value + questionId) function ans(questionId: string, value: unknown) { return { questionId, value } as any } // Helper: create a minimal triggered-trigger (engine shape, not types shape) function trigger(ruleId: string, minimumLevel: string, opts: Record = {}) { return { ruleId, minimumLevel, mandatoryDocuments: [], requiresDSFA: false, category: 'test', ...opts } as any } // ============================================================================ // calculateScores // ============================================================================ describe('calculateScores', () => { it('returns zero composite for empty answers', () => { const scores = complianceScopeEngine.calculateScores([]) expect(scores.composite_score).toBe(0) expect(scores.risk_score).toBe(0) expect(scores.complexity_score).toBe(0) expect(scores.assurance_need).toBe(0) }) it('all-false boolean answers → zero composite', () => { const scores = complianceScopeEngine.calculateScores([ ans('data_art9', false), ans('data_minors', false), ans('proc_ai_usage', false), ]) expect(scores.composite_score).toBe(0) }) it('boolean true answer increases risk score', () => { const scoresFalse = complianceScopeEngine.calculateScores([ans('data_art9', false)]) const scoresTrue = complianceScopeEngine.calculateScores([ans('data_art9', true)]) expect(scoresTrue.risk_score).toBeGreaterThan(scoresFalse.risk_score) }) it('composite is weighted sum: risk×0.4 + complexity×0.3 + assurance×0.3', () => { const scores = complianceScopeEngine.calculateScores([ans('data_art9', true)]) const expected = Math.round((scores.risk_score * 0.4 + scores.complexity_score * 0.3 + scores.assurance_need * 0.3) * 10) / 10 expect(scores.composite_score).toBe(expected) }) it('numeric answer uses logarithmic normalization — higher value → higher score', () => { const scoresLow = complianceScopeEngine.calculateScores([ans('data_volume', 10)]) const scoresHigh = complianceScopeEngine.calculateScores([ans('data_volume', 999)]) expect(scoresHigh.composite_score).toBeGreaterThan(scoresLow.composite_score) }) it('array answer score proportional to count (max 1.0 at 5+)', () => { const scores1 = complianceScopeEngine.calculateScores([ans('data_art9', ['gesundheit'])]) const scores5 = complianceScopeEngine.calculateScores([ ans('data_art9', ['gesundheit', 'biometrie', 'genetik', 'politisch', 'religion']), ]) expect(scores5.composite_score).toBeGreaterThan(scores1.composite_score) }) it('empty array answer → zero contribution', () => { const scoresEmpty = complianceScopeEngine.calculateScores([ans('data_art9', [])]) const scoresNone = complianceScopeEngine.calculateScores([]) expect(scoresEmpty.composite_score).toBe(scoresNone.composite_score) }) }) // ============================================================================ // determineLevel // ============================================================================ describe('determineLevel', () => { it('composite ≤25 → L1', () => { const level = complianceScopeEngine.determineLevel({ composite_score: 20 } as any, []) expect(level).toBe('L1') }) it('composite exactly 25 → L1', () => { const level = complianceScopeEngine.determineLevel({ composite_score: 25 } as any, []) expect(level).toBe('L1') }) it('composite 26–50 → L2', () => { const level = complianceScopeEngine.determineLevel({ composite_score: 40 } as any, []) expect(level).toBe('L2') }) it('composite exactly 50 → L2', () => { const level = complianceScopeEngine.determineLevel({ composite_score: 50 } as any, []) expect(level).toBe('L2') }) it('composite 51–75 → L3', () => { const level = complianceScopeEngine.determineLevel({ composite_score: 60 } as any, []) expect(level).toBe('L3') }) it('composite >75 → L4', () => { const level = complianceScopeEngine.determineLevel({ composite_score: 80 } as any, []) expect(level).toBe('L4') }) it('hard trigger with minimumLevel L3 overrides score-based L1', () => { const t = trigger('HT-A01', 'L3') const level = complianceScopeEngine.determineLevel({ composite_score: 10 } as any, [t]) expect(level).toBe('L3') }) it('hard trigger with minimumLevel L4 overrides score-based L2', () => { const t = trigger('HT-F01', 'L4') const level = complianceScopeEngine.determineLevel({ composite_score: 40 } as any, [t]) expect(level).toBe('L4') }) it('level = max(score-level, trigger-level) — score wins when higher', () => { const t = trigger('HT-B01', 'L2') // score gives L3, trigger gives L2 → max = L3 const level = complianceScopeEngine.determineLevel({ composite_score: 60 } as any, [t]) expect(level).toBe('L3') }) it('no triggers with zero composite → L1', () => { const level = complianceScopeEngine.determineLevel({ composite_score: 0 } as any, []) expect(level).toBe('L1') }) }) // ============================================================================ // evaluateHardTriggers // ============================================================================ describe('evaluateHardTriggers', () => { it('empty answers → no triggers', () => { const triggers = complianceScopeEngine.evaluateHardTriggers([]) expect(triggers).toHaveLength(0) }) it('art9 health data answer triggers category art9 with minimumLevel L3', () => { const triggers = complianceScopeEngine.evaluateHardTriggers([ ans('data_art9', ['gesundheit']), ]) const art9 = triggers.find((t: any) => t.category === 'art9') expect(art9).toBeDefined() expect((art9 as any).minimumLevel).toBe('L3') }) it('minors answer sets requiresDSFA = true', () => { const triggers = complianceScopeEngine.evaluateHardTriggers([ ans('data_minors', true), ]) const minorsTrigger = triggers.find((t: any) => t.category === 'vulnerable') expect(minorsTrigger).toBeDefined() expect((minorsTrigger as any).requiresDSFA).toBe(true) }) it('AI scoring usage answer triggers minimumLevel L2 (adm category)', () => { const triggers = complianceScopeEngine.evaluateHardTriggers([ ans('proc_ai_usage', ['scoring']), ]) const aiTrigger = triggers.find((t: any) => t.category === 'adm') expect(aiTrigger).toBeDefined() expect(['L2', 'L3', 'L4']).toContain((aiTrigger as any).minimumLevel) }) it('multiple triggers all returned when multiple questions answered', () => { const triggers = complianceScopeEngine.evaluateHardTriggers([ ans('data_art9', ['gesundheit']), ans('data_minors', true), ]) expect(triggers.length).toBeGreaterThanOrEqual(2) }) it('false answer for boolean trigger does not fire it', () => { const triggersBefore = complianceScopeEngine.evaluateHardTriggers([]) const triggersWithFalse = complianceScopeEngine.evaluateHardTriggers([ ans('data_minors', false), ]) expect(triggersWithFalse.length).toBe(triggersBefore.length) }) }) // ============================================================================ // evaluate — integration // ============================================================================ describe('evaluate — integration', () => { it('empty answers → L1 ScopeDecision with all required fields', () => { const decision = complianceScopeEngine.evaluate([]) expect(decision.scores).toBeDefined() expect(decision.triggeredHardTriggers).toBeDefined() expect(decision.requiredDocuments).toBeDefined() expect(decision.riskFlags).toBeDefined() expect(decision.gaps).toBeDefined() expect(decision.nextActions).toBeDefined() expect(decision.reasoning).toBeDefined() expect(decision.determinedLevel).toBe('L1') expect(decision.evaluatedAt).toBeDefined() }) it('art9 + minors answers → determinedLevel L3 or L4', () => { const decision = complianceScopeEngine.evaluate([ ans('data_art9', ['gesundheit', 'biometrie']), ans('data_minors', true), ]) expect(['L3', 'L4']).toContain(decision.determinedLevel) }) it('triggeredHardTriggers populated on evaluate with art9 answer', () => { const decision = complianceScopeEngine.evaluate([ ans('data_art9', ['gesundheit']), ]) expect(decision.triggeredHardTriggers.length).toBeGreaterThan(0) }) it('composite score non-negative', () => { const decision = complianceScopeEngine.evaluate([ans('org_employee_count', '50-249')]) expect((decision.scores as any).composite_score).toBeGreaterThanOrEqual(0) }) it('evaluate returns array types for collections', () => { const decision = complianceScopeEngine.evaluate([]) expect(Array.isArray(decision.triggeredHardTriggers)).toBe(true) expect(Array.isArray(decision.requiredDocuments)).toBe(true) expect(Array.isArray(decision.riskFlags)).toBe(true) expect(Array.isArray(decision.gaps)).toBe(true) expect(Array.isArray(decision.nextActions)).toBe(true) }) }) // ============================================================================ // buildDocumentScope // ============================================================================ describe('buildDocumentScope', () => { it('returns an array', () => { const docs = complianceScopeEngine.buildDocumentScope('L1', [], []) expect(Array.isArray(docs)).toBe(true) }) it('each returned document has documentType, label, estimatedEffort', () => { // Use a trigger with uppercase docType to test engine behaviour const t = trigger('HT-A01', 'L3', { category: 'art9', mandatoryDocuments: ['VVT'], }) const docs = complianceScopeEngine.buildDocumentScope('L3', [t], []) docs.forEach((doc: any) => { expect(doc.documentType).toBeDefined() expect(doc.label).toBeDefined() expect(typeof doc.estimatedEffort).toBe('number') expect(doc.estimatedEffort).toBeGreaterThan(0) }) }) it('mandatory documents have high priority', () => { const t = trigger('HT-A01', 'L3', { category: 'art9', mandatoryDocuments: ['VVT'], }) const docs = complianceScopeEngine.buildDocumentScope('L3', [t], []) const mandatoryDocs = docs.filter((d: any) => d.requirement === 'mandatory') mandatoryDocs.forEach((doc: any) => { expect(doc.priority).toBe('high') }) }) it('normalizes UPPERCASE trigger doc names to lowercase ScopeDocumentType', () => { const t = trigger('HT-test', 'L2', { category: 'test', mandatoryDocuments: ['VVT', 'TOM', 'DSFA'], }) const docs = complianceScopeEngine.buildDocumentScope('L2', [t], []) const vvt = docs.find((d: any) => d.documentType === 'vvt') const tom = docs.find((d: any) => d.documentType === 'tom') const dsfa = docs.find((d: any) => d.documentType === 'dsfa') expect(vvt).toBeDefined() expect(vvt!.requirement).toBe('mandatory') expect(vvt!.triggeredBy).toContain('HT-test') expect(tom).toBeDefined() expect(tom!.requirement).toBe('mandatory') expect(dsfa).toBeDefined() expect(dsfa!.requirement).toBe('mandatory') }) it('normalizes aliased doc names (DSE→dsi, LOESCHKONZEPT→lf)', () => { const t = trigger('HT-alias', 'L2', { category: 'test', mandatoryDocuments: ['DSE', 'LOESCHKONZEPT', 'DSR_PROZESS'], }) const docs = complianceScopeEngine.buildDocumentScope('L2', [t], []) const dsi = docs.find((d: any) => d.documentType === 'dsi') const lf = docs.find((d: any) => d.documentType === 'lf') const betroffenenrechte = docs.find((d: any) => d.documentType === 'betroffenenrechte') expect(dsi).toBeDefined() expect(dsi!.requirement).toBe('mandatory') expect(dsi!.triggeredBy).toContain('HT-alias') expect(lf).toBeDefined() expect(lf!.requirement).toBe('mandatory') expect(betroffenenrechte).toBeDefined() expect(betroffenenrechte!.requirement).toBe('mandatory') }) it('documents sorted: mandatory first', () => { const decision = complianceScopeEngine.evaluate([ ans('data_art9', ['gesundheit']), ]) const docs = decision.requiredDocuments if (docs.length > 1) { // mandatory docs should appear before recommended ones let seenNonMandatory = false for (const doc of docs) { if ((doc as any).requirement !== 'mandatory') seenNonMandatory = true if (seenNonMandatory) { expect((doc as any).requirement).not.toBe('mandatory') } } } }) it('sdkStepUrl present for known document types when available', () => { const decision = complianceScopeEngine.evaluate([ ans('data_art9', ['gesundheit']), ]) // sdkStepUrl is optional — just verify it's a string when present decision.requiredDocuments.forEach((doc: any) => { if (doc.sdkStepUrl !== undefined) { expect(typeof doc.sdkStepUrl).toBe('string') } }) }) }) // ============================================================================ // evaluateRiskFlags // ============================================================================ describe('evaluateRiskFlags', () => { it('no answers → empty flags array', () => { const flags = complianceScopeEngine.evaluateRiskFlags([], 'L1') expect(Array.isArray(flags)).toBe(true) }) it('no encryption transit at L2+ → high risk flag (technical)', () => { const flags = complianceScopeEngine.evaluateRiskFlags( [ans('tech_encryption_transit', false)], 'L2', ) const encFlag = flags.find((f: any) => f.category === 'technical') expect(encFlag).toBeDefined() expect((encFlag as any).severity).toBe('high') }) it('no encryption rest at L2+ → high risk flag (technical)', () => { const flags = complianceScopeEngine.evaluateRiskFlags( [ans('tech_encryption_rest', false)], 'L2', ) const encFlag = flags.find((f: any) => f.category === 'technical') expect(encFlag).toBeDefined() expect((encFlag as any).severity).toBe('high') }) it('encryption flags not raised at L1', () => { const flags = complianceScopeEngine.evaluateRiskFlags( [ans('tech_encryption_transit', false)], 'L1', ) const encFlag = flags.find((f: any) => f.message?.includes('Verschlüsselung')) expect(encFlag).toBeUndefined() }) it('third country transfer without guarantees → legal flag', () => { const flags = complianceScopeEngine.evaluateRiskFlags( [ ans('tech_third_country', true), ans('tech_hosting_location', 'drittland'), ], 'L1', ) const legalFlag = flags.find((f: any) => f.category === 'legal') expect(legalFlag).toBeDefined() }) it('≥250 employees without DPO → organizational flag', () => { const flags = complianceScopeEngine.evaluateRiskFlags( [ ans('org_has_dpo', false), ans('org_employee_count', '250-999'), ], 'L2', ) const orgFlag = flags.find((f: any) => f.category === 'organizational') expect(orgFlag).toBeDefined() }) it('DPO present with 250+ employees → no DPO flag', () => { const flags = complianceScopeEngine.evaluateRiskFlags( [ ans('org_has_dpo', true), ans('org_employee_count', '250-999'), ], 'L2', ) const orgFlag = flags.find((f: any) => f.category === 'organizational') expect(orgFlag).toBeUndefined() }) }) // ============================================================================ // HT-H01a/b: B2B vs B2C Webshop Trigger Split // ============================================================================ describe('HT-H01a/b — B2B vs B2C Webshop', () => { it('B2C webshop triggers HT-H01a with Verbraucherschutz documents', () => { const triggers = complianceScopeEngine.evaluateHardTriggers([ ans('prod_webshop', true), ans('org_business_model', 'B2C'), ]) const h01a = triggers.find((t: any) => t.ruleId === 'HT-H01a') const h01b = triggers.find((t: any) => t.ruleId === 'HT-H01b') expect(h01a).toBeDefined() expect(h01b).toBeUndefined() expect(h01a!.mandatoryDocuments).toContain('WIDERRUFSBELEHRUNG') expect(h01a!.mandatoryDocuments).toContain('PREISANGABEN') expect(h01a!.mandatoryDocuments).toContain('FERNABSATZ_INFO') expect(h01a!.mandatoryDocuments).toContain('STREITBEILEGUNG') }) it('B2B webshop triggers HT-H01b without Verbraucherschutz documents', () => { const triggers = complianceScopeEngine.evaluateHardTriggers([ ans('prod_webshop', true), ans('org_business_model', 'B2B'), ]) const h01a = triggers.find((t: any) => t.ruleId === 'HT-H01a') const h01b = triggers.find((t: any) => t.ruleId === 'HT-H01b') expect(h01a).toBeUndefined() expect(h01b).toBeDefined() expect(h01b!.mandatoryDocuments).toContain('DSE') expect(h01b!.mandatoryDocuments).toContain('AGB') expect(h01b!.mandatoryDocuments).toContain('COOKIE_BANNER') expect(h01b!.mandatoryDocuments).not.toContain('WIDERRUFSBELEHRUNG') expect(h01b!.mandatoryDocuments).not.toContain('PREISANGABEN') }) it('B2B_B2C (hybrid) webshop triggers HT-H01a (Verbraucherschutz applies)', () => { const triggers = complianceScopeEngine.evaluateHardTriggers([ ans('prod_webshop', true), ans('org_business_model', 'B2B_B2C'), ]) const h01a = triggers.find((t: any) => t.ruleId === 'HT-H01a') const h01b = triggers.find((t: any) => t.ruleId === 'HT-H01b') expect(h01a).toBeDefined() expect(h01b).toBeUndefined() }) it('no webshop → neither HT-H01a nor HT-H01b fires', () => { const triggers = complianceScopeEngine.evaluateHardTriggers([ ans('prod_webshop', false), ans('org_business_model', 'B2C'), ]) const h01a = triggers.find((t: any) => t.ruleId === 'HT-H01a') const h01b = triggers.find((t: any) => t.ruleId === 'HT-H01b') expect(h01a).toBeUndefined() expect(h01b).toBeUndefined() }) it('webshop without business_model answer → HT-H01a fires (excludeWhen not matched)', () => { const triggers = complianceScopeEngine.evaluateHardTriggers([ ans('prod_webshop', true), ]) const h01a = triggers.find((t: any) => t.ruleId === 'HT-H01a') const h01b = triggers.find((t: any) => t.ruleId === 'HT-H01b') // excludeWhen B2B: not matched (undefined !== 'B2B') → fires expect(h01a).toBeDefined() // requireWhen B2B: not matched (undefined !== 'B2B') → does not fire expect(h01b).toBeUndefined() }) }) // ============================================================================ // excludeWhen / requireWhen Logic (unit) // ============================================================================ describe('excludeWhen / requireWhen — generic logic', () => { it('excludeWhen with array value excludes any matching value', () => { // HT-H01a has excludeWhen: { questionId: 'org_business_model', value: 'B2B' } // This test verifies the single-value case works (B2B excluded) const triggers = complianceScopeEngine.evaluateHardTriggers([ ans('prod_webshop', true), ans('org_business_model', 'B2B'), ]) const h01a = triggers.find((t: any) => t.ruleId === 'HT-H01a') expect(h01a).toBeUndefined() }) it('requireWhen with non-matching value prevents trigger', () => { // HT-H01b has requireWhen: { questionId: 'org_business_model', value: 'B2B' } const triggers = complianceScopeEngine.evaluateHardTriggers([ ans('prod_webshop', true), ans('org_business_model', 'B2C'), ]) const h01b = triggers.find((t: any) => t.ruleId === 'HT-H01b') expect(h01b).toBeUndefined() }) })