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.
377 lines
10 KiB
Go
377 lines
10 KiB
Go
package services
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// TestProjectCodeSegment pins the per-type segment derivation rules
|
|
// from t-paliad-222 design §3.2:
|
|
//
|
|
// client → reference if set, else sanitized title (cap 8 chars)
|
|
// litigation → opponent_code verbatim (empty → skipped)
|
|
// patent → last 3 digits of patent_number
|
|
// case → uppercase tail of proceeding_types.code
|
|
// project → ""
|
|
func TestProjectCodeSegment(t *testing.T) {
|
|
str := func(s string) *string { return &s }
|
|
intp := func(i int) *int { return &i }
|
|
|
|
cases := []struct {
|
|
name string
|
|
row projectChainRow
|
|
want string
|
|
}{
|
|
// Client rows.
|
|
{
|
|
"client with reference",
|
|
projectChainRow{Type: "client", Title: "Example Co.", Reference: str("EXMPL")},
|
|
"EXMPL",
|
|
},
|
|
{
|
|
"client without reference falls back to slug(title)",
|
|
projectChainRow{Type: "client", Title: "Example Co.", Reference: nil},
|
|
"EXAMPLEC",
|
|
},
|
|
{
|
|
"client without reference, diacritics stripped",
|
|
projectChainRow{Type: "client", Title: "Müller GmbH"},
|
|
"MULLERGM",
|
|
},
|
|
{
|
|
"client with empty reference falls back to title",
|
|
projectChainRow{Type: "client", Title: "ACME", Reference: str(" ")},
|
|
"ACME",
|
|
},
|
|
{
|
|
"client with empty title and no reference → empty",
|
|
projectChainRow{Type: "client", Title: ""},
|
|
"",
|
|
},
|
|
|
|
// Litigation rows.
|
|
{
|
|
"litigation with opponent_code",
|
|
projectChainRow{Type: "litigation", Title: "X v Y", OpponentCode: str("OPNT")},
|
|
"OPNT",
|
|
},
|
|
{
|
|
"litigation without opponent_code → empty",
|
|
projectChainRow{Type: "litigation", Title: "X v Y", OpponentCode: nil},
|
|
"",
|
|
},
|
|
|
|
// Patent rows.
|
|
{
|
|
"patent EP1234567 → 567",
|
|
projectChainRow{Type: "patent", PatentNumber: str("EP1234567")},
|
|
"567",
|
|
},
|
|
{
|
|
"patent with spaces EP 3 456 789 → 789",
|
|
projectChainRow{Type: "patent", PatentNumber: str("EP 3 456 789")},
|
|
"789",
|
|
},
|
|
{
|
|
"patent with kind code EP3456789A1 → 789",
|
|
projectChainRow{Type: "patent", PatentNumber: str("EP3456789A1")},
|
|
"789",
|
|
},
|
|
{
|
|
"patent WO2020/123456 → 456",
|
|
projectChainRow{Type: "patent", PatentNumber: str("WO2020/123456")},
|
|
"456",
|
|
},
|
|
{
|
|
"patent shorter than 3 digits → full",
|
|
projectChainRow{Type: "patent", PatentNumber: str("DE12")},
|
|
"12",
|
|
},
|
|
{
|
|
"patent nil → empty",
|
|
projectChainRow{Type: "patent", PatentNumber: nil},
|
|
"",
|
|
},
|
|
{
|
|
"patent empty digit-stream → empty",
|
|
projectChainRow{Type: "patent", PatentNumber: str("EP")},
|
|
"",
|
|
},
|
|
|
|
// Case rows.
|
|
{
|
|
"case upc.inf.cfi → INF.CFI",
|
|
projectChainRow{Type: "case", ProceedingTypeID: intp(8), ProceedingCode: str("upc.inf.cfi")},
|
|
"INF.CFI",
|
|
},
|
|
{
|
|
"case upc.apl.merits → APL.MERITS",
|
|
projectChainRow{Type: "case", ProceedingTypeID: intp(11), ProceedingCode: str("upc.apl.merits")},
|
|
"APL.MERITS",
|
|
},
|
|
{
|
|
"case de.inf.lg → INF.LG",
|
|
projectChainRow{Type: "case", ProceedingTypeID: intp(12), ProceedingCode: str("de.inf.lg")},
|
|
"INF.LG",
|
|
},
|
|
{
|
|
"case without proceeding_code → empty",
|
|
projectChainRow{Type: "case", ProceedingTypeID: nil, ProceedingCode: nil},
|
|
"",
|
|
},
|
|
{
|
|
"case with single-segment code → empty (no tail)",
|
|
projectChainRow{Type: "case", ProceedingCode: str("single")},
|
|
"",
|
|
},
|
|
|
|
// Generic project rows contribute nothing.
|
|
{
|
|
"generic project → empty",
|
|
projectChainRow{Type: "project", Title: "Whatever"},
|
|
"",
|
|
},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
got := projectCodeSegment(c.row)
|
|
if got != c.want {
|
|
t.Errorf("projectCodeSegment() = %q, want %q", got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestAssembleProjectCode covers the chain assembler, including the
|
|
// custom-override fast-path on the target row's `reference`.
|
|
func TestAssembleProjectCode(t *testing.T) {
|
|
str := func(s string) *string { return &s }
|
|
intp := func(i int) *int { return &i }
|
|
|
|
// The reference tree from the issue body: EXMPL → OPNT → EP3456789 → upc.inf.cfi.
|
|
fullChain := []projectChainRow{
|
|
{ID: uuid.New(), Type: "client", Title: "Example Co.", Reference: str("EXMPL")},
|
|
{ID: uuid.New(), Type: "litigation", Title: "Ex v Op", OpponentCode: str("OPNT")},
|
|
{ID: uuid.New(), Type: "patent", PatentNumber: str("EP3456789")},
|
|
{ID: uuid.New(), Type: "case", ProceedingTypeID: intp(8), ProceedingCode: str("upc.inf.cfi")},
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
chain []projectChainRow
|
|
want string
|
|
}{
|
|
{
|
|
"reference tree → EXMPL.OPNT.789.INF.CFI",
|
|
fullChain,
|
|
"EXMPL.OPNT.789.INF.CFI",
|
|
},
|
|
{
|
|
"empty chain → empty",
|
|
nil,
|
|
"",
|
|
},
|
|
{
|
|
"override on target wins outright",
|
|
append(append([]projectChainRow{}, fullChain[:3]...), projectChainRow{
|
|
ID: uuid.New(), Type: "case", Reference: str("CUSTOM-CODE"),
|
|
ProceedingTypeID: intp(8), ProceedingCode: str("upc.inf.cfi"),
|
|
}),
|
|
"CUSTOM-CODE",
|
|
},
|
|
{
|
|
"override with surrounding whitespace is trimmed",
|
|
append(append([]projectChainRow{}, fullChain[:3]...), projectChainRow{
|
|
ID: uuid.New(), Type: "case", Reference: str(" TRIMMED "),
|
|
}),
|
|
"TRIMMED",
|
|
},
|
|
{
|
|
"override empty string falls through to derivation",
|
|
append(append([]projectChainRow{}, fullChain[:3]...), projectChainRow{
|
|
ID: uuid.New(), Type: "case", Reference: str(""),
|
|
ProceedingTypeID: intp(8), ProceedingCode: str("upc.inf.cfi"),
|
|
}),
|
|
"EXMPL.OPNT.789.INF.CFI",
|
|
},
|
|
{
|
|
"missing ancestors are skipped silently — case directly under client",
|
|
[]projectChainRow{
|
|
{Type: "client", Reference: str("EXMPL")},
|
|
{Type: "case", ProceedingCode: str("upc.inf.cfi")},
|
|
},
|
|
"EXMPL.INF.CFI",
|
|
},
|
|
{
|
|
"missing patent contributes nothing; client+litigation+case",
|
|
[]projectChainRow{
|
|
{Type: "client", Reference: str("EXMPL")},
|
|
{Type: "litigation", OpponentCode: str("OPNT")},
|
|
{Type: "case", ProceedingCode: str("upc.inf.cfi")},
|
|
},
|
|
"EXMPL.OPNT.INF.CFI",
|
|
},
|
|
{
|
|
"target itself is a litigation row (no case below) → up to opponent code",
|
|
[]projectChainRow{
|
|
{Type: "client", Reference: str("EXMPL")},
|
|
{Type: "litigation", OpponentCode: str("OPNT")},
|
|
},
|
|
"EXMPL.OPNT",
|
|
},
|
|
{
|
|
"litigation without opponent_code is skipped silently",
|
|
[]projectChainRow{
|
|
{Type: "client", Reference: str("EXMPL")},
|
|
{Type: "litigation", OpponentCode: nil},
|
|
{Type: "patent", PatentNumber: str("EP3456789")},
|
|
{Type: "case", ProceedingCode: str("upc.inf.cfi")},
|
|
},
|
|
"EXMPL.789.INF.CFI",
|
|
},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
got := assembleProjectCode(c.chain)
|
|
if got != c.want {
|
|
t.Errorf("assembleProjectCode() = %q, want %q", got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPatentLast3 pins the digit-extraction rule across the common
|
|
// patent-number formats users type.
|
|
func TestPatentLast3(t *testing.T) {
|
|
cases := []struct {
|
|
in, want string
|
|
}{
|
|
{"EP1234567", "567"},
|
|
{"EP 1 234 567", "567"},
|
|
{"EP3456789A1", "789"},
|
|
{"WO2020/123456A1", "456"},
|
|
{"DE12", "12"},
|
|
{"EP", ""},
|
|
{"", ""},
|
|
{"NoDigitsAtAll", ""},
|
|
{"1", "1"},
|
|
{"12", "12"},
|
|
{"123", "123"},
|
|
{"1234", "234"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.in, func(t *testing.T) {
|
|
if got := patentLast3(c.in); got != c.want {
|
|
t.Errorf("patentLast3(%q) = %q, want %q", c.in, got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSanitizeClientShort pins the client-short slug rule (uppercase,
|
|
// strip diacritics, drop non-alnum, cap 8).
|
|
func TestSanitizeClientShort(t *testing.T) {
|
|
cases := []struct {
|
|
in, want string
|
|
}{
|
|
{"EXMPL", "EXMPL"},
|
|
{"Example Co.", "EXAMPLEC"},
|
|
{"Müller GmbH", "MULLERGM"},
|
|
{" ACME ", "ACME"},
|
|
{"", ""},
|
|
{" ", ""},
|
|
{"Hogan Lovells International LLP", "HOGANLOV"},
|
|
{"A&B (Patents) Ltd.", "ABPATENT"},
|
|
{"Société Générale", "SOCIETEG"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.in, func(t *testing.T) {
|
|
if got := sanitizeClientShort(c.in); got != c.want {
|
|
t.Errorf("sanitizeClientShort(%q) = %q, want %q", c.in, got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestProceedingTail pins the jurisdiction-strip rule.
|
|
func TestProceedingTail(t *testing.T) {
|
|
cases := []struct {
|
|
in, want string
|
|
}{
|
|
{"upc.inf.cfi", "INF.CFI"},
|
|
{"upc.rev.cfi", "REV.CFI"},
|
|
{"upc.pi.cfi", "PI.CFI"},
|
|
{"upc.apl.merits", "APL.MERITS"},
|
|
{"de.inf.lg", "INF.LG"},
|
|
{"de.inf.olg", "INF.OLG"},
|
|
{"single", ""},
|
|
{"", ""},
|
|
{"a.b", "B"},
|
|
{" upc.inf.cfi ", "INF.CFI"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.in, func(t *testing.T) {
|
|
if got := proceedingTail(c.in); got != c.want {
|
|
t.Errorf("proceedingTail(%q) = %q, want %q", c.in, got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestValidateOpponentCode pins the slug-validation rule + the
|
|
// type='litigation' pairing. Empty string is the explicit clear
|
|
// sentinel and always passes.
|
|
func TestValidateOpponentCode(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
code string
|
|
ptype string
|
|
wantE bool
|
|
}{
|
|
{"empty clears, any type", "", "case", false},
|
|
{"empty clears, litigation", "", "litigation", false},
|
|
{"valid slug on litigation", "OPNT", "litigation", false},
|
|
{"valid slug with digits on litigation", "OPNT-2026", "litigation", false},
|
|
{"valid slug projectType empty (Update path)", "OPNT", "", false},
|
|
{"lowercase rejected", "opnt", "litigation", true},
|
|
{"underscore rejected", "OPNT_1", "litigation", true},
|
|
{"too long rejected", "OPNT-AND-A-VERY-LONG-NAME", "litigation", true},
|
|
{"non-litigation type rejected", "OPNT", "case", true},
|
|
{"non-litigation type rejected (patent)", "OPNT", "patent", true},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
err := validateOpponentCode(c.code, c.ptype)
|
|
if (err != nil) != c.wantE {
|
|
t.Errorf("validateOpponentCode(%q, %q) error = %v, wantErr=%v",
|
|
c.code, c.ptype, err, c.wantE)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestValidateOurSideSubRoles pins the widened allowlist (mig 112).
|
|
func TestValidateOurSideSubRoles(t *testing.T) {
|
|
valid := []string{
|
|
"", "claimant", "defendant", "applicant", "appellant",
|
|
"respondent", "third_party", "other",
|
|
}
|
|
invalid := []string{"court", "both", "unknown", "CLAIMANT", "Defendant"}
|
|
|
|
for _, v := range valid {
|
|
t.Run("valid_"+v, func(t *testing.T) {
|
|
if err := validateOurSide(v); err != nil {
|
|
t.Errorf("validateOurSide(%q) unexpected error: %v", v, err)
|
|
}
|
|
})
|
|
}
|
|
for _, v := range invalid {
|
|
t.Run("invalid_"+v, func(t *testing.T) {
|
|
if err := validateOurSide(v); err == nil {
|
|
t.Errorf("validateOurSide(%q) expected error, got nil", v)
|
|
}
|
|
})
|
|
}
|
|
}
|