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.
325 lines
8.8 KiB
Go
325 lines
8.8 KiB
Go
package services
|
|
|
|
// Live-DB tests for UserViewService. Skipped when TEST_DATABASE_URL is
|
|
// unset, mirroring the rest of the live-DB test suite.
|
|
|
|
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 userViewTestEnv struct {
|
|
t *testing.T
|
|
pool *sqlx.DB
|
|
svc *UserViewService
|
|
userID uuid.UUID
|
|
cleanup func()
|
|
}
|
|
|
|
func setupUserViewTest(t *testing.T) *userViewTestEnv {
|
|
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 (continuing — auth schema may be locked down)", err)
|
|
}
|
|
if _, err := pool.ExecContext(ctx,
|
|
`INSERT INTO paliad.users (id, email, display_name, office, global_role)
|
|
VALUES ($1, $1::text || '@test.local', 'View Test User', 'munich', 'standard')
|
|
ON CONFLICT (id) DO NOTHING`, userID); err != nil {
|
|
t.Fatalf("seed paliad.users: %v", err)
|
|
}
|
|
|
|
cleanup := func() {
|
|
ctx := context.Background()
|
|
pool.ExecContext(ctx, `DELETE FROM paliad.user_views WHERE user_id = $1`, userID)
|
|
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
|
|
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
|
|
pool.Close()
|
|
}
|
|
|
|
return &userViewTestEnv{
|
|
t: t,
|
|
pool: pool,
|
|
svc: NewUserViewService(pool),
|
|
userID: userID,
|
|
cleanup: cleanup,
|
|
}
|
|
}
|
|
|
|
func goodCreateInput(slug, name string) CreateUserViewInput {
|
|
return CreateUserViewInput{
|
|
Slug: slug,
|
|
Name: name,
|
|
FilterSpec: DefaultFilterSpec(),
|
|
RenderSpec: DefaultRenderSpec(),
|
|
}
|
|
}
|
|
|
|
func TestUserViewService_CreateAndList(t *testing.T) {
|
|
env := setupUserViewTest(t)
|
|
defer env.cleanup()
|
|
|
|
ctx := context.Background()
|
|
|
|
created, err := env.svc.Create(ctx, env.userID, goodCreateInput("freitag-stand", "Freitag-Stand"))
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
if created.Slug != "freitag-stand" || created.Name != "Freitag-Stand" {
|
|
t.Errorf("created shape: %+v", created)
|
|
}
|
|
if created.SortOrder != 0 {
|
|
t.Errorf("first view sort_order = %d, want 0", created.SortOrder)
|
|
}
|
|
|
|
second, err := env.svc.Create(ctx, env.userID, goodCreateInput("siemens", "Siemens-Aktivität"))
|
|
if err != nil {
|
|
t.Fatalf("second create: %v", err)
|
|
}
|
|
if second.SortOrder != 1 {
|
|
t.Errorf("second view sort_order = %d, want 1", second.SortOrder)
|
|
}
|
|
|
|
list, err := env.svc.ListForUser(ctx, env.userID)
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
if len(list) != 2 {
|
|
t.Fatalf("expected 2 views, got %d", len(list))
|
|
}
|
|
// sort_order ASC ordering
|
|
if list[0].Slug != "freitag-stand" || list[1].Slug != "siemens" {
|
|
t.Errorf("ordering: %v", []string{list[0].Slug, list[1].Slug})
|
|
}
|
|
}
|
|
|
|
func TestUserViewService_GetBySlugAndID(t *testing.T) {
|
|
env := setupUserViewTest(t)
|
|
defer env.cleanup()
|
|
ctx := context.Background()
|
|
|
|
created, err := env.svc.Create(ctx, env.userID, goodCreateInput("test-view", "Test View"))
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
|
|
bySlug, err := env.svc.GetBySlug(ctx, env.userID, "test-view")
|
|
if err != nil || bySlug == nil {
|
|
t.Fatalf("GetBySlug: %v / nil", err)
|
|
}
|
|
if bySlug.ID != created.ID {
|
|
t.Errorf("GetBySlug id mismatch")
|
|
}
|
|
|
|
byID, err := env.svc.GetByID(ctx, env.userID, created.ID)
|
|
if err != nil || byID == nil {
|
|
t.Fatalf("GetByID: %v / nil", err)
|
|
}
|
|
|
|
missing, err := env.svc.GetBySlug(ctx, env.userID, "does-not-exist")
|
|
if err != nil {
|
|
t.Fatalf("GetBySlug missing: %v", err)
|
|
}
|
|
if missing != nil {
|
|
t.Error("missing slug should return nil")
|
|
}
|
|
}
|
|
|
|
func TestUserViewService_SlugUniquenessPerUser(t *testing.T) {
|
|
env := setupUserViewTest(t)
|
|
defer env.cleanup()
|
|
ctx := context.Background()
|
|
|
|
if _, err := env.svc.Create(ctx, env.userID, goodCreateInput("dup", "First")); err != nil {
|
|
t.Fatalf("first create: %v", err)
|
|
}
|
|
_, err := env.svc.Create(ctx, env.userID, goodCreateInput("dup", "Second"))
|
|
if !errors.Is(err, ErrUserViewSlugTaken) {
|
|
t.Fatalf("duplicate slug must return ErrUserViewSlugTaken, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestUserViewService_Update(t *testing.T) {
|
|
env := setupUserViewTest(t)
|
|
defer env.cleanup()
|
|
ctx := context.Background()
|
|
|
|
created, err := env.svc.Create(ctx, env.userID, goodCreateInput("orig", "Original"))
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
|
|
newName := "Updated"
|
|
newShowCount := true
|
|
updated, err := env.svc.Update(ctx, env.userID, created.ID, UpdateUserViewInput{
|
|
Name: &newName,
|
|
ShowCount: &newShowCount,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("update: %v", err)
|
|
}
|
|
if updated.Name != "Updated" {
|
|
t.Errorf("name not updated: %s", updated.Name)
|
|
}
|
|
if !updated.ShowCount {
|
|
t.Errorf("show_count not updated")
|
|
}
|
|
// Slug should be unchanged.
|
|
if updated.Slug != "orig" {
|
|
t.Errorf("slug should be unchanged, got %s", updated.Slug)
|
|
}
|
|
}
|
|
|
|
func TestUserViewService_UpdateRejectsReservedSlug(t *testing.T) {
|
|
env := setupUserViewTest(t)
|
|
defer env.cleanup()
|
|
ctx := context.Background()
|
|
|
|
created, err := env.svc.Create(ctx, env.userID, goodCreateInput("freely", "Freely"))
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
reserved := "dashboard"
|
|
_, err = env.svc.Update(ctx, env.userID, created.ID, UpdateUserViewInput{Slug: &reserved})
|
|
if !errors.Is(err, ErrInvalidInput) {
|
|
t.Fatalf("update to reserved slug must reject, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestUserViewService_Delete(t *testing.T) {
|
|
env := setupUserViewTest(t)
|
|
defer env.cleanup()
|
|
ctx := context.Background()
|
|
|
|
created, err := env.svc.Create(ctx, env.userID, goodCreateInput("doomed", "Doomed"))
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
deleted, err := env.svc.Delete(ctx, env.userID, created.ID)
|
|
if err != nil || !deleted {
|
|
t.Fatalf("delete: %v, %v", deleted, err)
|
|
}
|
|
deletedAgain, err := env.svc.Delete(ctx, env.userID, created.ID)
|
|
if err != nil {
|
|
t.Fatalf("second delete: %v", err)
|
|
}
|
|
if deletedAgain {
|
|
t.Error("second delete should report not-deleted")
|
|
}
|
|
}
|
|
|
|
func TestUserViewService_TouchAndMostRecent(t *testing.T) {
|
|
env := setupUserViewTest(t)
|
|
defer env.cleanup()
|
|
ctx := context.Background()
|
|
|
|
a, _ := env.svc.Create(ctx, env.userID, goodCreateInput("a-view", "A"))
|
|
b, _ := env.svc.Create(ctx, env.userID, goodCreateInput("b-view", "B"))
|
|
|
|
// Before any touch — MostRecent is nil.
|
|
mr, err := env.svc.MostRecent(ctx, env.userID)
|
|
if err != nil {
|
|
t.Fatalf("most_recent: %v", err)
|
|
}
|
|
if mr != nil {
|
|
t.Errorf("MostRecent should be nil before any touch")
|
|
}
|
|
|
|
if err := env.svc.Touch(ctx, env.userID, a.ID); err != nil {
|
|
t.Fatalf("touch a: %v", err)
|
|
}
|
|
if err := env.svc.Touch(ctx, env.userID, b.ID); err != nil {
|
|
t.Fatalf("touch b: %v", err)
|
|
}
|
|
mr, err = env.svc.MostRecent(ctx, env.userID)
|
|
if err != nil || mr == nil {
|
|
t.Fatalf("most_recent after touch: %v / nil", err)
|
|
}
|
|
if mr.ID != b.ID {
|
|
t.Errorf("most-recent should be b (touched last), got %s", mr.Slug)
|
|
}
|
|
}
|
|
|
|
func TestUserViewService_RejectsReservedSlugOnCreate(t *testing.T) {
|
|
env := setupUserViewTest(t)
|
|
defer env.cleanup()
|
|
ctx := context.Background()
|
|
|
|
_, err := env.svc.Create(ctx, env.userID, goodCreateInput("inbox", "Inbox copy"))
|
|
if !errors.Is(err, ErrInvalidInput) {
|
|
t.Fatalf("reserved slug on create must reject, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestUserViewService_RejectsBadSlug(t *testing.T) {
|
|
env := setupUserViewTest(t)
|
|
defer env.cleanup()
|
|
ctx := context.Background()
|
|
|
|
_, err := env.svc.Create(ctx, env.userID, goodCreateInput("Has Spaces", "Bad"))
|
|
if !errors.Is(err, ErrInvalidInput) {
|
|
t.Fatalf("slug with spaces must reject, got %v", err)
|
|
}
|
|
_, err = env.svc.Create(ctx, env.userID, goodCreateInput("UPPER", "Bad"))
|
|
if !errors.Is(err, ErrInvalidInput) {
|
|
t.Fatalf("uppercase slug must reject, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestUserViewService_RejectsEmptyName(t *testing.T) {
|
|
env := setupUserViewTest(t)
|
|
defer env.cleanup()
|
|
ctx := context.Background()
|
|
|
|
_, err := env.svc.Create(ctx, env.userID, CreateUserViewInput{
|
|
Slug: "no-name",
|
|
Name: "",
|
|
FilterSpec: DefaultFilterSpec(),
|
|
RenderSpec: DefaultRenderSpec(),
|
|
})
|
|
if !errors.Is(err, ErrInvalidInput) {
|
|
t.Fatalf("empty name must reject, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestUserViewService_RejectsInvalidSpec(t *testing.T) {
|
|
env := setupUserViewTest(t)
|
|
defer env.cleanup()
|
|
ctx := context.Background()
|
|
|
|
bad := DefaultFilterSpec()
|
|
bad.Sources = nil
|
|
_, err := env.svc.Create(ctx, env.userID, CreateUserViewInput{
|
|
Slug: "bad-spec",
|
|
Name: "Bad",
|
|
FilterSpec: bad,
|
|
RenderSpec: DefaultRenderSpec(),
|
|
})
|
|
if !errors.Is(err, ErrInvalidInput) {
|
|
t.Fatalf("invalid spec must reject, got %v", err)
|
|
}
|
|
}
|