Files
paliad/internal/services/projection_service_unit_test.go
mAi 5dea0a703b wip(projects): t-paliad-222 — backend + frontend changes (pre-merge checkpoint)
Backend: mig 110/111 (will be renumbered after merging main),
validators + helpers widened, BuildProjectCode helper + projection
populator wired into List/GetByID/ListAncestors/GetTree/CCR. All
internal Go tests pass.

Frontend: ProjectFormFields conditional render — opponent_code on
litigation, our_side renamed to Client Role on case with grouped
optgroups. i18n keys for both DE and EN. fristenrechner perspective
mapping widened. project-form.ts payload reader/writer + showFieldsForType
toggle for new litigation block.

Migration slots about to be bumped (mig 110 was claimed by euler's
project_type_other on main).
2026-05-20 14:55:55 +02:00

362 lines
12 KiB
Go

package services
// Pure-function tests for ProjectionService — no DB required, runs by
// default. Validates the deterministic sort order and status-mapping
// behaviour; the live integration test in projection_service_test.go
// covers the SQL paths.
import (
"encoding/json"
"testing"
"time"
"github.com/google/uuid"
)
func TestSortTimeline_DateAscUndatedLast(t *testing.T) {
d1 := uuid.New()
d2 := uuid.New()
a1 := uuid.New()
pe1 := uuid.New()
mar1 := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
mar5 := time.Date(2026, 3, 5, 12, 0, 0, 0, time.UTC)
mar10 := time.Date(2026, 3, 10, 0, 0, 0, 0, time.UTC)
rows := []TimelineEvent{
{Kind: "milestone", Title: "Undated milestone", ProjectEventID: &pe1}, // Date nil
{Kind: "deadline", Date: &mar10, Title: "Mar10 deadline", DeadlineID: &d2},
{Kind: "deadline", Date: &mar1, Title: "Mar1 deadline", DeadlineID: &d1},
{Kind: "appointment", Date: &mar5, Title: "Mar5 appointment", AppointmentID: &a1},
}
sortTimeline(rows)
// Date ASC (Mar1, Mar5, Mar10), undated last.
if rows[0].Title != "Mar1 deadline" {
t.Errorf("rows[0] = %q, want Mar1 deadline", rows[0].Title)
}
if rows[1].Title != "Mar5 appointment" {
t.Errorf("rows[1] = %q, want Mar5 appointment", rows[1].Title)
}
if rows[2].Title != "Mar10 deadline" {
t.Errorf("rows[2] = %q, want Mar10 deadline", rows[2].Title)
}
if rows[3].Title != "Undated milestone" {
t.Errorf("rows[3] = %q, want Undated milestone", rows[3].Title)
}
}
func TestSortTimeline_SameDateTiebreak(t *testing.T) {
mar5 := time.Date(2026, 3, 5, 0, 0, 0, 0, time.UTC)
d1 := uuid.New()
a1 := uuid.New()
pe1 := uuid.New()
rows := []TimelineEvent{
{Kind: "milestone", Date: &mar5, Title: "C", ProjectEventID: &pe1},
{Kind: "appointment", Date: &mar5, Title: "B", AppointmentID: &a1},
{Kind: "deadline", Date: &mar5, Title: "A", DeadlineID: &d1},
}
sortTimeline(rows)
// Tiebreak: deadline > appointment > milestone (kindOrder).
if rows[0].Kind != "deadline" {
t.Errorf("rows[0].Kind = %q, want deadline", rows[0].Kind)
}
if rows[1].Kind != "appointment" {
t.Errorf("rows[1].Kind = %q, want appointment", rows[1].Kind)
}
if rows[2].Kind != "milestone" {
t.Errorf("rows[2].Kind = %q, want milestone", rows[2].Kind)
}
}
func TestDeadlineStatus(t *testing.T) {
today := time.Now().UTC()
yesterday := today.AddDate(0, 0, -1)
tomorrow := today.AddDate(0, 0, 1)
cases := []struct {
name string
status string
due time.Time
want string
}{
{"completed regardless of date", "completed", yesterday, "done"},
{"completed even if future", "completed", tomorrow, "done"},
{"pending past = overdue", "pending", yesterday, "overdue"},
{"pending today = open", "pending", today, "open"},
{"pending future = open", "pending", tomorrow, "open"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := deadlineStatus(c.status, c.due)
if got != c.want {
t.Errorf("deadlineStatus(%q, %v) = %q, want %q",
c.status, c.due, got, c.want)
}
})
}
}
func TestAppointmentStatus(t *testing.T) {
now := time.Date(2026, 5, 8, 12, 0, 0, 0, time.UTC)
past := now.Add(-1 * time.Hour)
future := now.Add(1 * time.Hour)
if got := appointmentStatus(past, now); got != "done" {
t.Errorf("past appointment status = %q, want done", got)
}
if got := appointmentStatus(future, now); got != "open" {
t.Errorf("future appointment status = %q, want open", got)
}
}
func TestMilestoneStatus(t *testing.T) {
custom := "custom_milestone"
other := "counterclaim_filed"
cases := []struct {
name string
timelineKind *string
eventType *string
want string
}{
{"custom_milestone via timeline_kind", &custom, nil, "off_script"},
{"custom_milestone via event_type fallback", nil, &custom, "off_script"},
{"structural milestone = done", nil, &other, "done"},
{"both nil = done", nil, nil, "done"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := milestoneStatus(c.timelineKind, c.eventType)
if got != c.want {
t.Errorf("milestoneStatus = %q, want %q", got, c.want)
}
})
}
}
func TestKindOrder(t *testing.T) {
// Lock the exact ordering — frontend assumes deadline before
// appointment before milestone before projected on same-date ties.
if kindOrder("deadline") >= kindOrder("appointment") {
t.Error("deadline should sort before appointment")
}
if kindOrder("appointment") >= kindOrder("milestone") {
t.Error("appointment should sort before milestone")
}
if kindOrder("milestone") >= kindOrder("projected") {
t.Error("milestone should sort before projected")
}
}
// TestLevelPolicy pins the (kinds, statuses, lane_axis) triple per
// project type per design §5.1 (t-paliad-175 SmartTimeline Slice 4).
// These are user-visible policy decisions — locked here to catch
// accidental shifts during refactors.
func TestLevelPolicy(t *testing.T) {
cases := []struct {
projectType string
kinds []string
statuses []string
laneAxis string
}{
{"case", nil, nil, "self_plus_ccr"},
{"", nil, nil, "self_plus_ccr"}, // unknown falls back to case behaviour
{"unknown", nil, nil, "self_plus_ccr"},
{
"patent",
[]string{"deadline", "milestone"},
[]string{"done", "open", "overdue"},
"child_case",
},
{
"litigation",
[]string{"milestone"},
[]string{"done"},
"child_patent",
},
{
"client",
[]string{"milestone"},
[]string{"done"},
"child_litigation",
},
}
for _, c := range cases {
t.Run(c.projectType, func(t *testing.T) {
got := levelPolicy(c.projectType)
if got.LaneAxis != c.laneAxis {
t.Errorf("LaneAxis = %q, want %q", got.LaneAxis, c.laneAxis)
}
if !sliceEqual(got.Kinds, c.kinds) {
t.Errorf("Kinds = %v, want %v", got.Kinds, c.kinds)
}
if !sliceEqual(got.Statuses, c.statuses) {
t.Errorf("Statuses = %v, want %v", got.Statuses, c.statuses)
}
})
}
}
func sliceEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// TestRowSurvivesPolicy_BubbleUpOverridesFilter pins the contract that
// a project_event milestone with bubble_up=true survives the level
// policy's kind/status filter at higher levels (design §5.3 + Q5).
func TestRowSurvivesPolicy_BubbleUpOverridesFilter(t *testing.T) {
allowKind := stringSet([]string{"deadline"}) // milestones excluded
allowStatus := stringSet([]string{"done"}) // off_script excluded
bubbledMilestone := TimelineEvent{
Kind: "milestone",
Status: "off_script",
BubbleUp: true,
}
if !rowSurvivesPolicy(bubbledMilestone, allowKind, allowStatus) {
t.Error("bubble_up=true row should survive both kind and status filters")
}
regularMilestone := TimelineEvent{
Kind: "milestone",
Status: "off_script",
}
if rowSurvivesPolicy(regularMilestone, allowKind, allowStatus) {
t.Error("regular milestone should be filtered when kind/status both excluded")
}
// kind allowed, status excluded → drop.
allowedKindBadStatus := TimelineEvent{
Kind: "deadline",
Status: "open",
}
if rowSurvivesPolicy(allowedKindBadStatus, allowKind, allowStatus) {
t.Error("excluded status should drop a row even when kind allowed")
}
// kind excluded, status allowed → drop.
badKindGoodStatus := TimelineEvent{
Kind: "appointment",
Status: "done",
}
if rowSurvivesPolicy(badKindGoodStatus, allowKind, allowStatus) {
t.Error("excluded kind should drop a row even when status allowed")
}
// Empty filters = pass-through.
if !rowSurvivesPolicy(badKindGoodStatus, nil, nil) {
t.Error("empty filters should pass everything")
}
}
// TestExtractBubbleUp pins the per-event-type defaults (Q5):
// - counterclaim_created / third_party_intervention / scope_change
// default to true.
// - custom_milestone defaults to false.
// - Explicit metadata.bubble_up always wins.
func TestExtractBubbleUp(t *testing.T) {
str := func(s string) *string { return &s }
cases := []struct {
name string
raw string
eventType *string
timelineKind *string
want bool
}{
{"counterclaim_created defaults true", "{}", str("counterclaim_created"), str("milestone"), true},
{"third_party_intervention defaults true", "", str("third_party_intervention"), nil, true},
{"scope_change defaults true", "", str("scope_change"), nil, true},
{"custom_milestone defaults false", "{}", str("custom_milestone"), str("custom_milestone"), false},
{"unknown defaults false", "{}", str("note_created"), nil, false},
{"explicit true overrides", `{"bubble_up":true}`, str("custom_milestone"), nil, true},
{"explicit false overrides", `{"bubble_up":false}`, str("counterclaim_created"), nil, false},
{"string \"true\" parses", `{"bubble_up":"true"}`, str("custom_milestone"), nil, true},
{"string \"1\" parses", `{"bubble_up":"1"}`, str("custom_milestone"), nil, true},
{"non-bool ignored", `{"bubble_up":42}`, str("custom_milestone"), nil, false},
{"malformed metadata falls back to default", `{`, str("counterclaim_created"), nil, true},
{"empty metadata + nil event_type = false", "", nil, nil, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := extractBubbleUp(json.RawMessage(c.raw), c.eventType, c.timelineKind)
if got != c.want {
t.Errorf("extractBubbleUp = %v, want %v", got, c.want)
}
})
}
}
// TestChildTypeForAxis pins the axis → project type map.
func TestChildTypeForAxis(t *testing.T) {
cases := map[string]string{
"child_case": "case",
"child_patent": "patent",
"child_litigation": "litigation",
"self_plus_ccr": "",
"": "",
"bogus": "",
}
for axis, want := range cases {
if got := childTypeForAxis(axis); got != want {
t.Errorf("childTypeForAxis(%q) = %q, want %q", axis, got, want)
}
}
}
// TestDerivedCounterclaimOurSide pins the our_side flip semantics
// (t-paliad-174 §11 Q2, widened in t-paliad-222 / m/paliad#47):
// - Default (override nil): flip across the active / reactive axis —
// claimant ↔ defendant, applicant ↔ respondent, appellant →
// respondent. third_party / other / NULL pass through.
// - Override true: same default-flip semantics.
// - Override false (R.49.2.b CCI edge case): keep parent's side.
// - NULL parent_side yields empty string (no flip without a starting side).
func TestDerivedCounterclaimOurSide(t *testing.T) {
tru := true
fal := false
str := func(s string) *string { return &s }
cases := []struct {
name string
parent *string
override *bool
want string
}{
{"nil parent → empty", nil, nil, ""},
{"nil parent + override → empty", nil, &tru, ""},
{"claimant → defendant (default)", str("claimant"), nil, "defendant"},
{"defendant → claimant (default)", str("defendant"), nil, "claimant"},
{"applicant → respondent (default)", str("applicant"), nil, "respondent"},
{"respondent → applicant (default)", str("respondent"), nil, "applicant"},
{"appellant → respondent (default)", str("appellant"), nil, "respondent"},
{"third_party passes through", str("third_party"), nil, "third_party"},
{"other passes through", str("other"), nil, "other"},
{"explicit flip=true", str("claimant"), &tru, "defendant"},
{"explicit flip=false keeps parent's side", str("claimant"), &fal, "claimant"},
{"flip=false on defendant keeps defendant", str("defendant"), &fal, "defendant"},
{"flip=false on applicant keeps applicant", str("applicant"), &fal, "applicant"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := derivedCounterclaimOurSide(c.parent, c.override)
if got != c.want {
t.Errorf("derivedCounterclaimOurSide(%v, %v) = %q, want %q",
c.parent, c.override, got, c.want)
}
})
}
}