Migration 109 + DashboardLayoutSpec + Service + WidgetCatalog. No HTTP
handlers and no frontend yet — those land in A2/A3/A4 as separate commits
for cleaner review.
Why slot 109 (not 107 from the design doc): leibniz claimed 107 for
caldav_sync_log.binding_id and 108 for caldav_mkcalendar_capability after
the design was filed. Boltzmann's gap-tolerant runner (c85c382) lets any
embedded migration apply regardless of authoring order.
What ships:
- paliad.user_dashboard_layouts table: single-row PK on user_id (Q2 pick
was single layout per user — no named-layout switcher). RLS owner-only,
mirrors user_card_layouts / user_views patterns.
- DashboardLayoutSpec: { v: 1, widgets: [{ key, visible, settings? }] }.
Validation is strict on write (catalog membership + per-widget settings
schema, duplicate-key check, 32-widget cap, version pin). SanitizeForRead
is forgiving — unknown keys dropped silently per design §10 versioning
rule.
- DashboardLayoutService: GetOrSeed (auto-seeds factory default on first
call, idempotent under concurrent first-load via ON CONFLICT DO NOTHING),
Update (validates + upserts), ResetToDefault.
- WidgetCatalog: 7 v1 widget defs (deadline-summary, matter-summary,
upcoming-deadlines, upcoming-appointments, inline-agenda, recent-activity,
inbox-approvals). Per-widget WidgetSettingsSchema with CountOptions +
HorizonOptions per design §18 Note B. pinned-projects const reserved
but omitted from KnownWidgetKeys until Slice C lands its widget module.
- 18 pure-function tests pin: factory layout shape, validation failures
(wrong version / over cap / unknown key / duplicate / bad settings),
sanitize-on-read (drop unknown / noop on clean / bump version), JSON
round-trip, catalog completeness, nil-schema behaviour.
- 4 live-DB tests (skipped without TEST_DATABASE_URL): GetOrSeed
auto-seeds + idempotent, Update round-trips, Update rejects invalid,
ResetToDefault overwrites.
Migration SQL dry-run live in BEGIN..ROLLBACK against supabase — clean.
go build + go test ./internal/services/ -short both clean.
Slice C0 (pin-machinery) from the design doc is OBSOLETE — paliad
.user_pinned_projects + PinService already exist (pre-dates t-paliad-219).
Slice C in the original plan becomes a single PR adding the
pinned-projects widget module that reads from the existing service.
Design: docs/design-dashboard-configurable-2026-05-20.md §5 + §18.
182 lines
5.0 KiB
Go
182 lines
5.0 KiB
Go
package services
|
|
|
|
// Live-DB tests for DashboardLayoutService. Skipped when TEST_DATABASE_URL
|
|
// is unset.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
_ "github.com/lib/pq"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/db"
|
|
)
|
|
|
|
type dashboardLayoutTestEnv struct {
|
|
t *testing.T
|
|
pool *sqlx.DB
|
|
svc *DashboardLayoutService
|
|
userID uuid.UUID
|
|
cleanup func()
|
|
}
|
|
|
|
func setupDashboardLayoutTest(t *testing.T) *dashboardLayoutTestEnv {
|
|
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', 'Dashboard 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_dashboard_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 &dashboardLayoutTestEnv{
|
|
t: t,
|
|
pool: pool,
|
|
svc: NewDashboardLayoutService(pool),
|
|
userID: userID,
|
|
cleanup: cleanup,
|
|
}
|
|
}
|
|
|
|
func TestDashboardLayoutService_GetOrSeedAutoSeeds(t *testing.T) {
|
|
env := setupDashboardLayoutTest(t)
|
|
defer env.cleanup()
|
|
ctx := context.Background()
|
|
|
|
spec, err := env.svc.GetOrSeed(ctx, env.userID)
|
|
if err != nil {
|
|
t.Fatalf("GetOrSeed: %v", err)
|
|
}
|
|
if spec.Version != LayoutSpecVersion {
|
|
t.Errorf("seeded version=%d; want %d", spec.Version, LayoutSpecVersion)
|
|
}
|
|
if len(spec.Widgets) != len(KnownWidgetKeys) {
|
|
t.Errorf("seeded widget count=%d; want %d", len(spec.Widgets), len(KnownWidgetKeys))
|
|
}
|
|
|
|
// Second call returns the same row, not a second seed.
|
|
spec2, err := env.svc.GetOrSeed(ctx, env.userID)
|
|
if err != nil {
|
|
t.Fatalf("GetOrSeed second: %v", err)
|
|
}
|
|
if len(spec2.Widgets) != len(spec.Widgets) {
|
|
t.Errorf("second call widget count drifted: %d vs %d", len(spec2.Widgets), len(spec.Widgets))
|
|
}
|
|
}
|
|
|
|
func TestDashboardLayoutService_UpdateRoundTrips(t *testing.T) {
|
|
env := setupDashboardLayoutTest(t)
|
|
defer env.cleanup()
|
|
ctx := context.Background()
|
|
|
|
// Seed first so the row exists.
|
|
if _, err := env.svc.GetOrSeed(ctx, env.userID); err != nil {
|
|
t.Fatalf("GetOrSeed: %v", err)
|
|
}
|
|
|
|
// Custom layout: hide matter-summary, reorder.
|
|
custom := DashboardLayoutSpec{
|
|
Version: LayoutSpecVersion,
|
|
Widgets: []DashboardWidgetRef{
|
|
{Key: WidgetUpcomingDeadlines, Visible: true, Settings: json.RawMessage(`{"count": 5, "horizon_days": 14}`)},
|
|
{Key: WidgetMatterSummary, Visible: false},
|
|
{Key: WidgetDeadlineSummary, Visible: true},
|
|
},
|
|
}
|
|
out, err := env.svc.Update(ctx, env.userID, custom)
|
|
if err != nil {
|
|
t.Fatalf("Update: %v", err)
|
|
}
|
|
if len(out.Widgets) != 3 {
|
|
t.Fatalf("Update returned %d widgets; want 3", len(out.Widgets))
|
|
}
|
|
if out.Widgets[0].Key != WidgetUpcomingDeadlines {
|
|
t.Errorf("Update returned widgets[0]=%q; want %q", out.Widgets[0].Key, WidgetUpcomingDeadlines)
|
|
}
|
|
if out.Widgets[1].Visible {
|
|
t.Errorf("Update returned widgets[1].Visible=true; want false")
|
|
}
|
|
|
|
// Re-read confirms persistence.
|
|
got, err := env.svc.GetOrSeed(ctx, env.userID)
|
|
if err != nil {
|
|
t.Fatalf("GetOrSeed after update: %v", err)
|
|
}
|
|
if len(got.Widgets) != 3 {
|
|
t.Errorf("GetOrSeed after update: %d widgets; want 3", len(got.Widgets))
|
|
}
|
|
}
|
|
|
|
func TestDashboardLayoutService_UpdateRejectsInvalid(t *testing.T) {
|
|
env := setupDashboardLayoutTest(t)
|
|
defer env.cleanup()
|
|
ctx := context.Background()
|
|
|
|
bad := DashboardLayoutSpec{
|
|
Version: LayoutSpecVersion,
|
|
Widgets: []DashboardWidgetRef{
|
|
{Key: "fake-widget-key", Visible: true},
|
|
},
|
|
}
|
|
if _, err := env.svc.Update(ctx, env.userID, bad); err == nil {
|
|
t.Fatalf("Update accepted invalid layout")
|
|
}
|
|
}
|
|
|
|
func TestDashboardLayoutService_ResetToDefault(t *testing.T) {
|
|
env := setupDashboardLayoutTest(t)
|
|
defer env.cleanup()
|
|
ctx := context.Background()
|
|
|
|
// Custom layout first.
|
|
custom := DashboardLayoutSpec{
|
|
Version: LayoutSpecVersion,
|
|
Widgets: []DashboardWidgetRef{
|
|
{Key: WidgetDeadlineSummary, Visible: true},
|
|
},
|
|
}
|
|
if _, err := env.svc.Update(ctx, env.userID, custom); err != nil {
|
|
t.Fatalf("Update: %v", err)
|
|
}
|
|
|
|
// Reset.
|
|
reset, err := env.svc.ResetToDefault(ctx, env.userID)
|
|
if err != nil {
|
|
t.Fatalf("ResetToDefault: %v", err)
|
|
}
|
|
if len(reset.Widgets) != len(KnownWidgetKeys) {
|
|
t.Errorf("reset widget count=%d; want %d", len(reset.Widgets), len(KnownWidgetKeys))
|
|
}
|
|
}
|