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.
242 lines
8.1 KiB
Go
242 lines
8.1 KiB
Go
package services
|
|
|
|
// Pure-function tests for DashboardLayoutSpec + WidgetCatalog.
|
|
// No DB; safe to run in any environment.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestFactoryDefaultLayout_AllKnownWidgetsPresent(t *testing.T) {
|
|
def := FactoryDefaultLayout()
|
|
if def.Version != LayoutSpecVersion {
|
|
t.Errorf("FactoryDefaultLayout version=%d; want %d", def.Version, LayoutSpecVersion)
|
|
}
|
|
if len(def.Widgets) != len(KnownWidgetKeys) {
|
|
t.Fatalf("FactoryDefaultLayout has %d widgets; want %d", len(def.Widgets), len(KnownWidgetKeys))
|
|
}
|
|
for i, k := range KnownWidgetKeys {
|
|
if def.Widgets[i].Key != k {
|
|
t.Errorf("widgets[%d].Key = %q; want %q", i, def.Widgets[i].Key, k)
|
|
}
|
|
if !def.Widgets[i].Visible {
|
|
t.Errorf("widgets[%d].Visible = false; factory default should be all-visible", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFactoryDefaultLayout_SettingsDefaultsPresent(t *testing.T) {
|
|
def := FactoryDefaultLayout()
|
|
for _, w := range def.Widgets {
|
|
catalogDef, ok := LookupWidgetDef(w.Key)
|
|
if !ok {
|
|
t.Errorf("factory widget %q is not in catalog", w.Key)
|
|
continue
|
|
}
|
|
hasDefaults := catalogDef.DefaultCount != nil || catalogDef.DefaultHorizon != nil
|
|
if hasDefaults && len(w.Settings) == 0 {
|
|
t.Errorf("widget %q has catalog defaults but factory layout has empty settings", w.Key)
|
|
}
|
|
if !hasDefaults && len(w.Settings) > 0 {
|
|
t.Errorf("widget %q has no catalog defaults but factory layout has settings %s", w.Key, string(w.Settings))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFactoryDefaultLayout_PassesValidation(t *testing.T) {
|
|
def := FactoryDefaultLayout()
|
|
if err := def.Validate(); err != nil {
|
|
t.Fatalf("factory default failed Validate(): %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDashboardLayoutSpec_Validate_WrongVersion(t *testing.T) {
|
|
s := DashboardLayoutSpec{Version: 99, Widgets: []DashboardWidgetRef{{Key: WidgetDeadlineSummary, Visible: true}}}
|
|
err := s.Validate()
|
|
if !errors.Is(err, ErrInvalidInput) {
|
|
t.Fatalf("Validate returned %v; want ErrInvalidInput", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "version") {
|
|
t.Errorf("error %q should mention 'version'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestDashboardLayoutSpec_Validate_TooManyWidgets(t *testing.T) {
|
|
widgets := make([]DashboardWidgetRef, LayoutWidgetCap+1)
|
|
for i := range widgets {
|
|
widgets[i] = DashboardWidgetRef{Key: WidgetDeadlineSummary, Visible: true}
|
|
}
|
|
s := DashboardLayoutSpec{Version: 1, Widgets: widgets}
|
|
err := s.Validate()
|
|
if !errors.Is(err, ErrInvalidInput) {
|
|
t.Fatalf("Validate returned %v; want ErrInvalidInput", err)
|
|
}
|
|
}
|
|
|
|
func TestDashboardLayoutSpec_Validate_UnknownKey(t *testing.T) {
|
|
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
|
|
{Key: "not-a-real-widget", Visible: true},
|
|
}}
|
|
err := s.Validate()
|
|
if !errors.Is(err, ErrInvalidInput) {
|
|
t.Fatalf("Validate returned %v; want ErrInvalidInput", err)
|
|
}
|
|
}
|
|
|
|
func TestDashboardLayoutSpec_Validate_DuplicateKey(t *testing.T) {
|
|
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
|
|
{Key: WidgetDeadlineSummary, Visible: true},
|
|
{Key: WidgetDeadlineSummary, Visible: false},
|
|
}}
|
|
err := s.Validate()
|
|
if !errors.Is(err, ErrInvalidInput) {
|
|
t.Fatalf("Validate returned %v; want ErrInvalidInput", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "duplicate") {
|
|
t.Errorf("error %q should mention 'duplicate'", err.Error())
|
|
}
|
|
}
|
|
|
|
func TestDashboardLayoutSpec_Validate_BadSettings(t *testing.T) {
|
|
// count not in CountOptions for upcoming-deadlines (legal: 1,3,5,10,20)
|
|
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
|
|
{Key: WidgetUpcomingDeadlines, Visible: true, Settings: json.RawMessage(`{"count": 7}`)},
|
|
}}
|
|
err := s.Validate()
|
|
if !errors.Is(err, ErrInvalidInput) {
|
|
t.Fatalf("Validate returned %v; want ErrInvalidInput", err)
|
|
}
|
|
}
|
|
|
|
func TestDashboardLayoutSpec_Validate_AcceptsValidSettings(t *testing.T) {
|
|
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
|
|
{Key: WidgetUpcomingDeadlines, Visible: true, Settings: json.RawMessage(`{"count": 5, "horizon_days": 14}`)},
|
|
{Key: WidgetInlineAgenda, Visible: true, Settings: json.RawMessage(`{"horizon_days": 60}`)},
|
|
{Key: WidgetRecentActivity, Visible: false},
|
|
}}
|
|
if err := s.Validate(); err != nil {
|
|
t.Fatalf("Validate returned %v; want nil", err)
|
|
}
|
|
}
|
|
|
|
func TestDashboardLayoutSpec_Validate_SettingsOnNoSettingsWidget(t *testing.T) {
|
|
// deadline-summary has no Settings schema.
|
|
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
|
|
{Key: WidgetDeadlineSummary, Visible: true, Settings: json.RawMessage(`{"count": 5}`)},
|
|
}}
|
|
err := s.Validate()
|
|
if !errors.Is(err, ErrInvalidInput) {
|
|
t.Fatalf("Validate returned %v; want ErrInvalidInput", err)
|
|
}
|
|
}
|
|
|
|
func TestDashboardLayoutSpec_SanitizeForRead_DropsUnknownKeys(t *testing.T) {
|
|
s := DashboardLayoutSpec{Version: 1, Widgets: []DashboardWidgetRef{
|
|
{Key: WidgetDeadlineSummary, Visible: true},
|
|
{Key: "deprecated-widget", Visible: true},
|
|
{Key: WidgetInlineAgenda, Visible: true},
|
|
}}
|
|
changed := s.SanitizeForRead()
|
|
if !changed {
|
|
t.Errorf("SanitizeForRead returned false; expected true (one entry dropped)")
|
|
}
|
|
if len(s.Widgets) != 2 {
|
|
t.Errorf("after sanitize: %d widgets; want 2", len(s.Widgets))
|
|
}
|
|
if s.Widgets[0].Key != WidgetDeadlineSummary || s.Widgets[1].Key != WidgetInlineAgenda {
|
|
t.Errorf("after sanitize: keys = %v %v; want %v %v",
|
|
s.Widgets[0].Key, s.Widgets[1].Key, WidgetDeadlineSummary, WidgetInlineAgenda)
|
|
}
|
|
}
|
|
|
|
func TestDashboardLayoutSpec_SanitizeForRead_NoopOnClean(t *testing.T) {
|
|
s := FactoryDefaultLayout()
|
|
if s.SanitizeForRead() {
|
|
t.Errorf("SanitizeForRead on factory default returned true; want false (already clean)")
|
|
}
|
|
}
|
|
|
|
func TestDashboardLayoutSpec_SanitizeForRead_BumpsVersion(t *testing.T) {
|
|
s := DashboardLayoutSpec{Version: 0, Widgets: []DashboardWidgetRef{{Key: WidgetDeadlineSummary, Visible: true}}}
|
|
if !s.SanitizeForRead() {
|
|
t.Errorf("SanitizeForRead returned false; expected version bump")
|
|
}
|
|
if s.Version != LayoutSpecVersion {
|
|
t.Errorf("after sanitize: Version=%d; want %d", s.Version, LayoutSpecVersion)
|
|
}
|
|
}
|
|
|
|
func TestParseDashboardLayoutSpec_RoundTrip(t *testing.T) {
|
|
def := FactoryDefaultLayout()
|
|
bytes, err := json.Marshal(def)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
parsed, err := ParseDashboardLayoutSpec(bytes)
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if parsed.Version != def.Version {
|
|
t.Errorf("version mismatch: %d vs %d", parsed.Version, def.Version)
|
|
}
|
|
if len(parsed.Widgets) != len(def.Widgets) {
|
|
t.Errorf("widget count mismatch: %d vs %d", len(parsed.Widgets), len(def.Widgets))
|
|
}
|
|
}
|
|
|
|
func TestParseDashboardLayoutSpec_InvalidJSON(t *testing.T) {
|
|
_, err := ParseDashboardLayoutSpec([]byte(`{not-json}`))
|
|
if !errors.Is(err, ErrInvalidInput) {
|
|
t.Fatalf("ParseDashboardLayoutSpec returned %v; want ErrInvalidInput", err)
|
|
}
|
|
}
|
|
|
|
func TestWidgetCatalog_AllKnownKeysHaveDef(t *testing.T) {
|
|
for _, k := range KnownWidgetKeys {
|
|
def, ok := LookupWidgetDef(k)
|
|
if !ok {
|
|
t.Errorf("KnownWidgetKeys entry %q has no WidgetDef", k)
|
|
continue
|
|
}
|
|
if def.TitleDE == "" || def.TitleEN == "" {
|
|
t.Errorf("widget %q missing title (de=%q en=%q)", k, def.TitleDE, def.TitleEN)
|
|
}
|
|
if def.DescriptionDE == "" || def.DescriptionEN == "" {
|
|
t.Errorf("widget %q missing description", k)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWidgetCatalog_NoOrphanDefs(t *testing.T) {
|
|
known := make(map[WidgetKey]bool, len(KnownWidgetKeys))
|
|
for _, k := range KnownWidgetKeys {
|
|
known[k] = true
|
|
}
|
|
for _, def := range WidgetCatalog() {
|
|
if !known[def.Key] {
|
|
// Orphans are allowed (forward-compat: pinned-projects const
|
|
// exists in widget_catalog.go before its widget module ships).
|
|
// But verify the catalog entry is internally coherent.
|
|
if def.TitleDE == "" || def.TitleEN == "" {
|
|
t.Errorf("orphan catalog entry %q must still have titles", def.Key)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWidgetSettingsSchema_NilRejectsNonEmpty(t *testing.T) {
|
|
var sch *WidgetSettingsSchema
|
|
if err := sch.Validate(json.RawMessage(`{"count": 5}`)); !errors.Is(err, ErrInvalidInput) {
|
|
t.Fatalf("nil schema accepted settings; got %v", err)
|
|
}
|
|
if err := sch.Validate(nil); err != nil {
|
|
t.Errorf("nil schema rejected empty settings: %v", err)
|
|
}
|
|
if err := sch.Validate(json.RawMessage(`null`)); err != nil {
|
|
t.Errorf("nil schema rejected 'null' settings: %v", err)
|
|
}
|
|
}
|