Files
paliad/internal/services/project_code_test.go
mAi ea0715a8c7 feat(projects): t-paliad-222 — Client Role + auto-derived project codes
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.
2026-05-20 14:55:55 +02:00

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)
}
})
}
}