package services import ( "context" "os" "testing" "github.com/google/uuid" "github.com/jmoiron/sqlx" _ "github.com/lib/pq" "mgit.msbls.de/m/paliad/internal/db" ) // TestProjectList_OrderedByPath pins the t-paliad-125 contract: ProjectService.List // returns rows ordered by `path`, so any descendant immediately follows its // ancestor — the same ordering BuildTree produces. Picker dropdowns rely on // this so they can render the project tree as a flat indented list. A previous // `ORDER BY updated_at DESC` interleaved cousins by recency and broke the // visual hierarchy in the /events Project filter (m's report 2026-05-04). // // Skipped when TEST_DATABASE_URL is unset. func TestProjectList_OrderedByPath(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() // Two siblings (clientA, clientB) chosen so clientA's UUID sorts BEFORE // clientB's — this lets us assert that an updated child of clientA still // appears between clientA and clientB rather than floating to the top by // recency. We retry until we get suitable IDs (UUIDv4 ordering is random). var clientA, clientB uuid.UUID for { a, b := uuid.New(), uuid.New() if a.String() < b.String() { clientA, clientB = a, b break } } caseUnderA := uuid.New() caseUnderB := uuid.New() cleanup := func() { for _, pid := range []uuid.UUID{caseUnderA, caseUnderB, clientA, clientB} { pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id = $1`, pid) pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, pid) pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, pid) } pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, adminID) pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, adminID) } cleanup() defer cleanup() if _, err := pool.ExecContext(ctx, `INSERT INTO auth.users (id, email) VALUES ($1, $2)`, adminID, "list-order@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, 'List Order', 'munich', 'global_admin', 'de')`, adminID, "list-order@hlc.com"); err != nil { t.Fatalf("seed paliad.users: %v", err) } // Insert order is (B, caseB, A, caseA) so updated_at DESC would put A's // branch first across both clients — interleaving caseUnderA between // clientB rows. Path order must override that and group each branch. rows := []struct { id uuid.UUID typ string parent *uuid.UUID title string reference string }{ {clientB, "client", nil, "Bravo Corp", "2026/9101"}, {caseUnderB, "case", &clientB, "Bravo Case", "2026/9102"}, {clientA, "client", nil, "Alpha Corp", "2026/9103"}, {caseUnderA, "case", &clientA, "Alpha Case", "2026/9104"}, } for _, p := range rows { 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, reference, status, created_by) VALUES ($1, $2, $3, $4, $5, $6, 'active', $7)`, p.id, p.typ, parent, p.id.String(), p.title, p.reference, adminID); err != nil { t.Fatalf("seed paliad.projects %s: %v", p.id, err) } } users := NewUserService(pool) projects := NewProjectService(pool, users) got, err := projects.List(ctx, adminID, ProjectFilter{}) if err != nil { t.Fatalf("List: %v", err) } // Restrict to the seed set to ignore unrelated rows in the shared dev DB. seed := map[uuid.UUID]bool{clientA: true, caseUnderA: true, clientB: true, caseUnderB: true} var ids []uuid.UUID for _, p := range got { if seed[p.ID] { ids = append(ids, p.ID) } } want := []uuid.UUID{clientA, caseUnderA, clientB, caseUnderB} if len(ids) != len(want) { t.Fatalf("List returned %d seed rows, want %d (got=%v)", len(ids), len(want), ids) } for i, id := range want { if ids[i] != id { t.Errorf("List[%d] = %s, want %s — order is not path-based", i, ids[i], id) } } }