The bar's chip clicks POST a payload shaped as `predicates: {<source>:
<per-source>}` — flat, one entry per data source. Go declared
`Predicates map[DataSource]Predicates` — a doubled-nested wrapper where
each map value was itself a Predicates struct with named per-source
fields. The JSON shape Go expected was
`{"deadline": {"deadline": {"status": [...]}}}`; the shape the bar
emitted was `{"deadline": {"status": [...]}}`. Go silently unmarshalled
the bar's payload as `Predicates{}` (all source fields nil), so every
chip click on /views/any was a server-side no-op — the regression in
#115.
The latent contract bug was present since t-paliad-144 A1 (b516201) but
only surfaced now: /inbox uses the InboxSystemView's code-resident
predicates (built in Go directly, doubled shape works) and saved views
never carried predicates in the DB, so chip-click overlays were the
only path that exercised the wire-format wrong way. /views/any made
that path visible because all four sources need narrowing.
Fix: align Go to the flat shape the frontend already emits.
- FilterSpec.Predicates: `map[DataSource]Predicates` → `*Predicates`.
- All `spec.Predicates[SourceX]` access sites in view_service.go +
approvalStatusMatches + allowed* helpers + system_views literals
+ tests rewritten to `spec.Predicates.X` with a nil-spec.Predicates
guard.
- Frontend FilterSpec.predicates type tightened from
`Partial<Record<DataSource, Predicates>>` (which silently allowed
the wrong runtime write) to `Predicates`.
Regression coverage:
- `filter_spec_predicates_test.go` (new, Go) pins three contracts:
the bar's exact wire payload unmarshals into a non-nil per-source
predicate; marshalling a Go-constructed spec produces the same flat
shape; the "Erledigt" chip's request narrows to completed deadlines.
- `compute-effective.test.ts` (new, bun:test) pins 12 chip-overlay
cases for /views/any (every axis the saved view's sources expose).
Build hygiene:
- `go build ./...` clean.
- `go test ./... -count 1` clean (existing inbox + filter_spec tests
updated for the new struct shape; new tests pass).
- `cd frontend && bun run build` clean.
- `cd frontend && bun test src/` — 169 pass, 0 fail.
No migration: paliad.user_views.filter_spec jsonb rows live with
`predicates: {}` or no predicates field; both unmarshal as nil
*Predicates under the new type, identical to the no-narrowing behaviour
the old map type produced for the same rows.
110 lines
3.4 KiB
Go
110 lines
3.4 KiB
Go
package services
|
|
|
|
import (
|
|
"slices"
|
|
"testing"
|
|
)
|
|
|
|
// Pure-Go tests for the SystemView registry. Each system view's specs
|
|
// must self-validate; the slugs must be reserved.
|
|
|
|
func TestSystemViews_AllValidate(t *testing.T) {
|
|
for _, sv := range AllSystemViews() {
|
|
t.Run(sv.Slug, func(t *testing.T) {
|
|
if err := sv.Filter.Validate(); err != nil {
|
|
t.Errorf("%s filter spec invalid: %v", sv.Slug, err)
|
|
}
|
|
if err := sv.Render.Validate(); err != nil {
|
|
t.Errorf("%s render spec invalid: %v", sv.Slug, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSystemViews_SlugsReserved(t *testing.T) {
|
|
for _, sv := range AllSystemViews() {
|
|
t.Run(sv.Slug, func(t *testing.T) {
|
|
if !IsReservedUserViewSlug(sv.Slug) {
|
|
t.Errorf("system slug %q must be reserved against user_views", sv.Slug)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestReservedSlugs_CaseFolded(t *testing.T) {
|
|
if !IsReservedUserViewSlug("Dashboard") {
|
|
t.Error("reserved-slug check must be case-insensitive")
|
|
}
|
|
if !IsReservedUserViewSlug("INBOX") {
|
|
t.Error("reserved-slug check must be case-insensitive")
|
|
}
|
|
}
|
|
|
|
func TestReservedSlugs_NonReservedAccepted(t *testing.T) {
|
|
cases := []string{"freitag-stand", "approval-pending-mine", "siemens", "my-view"}
|
|
for _, slug := range cases {
|
|
if IsReservedUserViewSlug(slug) {
|
|
t.Errorf("user-friendly slug %q must not be reserved", slug)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------------
|
|
// InboxSystemView shape — t-paliad-249
|
|
// ----------------------------------------------------------------------
|
|
|
|
func TestInboxSystemView_Sources(t *testing.T) {
|
|
sv := InboxSystemView()
|
|
if !slices.Contains(sv.Filter.Sources, SourceApprovalRequest) {
|
|
t.Errorf("InboxSystemView must include SourceApprovalRequest, got %v", sv.Filter.Sources)
|
|
}
|
|
if !slices.Contains(sv.Filter.Sources, SourceProjectEvent) {
|
|
t.Errorf("InboxSystemView must include SourceProjectEvent, got %v", sv.Filter.Sources)
|
|
}
|
|
}
|
|
|
|
func TestInboxSystemView_DefaultsToPast30d(t *testing.T) {
|
|
sv := InboxSystemView()
|
|
if sv.Filter.Time.Horizon != HorizonPast30d {
|
|
t.Errorf("default horizon must be past_30d, got %q", sv.Filter.Time.Horizon)
|
|
}
|
|
}
|
|
|
|
func TestInboxSystemView_RowActionInbox(t *testing.T) {
|
|
sv := InboxSystemView()
|
|
if sv.Render.List == nil {
|
|
t.Fatal("InboxSystemView must define a list config")
|
|
}
|
|
if sv.Render.List.RowAction != RowActionInbox {
|
|
t.Errorf("row_action must be inbox, got %q", sv.Render.List.RowAction)
|
|
}
|
|
}
|
|
|
|
func TestInboxSystemView_CuratedProjectEventKinds(t *testing.T) {
|
|
sv := InboxSystemView()
|
|
if sv.Filter.Predicates == nil || sv.Filter.Predicates.ProjectEvent == nil {
|
|
t.Fatal("InboxSystemView must narrow project_event predicates")
|
|
}
|
|
got := sv.Filter.Predicates.ProjectEvent.EventTypes
|
|
if len(got) != len(InboxProjectEventKinds) {
|
|
t.Errorf("expected %d curated kinds, got %d", len(InboxProjectEventKinds), len(got))
|
|
}
|
|
for _, k := range got {
|
|
if slices.Contains([]string{"status_changed", "project_created"}, k) {
|
|
t.Errorf("inbox must NOT include noisy kind %q", k)
|
|
}
|
|
// No *_approval_* audit duplicates either — view_service dedups
|
|
// at query time but the curated list shouldn't carry them.
|
|
if isApprovalAuditKind(k) {
|
|
t.Errorf("inbox curated list must not include audit-dup %q", k)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInboxSystemView_NewestFirst(t *testing.T) {
|
|
sv := InboxSystemView()
|
|
if sv.Render.List == nil || sv.Render.List.Sort != SortDateDesc {
|
|
t.Errorf("inbox must sort newest-first by default, got %q", sv.Render.List.Sort)
|
|
}
|
|
}
|