package services // Live-DB tests for PinService and the BuildTreeWithOptions chip branches. // Skipped when TEST_DATABASE_URL is unset. import ( "context" "errors" "os" "testing" "github.com/google/uuid" "github.com/jmoiron/sqlx" _ "github.com/lib/pq" "mgit.msbls.de/m/paliad/internal/db" ) type pinTestEnv struct { t *testing.T pool *sqlx.DB pin *PinService projects *ProjectService userID uuid.UUID otherUserID uuid.UUID clientID uuid.UUID // root, directly-staffed caseID uuid.UUID // child of client, directly-staffed otherID uuid.UUID // root, NOT directly-staffed (visible only to admin) cleanup func() } func setupPinTest(t *testing.T) *pinTestEnv { t.Helper() 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) } ctx := context.Background() userID := uuid.New() // standard user otherUserID := uuid.New() // separate user (used as creator of the "other" project) clientID := uuid.New() caseID := uuid.New() otherID := uuid.New() cleanup := func() { c := context.Background() pool.ExecContext(c, `DELETE FROM paliad.user_pinned_projects WHERE user_id IN ($1, $2)`, userID, otherUserID) for _, pid := range []uuid.UUID{caseID, clientID, otherID} { pool.ExecContext(c, `DELETE FROM paliad.project_teams WHERE project_id = $1`, pid) pool.ExecContext(c, `DELETE FROM paliad.project_events WHERE project_id = $1`, pid) } pool.ExecContext(c, `DELETE FROM paliad.projects WHERE id IN ($1, $2)`, caseID, clientID) pool.ExecContext(c, `DELETE FROM paliad.projects WHERE id = $1`, otherID) pool.ExecContext(c, `DELETE FROM paliad.users WHERE id IN ($1, $2)`, userID, otherUserID) pool.ExecContext(c, `DELETE FROM auth.users WHERE id IN ($1, $2)`, userID, otherUserID) } cleanup() for _, u := range []struct { id uuid.UUID email string role string }{ {userID, "pin-test-user@hlc.com", "standard"}, {otherUserID, "pin-test-other@hlc.com", "standard"}, } { if _, err := pool.ExecContext(ctx, `INSERT INTO auth.users (id, email) VALUES ($1, $2)`, u.id, u.email); 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, 'Pin Test', 'munich', $3, 'de')`, u.id, u.email, u.role); err != nil { t.Fatalf("seed paliad.users: %v", err) } } // Build a small tree owned by userID (so userID is staffed direct on // clientID and caseID). otherID is owned by otherUserID — userID has // no visibility into it. for _, p := range []struct { id uuid.UUID typ string parent *uuid.UUID title string creator uuid.UUID staff uuid.UUID }{ {clientID, "client", nil, "Pin Client", userID, userID}, {caseID, "case", &clientID, "Pin Case", userID, userID}, {otherID, "client", nil, "Other Client", otherUserID, otherUserID}, } { var parent any if p.parent != nil { parent = *p.parent } if _, err := pool.ExecContext(ctx, `INSERT INTO paliad.projects (id, type, parent_id, path, title, status, created_by) VALUES ($1, $2, $3, $4, $5, 'active', $6)`, p.id, p.typ, parent, p.id.String(), p.title, p.creator); err != nil { t.Fatalf("seed paliad.projects %s: %v", p.id, err) } if _, err := pool.ExecContext(ctx, `INSERT INTO paliad.project_teams (project_id, user_id, responsibility, inherited, added_by) VALUES ($1, $2, 'lead', false, $2)`, p.id, p.staff); err != nil { t.Fatalf("seed project_teams %s: %v", p.id, err) } } users := NewUserService(pool) projects := NewProjectService(pool, users) pin := NewPinService(pool, projects) return &pinTestEnv{ t: t, pool: pool, pin: pin, projects: projects, userID: userID, otherUserID: otherUserID, clientID: clientID, caseID: caseID, otherID: otherID, cleanup: func() { cleanup(); pool.Close() }, } } func TestPinService_PinAndIsPinned(t *testing.T) { env := setupPinTest(t) defer env.cleanup() ctx := context.Background() // Initially not pinned. if pinned, err := env.pin.IsPinned(ctx, env.userID, env.clientID); err != nil || pinned { t.Fatalf("IsPinned before = (%v, %v); want (false, nil)", pinned, err) } // Pin succeeds and is idempotent. if err := env.pin.Pin(ctx, env.userID, env.clientID); err != nil { t.Fatalf("Pin: %v", err) } if err := env.pin.Pin(ctx, env.userID, env.clientID); err != nil { t.Fatalf("Pin (idempotent): %v", err) } // IsPinned now true. if pinned, err := env.pin.IsPinned(ctx, env.userID, env.clientID); err != nil || !pinned { t.Fatalf("IsPinned after = (%v, %v); want (true, nil)", pinned, err) } // PinnedSet contains exactly one entry. set, err := env.pin.PinnedSet(ctx, env.userID) if err != nil { t.Fatalf("PinnedSet: %v", err) } if len(set) != 1 { t.Errorf("PinnedSet size = %d, want 1", len(set)) } if _, ok := set[env.clientID]; !ok { t.Errorf("PinnedSet missing clientID") } // ListPinned returns slice form. ids, err := env.pin.ListPinned(ctx, env.userID) if err != nil { t.Fatalf("ListPinned: %v", err) } if len(ids) != 1 || ids[0] != env.clientID { t.Errorf("ListPinned = %v, want [clientID]", ids) } } func TestPinService_PinInvisible(t *testing.T) { env := setupPinTest(t) defer env.cleanup() ctx := context.Background() // userID cannot see otherID — Pin must return ErrNotVisible. if err := env.pin.Pin(ctx, env.userID, env.otherID); !errors.Is(err, ErrNotVisible) { t.Fatalf("Pin invisible = %v, want ErrNotVisible", err) } } func TestPinService_UnpinIdempotent(t *testing.T) { env := setupPinTest(t) defer env.cleanup() ctx := context.Background() // Unpin without prior pin = no-op. if err := env.pin.Unpin(ctx, env.userID, env.clientID); err != nil { t.Fatalf("Unpin (never pinned): %v", err) } // Pin then unpin twice; no error. if err := env.pin.Pin(ctx, env.userID, env.clientID); err != nil { t.Fatalf("Pin: %v", err) } if err := env.pin.Unpin(ctx, env.userID, env.clientID); err != nil { t.Fatalf("Unpin: %v", err) } if err := env.pin.Unpin(ctx, env.userID, env.clientID); err != nil { t.Fatalf("Unpin (idempotent): %v", err) } if pinned, _ := env.pin.IsPinned(ctx, env.userID, env.clientID); pinned { t.Errorf("IsPinned after double-unpin = true, want false") } } func TestPinService_OwnerScope(t *testing.T) { env := setupPinTest(t) defer env.cleanup() ctx := context.Background() // User A pins. User B's PinnedSet must be empty (RLS / explicit user_id). if err := env.pin.Pin(ctx, env.userID, env.clientID); err != nil { t.Fatalf("Pin: %v", err) } otherSet, err := env.pin.PinnedSet(ctx, env.otherUserID) if err != nil { t.Fatalf("PinnedSet (other user): %v", err) } if len(otherSet) != 0 { t.Errorf("other user PinnedSet size = %d, want 0", len(otherSet)) } } func TestBuildTreeWithOptions_PinnedSetSurfaces(t *testing.T) { env := setupPinTest(t) defer env.cleanup() ctx := context.Background() if err := env.pin.Pin(ctx, env.userID, env.clientID); err != nil { t.Fatalf("Pin: %v", err) } set, _ := env.pin.PinnedSet(ctx, env.userID) tree, err := env.projects.BuildTreeWithOptions(ctx, env.userID, BuildTreeOptions{ PinnedSet: set, IncludeSubtreeCounts: true, }) if err != nil { t.Fatalf("BuildTreeWithOptions: %v", err) } if len(tree) == 0 { t.Fatalf("tree empty; want clientID at root") } var found *ProjectTreeNode for _, r := range tree { if r.ID == env.clientID { found = r break } } if found == nil { t.Fatalf("clientID not at root; tree=%+v", tree) } if !found.Pinned { t.Errorf("clientID Pinned=false, want true") } // Case sub-node should NOT be pinned (we only pinned the client). if len(found.Children) == 0 || found.Children[0].Pinned { t.Errorf("child node should not be Pinned; got %+v", found.Children) } } func TestBuildTreeWithOptions_ScopeMineGreysAncestors(t *testing.T) { env := setupPinTest(t) defer env.cleanup() ctx := context.Background() // Remove the direct staffing on clientID so caseID becomes the only // directly-staffed project under the client. Then ScopeMine must keep // clientID as a greyed ancestor (InheritedVisibility=true). if _, err := env.pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1 AND user_id = $2`, env.clientID, env.userID); err != nil { t.Fatalf("delete client staffing: %v", err) } tree, err := env.projects.BuildTreeWithOptions(ctx, env.userID, BuildTreeOptions{ Scope: ScopeMine, }) if err != nil { t.Fatalf("BuildTreeWithOptions ScopeMine: %v", err) } if len(tree) == 0 { t.Fatalf("ScopeMine tree empty; want clientID greyed-ancestor") } var client *ProjectTreeNode for _, r := range tree { if r.ID == env.clientID { client = r break } } if client == nil { t.Fatalf("clientID missing from ScopeMine tree") } if !client.InheritedVisibility { t.Errorf("clientID InheritedVisibility=false; want true (greyed ancestor)") } if len(client.Children) != 1 || client.Children[0].ID != env.caseID { t.Fatalf("expected single child caseID; got %+v", client.Children) } if client.Children[0].InheritedVisibility { t.Errorf("caseID is direct-staffed; should NOT have InheritedVisibility=true") } }