Two regressions from SmartTimeline Slices 2-4 dogfood @ 2026-05-09: m/paliad#32 — clicking timeline_status / timeline_track / project_event_kind chips changed URL params but the rendered list never narrowed. Two causes: (1) the Verlauf bar mounted only "time" + "project_event_kind" axes — the timeline_status / timeline_track chips never appeared. (2) the customRunner drained predicates into `loadEvents` which writes the legacy `events` array; the SmartTimeline render reads `timelineRows`, so the filter pass was a dead branch. Fix: mount all three axes on the bar; rewrite customRunner to drain state into `verlaufFilters`; renderTimeline applies them client-side via `applyTimelineRowFilters` before handing rows to renderSmartTimeline. project_event_kind is forwarded through the substrate-shaped predicate map (effective.filter.predicates.project_event.event_types); timeline_status / timeline_track sit on raw BarState — the customRunner signature now accepts the BarState snapshot as a second arg so the bar's first run (before the handle is assigned) can read them. Backend adds `ProjectEventType` to TimelineEvent + frontend TimelineEvent — needed so the project_event_kind chip can match against the underlying paliad.project_events.event_type for milestone rows. m/paliad#33 — "Nur direkt" pill flipped subtreeMode and re-fetched the timeline with ?direct_only=true, but ProjectionService.For honoured the flag only at the deadline / appointment / project_events SQL level. CCR sub-project lanes (Slice 3) and child-case lanes (Slice 4) loaded unconditionally, so the "direct" view still showed everything. Fix: `For` short-circuits to `forDirectSelfOnly` whenever DirectOnly is set. Single "self" lane, no CCR / parent_context / child-case aggregation. The level-policy kind/status filter still applies at higher levels so a Patent-level direct view doesn't leak off_script custom milestones the aggregated view filters out. Tests: two new live-DB subtests in TestProjectionService_LevelAggregation_Live pin the contract — Patent direct_only collapses to a single 'self' lane and excludes child-case events; Case-A direct_only excludes the CCR child's milestones (with subtree default still surfacing them). Build: go build/vet/test clean. bun run build clean (2171 keys).
361 lines
14 KiB
Go
361 lines
14 KiB
Go
package services
|
|
|
|
// Live-DB integration test for parent-node lane aggregation
|
|
// (t-paliad-175 SmartTimeline Slice 4 §5). Skipped without TEST_DATABASE_URL.
|
|
//
|
|
// Builds a 3-level fixture (Patent → Case-A + Case-B → CCR-A) and walks
|
|
// the level policy at each viewpoint:
|
|
//
|
|
// - Case-A view: full detail + CCR sub-project track (single project,
|
|
// own actuals + projection, "self" lane + "counterclaim:<id>" lane).
|
|
// - Patent view: lanes per child case; events from each case subtree;
|
|
// deadlines + milestones surface, statuses done/open/overdue.
|
|
// - Bubble-up: a counterclaim_created milestone (default-on bubble_up)
|
|
// surfaces at Patent level under Case-A's lane.
|
|
// - Custom milestone with bubble_up=true surfaces too; without, it's
|
|
// filtered out.
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
_ "github.com/lib/pq"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/db"
|
|
)
|
|
|
|
func TestProjectionService_LevelAggregation_Live(t *testing.T) {
|
|
url := os.Getenv("TEST_DATABASE_URL")
|
|
if url == "" {
|
|
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
|
}
|
|
if err := db.ApplyMigrations(url); err != nil {
|
|
t.Fatalf("apply migrations: %v", err)
|
|
}
|
|
pool, err := sqlx.Connect("postgres", url)
|
|
if err != nil {
|
|
t.Fatalf("connect: %v", err)
|
|
}
|
|
defer pool.Close()
|
|
|
|
ctx := context.Background()
|
|
userID := uuid.New()
|
|
patentID := uuid.New()
|
|
caseAID := uuid.New()
|
|
caseBID := uuid.New()
|
|
|
|
cleanup := func() {
|
|
// CCR children (counterclaim_of points at one of the cases)
|
|
// must go first so the FK doesn't block the case delete.
|
|
pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id IN (
|
|
SELECT id FROM paliad.projects WHERE counterclaim_of IN ($1, $2))`, caseAID, caseBID)
|
|
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id IN (
|
|
SELECT id FROM paliad.projects WHERE counterclaim_of IN ($1, $2))`, caseAID, caseBID)
|
|
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE counterclaim_of IN ($1, $2)`, caseAID, caseBID)
|
|
|
|
pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id IN ($1, $2, $3)`, patentID, caseAID, caseBID)
|
|
pool.ExecContext(ctx, `DELETE FROM paliad.deadlines WHERE project_id IN ($1, $2, $3)`, patentID, caseAID, caseBID)
|
|
pool.ExecContext(ctx, `DELETE FROM paliad.appointments WHERE project_id IN ($1, $2, $3)`, patentID, caseAID, caseBID)
|
|
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id IN ($1, $2, $3)`, patentID, caseAID, caseBID)
|
|
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id IN ($1, $2, $3)`, patentID, caseAID, caseBID)
|
|
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
|
|
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
|
|
}
|
|
cleanup()
|
|
defer cleanup()
|
|
|
|
if _, err := pool.ExecContext(ctx,
|
|
`INSERT INTO auth.users (id, email) VALUES ($1, 'level-agg-test@hlc.com')`,
|
|
userID); err != nil {
|
|
t.Fatalf("seed auth.users: %v", err)
|
|
}
|
|
if _, err := pool.ExecContext(ctx,
|
|
`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
|
|
VALUES ($1, 'level-agg-test@hlc.com', 'Level Agg Test', 'munich', 'global_admin', 'de')`,
|
|
userID); err != nil {
|
|
t.Fatalf("seed paliad.users: %v", err)
|
|
}
|
|
// Patent hub.
|
|
if _, err := pool.ExecContext(ctx,
|
|
`INSERT INTO paliad.projects (id, type, path, title, patent_number, status, created_by)
|
|
VALUES ($1, 'patent', $1::text, 'EP9999999 — Test Patent', 'EP9999999', 'active', $2)`,
|
|
patentID, userID); err != nil {
|
|
t.Fatalf("seed patent: %v", err)
|
|
}
|
|
if _, err := pool.ExecContext(ctx,
|
|
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
|
|
VALUES ($1, $2, 'lead', 'lead', false, $2)`,
|
|
patentID, userID); err != nil {
|
|
t.Fatalf("seed patent team: %v", err)
|
|
}
|
|
// Case-A under the patent.
|
|
if _, err := pool.ExecContext(ctx,
|
|
`INSERT INTO paliad.projects (id, type, parent_id, path, title, status, created_by)
|
|
VALUES ($1, 'case', $2, $2::text || '.' || $1::text, 'Case A', 'active', $3)`,
|
|
caseAID, patentID, userID); err != nil {
|
|
t.Fatalf("seed case A: %v", err)
|
|
}
|
|
if _, err := pool.ExecContext(ctx,
|
|
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
|
|
VALUES ($1, $2, 'lead', 'lead', false, $2)`,
|
|
caseAID, userID); err != nil {
|
|
t.Fatalf("seed case A team: %v", err)
|
|
}
|
|
// Case-B under the patent.
|
|
if _, err := pool.ExecContext(ctx,
|
|
`INSERT INTO paliad.projects (id, type, parent_id, path, title, status, created_by)
|
|
VALUES ($1, 'case', $2, $2::text || '.' || $1::text, 'Case B', 'active', $3)`,
|
|
caseBID, patentID, userID); err != nil {
|
|
t.Fatalf("seed case B: %v", err)
|
|
}
|
|
if _, err := pool.ExecContext(ctx,
|
|
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
|
|
VALUES ($1, $2, 'lead', 'lead', false, $2)`,
|
|
caseBID, userID); err != nil {
|
|
t.Fatalf("seed case B team: %v", err)
|
|
}
|
|
|
|
// Case-A: one open deadline + one done milestone (bubble_up=true via
|
|
// counterclaim_created event_type) + one custom_milestone (bubble_up=false).
|
|
now := time.Now().UTC()
|
|
deadlineA := uuid.New()
|
|
bubbledMilestoneA := uuid.New()
|
|
regularMilestoneA := uuid.New()
|
|
|
|
if _, err := pool.ExecContext(ctx,
|
|
`INSERT INTO paliad.deadlines
|
|
(id, project_id, title, due_date, source, status, created_by)
|
|
VALUES ($1, $2, 'Case-A open deadline', $3::date, 'manual', 'pending', $4)`,
|
|
deadlineA, caseAID, now.AddDate(0, 0, 14).Format("2006-01-02"), userID); err != nil {
|
|
t.Fatalf("seed deadline A: %v", err)
|
|
}
|
|
if _, err := pool.ExecContext(ctx,
|
|
`INSERT INTO paliad.project_events
|
|
(id, project_id, event_type, title, event_date, created_by, metadata,
|
|
created_at, updated_at, timeline_kind)
|
|
VALUES ($1, $2, 'counterclaim_created', 'Widerklage angelegt', $3, $4,
|
|
'{"bubble_up":true}'::jsonb, $5, $5, 'milestone')`,
|
|
bubbledMilestoneA, caseAID, now.AddDate(0, 0, -7), userID, now); err != nil {
|
|
t.Fatalf("seed bubbled milestone A: %v", err)
|
|
}
|
|
if _, err := pool.ExecContext(ctx,
|
|
`INSERT INTO paliad.project_events
|
|
(id, project_id, event_type, title, event_date, created_by, metadata,
|
|
created_at, updated_at, timeline_kind)
|
|
VALUES ($1, $2, 'custom_milestone', 'Random Note (no bubble)', $3, $4,
|
|
'{}'::jsonb, $5, $5, 'custom_milestone')`,
|
|
regularMilestoneA, caseAID, now.AddDate(0, 0, -3), userID, now); err != nil {
|
|
t.Fatalf("seed regular milestone A: %v", err)
|
|
}
|
|
|
|
users := NewUserService(pool)
|
|
projects := NewProjectService(pool, users)
|
|
eventTypes := NewEventTypeService(pool, users)
|
|
deadlines := NewDeadlineService(pool, projects, eventTypes)
|
|
appointments := NewAppointmentService(pool, projects)
|
|
rules := NewDeadlineRuleService(pool)
|
|
holidays := NewHolidayService(pool)
|
|
courts := NewCourtService(pool)
|
|
fristen := NewFristenrechnerService(rules, holidays, courts)
|
|
projection := NewProjectionService(pool, projects, deadlines, appointments, fristen, rules)
|
|
|
|
t.Run("Case-level: lanes mirror tracks (self + CCR)", func(t *testing.T) {
|
|
_, meta, err := projection.For(ctx, userID, caseAID, ProjectionOpts{})
|
|
if err != nil {
|
|
t.Fatalf("For caseA: %v", err)
|
|
}
|
|
// At least the "self" lane is present.
|
|
var sawSelf bool
|
|
for _, l := range meta.Lanes {
|
|
if l.ID == "self" {
|
|
sawSelf = true
|
|
if l.Label != "Case A" {
|
|
t.Errorf("self lane label = %q, want Case A", l.Label)
|
|
}
|
|
}
|
|
}
|
|
if !sawSelf {
|
|
t.Errorf("Lanes = %v, want a 'self' entry", meta.Lanes)
|
|
}
|
|
})
|
|
|
|
t.Run("Patent-level: lanes per child case + milestones bubble", func(t *testing.T) {
|
|
rows, meta, err := projection.For(ctx, userID, patentID, ProjectionOpts{})
|
|
if err != nil {
|
|
t.Fatalf("For patent: %v", err)
|
|
}
|
|
|
|
// Lanes: one per child case.
|
|
laneIDs := map[string]LaneInfo{}
|
|
for _, l := range meta.Lanes {
|
|
laneIDs[l.ID] = l
|
|
}
|
|
if _, ok := laneIDs[caseAID.String()]; !ok {
|
|
t.Errorf("Lanes missing Case-A entry: %v", meta.Lanes)
|
|
}
|
|
if _, ok := laneIDs[caseBID.String()]; !ok {
|
|
t.Errorf("Lanes missing Case-B entry: %v", meta.Lanes)
|
|
}
|
|
|
|
// Bubbled-up milestone (counterclaim_created) surfaces under
|
|
// Case-A's lane.
|
|
var sawBubbled, sawRegular, sawDeadline bool
|
|
for _, r := range rows {
|
|
if r.ProjectEventID != nil && *r.ProjectEventID == bubbledMilestoneA {
|
|
sawBubbled = true
|
|
if r.LaneID != caseAID.String() {
|
|
t.Errorf("bubbled milestone LaneID = %q, want %s", r.LaneID, caseAID.String())
|
|
}
|
|
if !r.BubbleUp {
|
|
t.Errorf("bubbled milestone BubbleUp should be true")
|
|
}
|
|
}
|
|
if r.ProjectEventID != nil && *r.ProjectEventID == regularMilestoneA {
|
|
sawRegular = true
|
|
}
|
|
if r.DeadlineID != nil && *r.DeadlineID == deadlineA {
|
|
sawDeadline = true
|
|
if r.LaneID != caseAID.String() {
|
|
t.Errorf("deadline LaneID = %q, want %s", r.LaneID, caseAID.String())
|
|
}
|
|
}
|
|
}
|
|
if !sawBubbled {
|
|
t.Errorf("bubbled milestone (counterclaim_created) should surface at Patent level")
|
|
}
|
|
// Patent policy = milestones + deadlines, statuses done/open/overdue.
|
|
// The pending deadline (status=open) survives; the regular custom
|
|
// milestone (off_script status, no bubble_up) is filtered out.
|
|
if !sawDeadline {
|
|
t.Errorf("Case-A's open deadline should surface at Patent level (kinds=deadline allowed)")
|
|
}
|
|
if sawRegular {
|
|
t.Errorf("regular custom_milestone (no bubble_up, off_script status) should be filtered at Patent level")
|
|
}
|
|
})
|
|
|
|
t.Run("Patent-level: direct_only collapses to single 'self' lane (m/paliad#33)", func(t *testing.T) {
|
|
rows, meta, err := projection.For(ctx, userID, patentID, ProjectionOpts{DirectOnly: true})
|
|
if err != nil {
|
|
t.Fatalf("For patent direct_only: %v", err)
|
|
}
|
|
// Lanes should NOT include child cases — just one "self" entry
|
|
// pointing at the patent itself.
|
|
if len(meta.Lanes) != 1 || meta.Lanes[0].ID != "self" {
|
|
t.Errorf("DirectOnly Lanes = %v, want a single 'self' lane", meta.Lanes)
|
|
}
|
|
if len(meta.Lanes) > 0 && meta.Lanes[0].ProjectID != patentID.String() {
|
|
t.Errorf("self lane ProjectID = %q, want patent id", meta.Lanes[0].ProjectID)
|
|
}
|
|
// Case-A's deadline / milestones must NOT surface — they belong to
|
|
// the case subtree and direct_only excludes them.
|
|
for _, r := range rows {
|
|
if r.DeadlineID != nil && *r.DeadlineID == deadlineA {
|
|
t.Errorf("Case-A deadline should NOT surface at Patent level with direct_only=true (got %v)", r)
|
|
}
|
|
if r.ProjectEventID != nil && *r.ProjectEventID == bubbledMilestoneA {
|
|
t.Errorf("Case-A bubbled milestone should NOT surface at Patent level with direct_only=true")
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("Case-level: direct_only drops CCR sub-project lane", func(t *testing.T) {
|
|
// Seed a CCR child of Case-A so the default (subtree) path
|
|
// includes a "counterclaim:<id>" lane and direct_only excludes it.
|
|
ccrID := uuid.New()
|
|
ccrMilestoneID := uuid.New()
|
|
if _, err := pool.ExecContext(ctx,
|
|
`INSERT INTO paliad.projects (id, type, parent_id, counterclaim_of, path, title, status, created_by)
|
|
VALUES ($1, 'case', $2, $2, $2::text || '.' || $1::text, 'Case A — CCR', 'active', $3)`,
|
|
ccrID, caseAID, userID); err != nil {
|
|
t.Fatalf("seed CCR: %v", err)
|
|
}
|
|
if _, err := pool.ExecContext(ctx,
|
|
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
|
|
VALUES ($1, $2, 'lead', 'lead', false, $2)`,
|
|
ccrID, userID); err != nil {
|
|
t.Fatalf("seed CCR team: %v", err)
|
|
}
|
|
if _, err := pool.ExecContext(ctx,
|
|
`INSERT INTO paliad.project_events
|
|
(id, project_id, event_type, title, event_date, created_by, metadata,
|
|
created_at, updated_at, timeline_kind)
|
|
VALUES ($1, $2, 'custom_milestone', 'CCR-side note', $3, $4,
|
|
'{}'::jsonb, $5, $5, 'custom_milestone')`,
|
|
ccrMilestoneID, ccrID, now.AddDate(0, 0, -1), userID, now); err != nil {
|
|
t.Fatalf("seed CCR milestone: %v", err)
|
|
}
|
|
defer func() {
|
|
pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE id = $1`, ccrMilestoneID)
|
|
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, ccrID)
|
|
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, ccrID)
|
|
}()
|
|
|
|
// Default (subtree) path: Case-A timeline carries both "self" +
|
|
// "counterclaim:<ccrID>" lanes.
|
|
_, defaultMeta, err := projection.For(ctx, userID, caseAID, ProjectionOpts{})
|
|
if err != nil {
|
|
t.Fatalf("For caseA default: %v", err)
|
|
}
|
|
var sawCCRLane bool
|
|
for _, l := range defaultMeta.Lanes {
|
|
if l.ID == "counterclaim:"+ccrID.String() {
|
|
sawCCRLane = true
|
|
}
|
|
}
|
|
if !sawCCRLane {
|
|
t.Fatalf("default Case-A meta.Lanes should include the CCR child: %v", defaultMeta.Lanes)
|
|
}
|
|
|
|
// Direct-only path: only the "self" lane survives, CCR milestones
|
|
// are excluded.
|
|
rows, directMeta, err := projection.For(ctx, userID, caseAID, ProjectionOpts{DirectOnly: true})
|
|
if err != nil {
|
|
t.Fatalf("For caseA direct_only: %v", err)
|
|
}
|
|
if len(directMeta.Lanes) != 1 || directMeta.Lanes[0].ID != "self" {
|
|
t.Errorf("direct_only Lanes = %v, want only 'self'", directMeta.Lanes)
|
|
}
|
|
for _, r := range rows {
|
|
if r.ProjectEventID != nil && *r.ProjectEventID == ccrMilestoneID {
|
|
t.Errorf("CCR milestone should NOT surface at Case-A with direct_only=true")
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("Patent-level: bubble_up false → row dropped", func(t *testing.T) {
|
|
// Re-write the regular milestone with bubble_up=true and confirm
|
|
// it surfaces. Then revert.
|
|
if _, err := pool.ExecContext(ctx,
|
|
`UPDATE paliad.project_events
|
|
SET metadata = '{"bubble_up":true}'::jsonb
|
|
WHERE id = $1`, regularMilestoneA); err != nil {
|
|
t.Fatalf("flip bubble_up: %v", err)
|
|
}
|
|
defer pool.ExecContext(ctx,
|
|
`UPDATE paliad.project_events SET metadata = '{}'::jsonb WHERE id = $1`,
|
|
regularMilestoneA)
|
|
|
|
rows, _, err := projection.For(ctx, userID, patentID, ProjectionOpts{})
|
|
if err != nil {
|
|
t.Fatalf("For patent (after flip): %v", err)
|
|
}
|
|
var saw bool
|
|
for _, r := range rows {
|
|
if r.ProjectEventID != nil && *r.ProjectEventID == regularMilestoneA {
|
|
saw = true
|
|
if !r.BubbleUp {
|
|
t.Errorf("flipped milestone BubbleUp should be true")
|
|
}
|
|
}
|
|
}
|
|
if !saw {
|
|
t.Errorf("custom_milestone with bubble_up=true should surface at Patent level")
|
|
}
|
|
})
|
|
}
|