Phase A1 of the data-display-model rethink (m/paliad#5). Backend-only; no user-visible change in A1. A2 (frontend) lands separately. What's new: - Migration 056: paliad.user_views table with RLS scoped to caller (user_views_owner_all on auth.uid()=user_id). Composite UNIQUE (user_id, slug). No is_system flag — system defaults stay code- resident per Q8 lock-in. - internal/services/filter_spec.go (+test): structured FilterSpec with Sources / Scope / Time / Predicates. Server-side validator rejects unknown sources, duplicate sources, conflicting scope modes, horizon=all without explicit projects (Q26 clamp), and every per-source enum (deadline.status, appointment_types, project_event kinds, approval_request status / viewer_role). - internal/services/render_spec.go (+test): RenderSpec with three shapes (list / cards / calendar — Q4 lock-in 2026-05-07). Per-shape config kept separately so flipping shapes preserves tweaks. Validator over column / sort / density / group_by / default_view enums. - internal/services/system_views.go (+test): code-resident SystemView definitions for dashboard / agenda / events / inbox / inbox-mine. Reserved-slug list (Q23) prevents user-views from colliding with top-level URLs. Case-folded matching. - internal/services/view_service.go: extends EventService with RunSpec — runs a FilterSpec across all four substrate sources (deadline + appointment + project_event + approval_request) and merges into []ViewRow sorted by event_date. ViewRow is a discriminated projection (kind + common header + per-source Detail json.RawMessage). Q17 fail-open attribution: returns inaccessible_project_ids for explicit-scope queries where the caller can't see some IDs. - internal/services/user_view_service.go (+test): CRUD on paliad.user_views — Create (server-assigns sort_order MAX+1 in tx), GetBySlug, GetByID, Update (partial), Delete, Touch (last_used_at), MostRecent. Reserved-slug + slug-format validators on every write. - internal/handlers/views.go: nine HTTP handlers wiring the endpoints (GET/POST/PATCH/DELETE /api/user-views/..., POST /api/user-views/{id}/touch, POST /api/views/run, POST /api/views/{slug}/run, GET /api/views/system). - main.go + handlers.go + projects.go: wire UserViewService into the bundle; conditional route registration when both UserView + Event services are present. Pure-Go tests (no DB): 32 cases pass — filter spec validators, render spec validators, system view registry, reserved slugs. Live-DB tests (skip when TEST_DATABASE_URL unset): 12 cases covering create / list / get / uniqueness / update / delete / touch / most-recent / reserved-slug / bad-slug / empty-name / invalid-spec. Coexists with t-139 (in-flight on noether's other branch) and t-138 (shipped) without coordination commits — RunSpec uses the existing visibility predicate that t-139's migration 055 will extend with derivation. Approval-request source delegates to ApprovalService.ListPendingForApprover / ListSubmittedByUser (both already extended for derived_peer authority in t-139 Phase 3). Files: 15 changed, 3134 insertions. Build clean. Tests green.
104 lines
3.0 KiB
Go
104 lines
3.0 KiB
Go
package services
|
|
|
|
// Pure-Go tests for RenderSpec.
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
)
|
|
|
|
func TestRenderSpec_HappyPath(t *testing.T) {
|
|
s := DefaultRenderSpec()
|
|
if err := s.Validate(); err != nil {
|
|
t.Fatalf("default render spec must validate: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRenderSpec_ShapeMustBeKnown(t *testing.T) {
|
|
cases := []RenderShape{ShapeList, ShapeCards, ShapeCalendar}
|
|
for _, sh := range cases {
|
|
t.Run(string(sh), func(t *testing.T) {
|
|
s := RenderSpec{Shape: sh}
|
|
if err := s.Validate(); err != nil {
|
|
t.Fatalf("shape %q must validate: %v", sh, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRenderSpec_UnknownShapeRejects(t *testing.T) {
|
|
s := RenderSpec{Shape: "kanban"}
|
|
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
|
t.Fatalf("unknown shape must reject, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRenderSpec_ListColumnEnum(t *testing.T) {
|
|
s := RenderSpec{Shape: ShapeList, List: &ListConfig{Columns: []string{"date", "bogus"}}}
|
|
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
|
t.Fatalf("unknown list column must reject, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRenderSpec_KnownListColumnsAccepted(t *testing.T) {
|
|
s := RenderSpec{Shape: ShapeList, List: &ListConfig{Columns: KnownListColumns}}
|
|
if err := s.Validate(); err != nil {
|
|
t.Fatalf("known columns must validate: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRenderSpec_ListSortEnum(t *testing.T) {
|
|
s := RenderSpec{Shape: ShapeList, List: &ListConfig{Sort: "weird"}}
|
|
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
|
t.Fatalf("unknown sort must reject, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRenderSpec_ListDensityEnum(t *testing.T) {
|
|
s := RenderSpec{Shape: ShapeList, List: &ListConfig{Density: "huge"}}
|
|
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
|
t.Fatalf("unknown density must reject, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRenderSpec_CardsGroupByEnum(t *testing.T) {
|
|
s := RenderSpec{Shape: ShapeCards, Cards: &CardsConfig{GroupBy: "month"}}
|
|
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
|
t.Fatalf("unknown group_by must reject, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRenderSpec_CalendarViewEnum(t *testing.T) {
|
|
s := RenderSpec{Shape: ShapeCalendar, Calendar: &CalendarConfig{DefaultView: "year"}}
|
|
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
|
t.Fatalf("unknown default_view must reject, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRenderSpec_RoundTrip(t *testing.T) {
|
|
original := RenderSpec{
|
|
Shape: ShapeList,
|
|
List: &ListConfig{
|
|
Columns: []string{"time", "actor", "title", "project"},
|
|
Sort: SortDateDesc,
|
|
Density: DensityCompact,
|
|
},
|
|
Cards: &CardsConfig{GroupBy: CardsGroupByDay, Sort: SortDateAsc},
|
|
Calendar: &CalendarConfig{DefaultView: CalendarMonth},
|
|
}
|
|
b, err := MarshalRenderSpec(original)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
parsed, err := UnmarshalRenderSpec(b)
|
|
if err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if parsed.Shape != original.Shape {
|
|
t.Errorf("shape mismatch: %v vs %v", parsed.Shape, original.Shape)
|
|
}
|
|
if parsed.List == nil || parsed.List.Density != DensityCompact {
|
|
t.Errorf("list config not preserved: %+v", parsed.List)
|
|
}
|
|
}
|