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.
565 lines
19 KiB
Go
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)
|
|
}
|
|
}
|
|
})
|
|
}
|