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.
232 lines
6.5 KiB
Go
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)
|
|
}
|
|
}
|