feat(founding-wizard): Per-Person IP-Assignment + Prefill + E2E-Tests
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
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>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { FoundingWizardState } from '@/lib/sdk/founding/types'
|
||||
|
||||
interface Props {
|
||||
@@ -9,8 +10,73 @@ interface Props {
|
||||
|
||||
export function StepBasics({ state, update }: Props) {
|
||||
const b = state.basics
|
||||
const [prefillStatus, setPrefillStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
|
||||
|
||||
async function prefillFromCompanyProfile() {
|
||||
setPrefillStatus('loading')
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/company-profile', { cache: 'no-store' })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const payload = await res.json()
|
||||
const p = payload?.profile ?? payload
|
||||
if (!p || typeof p !== 'object') throw new Error('leeres Profil')
|
||||
const industries = Array.isArray(p.industry) ? p.industry.filter(Boolean) : []
|
||||
const industry = industries.length > 0
|
||||
? industries.join(', ')
|
||||
: (p.industryOther || b.industry)
|
||||
const address = [p.headquartersStreet, [p.headquartersZip, p.headquartersCity].filter(Boolean).join(' ')]
|
||||
.filter(Boolean).join(', ') || b.company_address
|
||||
const seat = p.headquartersCity || b.company_seat
|
||||
// Purpose ableiten aus offerings/businessModel — Fallback wenn nichts da
|
||||
const purposeBits: string[] = []
|
||||
if (p.businessModel) purposeBits.push(`Geschäftsmodell: ${p.businessModel}`)
|
||||
if (Array.isArray(p.offerings) && p.offerings.length > 0)
|
||||
purposeBits.push(`Leistungen: ${p.offerings.join(', ')}`)
|
||||
const purpose = purposeBits.length > 0
|
||||
? purposeBits.join('; ')
|
||||
: b.company_purpose_description
|
||||
update('basics', {
|
||||
...b,
|
||||
company_name: p.companyName || b.company_name,
|
||||
legal_form: (p.legalForm === 'UG' ? 'UG' : (p.legalForm === 'GmbH' ? 'GmbH' : b.legal_form)),
|
||||
company_seat: seat,
|
||||
company_address: address,
|
||||
industry,
|
||||
company_purpose_description: b.company_purpose_description.trim() === '' ? purpose : b.company_purpose_description,
|
||||
})
|
||||
setPrefillStatus('success')
|
||||
} catch (err) {
|
||||
console.error('[founding-wizard] prefill failed', err)
|
||||
setPrefillStatus('error')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-600">
|
||||
Stammdaten der Gesellschaft. Pflicht für Satzung, HRB-Anmeldung und SHA.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={prefillFromCompanyProfile}
|
||||
disabled={prefillStatus === 'loading'}
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-blue-300 bg-blue-50 hover:bg-blue-100 disabled:opacity-50"
|
||||
>
|
||||
{prefillStatus === 'loading' ? 'Lade…' : 'Aus Unternehmensprofil vorbefüllen'}
|
||||
</button>
|
||||
</div>
|
||||
{prefillStatus === 'success' && (
|
||||
<div className="text-xs text-green-700 bg-green-50 border border-green-200 rounded px-2 py-1">
|
||||
Daten aus Unternehmensprofil übernommen. Bitte prüfen und ergänzen.
|
||||
</div>
|
||||
)}
|
||||
{prefillStatus === 'error' && (
|
||||
<div className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded px-2 py-1">
|
||||
Konnte Unternehmensprofil nicht laden — bitte Felder manuell ausfüllen.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Firmenname</label>
|
||||
@@ -78,6 +144,35 @@ export function StepBasics({ state, update }: Props) {
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Registergericht
|
||||
</label>
|
||||
<input
|
||||
data-testid="register-court"
|
||||
type="text"
|
||||
value={b.register_court || ''}
|
||||
onChange={e => update('basics', { ...b, register_court: e.target.value })}
|
||||
placeholder="z.B. Amtsgericht Stuttgart"
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Zuständiges Amtsgericht für HRB-Eintragung
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
HRB-Nummer <span className="text-gray-400">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
data-testid="hrb-number"
|
||||
type="text"
|
||||
value={b.hrb_number || ''}
|
||||
onChange={e => update('basics', { ...b, hrb_number: e.target.value })}
|
||||
placeholder="z.B. HRB 12345 (leer falls noch nicht eingetragen)"
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -14,7 +14,7 @@ export function StepGesellschafter({ state, addGesellschafter, updateGesellschaf
|
||||
const [form, setForm] = useState({
|
||||
name: '', geburtsdatum: '', adresse: '', email: '',
|
||||
nennbetrag_eur: 12500, is_geschaeftsfuehrer: true, internal_role: '',
|
||||
has_academic_background: false,
|
||||
has_academic_background: false, ip_areas: '',
|
||||
})
|
||||
|
||||
const totalNennbetrag = state.gesellschafter.reduce((s, g) => s + g.nennbetrag_eur, 0)
|
||||
@@ -22,6 +22,8 @@ export function StepGesellschafter({ state, addGesellschafter, updateGesellschaf
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!form.name.trim()) return
|
||||
const ip_areas = form.ip_areas
|
||||
.split('\n').map(s => s.trim()).filter(Boolean)
|
||||
addGesellschafter({
|
||||
rolle: 'founder',
|
||||
name: form.name,
|
||||
@@ -32,9 +34,10 @@ export function StepGesellschafter({ state, addGesellschafter, updateGesellschaf
|
||||
is_geschaeftsfuehrer: form.is_geschaeftsfuehrer,
|
||||
internal_role: form.internal_role || undefined,
|
||||
has_academic_background: form.has_academic_background,
|
||||
ip_areas: ip_areas.length > 0 ? ip_areas : undefined,
|
||||
})
|
||||
setForm({ name: '', geburtsdatum: '', adresse: '', email: '', nennbetrag_eur: 12500,
|
||||
is_geschaeftsfuehrer: true, internal_role: '', has_academic_background: false })
|
||||
is_geschaeftsfuehrer: true, internal_role: '', has_academic_background: false, ip_areas: '' })
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -82,13 +85,22 @@ export function StepGesellschafter({ state, addGesellschafter, updateGesellschaf
|
||||
onChange={e => setForm({ ...form, nennbetrag_eur: parseInt(e.target.value) || 0 })}
|
||||
className="px-3 py-2 border rounded"
|
||||
/>
|
||||
<input
|
||||
<select
|
||||
data-testid="gs-role"
|
||||
placeholder="Interne Rolle (z.B. CEO, CTO)"
|
||||
value={form.internal_role}
|
||||
onChange={e => setForm({ ...form, internal_role: e.target.value })}
|
||||
className="px-3 py-2 border rounded"
|
||||
/>
|
||||
className="px-3 py-2 border rounded bg-white"
|
||||
>
|
||||
<option value="">Rolle wählen…</option>
|
||||
<option value="CEO">CEO (Chief Executive Officer)</option>
|
||||
<option value="CTO">CTO (Chief Technical Officer)</option>
|
||||
<option value="CFO">CFO (Chief Financial Officer)</option>
|
||||
<option value="COO">COO (Chief Operating Officer)</option>
|
||||
<option value="CPO">CPO (Chief Product Officer)</option>
|
||||
<option value="Geschäftsführer">Geschäftsführer (ohne Spezialisierung)</option>
|
||||
<option value="Gesellschafter">Gesellschafter (kein GF)</option>
|
||||
<option value="Sonstige">Sonstige</option>
|
||||
</select>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -108,6 +120,23 @@ export function StepGesellschafter({ state, addGesellschafter, updateGesellschaf
|
||||
<label className="text-sm">Akademischer Hintergrund</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
IP-Bereiche, die diese Person in die Gesellschaft einbringt
|
||||
<span className="text-gray-400"> (optional, eine Zeile pro Bereich)</span>
|
||||
</label>
|
||||
<textarea
|
||||
data-testid="gs-ip-areas"
|
||||
value={form.ip_areas}
|
||||
onChange={e => setForm({ ...form, ip_areas: e.target.value })}
|
||||
rows={3}
|
||||
placeholder={'z.B.\nCompliance-Engine (Quellcode + Architektur)\nRAG-Pipeline\nKonfigurationsdaten'}
|
||||
className="w-full px-3 py-2 border rounded font-mono text-xs"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Bei mehreren Gründern wird pro Person ein eigener IP-Assignment-Vertrag generiert.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
data-testid="add-gesellschafter"
|
||||
onClick={handleAdd}
|
||||
@@ -139,7 +168,14 @@ export function StepGesellschafter({ state, addGesellschafter, updateGesellschaf
|
||||
{state.gesellschafter.map(g => (
|
||||
<tr key={g.id} className="border-t" data-testid={`gs-row-${g.anteil_nr}`}>
|
||||
<td className="px-3 py-2">{g.anteil_nr}</td>
|
||||
<td className="px-3 py-2 font-medium">{g.name}{g.internal_role ? ` (${g.internal_role})` : ''}</td>
|
||||
<td className="px-3 py-2 font-medium">
|
||||
{g.name}{g.internal_role ? ` (${g.internal_role})` : ''}
|
||||
{g.ip_areas && g.ip_areas.length > 0 && (
|
||||
<div className="text-xs text-gray-500 mt-0.5">
|
||||
IP: {g.ip_areas.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2">{g.geburtsdatum || '—'}</td>
|
||||
<td className="px-3 py-2 text-right">{g.nennbetrag_eur.toLocaleString('de-DE')} €</td>
|
||||
<td className="px-3 py-2 text-right">{((g.nennbetrag_eur / Math.max(target, 1)) * 100).toFixed(2)}%</td>
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* E2E-Test fuer den Founding-Wizard
|
||||
*
|
||||
* Prueft den vollstaendigen 8-Step-Flow:
|
||||
* - Application-Errors / Console-Errors auf jeder Seite
|
||||
* - StepBasics: Prefill-Button + Registergericht/HRB-Felder
|
||||
* - StepGesellschafter: Rollen-Dropdown + IP-Bereiche fuer 2 Gruender
|
||||
* - Per-Person Generation: 2 IP-Assignment-Dokumente
|
||||
* - localStorage-Persistenz
|
||||
*
|
||||
* Backend wird per route.fulfill() gemockt — Test ist hermetisch.
|
||||
*/
|
||||
|
||||
import { test, expect, type Page, type ConsoleMessage } from '@playwright/test'
|
||||
|
||||
const BASE = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3002'
|
||||
const WIZARD_PATH = '/sdk/founding-wizard'
|
||||
|
||||
/** Filtert Browser-Console auf echte App-Errors (ignoriert Next.js / Hydration / 3rd-party Warnings). */
|
||||
function isRealAppError(msg: ConsoleMessage): boolean {
|
||||
if (msg.type() !== 'error') return false
|
||||
const text = msg.text()
|
||||
// Bekanntes Rauschen ausschliessen
|
||||
const ignored = [
|
||||
'Failed to load resource', // 404 fuer Icons etc.
|
||||
'Download the React DevTools', // React-Hinweis
|
||||
'net::ERR_', // Netzwerk (gemockt → erwartete Misses)
|
||||
'Hydration failed because', // Next 15 Pseudo-Errors bei dev
|
||||
'[founding-wizard] prefill failed', // Intentional UX-Logging im Prefill-Fehlerpfad
|
||||
]
|
||||
return !ignored.some(p => text.includes(p))
|
||||
}
|
||||
|
||||
const IGNORED_PAGE_ERRORS = [
|
||||
// Hydration mismatches durch dynamische Zeitstempel ("Gerade eben" vs "vor 1 Min")
|
||||
// im SDK-Header — pure dev-Mode-Symptom, kein App-Bug.
|
||||
'Hydration failed because the server rendered text didn',
|
||||
'There was an error while hydrating',
|
||||
// Next.js dev-mode signals fuer Hydration-Issues
|
||||
'Text content does not match server-rendered HTML',
|
||||
]
|
||||
|
||||
function isIgnoredPageError(err: Error): boolean {
|
||||
return IGNORED_PAGE_ERRORS.some(p => err.message.includes(p))
|
||||
}
|
||||
|
||||
/** Setzt Console-Error- und PageError-Listener. Wirft am Ende, wenn welche aufgetreten sind. */
|
||||
function installErrorTraps(page: Page): { assertNoErrors: () => void } {
|
||||
const consoleErrors: string[] = []
|
||||
const pageErrors: string[] = []
|
||||
|
||||
page.on('console', msg => {
|
||||
if (isRealAppError(msg)) consoleErrors.push(msg.text())
|
||||
})
|
||||
page.on('pageerror', err => {
|
||||
if (!isIgnoredPageError(err)) pageErrors.push(`${err.name}: ${err.message}`)
|
||||
})
|
||||
|
||||
return {
|
||||
assertNoErrors() {
|
||||
const all = [...pageErrors.map(e => `[pageerror] ${e}`), ...consoleErrors.map(e => `[console.error] ${e}`)]
|
||||
if (all.length > 0) {
|
||||
throw new Error(`Application-Errors waehrend des Flows:\n${all.join('\n')}`)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Mockt die zwei API-Endpoints, die der Wizard aufruft. */
|
||||
async function mockBackend(page: Page) {
|
||||
// 1) Company-Profile Prefill
|
||||
await page.route('**/api/sdk/v1/company-profile**', async route => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
profile: {
|
||||
companyName: 'Breakpilot GmbH',
|
||||
legalForm: 'GmbH',
|
||||
industry: ['Software', 'KI/ML'],
|
||||
businessModel: 'SaaS',
|
||||
offerings: ['SaaS-Plattform', 'Compliance-API'],
|
||||
headquartersStreet: 'Königstraße 1',
|
||||
headquartersZip: '70173',
|
||||
headquartersCity: 'Stuttgart',
|
||||
},
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
// 2) Founding-Wizard Generate (gibt 9 Dokumente zurueck: 7 normale + 2 per-person IP-Assignments)
|
||||
await page.route('**/api/v1/founding-wizard/generate', async route => {
|
||||
const request = route.request()
|
||||
const body = JSON.parse(request.postData() || '{}')
|
||||
const selected: string[] = body.selected_documents || []
|
||||
const gesellschafter: Array<{ name?: string; is_geschaeftsfuehrer?: boolean }> = body.gesellschafter || []
|
||||
|
||||
const PER_PERSON = ['ip_assignment_agreement', 'managing_director_employment_contract']
|
||||
const docs: unknown[] = []
|
||||
const tinyDocx = 'UEsDBBQAAAAIAA==' // gueltige base64-Stub (Playwright braucht keinen echten DOCX)
|
||||
|
||||
for (const docType of selected) {
|
||||
if (PER_PERSON.includes(docType)) {
|
||||
const persons = docType === 'managing_director_employment_contract'
|
||||
? gesellschafter.filter(g => g.is_geschaeftsfuehrer)
|
||||
: gesellschafter
|
||||
for (const p of persons) {
|
||||
docs.push({
|
||||
document_type: docType,
|
||||
title: `${docType} — ${p.name}`,
|
||||
filename: `${docType}_${(p.name || 'X').replace(/\s/g, '_')}.docx`,
|
||||
download_url: `data:application/vnd.openxmlformats-officedocument.wordprocessingml.document;base64,${tinyDocx}`,
|
||||
size_bytes: 12345,
|
||||
generated_at: '2026-05-21T12:00:00Z',
|
||||
})
|
||||
}
|
||||
} else {
|
||||
docs.push({
|
||||
document_type: docType,
|
||||
title: docType,
|
||||
filename: `${docType}.docx`,
|
||||
download_url: `data:application/vnd.openxmlformats-officedocument.wordprocessingml.document;base64,${tinyDocx}`,
|
||||
size_bytes: 12345,
|
||||
generated_at: '2026-05-21T12:00:00Z',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ documents: docs, warnings: [] }),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Clears wizard-state and pre-accepts cookies so the CookieBannerOverlay
|
||||
* does not intercept clicks during the test. */
|
||||
async function resetWizardState(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
try {
|
||||
window.localStorage.removeItem('breakpilot:founding-wizard:state:v1')
|
||||
// CookieBannerOverlay liest 'bp-sdk-cookie-consent' und blendet sich aus,
|
||||
// sobald ein Eintrag existiert. Wir setzen Minimal-Consent.
|
||||
window.localStorage.setItem('bp-sdk-cookie-consent', JSON.stringify({
|
||||
necessary: true, statistics: false, marketing: false, functional: false,
|
||||
ewrOnly: false, blockedVendors: [], timestamp: new Date().toISOString(),
|
||||
}))
|
||||
} catch {}
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Founding-Wizard E2E', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await resetWizardState(page)
|
||||
await mockBackend(page)
|
||||
})
|
||||
|
||||
test('vollstaendiger 8-Step-Flow ohne Application-Errors', async ({ page }) => {
|
||||
const errors = installErrorTraps(page)
|
||||
|
||||
await page.goto(`${BASE}${WIZARD_PATH}`)
|
||||
await expect(page.getByTestId('founding-wizard')).toBeVisible()
|
||||
await expect(page.getByTestId('step-content-1')).toBeVisible()
|
||||
|
||||
// --- Step 1: Basics + Prefill ---
|
||||
await page.getByRole('button', { name: /Aus Unternehmensprofil vorbef/i }).click()
|
||||
await expect(page.getByTestId('company-name')).toHaveValue('Breakpilot GmbH', { timeout: 5000 })
|
||||
await expect(page.getByTestId('company-seat')).toHaveValue('Stuttgart')
|
||||
|
||||
// Pflichtfeld: company_purpose_description (mind. 10 Zeichen)
|
||||
await page.getByTestId('company-purpose').fill(
|
||||
'die Entwicklung, Bereitstellung und der Betrieb von KI-gestuetzten Compliance-Werkzeugen sowie damit verbundener Beratungsleistungen.'
|
||||
)
|
||||
|
||||
// Neue Felder: Registergericht + HRB
|
||||
await page.getByTestId('register-court').fill('Amtsgericht Stuttgart')
|
||||
await page.getByTestId('hrb-number').fill('') // noch nicht eingetragen
|
||||
|
||||
await page.getByTestId('next-step').click()
|
||||
|
||||
// --- Step 2: Gesellschafter ---
|
||||
await expect(page.getByTestId('step-content-2')).toBeVisible()
|
||||
|
||||
// Benjamin (CEO, IP: Compliance + RAG)
|
||||
await page.getByTestId('gs-name').fill('Benjamin Bönisch')
|
||||
await page.getByTestId('gs-birthdate').fill('1985-01-15')
|
||||
await page.getByTestId('gs-address').fill('Teststraße 1, 70173 Stuttgart')
|
||||
await page.getByTestId('gs-email').fill('benjamin@breakpilot.ai')
|
||||
await page.getByTestId('gs-nennbetrag').fill('12500')
|
||||
await page.getByTestId('gs-role').selectOption('CEO')
|
||||
await page.getByTestId('gs-ip-areas').fill(
|
||||
'Compliance-Engine (Quellcode + Architektur)\nRAG-Pipeline\nProdukt-Konzepte'
|
||||
)
|
||||
await page.getByTestId('add-gesellschafter').click()
|
||||
await expect(page.getByTestId('gs-row-1')).toBeVisible()
|
||||
|
||||
// Sharang (CTO, IP: Security + Infrastruktur)
|
||||
await page.getByTestId('gs-name').fill('Sharang Parnerkar')
|
||||
await page.getByTestId('gs-birthdate').fill('1990-06-20')
|
||||
await page.getByTestId('gs-address').fill('Teststraße 2, 70173 Stuttgart')
|
||||
await page.getByTestId('gs-email').fill('sharang@breakpilot.ai')
|
||||
await page.getByTestId('gs-nennbetrag').fill('12500')
|
||||
await page.getByTestId('gs-role').selectOption('CTO')
|
||||
await page.getByTestId('gs-ip-areas').fill('Security-Modul\nInfrastructure-as-Code')
|
||||
await page.getByTestId('add-gesellschafter').click()
|
||||
await expect(page.getByTestId('gs-row-2')).toBeVisible()
|
||||
|
||||
// Summe Nennbetraege muss Stammkapital entsprechen (25.000)
|
||||
await expect(page.getByTestId('gs-total')).toContainText('25.000')
|
||||
|
||||
await page.getByTestId('next-step').click()
|
||||
|
||||
// --- Step 3: GF-Assignment (Defaults sind ok, beide bereits GF) ---
|
||||
await expect(page.getByTestId('step-content-3')).toBeVisible()
|
||||
await expect(page.getByTestId('gf-assignment-table')).toBeVisible()
|
||||
await page.getByTestId('next-step').click()
|
||||
|
||||
// --- Step 4: Kapital (Defaults: 25000) ---
|
||||
await expect(page.getByTestId('step-content-4')).toBeVisible()
|
||||
await expect(page.getByTestId('stammkapital')).toHaveValue('25000')
|
||||
await page.getByTestId('next-step').click()
|
||||
|
||||
// --- Step 5: Notar ---
|
||||
await expect(page.getByTestId('step-content-5')).toBeVisible()
|
||||
await page.getByTestId('notary-name').fill('Dr. Max Mustermann')
|
||||
await page.getByTestId('notary-place').fill('Stuttgart')
|
||||
await page.getByTestId('notary-address').fill('Königstraße 99, 70173 Stuttgart')
|
||||
await page.getByTestId('notarial-date').fill('2026-06-15')
|
||||
await page.getByTestId('next-step').click()
|
||||
|
||||
// --- Step 6: SHA-Optionen (Defaults sind ok) ---
|
||||
await expect(page.getByTestId('step-content-6')).toBeVisible()
|
||||
await expect(page.getByTestId('has-sha')).toBeChecked()
|
||||
await page.getByTestId('next-step').click()
|
||||
|
||||
// --- Step 7: GF-Vertraege (fuer jeden GF einen) ---
|
||||
await expect(page.getByTestId('step-content-7')).toBeVisible()
|
||||
// Beide GF-Contract-Karten muessen sichtbar sein
|
||||
const contractCards = page.locator('[data-testid^="contract-"]')
|
||||
await expect(contractCards).toHaveCount(2)
|
||||
// Salary in beiden Cards anfassen → registriert Contracts (canProceed-Bedingung).
|
||||
// Wir setzen einen anderen Wert als Default (84000) damit React onChange feuert.
|
||||
const salaryInputs = page.locator('[data-testid^="salary-"]')
|
||||
const salaryCount = await salaryInputs.count()
|
||||
for (let i = 0; i < salaryCount; i++) {
|
||||
await salaryInputs.nth(i).fill('90000')
|
||||
}
|
||||
// Warten bis "Weiter" enabled ist
|
||||
await expect(page.getByTestId('next-step')).toBeEnabled()
|
||||
await page.getByTestId('next-step').click()
|
||||
|
||||
// --- Step 8: Generate ---
|
||||
await expect(page.getByTestId('step-content-8')).toBeVisible()
|
||||
await expect(page.getByTestId('generate-summary')).toContainText('Breakpilot GmbH')
|
||||
await expect(page.getByTestId('generate-summary')).toContainText('2', { useInnerText: true })
|
||||
|
||||
// Notartermin-Bundle auswaehlen
|
||||
await page.getByTestId('select-notary-bundle').click()
|
||||
|
||||
// Generieren (Backend gemockt)
|
||||
await page.getByTestId('generate-docs').click()
|
||||
|
||||
// Generated-Docs-Block muss erscheinen
|
||||
await expect(page.getByTestId('generated-docs')).toBeVisible({ timeout: 10000 })
|
||||
|
||||
// Per-Person Verifikation: zwei IP-Assignment-Downloads erwartet
|
||||
const ipDownloads = page.locator('[data-testid="download-ip_assignment_agreement"]')
|
||||
await expect(ipDownloads).toHaveCount(2)
|
||||
|
||||
// Per-Person Verifikation: zwei GF-Vertraege erwartet
|
||||
const gfDownloads = page.locator('[data-testid="download-managing_director_employment_contract"]')
|
||||
await expect(gfDownloads).toHaveCount(2)
|
||||
|
||||
// Kein generate-error sichtbar
|
||||
await expect(page.getByTestId('generate-error')).toBeHidden()
|
||||
|
||||
// Final: keine Errors auf der Konsole
|
||||
errors.assertNoErrors()
|
||||
})
|
||||
|
||||
test('Prefill-Button setzt Fehler bei Backend-Fehler ohne Application-Error', async ({ page }) => {
|
||||
// Spezial-Mock: company-profile gibt 500 zurueck
|
||||
await page.route('**/api/sdk/v1/company-profile**', async route => {
|
||||
await route.fulfill({ status: 500, body: 'boom' })
|
||||
})
|
||||
|
||||
const errors = installErrorTraps(page)
|
||||
await page.goto(`${BASE}${WIZARD_PATH}`)
|
||||
|
||||
await page.getByRole('button', { name: /Aus Unternehmensprofil vorbef/i }).click()
|
||||
// UI muss Fehlermeldung anzeigen, NICHT crashen
|
||||
await expect(page.getByText('Konnte Unternehmensprofil nicht laden')).toBeVisible()
|
||||
|
||||
errors.assertNoErrors()
|
||||
})
|
||||
|
||||
test('Step-Navigation: Zurueck und Reset funktionieren ohne Errors', async ({ page }) => {
|
||||
const errors = installErrorTraps(page)
|
||||
await page.goto(`${BASE}${WIZARD_PATH}`)
|
||||
|
||||
// Minimum Step 1 fuellen
|
||||
await page.getByTestId('company-name').fill('Breakpilot GmbH')
|
||||
await page.getByTestId('company-seat').fill('Stuttgart')
|
||||
await page.getByTestId('company-purpose').fill('die Entwicklung von Compliance-Software fuer Unternehmen.')
|
||||
|
||||
await page.getByTestId('next-step').click()
|
||||
await expect(page.getByTestId('step-content-2')).toBeVisible()
|
||||
|
||||
// Zurueck
|
||||
await page.getByTestId('prev-step').click()
|
||||
await expect(page.getByTestId('step-content-1')).toBeVisible()
|
||||
|
||||
// Eingaben muessen erhalten geblieben sein (localStorage-persistence)
|
||||
await expect(page.getByTestId('company-name')).toHaveValue('Breakpilot GmbH')
|
||||
|
||||
// Reset (mit Dialog-Bestaetigung)
|
||||
page.once('dialog', dialog => dialog.accept())
|
||||
await page.getByTestId('reset-wizard').click()
|
||||
await expect(page.getByTestId('company-name')).toHaveValue('')
|
||||
|
||||
errors.assertNoErrors()
|
||||
})
|
||||
|
||||
test('IP-Areas + Rollen-Dropdown in Step 2', async ({ page }) => {
|
||||
const errors = installErrorTraps(page)
|
||||
await page.goto(`${BASE}${WIZARD_PATH}`)
|
||||
|
||||
// Step 1 zuegig fuellen
|
||||
await page.getByTestId('company-name').fill('Breakpilot GmbH')
|
||||
await page.getByTestId('company-seat').fill('Stuttgart')
|
||||
await page.getByTestId('company-purpose').fill('die Entwicklung von Compliance-Software fuer Unternehmen.')
|
||||
await page.getByTestId('next-step').click()
|
||||
|
||||
// Rollen-Dropdown muss ein <select> sein, nicht <input>
|
||||
const role = page.getByTestId('gs-role')
|
||||
await expect(role).toHaveJSProperty('tagName', 'SELECT')
|
||||
|
||||
// CEO-Option waehlbar
|
||||
await page.getByTestId('gs-name').fill('Benjamin Bönisch')
|
||||
await page.getByTestId('gs-address').fill('Test 1')
|
||||
await page.getByTestId('gs-nennbetrag').fill('25000')
|
||||
await role.selectOption('CEO')
|
||||
await page.getByTestId('gs-ip-areas').fill('Compliance-Engine\nRAG-Pipeline')
|
||||
await page.getByTestId('add-gesellschafter').click()
|
||||
|
||||
// Tabelle muss IP-Bereiche anzeigen
|
||||
const row = page.getByTestId('gs-row-1')
|
||||
await expect(row).toContainText('Benjamin Bönisch')
|
||||
await expect(row).toContainText('CEO')
|
||||
await expect(row).toContainText('Compliance-Engine')
|
||||
|
||||
errors.assertNoErrors()
|
||||
})
|
||||
})
|
||||
@@ -25,6 +25,8 @@ export interface Gesellschafter {
|
||||
internal_role?: string
|
||||
/** Falls Gründer akademischen Hintergrund hat (Professur etc.) */
|
||||
has_academic_background?: boolean
|
||||
/** IP-Bereiche die der Gründer für die GmbH einbringt (z.B. ["Compliance-Engine", "RAG-Pipeline"]) */
|
||||
ip_areas?: string[]
|
||||
}
|
||||
|
||||
export interface NotarData {
|
||||
@@ -46,6 +48,10 @@ export interface CompanyBasics {
|
||||
industry: string
|
||||
business_year: string // z.B. "Kalenderjahr"
|
||||
has_research_focus: boolean
|
||||
/** Registergericht (z.B. "Amtsgericht Stuttgart"). Pflicht für HRB-Anmeldung. */
|
||||
register_court?: string
|
||||
/** HRB-Nummer (z.B. "HRB 12345"). Leer falls noch nicht eingetragen. */
|
||||
hrb_number?: string
|
||||
}
|
||||
|
||||
export interface CapitalConfig {
|
||||
@@ -145,6 +151,8 @@ export function defaultFoundingWizardState(): FoundingWizardState {
|
||||
industry: '',
|
||||
business_year: 'Kalenderjahr',
|
||||
has_research_focus: false,
|
||||
register_court: '',
|
||||
hrb_number: '',
|
||||
},
|
||||
gesellschafter: [],
|
||||
capital: {
|
||||
|
||||
@@ -98,19 +98,35 @@ def _load_template(db: Session, document_type: str) -> dict[str, Any] | None:
|
||||
}
|
||||
|
||||
|
||||
def _render_one(db: Session, doc_type: str, context: dict[str, Any]) -> DocumentResult | None:
|
||||
def _safe_slug(name: str) -> str:
|
||||
"""Erzeugt einen filename-tauglichen Slug aus einem Namen."""
|
||||
import re as _re
|
||||
s = _re.sub(r"[^a-zA-Z0-9_-]+", "_", name.strip())
|
||||
return s.strip("_") or "Person"
|
||||
|
||||
|
||||
def _render_one(
|
||||
db: Session,
|
||||
doc_type: str,
|
||||
context: dict[str, Any],
|
||||
name_suffix: str = "",
|
||||
) -> DocumentResult | None:
|
||||
template = _load_template(db, doc_type)
|
||||
if not template:
|
||||
logger.warning("No template found for document_type=%s", doc_type)
|
||||
return None
|
||||
rendered_md = render_template(template["content"], context)
|
||||
title = template.get("title") or DOC_TITLES.get(doc_type, doc_type)
|
||||
if name_suffix:
|
||||
title = f"{title} — {name_suffix}"
|
||||
docx_bytes = markdown_to_docx_bytes(rendered_md, title=None)
|
||||
from datetime import datetime
|
||||
suffix_slug = f"_{_safe_slug(name_suffix)}" if name_suffix else ""
|
||||
company_slug = _safe_slug(context.get("COMPANY_NAME", "Unternehmen"))
|
||||
return DocumentResult(
|
||||
document_type=doc_type,
|
||||
title=title,
|
||||
filename=f"{doc_type}_{context.get('COMPANY_NAME', 'Unternehmen')}.docx".replace(" ", "_"),
|
||||
filename=f"{doc_type}{suffix_slug}_{company_slug}.docx",
|
||||
content_base64=base64.b64encode(docx_bytes).decode("ascii"),
|
||||
size_bytes=len(docx_bytes),
|
||||
generated_at=datetime.utcnow().isoformat() + "Z",
|
||||
@@ -118,6 +134,56 @@ def _render_one(db: Session, doc_type: str, context: dict[str, Any]) -> Document
|
||||
)
|
||||
|
||||
|
||||
# Dokumente die PRO Person (Gründer/GF) generiert werden
|
||||
PER_PERSON_DOCS = {
|
||||
"ip_assignment_agreement", # Pro Gründer einer (individuelles IP)
|
||||
"managing_director_employment_contract", # Pro GF einer
|
||||
}
|
||||
|
||||
|
||||
def _build_person_context(
|
||||
base_ctx: dict[str, Any],
|
||||
person: dict[str, Any],
|
||||
doc_type: str,
|
||||
gf_contract: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Erweitert base_context um person-spezifische Felder fuer Per-Person-Dokumente."""
|
||||
ctx = dict(base_ctx)
|
||||
name = person.get("name", "")
|
||||
ctx["ASSIGNOR_NAME"] = name
|
||||
ctx["ASSIGNOR_BIRTHDATE"] = person.get("geburtsdatum", "")
|
||||
ctx["ASSIGNOR_ADDRESS"] = person.get("adresse", "")
|
||||
ctx["ASSIGNOR_ROLE"] = person.get("internal_role") or "Gründer und Geschäftsführer"
|
||||
ctx["HAS_ACADEMIC_BACKGROUND"] = bool(person.get("has_academic_background"))
|
||||
# GF-Vertrag spezifisch
|
||||
ctx["GF_NAME"] = name
|
||||
ctx["GF_BIRTHDATE"] = person.get("geburtsdatum", "")
|
||||
ctx["GF_ADDRESS"] = person.get("adresse", "")
|
||||
ctx["GF_INTERNAL_TITLE"] = person.get("internal_role", "Geschäftsführer")
|
||||
# IP-Bereiche: Person-spezifisch wenn vorhanden
|
||||
ip_areas = person.get("ip_areas") or []
|
||||
if ip_areas:
|
||||
if isinstance(ip_areas, list):
|
||||
ctx["IP_LIST_DETAILS"] = "\n".join(
|
||||
f"- {area}" for area in ip_areas
|
||||
)
|
||||
else:
|
||||
ctx["IP_LIST_DETAILS"] = str(ip_areas)
|
||||
# GF-Contract Daten anwenden wenn vorhanden
|
||||
if gf_contract:
|
||||
if gf_contract.get("gross_annual_salary_eur"):
|
||||
ctx["GROSS_ANNUAL_SALARY_EUR"] = f"{gf_contract['gross_annual_salary_eur']:,}".replace(",", ".")
|
||||
ctx["HAS_BONUS"] = bool(gf_contract.get("has_bonus"))
|
||||
ctx["HAS_COMPANY_CAR"] = bool(gf_contract.get("has_company_car"))
|
||||
ctx["HAS_BAV"] = bool(gf_contract.get("has_bav"))
|
||||
ctx["VACATION_DAYS"] = gf_contract.get("vacation_days", 30)
|
||||
ctx["KUENDIGUNGSFRIST_GESELLSCHAFT_MONATE"] = gf_contract.get("kuendigungsfrist_gesellschaft_monate", 6)
|
||||
ctx["KUENDIGUNGSFRIST_GF_MONATE"] = gf_contract.get("kuendigungsfrist_gf_monate", 3)
|
||||
ctx["HAS_PARA_181_RELEASE"] = bool(gf_contract.get("para_181_release"))
|
||||
ctx["SV_STATUS"] = gf_contract.get("sv_status", "sozialversicherungsfrei")
|
||||
return ctx
|
||||
|
||||
|
||||
@router.post("/generate", response_model=GenerationResponse)
|
||||
def generate_documents(req: GenerationRequest, request: Request) -> GenerationResponse:
|
||||
"""Hauptendpunkt: nimmt Wizard-State entgegen, generiert DOCX fuer alle ausgewaehlten Dokumente."""
|
||||
@@ -130,7 +196,42 @@ def generate_documents(req: GenerationRequest, request: Request) -> GenerationRe
|
||||
results: list[DocumentResult] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
# Gesellschafter + GF-Listen aus Request
|
||||
gesellschafter = req.gesellschafter
|
||||
gf_list = [g for g in gesellschafter if g.get("is_geschaeftsfuehrer")]
|
||||
gf_contracts_map = {
|
||||
c["gesellschafter_id"]: c
|
||||
for c in req.gf_contracts
|
||||
if c.get("gesellschafter_id")
|
||||
}
|
||||
|
||||
for doc_type in req.selected_documents:
|
||||
if doc_type in PER_PERSON_DOCS:
|
||||
# Pro Person ein Dokument
|
||||
if doc_type == "ip_assignment_agreement":
|
||||
# IP-Assignment: pro Gründer (alle Gesellschafter, nicht nur GFs)
|
||||
persons = gesellschafter or [{}]
|
||||
elif doc_type == "managing_director_employment_contract":
|
||||
# GF-Vertrag: nur pro GF
|
||||
persons = gf_list or [{}]
|
||||
else:
|
||||
persons = [{}]
|
||||
if not persons:
|
||||
warnings.append(f"Keine Personen für '{doc_type}' vorhanden")
|
||||
continue
|
||||
for p in persons:
|
||||
contract = gf_contracts_map.get(p.get("id"))
|
||||
person_ctx = _build_person_context(context, p, doc_type, contract)
|
||||
result = _render_one(
|
||||
db, doc_type, person_ctx,
|
||||
name_suffix=p.get("name", "")
|
||||
)
|
||||
if result is None:
|
||||
warnings.append(f"Template '{doc_type}' nicht in Datenbank gefunden")
|
||||
break
|
||||
results.append(result)
|
||||
else:
|
||||
# Standard: ein Dokument pro Auswahl
|
||||
result = _render_one(db, doc_type, context)
|
||||
if result is None:
|
||||
warnings.append(f"Template '{doc_type}' nicht in Datenbank gefunden")
|
||||
|
||||
@@ -166,7 +166,8 @@ def base_context(state: dict[str, Any]) -> dict[str, Any]:
|
||||
"ANMELDUNG_TYP": "Ersteintragung gemäß § 7 GmbHG",
|
||||
"ANMELDUNG_DATE": notar.get("notarial_date", "[Notartermin folgt]"),
|
||||
"REGISTRY_COURT_ADDRESS": "[Adresse des zuständigen Registergerichts]",
|
||||
"COMPANY_REGISTRY_COURT": "[zuständiges Amtsgericht]",
|
||||
"COMPANY_REGISTRY_COURT": basics.get("register_court") or "[zuständiges Amtsgericht]",
|
||||
"REGISTER_COURT": basics.get("register_court") or "[zuständiges Amtsgericht]",
|
||||
# Common
|
||||
"DOCUMENT_VERSION": "1.0.0",
|
||||
"EFFECTIVE_DATE": notar.get("notarial_date", "[Datum der Beurkundung]"),
|
||||
@@ -182,8 +183,8 @@ def base_context(state: dict[str, Any]) -> dict[str, Any]:
|
||||
"HAS_TEXAS_SHOOTOUT": sha.get("has_texas_shootout", False),
|
||||
"HAS_CEO_DESIGNATION": sha.get("has_ceo_designation", False),
|
||||
"CEO_NAME": sha.get("ceo_name", ""),
|
||||
"HAS_HRB": False,
|
||||
"HRB_NUMBER": "[wird vergeben]",
|
||||
"HAS_HRB": bool(basics.get("hrb_number")),
|
||||
"HRB_NUMBER": basics.get("hrb_number") or "[wird vergeben]",
|
||||
"IS_UG": basics.get("legal_form") == "UG",
|
||||
# GO-GF dynamische §-Numerierung
|
||||
"P_INFO": 5,
|
||||
|
||||
@@ -227,6 +227,70 @@ class TestMarkdownToDocx:
|
||||
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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user