package services import ( "context" "os" "sort" "testing" "time" "github.com/google/uuid" "github.com/jmoiron/sqlx" _ "github.com/lib/pq" "mgit.msbls.de/m/paliad/internal/db" "mgit.msbls.de/m/paliad/internal/models" ) // TestProjectDeadline_ShapeStable spot-checks projectDeadline so a future // addition to DeadlineWithProject doesn't silently drop a field from the // EventListItem JSON shape the frontend relies on. func TestProjectDeadline_ShapeStable(t *testing.T) { due := time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC) descr := "Reply to Defence" src := "fristenrechner" rcode := "RoP.029" rname := "Replik" rnameEN := "Reply" d := models.DeadlineWithProject{ Deadline: models.Deadline{ ID: uuid.New(), ProjectID: uuid.New(), Title: "Statement of Defence", Description: &descr, DueDate: due, Source: src, RuleCode: &rcode, Status: "pending", EventTypeIDs: []uuid.UUID{uuid.New()}, }, ProjectTitle: "Acme v. Foo", ProjectType: "case", RuleName: &rname, RuleNameEN: &rnameEN, } out := projectDeadline(d) if out.Type != "deadline" { t.Fatalf("type = %q, want deadline", out.Type) } if !out.EventDate.Equal(due) { t.Errorf("event_date = %v, want %v", out.EventDate, due) } if out.DueDate == nil || *out.DueDate != "2026-08-31" { t.Errorf("due_date = %v, want 2026-08-31", out.DueDate) } if out.Status == nil || *out.Status != "pending" { t.Errorf("status = %v, want pending", out.Status) } if out.RuleCode == nil || *out.RuleCode != "RoP.029" { t.Errorf("rule_code = %v, want RoP.029", out.RuleCode) } if out.StartAt != nil || out.EndAt != nil || out.Location != nil || out.AppointmentType != nil { t.Errorf("appointment-only fields leaked onto deadline projection: %+v", out) } if len(out.EventTypeIDs) != 1 { t.Errorf("event_type_ids length = %d, want 1", len(out.EventTypeIDs)) } } // TestProjectAppointment_ShapeStable does the same for the appointment side. func TestProjectAppointment_ShapeStable(t *testing.T) { start := time.Date(2026, 9, 15, 9, 0, 0, 0, time.UTC) end := start.Add(2 * time.Hour) loc := "UPC LD München" atype := "hearing" pid := uuid.New() ptitle := "Acme v. Foo" ptype := "case" a := models.AppointmentWithProject{ Appointment: models.Appointment{ ID: uuid.New(), ProjectID: &pid, Title: "Mündliche Verhandlung", StartAt: start, EndAt: &end, Location: &loc, AppointmentType: &atype, }, ProjectTitle: &ptitle, ProjectType: &ptype, } out := projectAppointment(a) if out.Type != "appointment" { t.Fatalf("type = %q, want appointment", out.Type) } if !out.EventDate.Equal(start) { t.Errorf("event_date = %v, want %v", out.EventDate, start) } if out.StartAt == nil || !out.StartAt.Equal(start) { t.Errorf("start_at = %v, want %v", out.StartAt, start) } if out.EndAt == nil || !out.EndAt.Equal(end) { t.Errorf("end_at = %v, want %v", out.EndAt, end) } if out.AppointmentType == nil || *out.AppointmentType != "hearing" { t.Errorf("appointment_type = %v, want hearing", out.AppointmentType) } if out.DueDate != nil || out.Status != nil || out.RuleCode != nil || len(out.EventTypeIDs) != 0 { t.Errorf("deadline-only fields leaked onto appointment projection: %+v", out) } } // TestInDateWindow checks the inclusive-on-both-ends window math used to // post-filter deadlines in ListVisibleForUser when the caller passes from/to. func TestInDateWindow(t *testing.T) { due := time.Date(2026, 5, 7, 0, 0, 0, 0, time.UTC) before := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC) after := time.Date(2026, 5, 14, 0, 0, 0, 0, time.UTC) if !inDateWindow(due, nil, nil) { t.Error("nil/nil window must include any date") } if !inDateWindow(due, &before, &after) { t.Error("expected due ∈ [before, after]") } if inDateWindow(due, &after, nil) { t.Error("date strictly before `from` must be excluded") } if inDateWindow(due, nil, &before) { t.Error("date strictly after `to` must be excluded") } // boundary inclusivity if !inDateWindow(due, &due, &due) { t.Error("from == to == due must be inclusive on both ends") } } // TestEventService_ListAndSummary_Live exercises the union list + bucket // counts against a real database. Skipped when TEST_DATABASE_URL is unset. func TestEventService_ListAndSummary_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() adminID := uuid.New() otherID := uuid.New() // co-admin used to seed items NOT created by adminID projectID := uuid.New() projectID2 := uuid.New() // second project so personal_only crosses projects d1 := uuid.New() // overdue pending (admin) d2 := uuid.New() // today pending (admin) d3 := uuid.New() // next week pending (admin) d4 := uuid.New() // completed (admin) d5 := uuid.New() // pending in projectID created by otherID d6 := uuid.New() // pending in projectID2 created by adminID a1 := uuid.New() // today appointment (admin, projectID) a2 := uuid.New() // later appointment (admin, projectID) a3 := uuid.New() // soon appointment created by otherID (projectID) a4 := uuid.New() // personal calendar appointment by adminID (no project) cleanup := func() { pool.ExecContext(ctx, `DELETE FROM paliad.appointments WHERE id IN ($1, $2, $3, $4)`, a1, a2, a3, a4) pool.ExecContext(ctx, `DELETE FROM paliad.deadlines WHERE id IN ($1, $2, $3, $4, $5, $6)`, d1, d2, d3, d4, d5, d6) pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id IN ($1, $2)`, projectID, projectID2) pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id IN ($1, $2)`, projectID, projectID2) pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id IN ($1, $2)`, projectID, projectID2) pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id IN ($1, $2)`, adminID, otherID) pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id IN ($1, $2)`, adminID, otherID) } cleanup() defer cleanup() if _, err := pool.ExecContext(ctx, `INSERT INTO auth.users (id, email) VALUES ($1, $2), ($3, $4)`, adminID, "vis-events@hlc.com", otherID, "vis-events-other@hlc.com"); 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, $2, 'Vis Events', 'munich', 'global_admin', 'de'), ($3, $4, 'Vis Events Other', 'munich', 'global_admin', 'de')`, adminID, "vis-events@hlc.com", otherID, "vis-events-other@hlc.com"); err != nil { t.Fatalf("seed paliad.users: %v", err) } if _, err := pool.ExecContext(ctx, `INSERT INTO paliad.projects (id, type, path, title, reference, status, created_by) VALUES ($1, 'project', $1::text, 'Vis Events Project', '2026/9994', 'active', $2), ($3, 'project', $3::text, 'Vis Events Project 2', '2026/9995', 'active', $2)`, projectID, adminID, projectID2); err != nil { t.Fatalf("seed paliad.projects: %v", err) } now := time.Now().UTC() today := now.Truncate(24 * time.Hour) yesterday := today.AddDate(0, 0, -1) nextMon := today.AddDate(0, 0, ((7-int(today.Weekday()))%7)+1) // Mon-of-next-week farFuture := today.AddDate(0, 1, 0) // Six deadlines: 4 spanning bucket shapes (admin/projectID), one pending // in projectID created by otherID (so personal_only filters it out), one // pending in projectID2 created by adminID (so personal_only spans // projects). for _, d := range []struct { id uuid.UUID project uuid.UUID due time.Time status string createdBy uuid.UUID }{ {d1, projectID, yesterday, "pending", adminID}, {d2, projectID, today, "pending", adminID}, {d3, projectID, nextMon, "pending", adminID}, {d4, projectID, today, "completed", adminID}, {d5, projectID, nextMon, "pending", otherID}, {d6, projectID2, nextMon, "pending", adminID}, } { if _, err := pool.ExecContext(ctx, `INSERT INTO paliad.deadlines (id, project_id, title, due_date, source, status, completed_at, created_by) VALUES ($1, $2, 'D', $3::date, 'manual', $4, CASE WHEN $4 = 'completed' THEN $5::timestamptz END, $6)`, d.id, d.project, d.due.Format("2006-01-02"), d.status, now, d.createdBy); err != nil { t.Fatalf("seed deadline %s: %v", d.id, err) } } // Four appointments: admin's today + far-future on projectID; otherID's // soon appointment on projectID (foreign creator); admin's personal // calendar entry with NULL project_id. soon := today.Add(36 * time.Hour) for _, a := range []struct { id uuid.UUID project *uuid.UUID start time.Time createdBy uuid.UUID }{ {a1, &projectID, today.Add(13 * time.Hour), adminID}, {a2, &projectID, farFuture, adminID}, {a3, &projectID, soon, otherID}, {a4, nil, today.Add(15 * time.Hour), adminID}, } { if _, err := pool.ExecContext(ctx, `INSERT INTO paliad.appointments (id, project_id, title, start_at, appointment_type, created_by) VALUES ($1, $2, 'A', $3, 'meeting', $4)`, a.id, a.project, a.start, a.createdBy); err != nil { t.Fatalf("seed appointment %s: %v", a.id, err) } } users := NewUserService(pool) projects := NewProjectService(pool, users) eventTypes := NewEventTypeService(pool, users) deadlines := NewDeadlineService(pool, projects, eventTypes) appointments := NewAppointmentService(pool, projects) events := NewEventService(pool, deadlines, appointments) t.Run("ListVisibleForUser type=all merges + sorts", func(t *testing.T) { rows, err := events.ListVisibleForUser(ctx, adminID, EventListFilter{Type: EventTypeAll}) if err != nil { t.Fatalf("List all: %v", err) } // Filter to seed rows so unrelated projects in the live DB don't // confuse the assertions. seedSet := map[uuid.UUID]string{ d1: "deadline", d2: "deadline", d3: "deadline", d4: "deadline", a1: "appointment", a2: "appointment", } seen := []EventListItem{} for _, r := range rows { if _, ok := seedSet[r.ID]; ok { seen = append(seen, r) } } if len(seen) != len(seedSet) { t.Fatalf("merged rows = %d, want %d", len(seen), len(seedSet)) } // Confirm sort: every neighbour pair is non-decreasing on EventDate. if !sort.SliceIsSorted(seen, func(i, j int) bool { return seen[i].EventDate.Before(seen[j].EventDate) }) { for _, r := range seen { t.Logf(" %s %s %s", r.Type, r.ID, r.EventDate.Format(time.RFC3339)) } t.Fatal("merged rows not sorted by event_date asc") } // Type discriminator must round-trip. for _, r := range seen { if want := seedSet[r.ID]; want != r.Type { t.Errorf("row %s: type = %q, want %q", r.ID, r.Type, want) } } }) t.Run("ListVisibleForUser type=deadline excludes appointments", func(t *testing.T) { rows, err := events.ListVisibleForUser(ctx, adminID, EventListFilter{Type: EventTypeDeadline}) if err != nil { t.Fatalf("List deadline: %v", err) } for _, r := range rows { if r.Type != "deadline" { t.Errorf("type=deadline returned %q row", r.Type) } if r.ID == a1 || r.ID == a2 { t.Errorf("type=deadline leaked appointment %s", r.ID) } } }) t.Run("ListVisibleForUser type=appointment excludes deadlines", func(t *testing.T) { rows, err := events.ListVisibleForUser(ctx, adminID, EventListFilter{Type: EventTypeAppointment}) if err != nil { t.Fatalf("List appointment: %v", err) } for _, r := range rows { if r.Type != "appointment" { t.Errorf("type=appointment returned %q row", r.Type) } } }) // t-paliad-123: the date-bucket Status filter (today/this_week/next_week/ // later) must apply to appointments too, narrowing by start_at. The // frontend exposes these bucket values in the Termine view's Status // dropdown — backend must honour them. t.Run("ListVisibleForUser type=appointment + status=today narrows to today's appointments", func(t *testing.T) { rows, err := events.ListVisibleForUser(ctx, adminID, EventListFilter{ Type: EventTypeAppointment, Status: DeadlineFilterToday, }) if err != nil { t.Fatalf("List appointment+today: %v", err) } sawA1 := false for _, r := range rows { if r.ID == a1 { sawA1 = true } if r.ID == a2 { t.Errorf("status=today must exclude far-future appointment %s", a2) } } if !sawA1 { t.Errorf("status=today must include today's appointment %s", a1) } }) t.Run("ListVisibleForUser type=appointment + status=later narrows to far-future", func(t *testing.T) { rows, err := events.ListVisibleForUser(ctx, adminID, EventListFilter{ Type: EventTypeAppointment, Status: DeadlineFilterLater, }) if err != nil { t.Fatalf("List appointment+later: %v", err) } sawA2 := false for _, r := range rows { if r.ID == a1 { t.Errorf("status=later must exclude today's appointment %s", a1) } if r.ID == a2 { sawA2 = true } } if !sawA2 { t.Errorf("status=later must include far-future appointment %s", a2) } }) // Defensive: deadline-only statuses (overdue, completed) collapse the // appointment rail — useful when type=all so the appointment side // disappears alongside the deadline filter (the dropdown excludes // these for type=appointment, but URL-hacking still must behave). t.Run("ListVisibleForUser type=appointment + status=completed returns no appointments", func(t *testing.T) { rows, err := events.ListVisibleForUser(ctx, adminID, EventListFilter{ Type: EventTypeAppointment, Status: DeadlineFilterCompleted, }) if err != nil { t.Fatalf("List appointment+completed: %v", err) } for _, r := range rows { if r.ID == a1 || r.ID == a2 { t.Errorf("status=completed must collapse appointments rail (got %s)", r.ID) } } }) t.Run("SummaryCounts type=all has both rails populated", func(t *testing.T) { s, err := events.SummaryCounts(ctx, adminID, EventSummaryFilter{Type: EventTypeAll, ProjectID: &projectID}) if err != nil { t.Fatalf("Summary all: %v", err) } if s.Deadlines == nil { t.Fatal("deadlines bucket missing") } if s.Appointments == nil { t.Fatal("appointments bucket missing") } // The seed has 1 overdue, 1 today, 1 next-week, 1 completed. if s.Deadlines.Overdue < 1 { t.Errorf("Deadlines.Overdue = %d, want >= 1", s.Deadlines.Overdue) } if s.Deadlines.Today < 1 { t.Errorf("Deadlines.Today = %d, want >= 1", s.Deadlines.Today) } if s.Deadlines.NextWeek < 1 { t.Errorf("Deadlines.NextWeek = %d, want >= 1", s.Deadlines.NextWeek) } if s.Deadlines.Completed < 1 { t.Errorf("Deadlines.Completed = %d, want >= 1", s.Deadlines.Completed) } // Appointments: 1 today, 1 later. if s.Appointments.Today < 1 { t.Errorf("Appointments.Today = %d, want >= 1", s.Appointments.Today) } if s.Appointments.Later < 1 { t.Errorf("Appointments.Later = %d, want >= 1", s.Appointments.Later) } }) t.Run("SummaryCounts type=deadline omits appointments rail", func(t *testing.T) { s, err := events.SummaryCounts(ctx, adminID, EventSummaryFilter{Type: EventTypeDeadline, ProjectID: &projectID}) if err != nil { t.Fatalf("Summary deadline: %v", err) } if s.Deadlines == nil { t.Fatal("deadlines bucket missing on type=deadline") } if s.Appointments != nil { t.Errorf("appointments rail must be omitted on type=deadline (got %+v)", s.Appointments) } }) t.Run("SummaryCounts type=appointment omits deadlines rail", func(t *testing.T) { s, err := events.SummaryCounts(ctx, adminID, EventSummaryFilter{Type: EventTypeAppointment, ProjectID: &projectID}) if err != nil { t.Fatalf("Summary appointment: %v", err) } if s.Appointments == nil { t.Fatal("appointments bucket missing on type=appointment") } if s.Deadlines != nil { t.Errorf("deadlines rail must be omitted on type=appointment (got %+v)", s.Deadlines) } }) // t-paliad-128: PersonalOnly narrows BOTH rails to rows the caller // created. Spans projects (so a deadline created by adminID on // projectID2 still shows up) and excludes rows otherID created on a // project adminID can otherwise see. seedDeadlines := map[uuid.UUID]bool{d1: true, d2: true, d3: true, d4: true, d5: true, d6: true} seedAppointments := map[uuid.UUID]bool{a1: true, a2: true, a3: true, a4: true} t.Run("PersonalOnly type=deadline returns only caller-created deadlines across projects", func(t *testing.T) { rows, err := events.ListVisibleForUser(ctx, adminID, EventListFilter{ Type: EventTypeDeadline, PersonalOnly: true, }) if err != nil { t.Fatalf("List deadline+personal: %v", err) } seen := map[uuid.UUID]bool{} for _, r := range rows { if !seedDeadlines[r.ID] { continue } if r.Type != "deadline" { t.Errorf("type=deadline returned %q row", r.Type) } if r.CreatedBy == nil || *r.CreatedBy != adminID { t.Errorf("personal_only leaked row %s with created_by=%v", r.ID, r.CreatedBy) } seen[r.ID] = true } // admin's: d1 d2 d3 d4 d6 — all should appear. d5 (otherID) must not. for _, want := range []uuid.UUID{d1, d2, d3, d4, d6} { if !seen[want] { t.Errorf("personal_only dropped admin-created deadline %s", want) } } if seen[d5] { t.Errorf("personal_only must exclude other-created deadline %s", d5) } }) t.Run("PersonalOnly type=appointment returns only caller-created appointments (with + without project)", func(t *testing.T) { rows, err := events.ListVisibleForUser(ctx, adminID, EventListFilter{ Type: EventTypeAppointment, PersonalOnly: true, }) if err != nil { t.Fatalf("List appointment+personal: %v", err) } seen := map[uuid.UUID]bool{} for _, r := range rows { if !seedAppointments[r.ID] { continue } if r.Type != "appointment" { t.Errorf("type=appointment returned %q row", r.Type) } if r.CreatedBy == nil || *r.CreatedBy != adminID { t.Errorf("personal_only leaked row %s with created_by=%v", r.ID, r.CreatedBy) } seen[r.ID] = true } // admin's: a1, a2 (project-attached), a4 (no project). a3 (otherID) must not. for _, want := range []uuid.UUID{a1, a2, a4} { if !seen[want] { t.Errorf("personal_only dropped admin-created appointment %s", want) } } if seen[a3] { t.Errorf("personal_only must exclude other-created appointment %s", a3) } }) t.Run("PersonalOnly type=all returns the union of both", func(t *testing.T) { rows, err := events.ListVisibleForUser(ctx, adminID, EventListFilter{ Type: EventTypeAll, PersonalOnly: true, }) if err != nil { t.Fatalf("List all+personal: %v", err) } seenD := map[uuid.UUID]bool{} seenA := map[uuid.UUID]bool{} for _, r := range rows { if r.CreatedBy == nil || *r.CreatedBy != adminID { if seedDeadlines[r.ID] || seedAppointments[r.ID] { t.Errorf("personal_only leaked row %s with created_by=%v", r.ID, r.CreatedBy) } continue } if r.Type == "deadline" && seedDeadlines[r.ID] { seenD[r.ID] = true } if r.Type == "appointment" && seedAppointments[r.ID] { seenA[r.ID] = true } } for _, want := range []uuid.UUID{d1, d2, d3, d4, d6} { if !seenD[want] { t.Errorf("personal_only union dropped admin-created deadline %s", want) } } for _, want := range []uuid.UUID{a1, a2, a4} { if !seenA[want] { t.Errorf("personal_only union dropped admin-created appointment %s", want) } } }) }