Files
paliad/internal/services/layout_spec_test.go
m 4e1d311a9c feat(t-paliad-149) PR2 step 1/3: backend — migration 061 + CardLayoutService + CardsPreview
Migration 061 (paliad.user_card_layouts): per-user named card layouts.
- Partial unique index on (user_id) WHERE is_default=true keeps "at most
  one default per user" honest at the DB level.
- UNIQUE (user_id, name) so the layout dropdown can use names as stable
  labels.
- RLS owner-only (mirrors paliad.user_views from t-144).

LayoutSpec (internal/services/layout_spec.go): structured JSON validator
with KnownFactKeys registry (11 fact keys: title-row, type-chip, status-
chip, client-matter, parent-path, deadline-counts, next-events, recent-
verlauf, team-chips, reference, last-activity-at). Validator enforces:
- title-row must be the first VISIBLE fact (always-on, structural)
- no duplicate keys
- count ∈ [1, 5] only on next-events / recent-verlauf
- density ∈ {compact, roomy} (CardDensity, distinct from t-144's
  ListDensity which only ranges over comfortable/compact)
- grid_columns ∈ {auto, 2, 3, 4}

DefaultLayoutSpec returns the m-locked rich content set per design §5b.4
(9 facts, roomy density, auto grid, leaf-ish projects only).

CardLayoutService: CRUD with auto-seed (GetDefault creates "Standard"
on first call) + tx-flip-default (setting is_default=true on B clears
A in the same transaction) + ErrUserCardLayoutDefaultGate (deleting
the active default returns 409). isPgUniqueViolation maps the partial
unique index conflict to ErrUserCardLayoutNameTaken.

ProjectService.CardsPreview: per-project event rollups for the Cards view.
4 source SQLs with ROW_NUMBER() OVER PARTITION BY project_id (top 3 each
for upcoming deadlines, upcoming appointments, recent project_events) +
team-chips JOIN. Single round-trip per source, visibility-gated. Returns
map[uuid.UUID]*ProjectCardPreview with last_activity_at computed across
all sources for the orchestrator's card-grid sort.

Handlers: 5 /api/user-card-layouts/* endpoints (GET list, POST create,
PATCH update, DELETE, POST set-default) + GET /api/projects/cards-preview
(narrowable via ?ids=<csv>).

Wired in handlers.go (Services struct + dbServices struct) and
cmd/server/main.go. ErrUserCardLayoutNameTaken / NotFound / DefaultGate
mapped to 409 / 404 / 409 respectively.

Tests:
- layout_spec_test.go (8 cases, pure-Go): valid default, empty rejection,
  title-row-first invariant, hidden leading allowed, dup-key rejection,
  unknown-key rejection, count-bounds + count-on-wrong-key, density/grid
  enum, ParseLayoutSpec round-trip.
- card_layout_service_test.go (6 cases, live-DB): GetDefault auto-seeds
  + idempotent, first Create auto-becomes default, SetDefault clears
  prior, Delete refuses active default, Delete non-default works,
  duplicate name rejected, Update round-trips layout JSON.

go build / vet / test (short) clean.

Design: docs/design-projects-page-2026-05-07.md §5b.3, §5b.5, §8.2.
2026-05-07 22:41:18 +02:00

156 lines
4.0 KiB
Go

package services
// Pure-Go validator tests for LayoutSpec. No DB required.
import (
"encoding/json"
"errors"
"testing"
)
func TestDefaultLayoutSpec_IsValid(t *testing.T) {
if err := DefaultLayoutSpec().Validate(); err != nil {
t.Fatalf("DefaultLayoutSpec invalid: %v", err)
}
}
func TestLayoutSpec_RejectsEmpty(t *testing.T) {
s := LayoutSpec{
Density: CardDensityRoomy,
GridColumns: GridAuto,
}
err := s.Validate()
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("empty facts: got %v, want ErrInvalidInput", err)
}
}
func TestLayoutSpec_RequiresTitleRowFirst(t *testing.T) {
s := LayoutSpec{
Facts: []LayoutFact{
{Key: FactTypeChip, Visible: true},
{Key: FactTitleRow, Visible: true},
},
Density: CardDensityRoomy,
GridColumns: GridAuto,
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("title-row not first: got %v, want ErrInvalidInput", err)
}
}
func TestLayoutSpec_AllowsTitleRowAfterHiddenLeading(t *testing.T) {
// type-chip is hidden so it doesn't count; title-row is the first VISIBLE.
s := LayoutSpec{
Facts: []LayoutFact{
{Key: FactTypeChip, Visible: false},
{Key: FactTitleRow, Visible: true},
{Key: FactStatusChip, Visible: true},
},
Density: CardDensityRoomy,
GridColumns: GridAuto,
}
if err := s.Validate(); err != nil {
t.Fatalf("title-row first-visible should be ok; got %v", err)
}
}
func TestLayoutSpec_RejectsDuplicateKey(t *testing.T) {
s := LayoutSpec{
Facts: []LayoutFact{
{Key: FactTitleRow, Visible: true},
{Key: FactTypeChip, Visible: true},
{Key: FactTypeChip, Visible: true},
},
Density: CardDensityRoomy,
GridColumns: GridAuto,
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("duplicate key: got %v, want ErrInvalidInput", err)
}
}
func TestLayoutSpec_RejectsUnknownKey(t *testing.T) {
s := LayoutSpec{
Facts: []LayoutFact{
{Key: FactTitleRow, Visible: true},
{Key: "made-up-fact", Visible: true},
},
Density: CardDensityRoomy,
GridColumns: GridAuto,
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown key: got %v, want ErrInvalidInput", err)
}
}
func TestLayoutSpec_CountBoundsAndPlacement(t *testing.T) {
bad := 7
s := LayoutSpec{
Facts: []LayoutFact{
{Key: FactTitleRow, Visible: true},
{Key: FactNextEvents, Visible: true, Count: &bad},
},
Density: CardDensityRoomy,
GridColumns: GridAuto,
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("count out-of-range: got %v, want ErrInvalidInput", err)
}
// count on a key that doesn't accept it.
good := 3
s = LayoutSpec{
Facts: []LayoutFact{
{Key: FactTitleRow, Visible: true},
{Key: FactStatusChip, Visible: true, Count: &good},
},
Density: CardDensityRoomy,
GridColumns: GridAuto,
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("count on wrong key: got %v, want ErrInvalidInput", err)
}
}
func TestLayoutSpec_DensityAndGrid(t *testing.T) {
s := LayoutSpec{
Facts: []LayoutFact{{Key: FactTitleRow, Visible: true}},
Density: "spacious", // invalid
GridColumns: GridAuto,
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("invalid density: got %v, want ErrInvalidInput", err)
}
s.Density = CardDensityRoomy
s.GridColumns = "5" // invalid
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("invalid grid: got %v, want ErrInvalidInput", err)
}
}
func TestParseLayoutSpec_RoundTrip(t *testing.T) {
in := DefaultLayoutSpec()
bytes := mustJSON(t, in)
out, err := ParseLayoutSpec(bytes)
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(out.Facts) != len(in.Facts) {
t.Errorf("facts len = %d, want %d", len(out.Facts), len(in.Facts))
}
if out.Density != in.Density || out.GridColumns != in.GridColumns {
t.Errorf("density/grid drift: out=(%s, %s) in=(%s, %s)",
out.Density, out.GridColumns, in.Density, in.GridColumns)
}
}
func mustJSON(t *testing.T, v any) []byte {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal: %v", err)
}
return b
}