package services import ( "context" "os" "testing" "time" "github.com/google/uuid" "github.com/jmoiron/sqlx" _ "github.com/lib/pq" "mgit.msbls.de/m/paliad/internal/db" ) // TestCanSee covers the two new visibility-only gates added for t-paliad-091: // // - ProjectService.CanSee // - AppointmentService.CanSee // // Both replace cross-service GetByID-for-visibility calls in NoteService // with a single EXISTS round-trip. The acceptance criteria from the task // spec are: admin sees everything; team-member sees their team's; // non-member sees nothing. // // Skipped when TEST_DATABASE_URL is unset, mirroring the other live-DB tests. func TestCanSee(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() memberID := uuid.New() outsiderID := uuid.New() projectID := uuid.New() personalAppointmentID := uuid.New() projectAppointmentID := uuid.New() cleanup := func() { pool.ExecContext(ctx, `DELETE FROM paliad.appointments WHERE id IN ($1, $2)`, personalAppointmentID, projectAppointmentID) pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, projectID) pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, projectID) pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id IN ($1, $2, $3)`, adminID, memberID, outsiderID) pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id IN ($1, $2, $3)`, adminID, memberID, outsiderID) } cleanup() defer cleanup() if _, err := pool.ExecContext(ctx, `INSERT INTO auth.users (id, email) VALUES ($1, $2), ($3, $4), ($5, $6)`, adminID, "cansee-admin@hlc.com", memberID, "cansee-member@hlc.com", outsiderID, "cansee-outsider@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, 'CanSee Admin', 'munich', 'global_admin', 'de'), ($3, $4, 'CanSee Member', 'munich', 'standard', 'de'), ($5, $6, 'CanSee Outsider', 'munich', 'standard', 'de')`, adminID, "cansee-admin@hlc.com", memberID, "cansee-member@hlc.com", outsiderID, "cansee-outsider@hlc.com"); err != nil { t.Fatalf("seed paliad.users: %v", err) } // Project owned by adminID. memberID joins the project team; outsiderID // stays out. project_teams intentionally has no admin row so the // global_admin shortcut is what makes adminID see it. if _, err := pool.ExecContext(ctx, `INSERT INTO paliad.projects (id, type, path, title, reference, status, created_by) VALUES ($1, 'project', $1::text, 'CanSee Project', '2026/9991', 'active', $2)`, projectID, adminID); err != nil { t.Fatalf("seed paliad.projects: %v", err) } if _, err := pool.ExecContext(ctx, `INSERT INTO paliad.project_teams (project_id, user_id, role) VALUES ($1, $2, 'associate')`, projectID, memberID); err != nil { t.Fatalf("seed paliad.project_teams: %v", err) } // Personal appointment created by memberID — only memberID may see it // (admin's global_admin role does NOT cover personal appointments by // design, since they have no project anchor). startAt := time.Now().UTC().Add(48 * time.Hour) if _, err := pool.ExecContext(ctx, `INSERT INTO paliad.appointments (id, project_id, title, start_at, appointment_type, created_by) VALUES ($1, NULL, 'Personal CanSee', $2, 'meeting', $3)`, personalAppointmentID, startAt, memberID); err != nil { t.Fatalf("seed personal appointment: %v", err) } // Project-anchored appointment — visibility flows through the project. if _, err := pool.ExecContext(ctx, `INSERT INTO paliad.appointments (id, project_id, title, start_at, appointment_type, created_by) VALUES ($1, $2, 'Project CanSee', $3, 'meeting', $4)`, projectAppointmentID, projectID, startAt, adminID); err != nil { t.Fatalf("seed project appointment: %v", err) } users := NewUserService(pool) projects := NewProjectService(pool, users) appointments := NewAppointmentService(pool, projects) t.Run("ProjectService.CanSee", func(t *testing.T) { t.Run("global_admin sees project even without team membership", func(t *testing.T) { got, err := projects.CanSee(ctx, adminID, projectID) if err != nil { t.Fatalf("CanSee admin: %v", err) } if !got { t.Errorf("CanSee admin = false, want true (global_admin shortcut)") } }) t.Run("team member sees project", func(t *testing.T) { got, err := projects.CanSee(ctx, memberID, projectID) if err != nil { t.Fatalf("CanSee member: %v", err) } if !got { t.Errorf("CanSee member = false, want true") } }) t.Run("non-member does not see project", func(t *testing.T) { got, err := projects.CanSee(ctx, outsiderID, projectID) if err != nil { t.Fatalf("CanSee outsider: %v", err) } if got { t.Errorf("CanSee outsider = true, want false") } }) t.Run("missing project is invisible to all", func(t *testing.T) { missing := uuid.New() for _, who := range []struct { name string id uuid.UUID }{{"admin", adminID}, {"member", memberID}, {"outsider", outsiderID}} { got, err := projects.CanSee(ctx, who.id, missing) if err != nil { t.Fatalf("CanSee %s missing: %v", who.name, err) } if got { t.Errorf("CanSee %s missing = true, want false", who.name) } } }) }) t.Run("AppointmentService.CanSee", func(t *testing.T) { t.Run("project-anchored: global_admin sees", func(t *testing.T) { got, err := appointments.CanSee(ctx, adminID, projectAppointmentID) if err != nil { t.Fatalf("CanSee admin: %v", err) } if !got { t.Errorf("CanSee admin = false, want true") } }) t.Run("project-anchored: team member sees", func(t *testing.T) { got, err := appointments.CanSee(ctx, memberID, projectAppointmentID) if err != nil { t.Fatalf("CanSee member: %v", err) } if !got { t.Errorf("CanSee member = false, want true") } }) t.Run("project-anchored: non-member does not see", func(t *testing.T) { got, err := appointments.CanSee(ctx, outsiderID, projectAppointmentID) if err != nil { t.Fatalf("CanSee outsider: %v", err) } if got { t.Errorf("CanSee outsider = true, want false") } }) t.Run("personal: only the creator sees it", func(t *testing.T) { got, err := appointments.CanSee(ctx, memberID, personalAppointmentID) if err != nil { t.Fatalf("CanSee member personal: %v", err) } if !got { t.Errorf("CanSee member personal = false, want true (own appointment)") } // global_admin does NOT see another user's personal appointment. got, err = appointments.CanSee(ctx, adminID, personalAppointmentID) if err != nil { t.Fatalf("CanSee admin personal: %v", err) } if got { t.Errorf("CanSee admin personal = true, want false (personal appointments are private)") } got, err = appointments.CanSee(ctx, outsiderID, personalAppointmentID) if err != nil { t.Fatalf("CanSee outsider personal: %v", err) } if got { t.Errorf("CanSee outsider personal = true, want false") } }) t.Run("missing appointment is invisible to all", func(t *testing.T) { missing := uuid.New() for _, who := range []struct { name string id uuid.UUID }{{"admin", adminID}, {"member", memberID}, {"outsider", outsiderID}} { got, err := appointments.CanSee(ctx, who.id, missing) if err != nil { t.Fatalf("CanSee %s missing: %v", who.name, err) } if got { t.Errorf("CanSee %s missing = true, want false", who.name) } } }) }) }