All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 36s
CI / test-python-backend-compliance (push) Successful in 32s
CI / test-python-document-crawler (push) Successful in 22s
CI / test-python-dsms-gateway (push) Successful in 18s
- contextBridge.ts: HostingCtx + FeaturesCtx (35 Felder), ~50 neue Platzhalter-Aliases - ruleEngine.ts: buildBoolContext() + applyConditionalBlocks() (IF/IF_NOT/IF_ANY) - ruleEngine.test.ts: 67 Tests (+18 für Phase C), alle grün - page.tsx: IF-Renderer in Pipeline, HOSTING+FEATURES Formular-Sections, erweiterter SDK-Prefill - scripts/apply_templates_023.py: 4 neue DE-Templates (Cookie v2, DSE, AGB, Impressum) - migrations/023_new_templates_de.sql: Dokumentation + Verifikations-Query Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
665 lines
25 KiB
TypeScript
665 lines
25 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import {
|
|
runRuleset,
|
|
getDocType,
|
|
applyBlockRemoval,
|
|
buildBoolContext,
|
|
applyConditionalBlocks,
|
|
type RuleInput,
|
|
} from './ruleEngine'
|
|
import { EMPTY_CONTEXT } from './contextBridge'
|
|
import type { TemplateContext } from './contextBridge'
|
|
|
|
// =============================================================================
|
|
// Helpers
|
|
// =============================================================================
|
|
|
|
function makeInput(overrides: Partial<RuleInput> = {}): 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>): 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)
|
|
})
|
|
})
|
|
|
|
// =============================================================================
|
|
// buildBoolContext
|
|
// =============================================================================
|
|
|
|
describe('buildBoolContext', () => {
|
|
it('maps computed flags directly', () => {
|
|
const ctx = withCtx({
|
|
CUSTOMER: { ...EMPTY_CONTEXT.CUSTOMER, IS_CONSUMER: true, IS_BUSINESS: false },
|
|
SERVICE: { ...EMPTY_CONTEXT.SERVICE, MODEL: 'SaaS' },
|
|
})
|
|
const r = runRuleset(makeInput({ context: ctx }))
|
|
const boolCtx = buildBoolContext(r.contextAfterDefaults, r.computedFlags)
|
|
expect(boolCtx.IS_B2C).toBe(true)
|
|
expect(boolCtx.IS_B2B).toBe(false)
|
|
expect(boolCtx.SERVICE_IS_SAAS).toBe(true)
|
|
expect(boolCtx.ANALYTICS_ENABLED).toBe(false)
|
|
})
|
|
|
|
it('HAS_REGISTER derived from PROVIDER.REGISTER_COURT', () => {
|
|
const ctx = withCtx({ PROVIDER: { ...EMPTY_CONTEXT.PROVIDER, REGISTER_COURT: 'AG München' } })
|
|
const r = runRuleset(makeInput({ context: ctx }))
|
|
const boolCtx = buildBoolContext(r.contextAfterDefaults, r.computedFlags)
|
|
expect(boolCtx.HAS_REGISTER).toBe(true)
|
|
})
|
|
|
|
it('HAS_REGISTER=false when REGISTER_COURT is empty', () => {
|
|
const ctx = withCtx({ PROVIDER: { ...EMPTY_CONTEXT.PROVIDER, REGISTER_COURT: '' } })
|
|
const r = runRuleset(makeInput({ context: ctx }))
|
|
const boolCtx = buildBoolContext(r.contextAfterDefaults, r.computedFlags)
|
|
expect(boolCtx.HAS_REGISTER).toBe(false)
|
|
})
|
|
|
|
it('HAS_VAT_ID derived from PROVIDER.VAT_ID', () => {
|
|
const ctx = withCtx({ PROVIDER: { ...EMPTY_CONTEXT.PROVIDER, VAT_ID: 'DE123456789' } })
|
|
const r = runRuleset(makeInput({ context: ctx }))
|
|
const boolCtx = buildBoolContext(r.contextAfterDefaults, r.computedFlags)
|
|
expect(boolCtx.HAS_VAT_ID).toBe(true)
|
|
})
|
|
|
|
it('HAS_DPO derived from PRIVACY.DPO_NAME', () => {
|
|
const ctx = withCtx({ PRIVACY: { ...EMPTY_CONTEXT.PRIVACY, DPO_NAME: 'Max Mustermann' } })
|
|
const r = runRuleset(makeInput({ context: ctx }))
|
|
const boolCtx = buildBoolContext(r.contextAfterDefaults, r.computedFlags)
|
|
expect(boolCtx.HAS_DPO).toBe(true)
|
|
})
|
|
|
|
it('FEATURES booleans are passed through', () => {
|
|
const ctx = withCtx({
|
|
FEATURES: {
|
|
...EMPTY_CONTEXT.FEATURES,
|
|
HAS_NEWSLETTER: true,
|
|
HAS_ACCOUNT: true,
|
|
HAS_PAID_PLANS: false,
|
|
HAS_THIRD_COUNTRY: true,
|
|
},
|
|
})
|
|
const r = runRuleset(makeInput({ context: ctx }))
|
|
const boolCtx = buildBoolContext(r.contextAfterDefaults, r.computedFlags)
|
|
expect(boolCtx.HAS_NEWSLETTER).toBe(true)
|
|
expect(boolCtx.HAS_ACCOUNT).toBe(true)
|
|
expect(boolCtx.HAS_PAID_PLANS).toBe(false)
|
|
expect(boolCtx.THIRD_COUNTRY_POSSIBLE).toBe(true)
|
|
})
|
|
|
|
it('MARKETING_ENABLED is alias for HAS_MARKETING', () => {
|
|
const ctx = withCtx({ CONSENT: { ...EMPTY_CONTEXT.CONSENT, MARKETING_PARTNERS: 'Meta Pixel' } })
|
|
const r = runRuleset(makeInput({ context: ctx }))
|
|
const boolCtx = buildBoolContext(r.contextAfterDefaults, r.computedFlags)
|
|
expect(boolCtx.HAS_MARKETING).toBe(true)
|
|
expect(boolCtx.MARKETING_ENABLED).toBe(true)
|
|
})
|
|
})
|
|
|
|
// =============================================================================
|
|
// applyConditionalBlocks
|
|
// =============================================================================
|
|
|
|
describe('applyConditionalBlocks', () => {
|
|
it('{{#IF}} shows block when condition is true', () => {
|
|
const result = applyConditionalBlocks(
|
|
'{{#IF HAS_ANALYTICS}}\nAnalytics section\n{{/IF}}',
|
|
{ HAS_ANALYTICS: true }
|
|
)
|
|
expect(result).toContain('Analytics section')
|
|
expect(result).not.toContain('{{#IF')
|
|
})
|
|
|
|
it('{{#IF}} hides block when condition is false', () => {
|
|
const result = applyConditionalBlocks(
|
|
'{{#IF HAS_ANALYTICS}}\nAnalytics section\n{{/IF}}',
|
|
{ HAS_ANALYTICS: false }
|
|
)
|
|
expect(result).toBe('')
|
|
expect(result).not.toContain('Analytics section')
|
|
})
|
|
|
|
it('{{#IF_NOT}} shows block when condition is false', () => {
|
|
const result = applyConditionalBlocks(
|
|
'{{#IF_NOT HAS_DPO}}\nKein DSB bestellt.\n{{/IF_NOT}}',
|
|
{ HAS_DPO: false }
|
|
)
|
|
expect(result).toContain('Kein DSB bestellt.')
|
|
})
|
|
|
|
it('{{#IF_NOT}} hides block when condition is true', () => {
|
|
const result = applyConditionalBlocks(
|
|
'{{#IF_NOT HAS_DPO}}\nKein DSB bestellt.\n{{/IF_NOT}}',
|
|
{ HAS_DPO: true }
|
|
)
|
|
expect(result).toBe('')
|
|
})
|
|
|
|
it('{{#IF_ANY}} shows block when any condition is true', () => {
|
|
const result = applyConditionalBlocks(
|
|
'{{#IF_ANY HAS_ANALYTICS HAS_MARKETING}}\nTracking aktiv.\n{{/IF_ANY}}',
|
|
{ HAS_ANALYTICS: false, HAS_MARKETING: true }
|
|
)
|
|
expect(result).toContain('Tracking aktiv.')
|
|
})
|
|
|
|
it('{{#IF_ANY}} hides block when all conditions are false', () => {
|
|
const result = applyConditionalBlocks(
|
|
'{{#IF_ANY HAS_ANALYTICS HAS_MARKETING}}\nTracking aktiv.\n{{/IF_ANY}}',
|
|
{ HAS_ANALYTICS: false, HAS_MARKETING: false }
|
|
)
|
|
expect(result).toBe('')
|
|
})
|
|
|
|
it('processes multiple blocks independently', () => {
|
|
const content = '{{#IF A}}\nBlock A\n{{/IF}}\n{{#IF_NOT B}}\nBlock B\n{{/IF_NOT}}'
|
|
const result = applyConditionalBlocks(content, { A: true, B: false })
|
|
expect(result).toContain('Block A')
|
|
expect(result).toContain('Block B')
|
|
})
|
|
|
|
it('handles multiline block content', () => {
|
|
const content = '{{#IF HAS_DPO}}\nName: {{DPO_NAME}}\nEmail: {{DPO_EMAIL}}\n{{/IF}}'
|
|
const result = applyConditionalBlocks(content, { HAS_DPO: true })
|
|
expect(result).toContain('Name: {{DPO_NAME}}')
|
|
expect(result).toContain('Email: {{DPO_EMAIL}}')
|
|
})
|
|
|
|
it('preserves placeholders inside kept blocks for later substitution', () => {
|
|
const content = '{{#IF HAS_ANALYTICS}}\nTool: {{ANALYTICS_TOOLS_LIST}}\n{{/IF}}'
|
|
const result = applyConditionalBlocks(content, { HAS_ANALYTICS: true })
|
|
expect(result).toContain('{{ANALYTICS_TOOLS_LIST}}')
|
|
})
|
|
|
|
it('is a no-op when content has no conditional directives', () => {
|
|
const plain = '# Impressum\n\n{{COMPANY_LEGAL_NAME}}'
|
|
expect(applyConditionalBlocks(plain, {})).toBe(plain)
|
|
})
|
|
|
|
it('handles unknown condition as false (not in boolCtx)', () => {
|
|
const result = applyConditionalBlocks(
|
|
'{{#IF UNKNOWN_COND}}\nHidden\n{{/IF}}',
|
|
{}
|
|
)
|
|
expect(result).toBe('')
|
|
expect(result).not.toContain('Hidden')
|
|
})
|
|
})
|