7335f64f4f
CI / loc-budget (push) Failing after 20s
CI / detect-changes (push) Successful in 12s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 19s
CI / nodejs-build (push) Successful in 3m17s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 43s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
Wizard unterstuetzt jetzt 2-4 Gesellschafter mit individuellem IP-Bereich: - Pro Gruender ein IP-Assignment-Vertrag (z.B. Benjamin: Compliance+RAG; Sharang: Security+Infrastruktur). Pro GF ein eigener Dienstvertrag. - Step 1: Prefill-Button aus Unternehmensprofil + Felder Registergericht und HRB-Nr. - Step 2: Rollen-Dropdown (CEO/CTO/CFO/COO/CPO/GF/Sonstige) statt freie Texteingabe, IP-Bereiche-Textarea pro Person. Backend: - generate_documents() iteriert pro Person fuer PER_PERSON_DOCS. - _build_person_context() injiziert ASSIGNOR_*, GF_*, IP_LIST_DETAILS aus person.ip_areas. - base_context() propagiert basics.register_court und basics.hrb_number. Tests: - 30/30 Pytest gruen (6 neue: Per-Person-Context, Slug-Helper, Registergericht-Propagation). - 4 neue Playwright-E2E-Specs (hermetisch via route.fulfill, mit Console-/Page-Error-Traps): kompletter 8-Step-Flow, Prefill-Fehlerpfad, Step-Navigation/Reset, Rollen-Dropdown + IP-Areas. - Spec setzt 'bp-sdk-cookie-consent' im addInitScript damit der CookieBannerOverlay nicht die Wizard-Buttons ueberlagert. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
321 lines
12 KiB
Python
321 lines
12 KiB
Python
"""Unit-Tests fuer den Founding-Wizard Service.
|
|
|
|
Testet:
|
|
- Template-Renderer mit verschiedenen Inputs
|
|
- Wizard-State -> Context Mapping
|
|
- Markdown -> DOCX Konvertierung
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from compliance.services.founding_wizard.template_renderer import (
|
|
find_undefined_placeholders,
|
|
render_template,
|
|
)
|
|
from compliance.services.founding_wizard.wizard_to_context import base_context
|
|
|
|
|
|
class TestTemplateRenderer:
|
|
def test_simple_variable_substitution(self):
|
|
result = render_template("Hallo {{NAME}}!", {"NAME": "Welt"})
|
|
assert result == "Hallo Welt!"
|
|
|
|
def test_missing_variable_placeholder(self):
|
|
result = render_template("Hallo {{NAME}}!", {})
|
|
assert "[NAME fehlt]" in result
|
|
|
|
def test_if_block_truthy(self):
|
|
result = render_template(
|
|
"Start {{#IF FLAG}}drin{{/IF}} Ende",
|
|
{"FLAG": True}
|
|
)
|
|
assert result == "Start drin Ende"
|
|
|
|
def test_if_block_falsy(self):
|
|
result = render_template(
|
|
"Start {{#IF FLAG}}drin{{/IF}} Ende",
|
|
{"FLAG": False}
|
|
)
|
|
assert result == "Start Ende"
|
|
|
|
def test_if_not_block(self):
|
|
result = render_template(
|
|
"{{#IF NOT FLAG}}negiert{{/IF}}",
|
|
{"FLAG": False}
|
|
)
|
|
assert "negiert" in result
|
|
|
|
def test_truthy_int(self):
|
|
result = render_template("{{#IF N}}yes{{/IF}}", {"N": 5})
|
|
assert "yes" in result
|
|
|
|
def test_falsy_zero(self):
|
|
result = render_template("{{#IF N}}yes{{/IF}}", {"N": 0})
|
|
assert "yes" not in result
|
|
|
|
def test_truthy_list(self):
|
|
result = render_template("{{#IF L}}yes{{/IF}}", {"L": [1, 2]})
|
|
assert "yes" in result
|
|
|
|
def test_falsy_empty_list(self):
|
|
result = render_template("{{#IF L}}yes{{/IF}}", {"L": []})
|
|
assert "yes" not in result
|
|
|
|
def test_nested_if_blocks(self):
|
|
template = "{{#IF A}}A-on{{#IF B}}+B-on{{/IF}}{{/IF}}"
|
|
result = render_template(template, {"A": True, "B": True})
|
|
assert result == "A-on+B-on"
|
|
|
|
def test_nested_outer_false_inner_true(self):
|
|
"""Bug-Regression: nested IF in outer-false darf nicht den falschen close-tag matchen."""
|
|
template = "{{#IF OUTER}}outer-{{#IF INNER}}inner{{/IF}}-end{{/IF}}AFTER"
|
|
result = render_template(template, {"OUTER": False, "INNER": True})
|
|
assert result == "AFTER"
|
|
assert "{{/IF}}" not in result
|
|
assert "outer" not in result
|
|
|
|
def test_consecutive_if_blocks(self):
|
|
"""Bug-Regression: 2 aufeinanderfolgende IF-Bloecke."""
|
|
template = "{{#IF A}}a{{/IF}}{{#IF B}}b{{/IF}}"
|
|
result = render_template(template, {"A": False, "B": True})
|
|
assert result == "b"
|
|
assert "{{" not in result
|
|
|
|
def test_orphan_if_tag_removed(self):
|
|
"""Orphan {{/IF}} aufraeumen."""
|
|
template = "Text{{/IF}}mehr"
|
|
result = render_template(template, {})
|
|
assert "{{/IF}}" not in result
|
|
|
|
def test_real_go_gf_pattern(self):
|
|
"""Realistic Pattern aus GO-GF Template."""
|
|
template = (
|
|
"{{#IF HAS_CEO_DESIGNATION}}Mit CEO {{CEO_NAME}}{{#IF HAS_SHA}} und SHA{{/IF}}.{{/IF}}"
|
|
"{{#IF NOT HAS_CEO_DESIGNATION}}Kein CEO. Eskalation nach § 6.{{/IF}}"
|
|
)
|
|
# Fall: kein CEO, kein SHA
|
|
r1 = render_template(template, {"HAS_CEO_DESIGNATION": False, "HAS_SHA": False})
|
|
assert r1 == "Kein CEO. Eskalation nach § 6."
|
|
# Fall: CEO + SHA
|
|
r2 = render_template(template, {"HAS_CEO_DESIGNATION": True, "HAS_SHA": True, "CEO_NAME": "Benjamin"})
|
|
assert r2 == "Mit CEO Benjamin und SHA."
|
|
# Fall: CEO ohne SHA
|
|
r3 = render_template(template, {"HAS_CEO_DESIGNATION": True, "HAS_SHA": False, "CEO_NAME": "Benjamin"})
|
|
assert r3 == "Mit CEO Benjamin."
|
|
|
|
def test_find_undefined_placeholders(self):
|
|
template = "{{X}} {{Y}} {{#IF Z}}.{{/IF}}"
|
|
undefined = find_undefined_placeholders(template, {"X": "1"})
|
|
assert "Y" in undefined
|
|
assert "Z" in undefined
|
|
assert "X" not in undefined
|
|
|
|
|
|
class TestWizardToContext:
|
|
def _basic_state(self) -> dict:
|
|
return {
|
|
"basics": {
|
|
"company_name": "Test GmbH",
|
|
"legal_form": "GmbH",
|
|
"company_seat": "Stuttgart",
|
|
"company_address": "Königstraße 1, 70173 Stuttgart",
|
|
"company_purpose_description": "Test purpose",
|
|
"company_purpose_bullets": ["a) Test", "b) Test 2"],
|
|
"industry": "SaaS",
|
|
"business_year": "Kalenderjahr",
|
|
"has_research_focus": True,
|
|
},
|
|
"capital": {
|
|
"stammkapital_eur": 25000,
|
|
"einlage_method": "Geld",
|
|
"einlage_quote_initial_pct": 50,
|
|
"has_sacheinlage": False,
|
|
},
|
|
"gesellschafter": [
|
|
{
|
|
"id": "g1", "anteil_nr": 1, "name": "Benjamin Bönisch",
|
|
"geburtsdatum": "1980-01-01", "adresse": "Test 1",
|
|
"nennbetrag_eur": 12500, "is_geschaeftsfuehrer": True,
|
|
"internal_role": "CEO", "rolle": "founder",
|
|
"has_academic_background": False,
|
|
},
|
|
{
|
|
"id": "g2", "anteil_nr": 2, "name": "Sharang Parnerkar",
|
|
"geburtsdatum": "1985-05-15", "adresse": "Test 2",
|
|
"nennbetrag_eur": 12500, "is_geschaeftsfuehrer": True,
|
|
"internal_role": "CTO", "rolle": "founder",
|
|
"has_academic_background": False,
|
|
},
|
|
],
|
|
"notar": {
|
|
"notary_name": "Dr. Notar",
|
|
"notary_place": "Stuttgart",
|
|
"notarial_date": "2026-06-01",
|
|
},
|
|
"sha": {
|
|
"has_sha": True,
|
|
"vesting_months": 48,
|
|
"cliff_months": 12,
|
|
"drag_along_threshold_pct": 75,
|
|
"tag_along_threshold_pct": 20,
|
|
"reserved_matters_majority_pct": 75,
|
|
"has_beirat": False,
|
|
"has_texas_shootout": False,
|
|
"has_ceo_designation": False,
|
|
},
|
|
}
|
|
|
|
def test_basics_in_context(self):
|
|
ctx = base_context(self._basic_state())
|
|
assert ctx["COMPANY_NAME"] == "Test GmbH"
|
|
assert ctx["COMPANY_LEGAL_FORM"] == "GmbH"
|
|
assert ctx["COMPANY_SEAT"] == "Stuttgart"
|
|
assert ctx["STAMMKAPITAL_EUR"] == "25.000"
|
|
|
|
def test_num_gf_2_man(self):
|
|
ctx = base_context(self._basic_state())
|
|
assert ctx["NUM_GF"] == 2
|
|
assert ctx["NUM_GF_TEXT"] == "zwei"
|
|
assert ctx["IS_MULTI_GF"] is True
|
|
assert ctx["NUM_GF_IS_2"] is True
|
|
assert ctx["NUM_GF_GT_2"] is False
|
|
|
|
def test_parties_list_format(self):
|
|
ctx = base_context(self._basic_state())
|
|
plist = ctx["PARTIES_LIST"]
|
|
assert "Benjamin Bönisch" in plist
|
|
assert "Sharang Parnerkar" in plist
|
|
assert "a)" in plist
|
|
assert "b)" in plist
|
|
|
|
def test_flags_default(self):
|
|
ctx = base_context(self._basic_state())
|
|
assert ctx["HAS_SHA"] is True
|
|
assert ctx["HAS_RESEARCH_FOCUS"] is True
|
|
assert ctx["HAS_ACADEMIC_FOUNDER"] is False
|
|
assert ctx["HAS_BEIRAT"] is False
|
|
|
|
def test_academic_flag_detection(self):
|
|
state = self._basic_state()
|
|
state["gesellschafter"][0]["has_academic_background"] = True
|
|
ctx = base_context(state)
|
|
assert ctx["HAS_ACADEMIC_FOUNDER"] is True
|
|
|
|
|
|
class TestMarkdownToDocx:
|
|
def test_basic_conversion(self):
|
|
from compliance.services.founding_wizard.markdown_to_docx import markdown_to_docx_bytes
|
|
md = "# Titel\n\nDas ist ein Absatz.\n\n## Unterthema\n\n- Punkt 1\n- Punkt 2"
|
|
result = markdown_to_docx_bytes(md)
|
|
assert isinstance(result, bytes)
|
|
# DOCX is a ZIP file starting with PK\x03\x04
|
|
assert result[:4] == b"PK\x03\x04"
|
|
assert len(result) > 1000 # reasonable size
|
|
|
|
def test_table_conversion(self):
|
|
from compliance.services.founding_wizard.markdown_to_docx import markdown_to_docx_bytes
|
|
md = "| A | B |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |"
|
|
result = markdown_to_docx_bytes(md)
|
|
assert result[:4] == b"PK\x03\x04"
|
|
|
|
def test_bold_italic(self):
|
|
from compliance.services.founding_wizard.markdown_to_docx import markdown_to_docx_bytes
|
|
md = "Das ist **fett** und _kursiv_ und `code`."
|
|
result = markdown_to_docx_bytes(md)
|
|
assert result[:4] == b"PK\x03\x04"
|
|
|
|
|
|
class TestPerPersonContext:
|
|
"""Tests fuer per-person Context-Building (IP-Assignment, GF-Vertrag)."""
|
|
|
|
def test_build_person_context_ip_areas_as_list(self):
|
|
from compliance.api.founding_wizard_routes import _build_person_context
|
|
base = {"COMPANY_NAME": "X GmbH"}
|
|
person = {
|
|
"name": "Benjamin Bönisch",
|
|
"geburtsdatum": "1980-01-01",
|
|
"adresse": "Test 1",
|
|
"internal_role": "CEO",
|
|
"ip_areas": ["Compliance-Engine", "RAG-Pipeline"],
|
|
}
|
|
ctx = _build_person_context(base, person, "ip_assignment_agreement")
|
|
assert ctx["ASSIGNOR_NAME"] == "Benjamin Bönisch"
|
|
assert "Compliance-Engine" in ctx["IP_LIST_DETAILS"]
|
|
assert "RAG-Pipeline" in ctx["IP_LIST_DETAILS"]
|
|
# Two distinct persons should yield distinct IP_LIST_DETAILS
|
|
person2 = {**person, "name": "Sharang", "ip_areas": ["Security", "Infrastruktur"]}
|
|
ctx2 = _build_person_context(base, person2, "ip_assignment_agreement")
|
|
assert ctx["IP_LIST_DETAILS"] != ctx2["IP_LIST_DETAILS"]
|
|
assert "Security" in ctx2["IP_LIST_DETAILS"]
|
|
|
|
def test_build_person_context_fallback_when_no_ip_areas(self):
|
|
"""Wenn keine ip_areas gesetzt sind, behaelt der Context den Default aus base."""
|
|
from compliance.api.founding_wizard_routes import _build_person_context
|
|
base = {"COMPANY_NAME": "X GmbH", "IP_LIST_DETAILS": "- Default IP"}
|
|
person = {"name": "Foo", "ip_areas": []}
|
|
ctx = _build_person_context(base, person, "ip_assignment_agreement")
|
|
assert ctx["IP_LIST_DETAILS"] == "- Default IP"
|
|
|
|
def test_safe_slug_handles_special_chars(self):
|
|
from compliance.api.founding_wizard_routes import _safe_slug
|
|
assert _safe_slug("Benjamin Bönisch") == "Benjamin_B_nisch"
|
|
assert _safe_slug("Sharang Parnerkar") == "Sharang_Parnerkar"
|
|
assert _safe_slug("") == "Person"
|
|
assert _safe_slug(" ") == "Person"
|
|
|
|
def test_per_person_docs_set_contains_expected(self):
|
|
from compliance.api.founding_wizard_routes import PER_PERSON_DOCS
|
|
assert "ip_assignment_agreement" in PER_PERSON_DOCS
|
|
assert "managing_director_employment_contract" in PER_PERSON_DOCS
|
|
# Satzung etc. duerfen NICHT per-person sein:
|
|
assert "articles_of_association" not in PER_PERSON_DOCS
|
|
assert "sha" not in PER_PERSON_DOCS
|
|
|
|
|
|
class TestBasicsRegisterCourt:
|
|
def test_register_court_propagates(self):
|
|
state = TestWizardToContext()._basic_state()
|
|
state["basics"]["register_court"] = "Amtsgericht Stuttgart"
|
|
state["basics"]["hrb_number"] = "HRB 12345"
|
|
ctx = base_context(state)
|
|
assert ctx["REGISTER_COURT"] == "Amtsgericht Stuttgart"
|
|
assert ctx["COMPANY_REGISTRY_COURT"] == "Amtsgericht Stuttgart"
|
|
assert ctx["HRB_NUMBER"] == "HRB 12345"
|
|
assert ctx["HAS_HRB"] is True
|
|
|
|
def test_register_court_default_when_missing(self):
|
|
ctx = base_context(TestWizardToContext()._basic_state())
|
|
assert "[zuständiges Amtsgericht]" in ctx["REGISTER_COURT"]
|
|
assert ctx["HAS_HRB"] is False
|
|
|
|
|
|
class TestEndToEndRendering:
|
|
"""Test mit echtem Template-aehnlichen Markdown + 2-Mann GmbH Daten."""
|
|
|
|
def test_minimum_satzung_render(self):
|
|
template = """# Satzung der {{COMPANY_NAME}}
|
|
|
|
## § 1 Firma
|
|
(1) Die Gesellschaft führt die Firma {{COMPANY_NAME}}.
|
|
(2) Sitz ist {{COMPANY_SEAT}}.
|
|
|
|
{{#IF HAS_SHA}}
|
|
## § 5 SHA-Verweis
|
|
Es gilt das SHA.
|
|
{{/IF}}
|
|
|
|
{{#IF NOT HAS_SHA}}
|
|
## § 5 Hinweis
|
|
Kein SHA vereinbart.
|
|
{{/IF}}
|
|
"""
|
|
ctx = base_context(TestWizardToContext()._basic_state())
|
|
result = render_template(template, ctx)
|
|
|
|
assert "Test GmbH" in result
|
|
assert "Stuttgart" in result
|
|
assert "§ 5 SHA-Verweis" in result
|
|
assert "Kein SHA vereinbart" not in result
|