Files
paliad/internal/services/card_layout_service_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

232 lines
6.5 KiB
Go

package services
// Live-DB tests for CardLayoutService. 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 cardLayoutTestEnv struct {
t *testing.T
pool *sqlx.DB
svc *CardLayoutService
userID uuid.UUID
cleanup func()
}
func setupCardLayoutTest(t *testing.T) *cardLayoutTestEnv {
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()
if _, err := pool.ExecContext(ctx,
`INSERT INTO auth.users (id, email) VALUES ($1, $1::text || '@test.local')
ON CONFLICT (id) DO NOTHING`, userID); err != nil {
t.Logf("skip auth.users seed: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, global_role)
VALUES ($1, $1::text || '@test.local', 'Card Layout Test', 'munich', 'standard')
ON CONFLICT (id) DO NOTHING`, userID); err != nil {
t.Fatalf("seed paliad.users: %v", err)
}
cleanup := func() {
c := context.Background()
pool.ExecContext(c, `DELETE FROM paliad.user_card_layouts WHERE user_id = $1`, userID)
pool.ExecContext(c, `DELETE FROM paliad.users WHERE id = $1`, userID)
pool.ExecContext(c, `DELETE FROM auth.users WHERE id = $1`, userID)
pool.Close()
}
return &cardLayoutTestEnv{
t: t,
pool: pool,
svc: NewCardLayoutService(pool),
userID: userID,
cleanup: cleanup,
}
}
func TestCardLayoutService_GetDefaultAutoSeeds(t *testing.T) {
env := setupCardLayoutTest(t)
defer env.cleanup()
ctx := context.Background()
// First call seeds the default.
def, err := env.svc.GetDefault(ctx, env.userID)
if err != nil {
t.Fatalf("GetDefault: %v", err)
}
if def.Name != "Standard" || !def.IsDefault {
t.Errorf("seeded default: name=%q is_default=%v; want Standard, true", def.Name, def.IsDefault)
}
// Second call returns the same row, not a new seed.
def2, err := env.svc.GetDefault(ctx, env.userID)
if err != nil {
t.Fatalf("GetDefault second: %v", err)
}
if def2.ID != def.ID {
t.Errorf("second GetDefault returned %v; want same id %v", def2.ID, def.ID)
}
}
func TestCardLayoutService_FirstCreateBecomesDefault(t *testing.T) {
env := setupCardLayoutTest(t)
defer env.cleanup()
ctx := context.Background()
row, err := env.svc.Create(ctx, env.userID, CreateCardLayoutInput{
Name: "Mein Erstes",
Layout: DefaultLayoutSpec(),
IsDefault: false, // even with false, first row becomes default.
})
if err != nil {
t.Fatalf("Create: %v", err)
}
if !row.IsDefault {
t.Errorf("first layout is_default=false; want true (auto-flip)")
}
}
func TestCardLayoutService_SetDefaultClearsPrior(t *testing.T) {
env := setupCardLayoutTest(t)
defer env.cleanup()
ctx := context.Background()
a, err := env.svc.Create(ctx, env.userID, CreateCardLayoutInput{Name: "A", Layout: DefaultLayoutSpec()})
if err != nil {
t.Fatalf("Create A: %v", err)
}
if !a.IsDefault {
t.Fatalf("A is_default=false; want true")
}
b, err := env.svc.Create(ctx, env.userID, CreateCardLayoutInput{Name: "B", Layout: DefaultLayoutSpec()})
if err != nil {
t.Fatalf("Create B: %v", err)
}
if b.IsDefault {
t.Fatalf("B is_default=true; want false (A is still default)")
}
// Flip B → default.
bAfter, err := env.svc.SetDefault(ctx, env.userID, b.ID)
if err != nil {
t.Fatalf("SetDefault B: %v", err)
}
if !bAfter.IsDefault {
t.Errorf("after SetDefault B.is_default=false")
}
// A should no longer be default.
aAfter, err := env.svc.Get(ctx, env.userID, a.ID)
if err != nil {
t.Fatalf("Get A: %v", err)
}
if aAfter.IsDefault {
t.Errorf("A still is_default=true after B took the flag")
}
}
func TestCardLayoutService_DeleteRefusesActiveDefault(t *testing.T) {
env := setupCardLayoutTest(t)
defer env.cleanup()
ctx := context.Background()
row, err := env.svc.Create(ctx, env.userID, CreateCardLayoutInput{Name: "OnlyOne", Layout: DefaultLayoutSpec()})
if err != nil {
t.Fatalf("Create: %v", err)
}
err = env.svc.Delete(ctx, env.userID, row.ID)
if !errors.Is(err, ErrUserCardLayoutDefaultGate) {
t.Errorf("Delete default = %v; want ErrUserCardLayoutDefaultGate", err)
}
}
func TestCardLayoutService_DeleteNonDefault(t *testing.T) {
env := setupCardLayoutTest(t)
defer env.cleanup()
ctx := context.Background()
_, _ = env.svc.Create(ctx, env.userID, CreateCardLayoutInput{Name: "Default", Layout: DefaultLayoutSpec()})
b, err := env.svc.Create(ctx, env.userID, CreateCardLayoutInput{Name: "Throwaway", Layout: DefaultLayoutSpec()})
if err != nil {
t.Fatalf("Create throwaway: %v", err)
}
if err := env.svc.Delete(ctx, env.userID, b.ID); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, err := env.svc.Get(ctx, env.userID, b.ID); !errors.Is(err, ErrUserCardLayoutNotFound) {
t.Errorf("Get after delete = %v; want ErrUserCardLayoutNotFound", err)
}
}
func TestCardLayoutService_DuplicateNameRejected(t *testing.T) {
env := setupCardLayoutTest(t)
defer env.cleanup()
ctx := context.Background()
if _, err := env.svc.Create(ctx, env.userID, CreateCardLayoutInput{Name: "Same", Layout: DefaultLayoutSpec()}); err != nil {
t.Fatalf("Create first: %v", err)
}
_, err := env.svc.Create(ctx, env.userID, CreateCardLayoutInput{Name: "Same", Layout: DefaultLayoutSpec()})
if !errors.Is(err, ErrUserCardLayoutNameTaken) {
t.Errorf("duplicate name = %v; want ErrUserCardLayoutNameTaken", err)
}
}
func TestCardLayoutService_UpdateRoundTrip(t *testing.T) {
env := setupCardLayoutTest(t)
defer env.cleanup()
ctx := context.Background()
row, err := env.svc.Create(ctx, env.userID, CreateCardLayoutInput{Name: "Editable", Layout: DefaultLayoutSpec()})
if err != nil {
t.Fatalf("Create: %v", err)
}
newName := "Renamed"
newLayout := DefaultLayoutSpec()
newLayout.Density = CardDensityCompact
updated, err := env.svc.Update(ctx, env.userID, row.ID, UpdateCardLayoutInput{
Name: &newName,
Layout: &newLayout,
})
if err != nil {
t.Fatalf("Update: %v", err)
}
if updated.Name != "Renamed" {
t.Errorf("name = %q; want Renamed", updated.Name)
}
parsed, err := ParseLayoutSpec(updated.LayoutJSON)
if err != nil {
t.Fatalf("ParseLayoutSpec on updated: %v", err)
}
if parsed.Density != CardDensityCompact {
t.Errorf("density round-trip = %q; want compact", parsed.Density)
}
}