On a CCR sub-project the SmartTimeline renders the parent inf project's
rules in the parent_context lane (correct — the CCR depends on the inf
schedule). Clicking "Datum setzen" on those rows bubbled up as a
generic "Konnte das Datum nicht setzen." because RecordAnchor only
looked up the rule under the CCR's own proceeding_type_id; for an
inf rule like upc.inf.cfi.soc that returned sql.ErrNoRows and dropped
into the catch-all error.
The anchor handler now mirrors the read view's broader rule scope: on
sql.ErrNoRows for a CCR project, we retry the lookup against the
parent project's proceeding_type_id. If the rule is found there, we
reject with a new CrossProceedingAnchorError carrying the parent
project's id + title so the frontend can render a clear DE/EN message
and a clickable link back to the parent ("anchor it on the
infringement proceeding, not the counterclaim"). We deliberately do
NOT auto-route the write across projects — that would silently mutate
the inf project's actuals and is out of scope per the brief.
Genuine "unknown submission_code" failures still surface as
ErrInvalidInput; the predecessor_missing 409 path keeps its existing
shape (the two errors discriminate on the response's `error` field).
Adds a Live-DB integration test that seeds an inf-only rule + a CCR
under a real inf project and verifies all three paths: CCR rejects
cross-proceeding, parent inf project accepts the same code, unknown
codes still report unknown_submission_code.
331 lines
10 KiB
Go
331 lines
10 KiB
Go
package services
|
|
|
|
// Pure-function tests for ProjectionService Slice 2 (t-paliad-173) —
|
|
// no DB required. Validates lookahead cap behaviour, anchor-kind
|
|
// dispatch, and extractMetadataString. The live integration test in
|
|
// projection_service_test.go covers SQL paths; this file covers the
|
|
// pure helpers a future refactor most likely to break.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/models"
|
|
)
|
|
|
|
func TestApplyLookaheadDefault(t *testing.T) {
|
|
cases := []struct {
|
|
in, want int
|
|
}{
|
|
{0, DefaultLookaheadCap},
|
|
{-5, DefaultLookaheadCap},
|
|
{1, 1},
|
|
{7, 7},
|
|
{50, 50},
|
|
{51, MaxLookaheadCap},
|
|
{1000, MaxLookaheadCap},
|
|
}
|
|
for _, c := range cases {
|
|
if got := applyLookaheadDefault(c.in); got != c.want {
|
|
t.Errorf("applyLookaheadDefault(%d) = %d, want %d", c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestApplyLookaheadCap_DropsBeyondCap_ExemptsOverdueAndCourtSet(t *testing.T) {
|
|
mar1 := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
|
|
apr1 := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
|
|
may1 := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
|
jun1 := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
|
|
jul1 := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
rows := []TimelineEvent{
|
|
// Two predicted_overdue (past) — must survive uncapped.
|
|
{Kind: "projected", Status: "predicted_overdue", Date: &mar1, RuleCode: "rule.past1", Title: "Past 1"},
|
|
{Kind: "projected", Status: "predicted_overdue", Date: &apr1, RuleCode: "rule.past2", Title: "Past 2"},
|
|
// One court_set future — exempt from cap.
|
|
{Kind: "projected", Status: "court_set", Date: &jul1, RuleCode: "rule.hearing", Title: "Hearing"},
|
|
// Three predicted future — cap=2 means the third drops.
|
|
{Kind: "projected", Status: "predicted", Date: &may1, RuleCode: "rule.fut1", Title: "Fut 1"},
|
|
{Kind: "projected", Status: "predicted", Date: &jun1, RuleCode: "rule.fut2", Title: "Fut 2"},
|
|
{Kind: "projected", Status: "predicted", Date: &jul1, RuleCode: "rule.fut3", Title: "Fut 3"},
|
|
}
|
|
|
|
kept, total, shown, overdue := applyLookaheadCap(rows, 2)
|
|
if total != 3 {
|
|
t.Errorf("ProjectedTotal = %d, want 3", total)
|
|
}
|
|
if shown != 2 {
|
|
t.Errorf("ProjectedShown = %d, want 2", shown)
|
|
}
|
|
if overdue != 2 {
|
|
t.Errorf("PredictedOverdue = %d, want 2", overdue)
|
|
}
|
|
// kept must include both overdue + court_set + first 2 predicted = 5 rows.
|
|
if len(kept) != 5 {
|
|
t.Errorf("kept rows = %d, want 5", len(kept))
|
|
}
|
|
// Past + court_set must remain.
|
|
pastTitles := map[string]bool{}
|
|
for _, r := range kept {
|
|
pastTitles[r.Title] = true
|
|
}
|
|
for _, want := range []string{"Past 1", "Past 2", "Hearing", "Fut 1", "Fut 2"} {
|
|
if !pastTitles[want] {
|
|
t.Errorf("expected kept row %q missing", want)
|
|
}
|
|
}
|
|
if pastTitles["Fut 3"] {
|
|
t.Errorf("Fut 3 should have been dropped (cap=2)")
|
|
}
|
|
}
|
|
|
|
func TestApplyLookaheadCap_NoCapWhenUnderLimit(t *testing.T) {
|
|
may1 := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
|
rows := []TimelineEvent{
|
|
{Kind: "projected", Status: "predicted", Date: &may1, RuleCode: "r1", Title: "1"},
|
|
}
|
|
kept, total, shown, _ := applyLookaheadCap(rows, 7)
|
|
if total != 1 || shown != 1 {
|
|
t.Errorf("counts = (%d, %d), want (1, 1)", total, shown)
|
|
}
|
|
if len(kept) != 1 {
|
|
t.Errorf("kept = %d, want 1", len(kept))
|
|
}
|
|
}
|
|
|
|
func TestRuleAnchorKind(t *testing.T) {
|
|
hearing := "hearing"
|
|
decision := "decision"
|
|
order := "order"
|
|
filing := "filing"
|
|
|
|
cases := []struct {
|
|
name string
|
|
rule *models.DeadlineRule
|
|
want string
|
|
}{
|
|
{"hearing → appointment", &models.DeadlineRule{EventType: &hearing}, "appointment"},
|
|
{"decision → appointment", &models.DeadlineRule{EventType: &decision}, "appointment"},
|
|
{"order → appointment", &models.DeadlineRule{EventType: &order}, "appointment"},
|
|
{"filing → deadline", &models.DeadlineRule{EventType: &filing}, "deadline"},
|
|
{"nil event_type → deadline", &models.DeadlineRule{}, "deadline"},
|
|
{"nil rule → deadline", nil, "deadline"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
if got := ruleAnchorKind(c.rule); got != c.want {
|
|
t.Errorf("ruleAnchorKind = %q, want %q", got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtractMetadataString(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
raw string
|
|
key string
|
|
want string
|
|
}{
|
|
{"present", `{"rule_code":"inf.sod","reason":"foo"}`, "rule_code", "inf.sod"},
|
|
{"missing key", `{"foo":"bar"}`, "rule_code", ""},
|
|
{"empty json", `{}`, "rule_code", ""},
|
|
{"empty raw", ``, "rule_code", ""},
|
|
{"non-string value", `{"rule_code":123}`, "rule_code", ""},
|
|
{"malformed", `{`, "rule_code", ""},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
got := extractMetadataString(json.RawMessage(c.raw), c.key)
|
|
if got != c.want {
|
|
t.Errorf("extractMetadataString = %q, want %q", got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLang(t *testing.T) {
|
|
cases := []struct {
|
|
in, want string
|
|
}{
|
|
{"", "de"},
|
|
{"de", "de"},
|
|
{"DE", "de"},
|
|
{"en", "en"},
|
|
{"EN", "en"},
|
|
{" en ", "en"},
|
|
{"fr", "de"},
|
|
}
|
|
for _, c := range cases {
|
|
if got := lang(c.in); got != c.want {
|
|
t.Errorf("lang(%q) = %q, want %q", c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRuleNameInLang(t *testing.T) {
|
|
r := models.DeadlineRule{Name: "Klageerwiderung", NameEN: "Statement of Defence"}
|
|
if got := ruleNameInLang(r, "de"); got != "Klageerwiderung" {
|
|
t.Errorf("de = %q", got)
|
|
}
|
|
if got := ruleNameInLang(r, "en"); got != "Statement of Defence" {
|
|
t.Errorf("en = %q", got)
|
|
}
|
|
rNoEN := models.DeadlineRule{Name: "Klageerwiderung"}
|
|
if got := ruleNameInLang(rNoEN, "en"); got != "Klageerwiderung" {
|
|
t.Errorf("missing EN should fall back to DE, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestCrossProceedingAnchorError(t *testing.T) {
|
|
parentID := uuid.New()
|
|
cpe := &CrossProceedingAnchorError{
|
|
RequestedRuleCode: "upc.inf.cfi.soc",
|
|
RequestedRuleNameDE: "Klageschrift",
|
|
RequestedRuleNameEN: "Statement of Claim",
|
|
ParentProjectID: parentID,
|
|
ParentProjectTitle: "UPC-CFI München — Klage",
|
|
}
|
|
got, ok := IsCrossProceedingAnchor(cpe)
|
|
if !ok {
|
|
t.Fatal("IsCrossProceedingAnchor on direct error should return ok")
|
|
}
|
|
if got != cpe {
|
|
t.Errorf("unwrapped pointer mismatch")
|
|
}
|
|
wrapped := wrap(cpe, "context")
|
|
got2, ok2 := IsCrossProceedingAnchor(wrapped)
|
|
if !ok2 {
|
|
t.Fatal("IsCrossProceedingAnchor on wrapped error should return ok")
|
|
}
|
|
if got2 != cpe {
|
|
t.Errorf("unwrapped wrapped pointer mismatch")
|
|
}
|
|
if _, ok := IsCrossProceedingAnchor(errOther{}); ok {
|
|
t.Error("non-CPE should not unwrap as CPE")
|
|
}
|
|
// And the inverse: a PredecessorMissingError must NOT match the
|
|
// cross-proceeding helper (the two coexist on the 409 response and a
|
|
// mistaken unwrap would render the wrong UI).
|
|
pme := &PredecessorMissingError{RequestedRuleCode: "x"}
|
|
if _, ok := IsCrossProceedingAnchor(pme); ok {
|
|
t.Error("PredecessorMissingError must not unwrap as CrossProceedingAnchorError")
|
|
}
|
|
}
|
|
|
|
func TestPredecessorMissingError(t *testing.T) {
|
|
pme := &PredecessorMissingError{
|
|
MissingRuleCode: "upc.inf.cfi.soc",
|
|
MissingRuleNameDE: "Klageschrift",
|
|
MissingRuleNameEN: "Statement of Claim",
|
|
RequestedRuleCode: "upc.inf.cfi.sod",
|
|
RequestedRuleNameDE: "Klageerwiderung",
|
|
RequestedRuleNameEN: "Statement of Defence",
|
|
}
|
|
got, ok := IsPredecessorMissing(pme)
|
|
if !ok {
|
|
t.Fatal("IsPredecessorMissing on direct error should return ok")
|
|
}
|
|
if got != pme {
|
|
t.Errorf("unwrapped pointer mismatch")
|
|
}
|
|
// Wrapping with errors.Errorf-style fmt should still unwrap.
|
|
wrapped := wrap(pme, "context")
|
|
got2, ok2 := IsPredecessorMissing(wrapped)
|
|
if !ok2 {
|
|
t.Fatal("IsPredecessorMissing on wrapped error should return ok")
|
|
}
|
|
if got2 != pme {
|
|
t.Errorf("unwrapped wrapped pointer mismatch")
|
|
}
|
|
// Random other error must not unwrap.
|
|
if _, ok := IsPredecessorMissing(errOther{}); ok {
|
|
t.Error("non-PME should not unwrap as PME")
|
|
}
|
|
}
|
|
|
|
// wrap is a tiny test helper that mimics fmt.Errorf("%w") wrapping.
|
|
func wrap(err error, msg string) error {
|
|
return wrappedErr{msg: msg, inner: err}
|
|
}
|
|
|
|
type wrappedErr struct {
|
|
msg string
|
|
inner error
|
|
}
|
|
|
|
func (w wrappedErr) Error() string { return w.msg + ": " + w.inner.Error() }
|
|
func (w wrappedErr) Unwrap() error { return w.inner }
|
|
|
|
type errOther struct{}
|
|
|
|
func (errOther) Error() string { return "other" }
|
|
|
|
func TestAnnotateDependsOn(t *testing.T) {
|
|
socID := uuid.New()
|
|
sodID := uuid.New()
|
|
replyID := uuid.New()
|
|
socCode := "upc.inf.cfi.soc"
|
|
sodCode := "upc.inf.cfi.sod"
|
|
replyCode := "upc.inf.cfi.reply"
|
|
|
|
rules := []models.DeadlineRule{
|
|
{ID: socID, SubmissionCode: &socCode, Name: "Klageschrift", NameEN: "Statement of Claim"},
|
|
{ID: sodID, ParentID: &socID, SubmissionCode: &sodCode, Name: "Klageerwiderung", NameEN: "Statement of Defence"},
|
|
{ID: replyID, ParentID: &sodID, SubmissionCode: &replyCode, Name: "Replik", NameEN: "Reply"},
|
|
}
|
|
|
|
socDate := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
|
|
sodDate := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
|
|
|
rows := []TimelineEvent{
|
|
// SoC actual.
|
|
{Kind: "deadline", Status: "done", Date: &socDate, RuleCode: socCode,
|
|
DeadlineRuleID: ptrUUID(socID)},
|
|
// SoD projected.
|
|
{Kind: "projected", Status: "predicted", Date: &sodDate, RuleCode: sodCode,
|
|
DeadlineRuleID: ptrUUID(sodID)},
|
|
// Reply projected — depends on SoD.
|
|
{Kind: "projected", Status: "predicted", RuleCode: replyCode,
|
|
DeadlineRuleID: ptrUUID(replyID)},
|
|
}
|
|
|
|
svc := &ProjectionService{}
|
|
svc.annotateDependsOn(rows, rules, "de")
|
|
|
|
// SoC has no parent — depends_on stays empty.
|
|
if rows[0].DependsOnRuleCode != "" {
|
|
t.Errorf("SoC should have no depends_on, got %q", rows[0].DependsOnRuleCode)
|
|
}
|
|
// SoD's depends_on is SoC, dated.
|
|
if rows[1].DependsOnRuleCode != socCode {
|
|
t.Errorf("SoD depends_on = %q, want %q", rows[1].DependsOnRuleCode, socCode)
|
|
}
|
|
if rows[1].DependsOnRuleName != "Klageschrift" {
|
|
t.Errorf("SoD depends_on name = %q (de)", rows[1].DependsOnRuleName)
|
|
}
|
|
if rows[1].DependsOnDate == nil || !rows[1].DependsOnDate.Equal(socDate) {
|
|
t.Errorf("SoD depends_on_date = %v, want %v", rows[1].DependsOnDate, socDate)
|
|
}
|
|
// Reply's depends_on is SoD, dated (from SoD's projected date).
|
|
if rows[2].DependsOnRuleCode != sodCode {
|
|
t.Errorf("Reply depends_on = %q", rows[2].DependsOnRuleCode)
|
|
}
|
|
if rows[2].DependsOnDate == nil || !rows[2].DependsOnDate.Equal(sodDate) {
|
|
t.Errorf("Reply depends_on_date = %v, want %v (SoD's projected date)",
|
|
rows[2].DependsOnDate, sodDate)
|
|
}
|
|
|
|
// English mode flips the name.
|
|
svc.annotateDependsOn(rows, rules, "en")
|
|
if rows[1].DependsOnRuleName != "Statement of Claim" {
|
|
t.Errorf("SoD depends_on name (en) = %q", rows[1].DependsOnRuleName)
|
|
}
|
|
}
|
|
|
|
func ptrUUID(u uuid.UUID) *uuid.UUID { return &u }
|