Implements m/paliad#47 (Client Role rework) + m/paliad#50 (auto-derived project codes from the ancestor tree) in one shift. Migrations: - mig 112_client_role_rework: widen paliad.projects.our_side CHECK to seven sub-roles (claimant / defendant / applicant / appellant / respondent / third_party / other); drop legacy 'court' / 'both' and backfill rows to NULL (no-op on prod, defensive on staging). - mig 113_projects_opponent_code: add paliad.projects.opponent_code text on litigation rows (slug pattern [A-Z0-9-]{1,16}); used as the middle segment when assembling auto-derived project codes. Backend: - internal/services/project_code.go — new package-level helpers BuildProjectCode (single row) + PopulateProjectCodes (bulk, one CTE-based round-trip). Walks the existing paliad.projects.path ltree; custom paliad.projects.reference on the target wins. - Wired into ProjectService.List, GetByID, ListAncestors, GetTree, LoadCounterclaimChildrenVisible, BuildTreeWithOptions — every service entry-point that returns []models.Project / *models.Project populates .Code before returning. - Models: Project.OurSide doc widened; new Project.OpponentCode (db:"opponent_code") and Project.Code (db:"-", projection-only). - CreateProjectInput / UpdateProjectInput accept OpponentCode; validateOpponentCode + nullableOpponentCode mirror our_side helpers. - validateOurSide widens to the seven sub-roles; legacy 'court' / 'both' rejected at the service layer with a clear error before the DB CHECK fires. - derivedCounterclaimOurSide CCR flip widened: applicant ↔ respondent, appellant → respondent; third_party / other / NULL pass through. - submission_vars: project.code added to the placeholder bag. ourSideDE / ourSideEN now use the gender-neutral "-Seite" / "-Partei" suffix shape (Klägerseite / Antragstellerseite / ...); better legal-prose default for a B2B patent practice, matches the form labels which already used this shape (cf. head's soft-note on Q4). Frontend: - ProjectFormFields: opponent_code on a new projekt-fields-litigation block (hidden by default, shown when type=litigation); our_side moved into projekt-fields-case and re-labelled "Client Role" / "Mandantenrolle" with three <optgroup>s + seven options. - project-form.ts: showFieldsForType toggles the new litigation block; readPayload / prefillForm wire opponent_code; our_side is now only emitted for type=case. - fristenrechner: ourSideToPerspective widened to the seven sub-roles (Active→claimant, Reactive→defendant, Other→null). ProjectOption type literal updated. - i18n.ts: new projects.field.client_role.* and projects.field.opponent_code.* keys (DE+EN). Legacy projects.field.our_side.* keys stay one release for cached bundles + Verlauf event-history rendering of the new sub-roles. Tests: - TestProjectCodeSegment, TestAssembleProjectCode, TestPatentLast3, TestSanitizeClientShort, TestProceedingTail, TestValidateOpponentCode, TestValidateOurSideSubRoles pin the new pure helpers. - TestOurSideTranslations widened to the seven sub-roles + new prose shape; 'court'/'both' arms now return "" (legacy rejected). - TestDerivedCounterclaimOurSide widened to the new flip map. Migration slot history (this branch was rebumped twice on 2026-05-20): mig 110 was claimed by m/paliad#51 (project_type_other, euler); mig 111 was claimed by m/paliad#48 (project_admin_and_select, gauss). Final slots 112 / 113. go build && go test ./internal/... && cd frontend && bun run build all clean.
399 lines
13 KiB
Go
399 lines
13 KiB
Go
package services
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"io"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// minimalDOCX builds a tiny .docx zip with one document.xml that
|
|
// contains the given body. Just enough to exercise the renderer
|
|
// without depending on Word's full OOXML scaffolding.
|
|
func minimalDOCX(t *testing.T, documentBody string) []byte {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
zw := zip.NewWriter(&buf)
|
|
w, err := zw.Create("word/document.xml")
|
|
if err != nil {
|
|
t.Fatalf("create document.xml: %v", err)
|
|
}
|
|
if _, err := io.WriteString(w, documentBody); err != nil {
|
|
t.Fatalf("write document.xml: %v", err)
|
|
}
|
|
// Drop in a stub Content-Types so the bytes look more like a real
|
|
// .docx for any downstream sanity checks; Word doesn't care about
|
|
// the content during our unit tests but the shape stays honest.
|
|
w2, err := zw.Create("[Content_Types].xml")
|
|
if err != nil {
|
|
t.Fatalf("create content types: %v", err)
|
|
}
|
|
if _, err := io.WriteString(w2, `<?xml version="1.0"?><Types/>`); err != nil {
|
|
t.Fatalf("write content types: %v", err)
|
|
}
|
|
if err := zw.Close(); err != nil {
|
|
t.Fatalf("close zip: %v", err)
|
|
}
|
|
return buf.Bytes()
|
|
}
|
|
|
|
// readDocumentXML pulls word/document.xml out of a rendered .docx.
|
|
func readDocumentXML(t *testing.T, b []byte) string {
|
|
t.Helper()
|
|
zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b)))
|
|
if err != nil {
|
|
t.Fatalf("open rendered zip: %v", err)
|
|
}
|
|
for _, f := range zr.File {
|
|
if f.Name != "word/document.xml" {
|
|
continue
|
|
}
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
t.Fatalf("open document.xml: %v", err)
|
|
}
|
|
defer rc.Close()
|
|
body, err := io.ReadAll(rc)
|
|
if err != nil {
|
|
t.Fatalf("read document.xml: %v", err)
|
|
}
|
|
return string(body)
|
|
}
|
|
t.Fatal("rendered .docx had no word/document.xml")
|
|
return ""
|
|
}
|
|
|
|
// TestRender_SingleRunPlaceholder covers the 99% case: a placeholder
|
|
// that sits inside a single <w:t> text node.
|
|
func TestRender_SingleRunPlaceholder(t *testing.T) {
|
|
doc := `<w:document><w:body><w:p><w:r><w:t>{{firm.name}}</w:t></w:r></w:p></w:body></w:document>`
|
|
tmpl := minimalDOCX(t, doc)
|
|
r := NewSubmissionRenderer()
|
|
out, err := r.Render(tmpl, PlaceholderMap{"firm.name": "HLC"}, nil)
|
|
if err != nil {
|
|
t.Fatalf("render: %v", err)
|
|
}
|
|
body := readDocumentXML(t, out)
|
|
if !strings.Contains(body, ">HLC<") {
|
|
t.Errorf("expected HLC in body, got %q", body)
|
|
}
|
|
if strings.Contains(body, "{{") {
|
|
t.Errorf("unreplaced placeholder marker in body: %q", body)
|
|
}
|
|
}
|
|
|
|
// TestRender_MultiplePlaceholdersPerRun is the case go-docx fails on
|
|
// — sibling placeholders inside the same <w:t> run. The in-house
|
|
// renderer must handle them.
|
|
func TestRender_MultiplePlaceholdersPerRun(t *testing.T) {
|
|
doc := `<w:document><w:body><w:p><w:r><w:t>{{parties.claimant.name}}, vertreten durch {{parties.claimant.representative}}</w:t></w:r></w:p></w:body></w:document>`
|
|
tmpl := minimalDOCX(t, doc)
|
|
r := NewSubmissionRenderer()
|
|
out, err := r.Render(tmpl, PlaceholderMap{
|
|
"parties.claimant.name": "Acme Inc.",
|
|
"parties.claimant.representative": "Kanzlei Müller",
|
|
}, nil)
|
|
if err != nil {
|
|
t.Fatalf("render: %v", err)
|
|
}
|
|
body := readDocumentXML(t, out)
|
|
if !strings.Contains(body, "Acme Inc.") || !strings.Contains(body, "Kanzlei Müller") {
|
|
t.Errorf("expected both party values, got %q", body)
|
|
}
|
|
if strings.Contains(body, "{{") {
|
|
t.Errorf("unreplaced placeholder marker in body: %q", body)
|
|
}
|
|
}
|
|
|
|
// TestRender_MissingMarker confirms unbound placeholders render the
|
|
// missing-value marker instead of failing the request.
|
|
func TestRender_MissingMarker(t *testing.T) {
|
|
doc := `<w:document><w:body><w:p><w:r><w:t>{{project.case_number}}</w:t></w:r></w:p></w:body></w:document>`
|
|
tmpl := minimalDOCX(t, doc)
|
|
r := NewSubmissionRenderer()
|
|
out, err := r.Render(tmpl, PlaceholderMap{}, DefaultMissingMarker("de"))
|
|
if err != nil {
|
|
t.Fatalf("render: %v", err)
|
|
}
|
|
body := readDocumentXML(t, out)
|
|
if !strings.Contains(body, "[KEIN WERT: project.case_number]") {
|
|
t.Errorf("expected KEIN WERT marker, got %q", body)
|
|
}
|
|
outEN, err := r.Render(tmpl, PlaceholderMap{}, DefaultMissingMarker("en"))
|
|
if err != nil {
|
|
t.Fatalf("render en: %v", err)
|
|
}
|
|
bodyEN := readDocumentXML(t, outEN)
|
|
if !strings.Contains(bodyEN, "[NO VALUE: project.case_number]") {
|
|
t.Errorf("expected NO VALUE marker, got %q", bodyEN)
|
|
}
|
|
}
|
|
|
|
// TestRender_CrossRunPlaceholder simulates Word fragmenting a
|
|
// placeholder across runs (autocorrect or post-edit run-split).
|
|
// Pass 2 must catch it.
|
|
func TestRender_CrossRunPlaceholder(t *testing.T) {
|
|
doc := `<w:document><w:body><w:p><w:r><w:t>Hello {{</w:t></w:r><w:r><w:t>project</w:t></w:r><w:r><w:t>.case_number}}!</w:t></w:r></w:p></w:body></w:document>`
|
|
tmpl := minimalDOCX(t, doc)
|
|
r := NewSubmissionRenderer()
|
|
out, err := r.Render(tmpl, PlaceholderMap{"project.case_number": "7 O 1234/26"}, nil)
|
|
if err != nil {
|
|
t.Fatalf("render: %v", err)
|
|
}
|
|
body := readDocumentXML(t, out)
|
|
if !strings.Contains(body, "7 O 1234/26") {
|
|
t.Errorf("expected case number after cross-run merge, got %q", body)
|
|
}
|
|
if strings.Contains(body, "{{") {
|
|
t.Errorf("orphan placeholder marker remained: %q", body)
|
|
}
|
|
}
|
|
|
|
// TestRender_XMLEscaping verifies special characters in placeholder
|
|
// values are escaped so they don't corrupt the document XML.
|
|
func TestRender_XMLEscaping(t *testing.T) {
|
|
doc := `<w:document><w:body><w:p><w:r><w:t>{{user.display_name}}</w:t></w:r></w:p></w:body></w:document>`
|
|
tmpl := minimalDOCX(t, doc)
|
|
r := NewSubmissionRenderer()
|
|
out, err := r.Render(tmpl, PlaceholderMap{
|
|
"user.display_name": `Müller & Söhne <GmbH> "Special"`,
|
|
}, nil)
|
|
if err != nil {
|
|
t.Fatalf("render: %v", err)
|
|
}
|
|
body := readDocumentXML(t, out)
|
|
if !strings.Contains(body, "Müller & Söhne <GmbH> "Special"") {
|
|
t.Errorf("expected escaped value, got %q", body)
|
|
}
|
|
}
|
|
|
|
// TestRender_PreservesNonWordEntries leaves the rest of the .docx
|
|
// untouched so any styles / theme / settings parts come through bit-
|
|
// for-bit.
|
|
func TestRender_PreservesNonWordEntries(t *testing.T) {
|
|
doc := `<w:document><w:body><w:p><w:r><w:t>{{firm.name}}</w:t></w:r></w:p></w:body></w:document>`
|
|
tmpl := minimalDOCX(t, doc)
|
|
r := NewSubmissionRenderer()
|
|
out, err := r.Render(tmpl, PlaceholderMap{"firm.name": "HLC"}, nil)
|
|
if err != nil {
|
|
t.Fatalf("render: %v", err)
|
|
}
|
|
zr, err := zip.NewReader(bytes.NewReader(out), int64(len(out)))
|
|
if err != nil {
|
|
t.Fatalf("open rendered: %v", err)
|
|
}
|
|
var sawTypes bool
|
|
for _, f := range zr.File {
|
|
if f.Name == "[Content_Types].xml" {
|
|
sawTypes = true
|
|
}
|
|
}
|
|
if !sawTypes {
|
|
t.Error("rendered .docx lost [Content_Types].xml")
|
|
}
|
|
}
|
|
|
|
// TestPlaceholderRegex_Boundaries pins the placeholder grammar.
|
|
func TestPlaceholderRegex_Boundaries(t *testing.T) {
|
|
tests := []struct {
|
|
in string
|
|
matches []string
|
|
}{
|
|
{"plain text", nil},
|
|
{"{{foo}}", []string{"{{foo}}"}},
|
|
{"{{ foo }}", []string{"{{ foo }}"}},
|
|
{"{{foo.bar}}", []string{"{{foo.bar}}"}},
|
|
{"{{ foo.bar_baz }}", []string{"{{ foo.bar_baz }}"}},
|
|
{"{{1bad}}", nil}, // must start with a letter
|
|
{"{{ foo }} and {{ bar }}", []string{"{{ foo }}", "{{ bar }}"}},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.in, func(t *testing.T) {
|
|
got := placeholderRegex.FindAllString(tc.in, -1)
|
|
if len(got) != len(tc.matches) {
|
|
t.Fatalf("got %d matches, want %d (in=%q)", len(got), len(tc.matches), tc.in)
|
|
}
|
|
for i := range got {
|
|
if got[i] != tc.matches[i] {
|
|
t.Errorf("match %d: got %q, want %q", i, got[i], tc.matches[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestFamilyOf covers the proceeding-family extraction used by the
|
|
// template registry's fallback chain.
|
|
func TestFamilyOf(t *testing.T) {
|
|
tests := map[string]string{
|
|
"de.inf.lg.erwidg": "de.inf.lg",
|
|
"upc.inf.cfi.soc": "upc.inf.cfi",
|
|
"dpma.opp.dpma": "", // only three segments → no family
|
|
"de.inf.lg": "",
|
|
"": "",
|
|
}
|
|
for in, want := range tests {
|
|
t.Run(in, func(t *testing.T) {
|
|
got := familyOf(in)
|
|
if got != want {
|
|
t.Errorf("familyOf(%q) = %q, want %q", in, got, want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestLegalSourcePretty covers the prefix table.
|
|
func TestLegalSourcePretty(t *testing.T) {
|
|
tests := []struct {
|
|
src, lang, want string
|
|
}{
|
|
{"DE.ZPO.276.1", "de", "§ 276 Abs. 1 ZPO"},
|
|
{"DE.ZPO.276.1", "en", "Section 276(1) ZPO"},
|
|
{"DE.ZPO.253", "de", "§ 253 ZPO"},
|
|
{"DE.ZPO.253", "en", "Section 253 ZPO"},
|
|
{"UPC.RoP.23.1", "de", "Regel 23.1 VerfO UPC"},
|
|
{"UPC.RoP.23.1", "en", "Rule 23.1 RoP UPC"},
|
|
{"UPC.RoP.198", "de", "Regel 198 VerfO UPC"},
|
|
{"DE.PatG.83", "de", "§ 83 PatG"},
|
|
{"EPC.123", "de", "Art. 123 EPÜ"},
|
|
{"EPC.123", "en", "Art. 123 EPC"},
|
|
// Unknown prefix → pass-through unchanged.
|
|
{"FOO.BAR.123", "de", "FOO.BAR.123"},
|
|
{"", "de", ""},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.src+"/"+tc.lang, func(t *testing.T) {
|
|
got := legalSourcePretty(tc.src, tc.lang)
|
|
if got != tc.want {
|
|
t.Errorf("legalSourcePretty(%q, %q) = %q, want %q", tc.src, tc.lang, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestOurSideTranslations pins the our_side enum → DE/EN prose
|
|
// mapping used by addProjectVars. Post t-paliad-222: seven sub-role
|
|
// values + the gender-neutral "-Seite" / "-Partei" suffix shape on
|
|
// DE. Legacy 'court' / 'both' yield "" (the column no longer accepts
|
|
// them after mig 112, but the function defensively handles stale
|
|
// in-memory values from older callers).
|
|
func TestOurSideTranslations(t *testing.T) {
|
|
cases := []struct {
|
|
in, wantDE, wantEN string
|
|
}{
|
|
{"claimant", "Klägerseite", "Claimant"},
|
|
{"defendant", "Beklagtenseite", "Defendant"},
|
|
{"applicant", "Antragstellerseite", "Applicant"},
|
|
{"appellant", "Berufungsklägerseite", "Appellant"},
|
|
{"respondent", "Antragsgegnerseite", "Respondent"},
|
|
{"third_party", "Drittpartei", "Third Party"},
|
|
{"other", "sonstige Verfahrensbeteiligte", "other party"},
|
|
{"court", "", ""},
|
|
{"both", "", ""},
|
|
{"", "", ""},
|
|
{"unknown", "", ""},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.in, func(t *testing.T) {
|
|
if got := ourSideDE(tc.in); got != tc.wantDE {
|
|
t.Errorf("ourSideDE(%q) = %q, want %q", tc.in, got, tc.wantDE)
|
|
}
|
|
if got := ourSideEN(tc.in); got != tc.wantEN {
|
|
t.Errorf("ourSideEN(%q) = %q, want %q", tc.in, got, tc.wantEN)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestTemplateRegistry_Candidates verifies the fallback-chain order
|
|
// matches the m-locked Q4 decision (firm → base/code → base/family →
|
|
// skeleton).
|
|
func TestTemplateRegistry_Candidates(t *testing.T) {
|
|
r := NewTemplateRegistry("", "HLC")
|
|
got := r.candidates("de.inf.lg.erwidg")
|
|
want := []string{
|
|
"templates/HLC/de.inf.lg.erwidg.docx",
|
|
"templates/_base/de.inf.lg.erwidg.docx",
|
|
"templates/_base/de.inf.lg.docx",
|
|
"templates/_base/_skeleton.docx",
|
|
}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("candidates = %v, want %v", got, want)
|
|
}
|
|
for i := range got {
|
|
if got[i] != want[i] {
|
|
t.Errorf("candidate[%d] = %q, want %q", i, got[i], want[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestTemplateRegistry_Candidates_NoFamily covers submission codes
|
|
// without a family suffix (only three dot-segments).
|
|
func TestTemplateRegistry_Candidates_NoFamily(t *testing.T) {
|
|
r := NewTemplateRegistry("", "HLC")
|
|
got := r.candidates("dpma.opp.dpma")
|
|
want := []string{
|
|
"templates/HLC/dpma.opp.dpma.docx",
|
|
"templates/_base/dpma.opp.dpma.docx",
|
|
"templates/_base/_skeleton.docx",
|
|
}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("candidates = %v, want %v", got, want)
|
|
}
|
|
for i := range got {
|
|
if got[i] != want[i] {
|
|
t.Errorf("candidate[%d] = %q, want %q", i, got[i], want[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestTemplateRegistry_Tiers labels each candidate slot. Must stay
|
|
// 1:1 with candidates().
|
|
func TestTemplateRegistry_Tiers(t *testing.T) {
|
|
r := NewTemplateRegistry("", "HLC")
|
|
codes := []string{"de.inf.lg.erwidg", "dpma.opp.dpma"}
|
|
for _, code := range codes {
|
|
c := r.candidates(code)
|
|
ts := r.tiers(code)
|
|
if len(c) != len(ts) {
|
|
t.Fatalf("candidate/tier mismatch for %q: %d vs %d", code, len(c), len(ts))
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPatentNumberUPC covers the kind-code parenthesisation that UPC
|
|
// briefs use (t-paliad-215 Slice 2, design §22 Q-S2-4).
|
|
func TestPatentNumberUPC(t *testing.T) {
|
|
tests := []struct {
|
|
in, want string
|
|
}{
|
|
// EP variants — the common case.
|
|
{"EP 1 234 567 B1", "EP 1 234 567 (B1)"},
|
|
{"EP 4 056 049 A1", "EP 4 056 049 (A1)"},
|
|
// DE national number with kind code.
|
|
{"DE 10 2020 123 456 A1", "DE 10 2020 123 456 (A1)"},
|
|
// No kind code → pass-through unchanged.
|
|
{"EP 1 234 567", "EP 1 234 567"},
|
|
// Leading + trailing whitespace trimmed.
|
|
{" EP 1 234 567 B1 ", "EP 1 234 567 (B1)"},
|
|
// Empty input.
|
|
{"", ""},
|
|
// Slash-separated forms (WO publication numbers) don't match
|
|
// the kind-code shape → pass through.
|
|
{"WO/2023/123456", "WO/2023/123456"},
|
|
// Two-digit kind code (e.g. B12) doesn't match the single-digit
|
|
// pattern; pass through. This is intentional — real EP kind
|
|
// codes are single-letter + single-digit.
|
|
{"EP 1 234 567 B12", "EP 1 234 567 B12"},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.in, func(t *testing.T) {
|
|
got := patentNumberUPC(tc.in)
|
|
if got != tc.want {
|
|
t.Errorf("patentNumberUPC(%q) = %q, want %q", tc.in, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|