import { describe, it, expect } from 'vitest' import { runRuleset, getDocType, applyBlockRemoval, type RuleInput, } from './ruleEngine' import { EMPTY_CONTEXT } from './contextBridge' import type { TemplateContext } from './contextBridge' // ============================================================================= // Helpers // ============================================================================= function makeInput(overrides: Partial = {}): RuleInput { return { doc_type: 'nda_de', render: { lang: 'de', variant: 'standard' }, context: JSON.parse(JSON.stringify(EMPTY_CONTEXT)) as TemplateContext, modules: { enabled: [] }, ...overrides, } } function withCtx(partial: Partial): TemplateContext { return { ...JSON.parse(JSON.stringify(EMPTY_CONTEXT)), ...partial, } } // ============================================================================= // getDocType // ============================================================================= describe('getDocType', () => { it('nda+de → nda_de', () => expect(getDocType('nda', 'de')).toBe('nda_de')) it('nda+en → nda_en', () => expect(getDocType('nda', 'en')).toBe('nda_en')) it('sla+de → sla_de', () => expect(getDocType('sla', 'de')).toBe('sla_de')) it('acceptable_use+en → acceptable_use_en', () => expect(getDocType('acceptable_use', 'en')).toBe('acceptable_use_en')) it('cloud_service_agreement+de → cloud_contract_de', () => expect(getDocType('cloud_service_agreement', 'de')).toBe('cloud_contract_de')) it('agb+de → agb_de', () => expect(getDocType('agb', 'de')).toBe('agb_de')) it('unknown type → fallback composite', () => expect(getDocType('some_custom', 'fr')).toBe('some_custom_fr')) }) // ============================================================================= // compute_flags // ============================================================================= describe('compute_flags', () => { it('IS_B2C=true when CUSTOMER.IS_CONSUMER=true', () => { const ctx = withCtx({ CUSTOMER: { ...EMPTY_CONTEXT.CUSTOMER, IS_CONSUMER: true, IS_BUSINESS: false }, }) const r = runRuleset(makeInput({ context: ctx })) expect(r.computedFlags.IS_B2C).toBe(true) expect(r.computedFlags.IS_B2B).toBe(false) }) it('IS_B2B=true when CUSTOMER.IS_BUSINESS=true', () => { const ctx = withCtx({ CUSTOMER: { ...EMPTY_CONTEXT.CUSTOMER, IS_CONSUMER: false, IS_BUSINESS: true }, }) const r = runRuleset(makeInput({ context: ctx })) expect(r.computedFlags.IS_B2B).toBe(true) expect(r.computedFlags.IS_B2C).toBe(false) }) it('SERVICE_IS_SAAS=true when SERVICE.MODEL=SaaS', () => { const ctx = withCtx({ SERVICE: { ...EMPTY_CONTEXT.SERVICE, MODEL: 'SaaS' } }) const r = runRuleset(makeInput({ context: ctx })) expect(r.computedFlags.SERVICE_IS_SAAS).toBe(true) expect(r.computedFlags.SERVICE_IS_HYBRID).toBe(false) }) it('HAS_PENALTY=true when NDA.PENALTY_AMOUNT_EUR is set', () => { const ctx = withCtx({ NDA: { ...EMPTY_CONTEXT.NDA, PENALTY_AMOUNT_EUR: 5000 } }) const r = runRuleset(makeInput({ context: ctx })) expect(r.computedFlags.HAS_PENALTY).toBe(true) }) it('HAS_PENALTY=false when NDA.PENALTY_AMOUNT_EUR is null', () => { const ctx = withCtx({ NDA: { ...EMPTY_CONTEXT.NDA, PENALTY_AMOUNT_EUR: null } }) const r = runRuleset(makeInput({ context: ctx })) expect(r.computedFlags.HAS_PENALTY).toBe(false) }) it('HAS_ANALYTICS=true when CONSENT.ANALYTICS_TOOLS is set', () => { const ctx = withCtx({ CONSENT: { ...EMPTY_CONTEXT.CONSENT, ANALYTICS_TOOLS: 'GA4' } }) const r = runRuleset(makeInput({ context: ctx })) expect(r.computedFlags.HAS_ANALYTICS).toBe(true) }) it('HAS_ANALYTICS=false when CONSENT.ANALYTICS_TOOLS is null', () => { const ctx = withCtx({ CONSENT: { ...EMPTY_CONTEXT.CONSENT, ANALYTICS_TOOLS: null } }) const r = runRuleset(makeInput({ context: ctx })) expect(r.computedFlags.HAS_ANALYTICS).toBe(false) }) }) // ============================================================================= // auto_defaults // ============================================================================= describe('auto_defaults', () => { it('sets LOG_RETENTION_DAYS=7 when value is 0', () => { const ctx = withCtx({ SECURITY: { ...EMPTY_CONTEXT.SECURITY, LOG_RETENTION_DAYS: 0 }, }) const r = runRuleset(makeInput({ context: ctx })) expect(r.contextAfterDefaults.SECURITY.LOG_RETENTION_DAYS).toBe(7) expect(r.appliedDefaults).toContain('DEFAULT_LOG_RETENTION') }) it('sets SECURITY_LOG_RETENTION_DAYS=30 when value is 0', () => { const ctx = withCtx({ SECURITY: { ...EMPTY_CONTEXT.SECURITY, SECURITY_LOG_RETENTION_DAYS: 0 }, }) const r = runRuleset(makeInput({ context: ctx })) expect(r.contextAfterDefaults.SECURITY.SECURITY_LOG_RETENTION_DAYS).toBe(30) expect(r.appliedDefaults).toContain('DEFAULT_SECURITY_LOG_RETENTION') }) it('does not apply default when LOG_RETENTION_DAYS is non-zero', () => { const ctx = withCtx({ SECURITY: { ...EMPTY_CONTEXT.SECURITY, LOG_RETENTION_DAYS: 14 }, }) const r = runRuleset(makeInput({ context: ctx })) expect(r.contextAfterDefaults.SECURITY.LOG_RETENTION_DAYS).toBe(14) expect(r.appliedDefaults).not.toContain('DEFAULT_LOG_RETENTION') }) it('does not mutate original context object', () => { const ctx = withCtx({ SECURITY: { ...EMPTY_CONTEXT.SECURITY, LOG_RETENTION_DAYS: 0 }, }) const originalValue = ctx.SECURITY.LOG_RETENTION_DAYS runRuleset(makeInput({ context: ctx })) expect(ctx.SECURITY.LOG_RETENTION_DAYS).toBe(originalValue) }) }) // ============================================================================= // hard_validations // ============================================================================= describe('hard_validations', () => { it('DOC_LANG_MATCH_DE: error when lang=en for nda_de', () => { const r = runRuleset(makeInput({ doc_type: 'nda_de', render: { lang: 'en', variant: 'standard' }, })) const ids = r.violations.map((v) => v.id) expect(ids).toContain('DOC_LANG_MATCH_DE') }) it('DOC_LANG_MATCH_DE: no error when lang=de for nda_de', () => { const r = runRuleset(makeInput({ doc_type: 'nda_de', render: { lang: 'de', variant: 'standard' }, })) const ids = r.violations.map((v) => v.id) expect(ids).not.toContain('DOC_LANG_MATCH_DE') }) it('DOC_LANG_MATCH_EN: error when lang=de for nda_en', () => { const r = runRuleset(makeInput({ doc_type: 'nda_en', render: { lang: 'de', variant: 'standard' }, })) const ids = r.violations.map((v) => v.id) expect(ids).toContain('DOC_LANG_MATCH_EN') }) it('CUSTOMER_ROLE_XOR: error when both IS_CONSUMER and IS_BUSINESS are true', () => { const ctx = withCtx({ CUSTOMER: { ...EMPTY_CONTEXT.CUSTOMER, IS_CONSUMER: true, IS_BUSINESS: true }, }) const r = runRuleset(makeInput({ context: ctx })) const ids = r.violations.map((v) => v.id) expect(ids).toContain('CUSTOMER_ROLE_XOR') }) it('CUSTOMER_ROLE_XOR: no error when only IS_BUSINESS=true', () => { const ctx = withCtx({ CUSTOMER: { ...EMPTY_CONTEXT.CUSTOMER, IS_CONSUMER: false, IS_BUSINESS: true }, }) const r = runRuleset(makeInput({ context: ctx })) const ids = r.violations.map((v) => v.id) expect(ids).not.toContain('CUSTOMER_ROLE_XOR') }) it('PROVIDER_IDENTITY: error when LEGAL_NAME is empty', () => { const ctx = withCtx({ PROVIDER: { ...EMPTY_CONTEXT.PROVIDER, LEGAL_NAME: '', EMAIL: 'test@example.com' }, }) const r = runRuleset(makeInput({ context: ctx })) const ids = r.violations.map((v) => v.id) expect(ids).toContain('PROVIDER_IDENTITY') }) it('PROVIDER_IDENTITY: no error when LEGAL_NAME and EMAIL are set', () => { const ctx = withCtx({ PROVIDER: { ...EMPTY_CONTEXT.PROVIDER, LEGAL_NAME: 'ACME GmbH', EMAIL: 'info@acme.de' }, }) const r = runRuleset(makeInput({ context: ctx })) const ids = r.violations.map((v) => v.id) expect(ids).not.toContain('PROVIDER_IDENTITY') }) it('SLA_AVAILABILITY_RANGE: error for sla_de with 85% availability', () => { const ctx = withCtx({ SLA: { ...EMPTY_CONTEXT.SLA, AVAILABILITY_PERCENT: 85 } }) const r = runRuleset(makeInput({ doc_type: 'sla_de', render: { lang: 'de', variant: 'standard' }, context: ctx, })) const ids = r.violations.map((v) => v.id) expect(ids).toContain('SLA_AVAILABILITY_RANGE') }) it('SLA_AVAILABILITY_RANGE: no error for sla_de with 99.5%', () => { const ctx = withCtx({ SLA: { ...EMPTY_CONTEXT.SLA, AVAILABILITY_PERCENT: 99.5 } }) const r = runRuleset(makeInput({ doc_type: 'sla_de', render: { lang: 'de', variant: 'standard' }, context: ctx, })) const ids = r.violations.map((v) => v.id) expect(ids).not.toContain('SLA_AVAILABILITY_RANGE') }) }) // ============================================================================= // auto_remove_blocks // ============================================================================= describe('auto_remove_blocks', () => { it('removes NDA_PENALTY_BLOCK when PENALTY_AMOUNT_EUR is null', () => { const ctx = withCtx({ NDA: { ...EMPTY_CONTEXT.NDA, PENALTY_AMOUNT_EUR: null } }) const r = runRuleset(makeInput({ context: ctx })) expect(r.removedBlocks).toContain('NDA_PENALTY_BLOCK') }) it('keeps NDA_PENALTY_BLOCK when PENALTY_AMOUNT_EUR is set', () => { const ctx = withCtx({ NDA: { ...EMPTY_CONTEXT.NDA, PENALTY_AMOUNT_EUR: 5000 } }) const r = runRuleset(makeInput({ context: ctx })) expect(r.removedBlocks).not.toContain('NDA_PENALTY_BLOCK') }) it('removes COOKIE_ANALYTICS_BLOCK when HAS_ANALYTICS=false', () => { const ctx = withCtx({ CONSENT: { ...EMPTY_CONTEXT.CONSENT, ANALYTICS_TOOLS: null } }) const r = runRuleset(makeInput({ context: ctx })) expect(r.removedBlocks).toContain('COOKIE_ANALYTICS_BLOCK') }) it('keeps COOKIE_ANALYTICS_BLOCK when ANALYTICS_TOOLS is set', () => { const ctx = withCtx({ CONSENT: { ...EMPTY_CONTEXT.CONSENT, ANALYTICS_TOOLS: 'GA4' } }) const r = runRuleset(makeInput({ context: ctx })) expect(r.removedBlocks).not.toContain('COOKIE_ANALYTICS_BLOCK') }) it('removes COOKIE_MARKETING_BLOCK when HAS_MARKETING=false', () => { const ctx = withCtx({ CONSENT: { ...EMPTY_CONTEXT.CONSENT, MARKETING_PARTNERS: null } }) const r = runRuleset(makeInput({ context: ctx })) expect(r.removedBlocks).toContain('COOKIE_MARKETING_BLOCK') }) it('keeps COOKIE_MARKETING_BLOCK when MARKETING_PARTNERS is set', () => { const ctx = withCtx({ CONSENT: { ...EMPTY_CONTEXT.CONSENT, MARKETING_PARTNERS: 'Facebook Ads' }, }) const r = runRuleset(makeInput({ context: ctx })) expect(r.removedBlocks).not.toContain('COOKIE_MARKETING_BLOCK') }) }) // ============================================================================= // module_requirements // ============================================================================= describe('module_requirements', () => { it('REQ_CLOUD_EXPORT_MODULE: ERROR for cloud_contract_de + SaaS without module', () => { const ctx = withCtx({ SERVICE: { ...EMPTY_CONTEXT.SERVICE, MODEL: 'SaaS' } }) const r = runRuleset(makeInput({ doc_type: 'cloud_contract_de', render: { lang: 'de', variant: 'standard' }, context: ctx, modules: { enabled: [] }, })) expect(r.violations.some((v) => v.id === 'REQ_CLOUD_EXPORT_MODULE')).toBe(true) }) it('REQ_CLOUD_EXPORT_MODULE: no error when module is enabled', () => { const ctx = withCtx({ SERVICE: { ...EMPTY_CONTEXT.SERVICE, MODEL: 'SaaS' } }) const r = runRuleset(makeInput({ doc_type: 'cloud_contract_de', render: { lang: 'de', variant: 'standard' }, context: ctx, modules: { enabled: ['CLOUD_EXPORT_DELETE_DE'] }, })) expect(r.violations.some((v) => v.id === 'REQ_CLOUD_EXPORT_MODULE')).toBe(false) }) it('REQ_B2C_WITHDRAWAL_MODULE: WARN for agb_de + B2C + SaaS without module', () => { const ctx = withCtx({ CUSTOMER: { ...EMPTY_CONTEXT.CUSTOMER, IS_CONSUMER: true, IS_BUSINESS: false }, SERVICE: { ...EMPTY_CONTEXT.SERVICE, MODEL: 'SaaS' }, }) const r = runRuleset(makeInput({ doc_type: 'agb_de', render: { lang: 'de', variant: 'standard' }, context: ctx, modules: { enabled: [] }, })) expect(r.warnings.some((w) => w.id === 'REQ_B2C_WITHDRAWAL_MODULE')).toBe(true) }) it('REQ_B2C_WITHDRAWAL_MODULE: no warn when module is enabled', () => { const ctx = withCtx({ CUSTOMER: { ...EMPTY_CONTEXT.CUSTOMER, IS_CONSUMER: true, IS_BUSINESS: false }, SERVICE: { ...EMPTY_CONTEXT.SERVICE, MODEL: 'SaaS' }, }) const r = runRuleset(makeInput({ doc_type: 'agb_de', render: { lang: 'de', variant: 'standard' }, context: ctx, modules: { enabled: ['B2C_WITHDRAWAL_DE'] }, })) expect(r.warnings.some((w) => w.id === 'REQ_B2C_WITHDRAWAL_MODULE')).toBe(false) }) }) // ============================================================================= // warnings // ============================================================================= describe('warnings', () => { it('WARN_LEGAL_REVIEW: always present', () => { const r = runRuleset(makeInput()) expect(r.warnings.some((w) => w.id === 'WARN_LEGAL_REVIEW')).toBe(true) }) it('WARN_EXPORT_FORMATS_MISSING: fires for cloud_contract_de + SaaS with EXPORT_WINDOW_DAYS=0', () => { const ctx = withCtx({ SERVICE: { ...EMPTY_CONTEXT.SERVICE, MODEL: 'SaaS', EXPORT_WINDOW_DAYS: 0 }, }) const r = runRuleset(makeInput({ doc_type: 'cloud_contract_de', render: { lang: 'de', variant: 'standard' }, context: ctx, })) expect(r.warnings.some((w) => w.id === 'WARN_EXPORT_FORMATS_MISSING')).toBe(true) }) it('WARN_EXPORT_FORMATS_MISSING: does not fire for non-SaaS cloud doc', () => { const ctx = withCtx({ SERVICE: { ...EMPTY_CONTEXT.SERVICE, MODEL: 'OnPrem', EXPORT_WINDOW_DAYS: 0 }, }) const r = runRuleset(makeInput({ doc_type: 'cloud_contract_de', render: { lang: 'de', variant: 'standard' }, context: ctx, })) expect(r.warnings.some((w) => w.id === 'WARN_EXPORT_FORMATS_MISSING')).toBe(false) }) it('WARN_EXPORT_FORMATS_MISSING: does not fire when EXPORT_WINDOW_DAYS >= 1', () => { const ctx = withCtx({ SERVICE: { ...EMPTY_CONTEXT.SERVICE, MODEL: 'SaaS', EXPORT_WINDOW_DAYS: 30 }, }) const r = runRuleset(makeInput({ doc_type: 'cloud_contract_de', render: { lang: 'de', variant: 'standard' }, context: ctx, })) expect(r.warnings.some((w) => w.id === 'WARN_EXPORT_FORMATS_MISSING')).toBe(false) }) }) // ============================================================================= // applyBlockRemoval // ============================================================================= describe('applyBlockRemoval', () => { const NDA_WITH_PENALTY = `# NDA ## §1 Zweck Inhalt. [BLOCK:NDA_PENALTY_BLOCK] ## §7 Vertragsstrafe Der Verletzer zahlt {{PENALTY_AMOUNT}} EUR. [/BLOCK:NDA_PENALTY_BLOCK] ## §8 Schlussbestimmungen ` it('removes the block when its ID is in removedBlocks', () => { const result = applyBlockRemoval(NDA_WITH_PENALTY, ['NDA_PENALTY_BLOCK']) expect(result).not.toContain('[BLOCK:NDA_PENALTY_BLOCK]') expect(result).not.toContain('Vertragsstrafe') expect(result).toContain('§8 Schlussbestimmungen') }) it('keeps the block when its ID is NOT in removedBlocks', () => { const result = applyBlockRemoval(NDA_WITH_PENALTY, []) expect(result).toContain('[BLOCK:NDA_PENALTY_BLOCK]') expect(result).toContain('Vertragsstrafe') }) it('removes multiple blocks independently', () => { const content = `Header\n[BLOCK:A]\nBlock A\n[/BLOCK:A]\nMiddle\n[BLOCK:B]\nBlock B\n[/BLOCK:B]\nFooter` const result = applyBlockRemoval(content, ['A', 'B']) expect(result).toContain('Header') expect(result).toContain('Middle') expect(result).toContain('Footer') expect(result).not.toContain('Block A') expect(result).not.toContain('Block B') }) it('handles multiline content within a block', () => { const content = `[BLOCK:X]\nLine 1\nLine 2\nLine 3\n[/BLOCK:X]\nAfter` const result = applyBlockRemoval(content, ['X']) expect(result).not.toContain('Line 1') expect(result).toContain('After') }) it('is a no-op when content has no block markers', () => { const plain = '# Simple document\n\nJust text.' expect(applyBlockRemoval(plain, ['NDA_PENALTY_BLOCK'])).toBe(plain) }) }) // ============================================================================= // Integration: valid NDA with penalty // ============================================================================= describe('integration', () => { it('valid NDA DE with penalty: 0 errors, correct flags', () => { const ctx = withCtx({ PROVIDER: { ...EMPTY_CONTEXT.PROVIDER, LEGAL_NAME: 'ACME GmbH', EMAIL: 'info@acme.de' }, CUSTOMER: { ...EMPTY_CONTEXT.CUSTOMER, IS_CONSUMER: false, IS_BUSINESS: true }, NDA: { PURPOSE: 'Software-Entwicklung', DURATION_YEARS: 5, PENALTY_AMOUNT_EUR: 10000 }, }) const r = runRuleset(makeInput({ doc_type: 'nda_de', render: { lang: 'de', variant: 'standard' }, context: ctx, })) expect(r.violations).toHaveLength(0) expect(r.computedFlags.HAS_PENALTY).toBe(true) expect(r.computedFlags.IS_B2B).toBe(true) expect(r.removedBlocks).not.toContain('NDA_PENALTY_BLOCK') // WARN_LEGAL_REVIEW always present expect(r.warnings.some((w) => w.id === 'WARN_LEGAL_REVIEW')).toBe(true) }) it('valid NDA DE without penalty: penalty block removed', () => { const ctx = withCtx({ PROVIDER: { ...EMPTY_CONTEXT.PROVIDER, LEGAL_NAME: 'ACME GmbH', EMAIL: 'info@acme.de' }, CUSTOMER: { ...EMPTY_CONTEXT.CUSTOMER, IS_CONSUMER: false, IS_BUSINESS: true }, NDA: { PURPOSE: 'Vertrieb', DURATION_YEARS: 3, PENALTY_AMOUNT_EUR: null }, }) const r = runRuleset(makeInput({ doc_type: 'nda_de', render: { lang: 'de', variant: 'standard' }, context: ctx, })) expect(r.violations).toHaveLength(0) expect(r.computedFlags.HAS_PENALTY).toBe(false) expect(r.removedBlocks).toContain('NDA_PENALTY_BLOCK') }) it('cookie banner DE with analytics: keeps analytics block', () => { const ctx = withCtx({ PROVIDER: { ...EMPTY_CONTEXT.PROVIDER, LEGAL_NAME: 'Shop GmbH', EMAIL: 'info@shop.de' }, CONSENT: { WEBSITE_NAME: 'MyShop', ANALYTICS_TOOLS: 'GA4', MARKETING_PARTNERS: null }, }) const r = runRuleset(makeInput({ doc_type: 'cookie_banner_de', render: { lang: 'de', variant: 'standard' }, context: ctx, })) expect(r.removedBlocks).not.toContain('COOKIE_ANALYTICS_BLOCK') expect(r.removedBlocks).toContain('COOKIE_MARKETING_BLOCK') expect(r.computedFlags.HAS_ANALYTICS).toBe(true) expect(r.computedFlags.HAS_MARKETING).toBe(false) }) })