Files
paliad/internal/services/project_list_order_test.go
m 4d7c74994a feat(t-paliad-125): sort project pickers by tree path with depth indent
The /events Project filter dropdown was sorted by `updated_at DESC`, so a
recently-touched Case appeared above its parent Client and cousins
interleaved unrelated branches — m's report (2026-05-04): "Siemens cases
come directly after 'mandant vs Gegner' and are not under 'Siemens-AG'".

Backend: switch ProjectService.List to ORDER BY p.path so every
descendant immediately follows its ancestor — the same ordering BuildTree
produces. Both callers (handleListProjects, searchProjects) gain a
stable, hierarchical default that matches user expectation.

Frontend: add project-indent.ts shared helper and apply NBSP indent
prefix in every <select> picker fed by /api/projects: events filter,
/deadlines/new, /appointments/new, checklist new-instance modal,
Fristenrechner save modal. NBSP avoids browser whitespace collapse
inside <option> labels. Multi-parent repetition is out of scope (data
model has singular parent_id today).

Tests: project_list_order_test pins the path-order contract against a
seeded mixed-recency tree.
2026-05-04 19:30:37 +02:00

133 lines
4.3 KiB
Go

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