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.
133 lines
4.3 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|