Files
paliad/internal/services/event_service_test.go
m 9919e04657 feat(t-paliad-128): /events 'Nur persönliche' = items I created
Redefines the "Nur persönliche" filter on /events from "appointment with
NULL project_id" to "items where created_by = me", applied uniformly to
deadlines and appointments.

Before: client-side filter dropped every deadline row because the type
guard was `x.type === "appointment"`. m saw zero deadlines under "Nur
persönliche" even though he created plenty.

After:
- /api/events?personal_only=true (and /api/events/summary?personal_only=true)
  narrow BOTH rails to f.created_by / t.created_by = current user.
  ProjectID is ignored when personal_only is set (the two are
  contradictory).
- DeadlineService.ListFilter and AppointmentService.AppointmentListFilter
  gain CreatedBy *uuid.UUID — composes with existing visibility (AND), so
  a row created on a team the user has since left still won't leak.
- Frontend drops the client-side filter; sends personal_only=true when
  projectFilter === PERSONAL. URL ?personal_only=true also accepted on
  initial load (bookmark-friendly alias for ?project_id=__personal__).
  Personal option now shows for type=Fristen too — applies uniformly.
- 3 new live subtests covering personal_only across type=deadline /
  appointment / all, with mixed-creator + multi-project + null-project
  fixtures.
2026-05-04 19:49:37 +02:00

565 lines
19 KiB
Go

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