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.
112 lines
3.3 KiB
Go
112 lines
3.3 KiB
Go
package services
|
|
|
|
import (
|
|
"slices"
|
|
"testing"
|
|
)
|
|
|
|
// t-paliad-249 — Slice A. Ensures the *_approval_* audit kinds are
|
|
// dropped from the project_event read path when ApprovalRequest is
|
|
// also fanning out, so the same fact isn't shown twice in /inbox.
|
|
|
|
func TestAllowedProjectEventKinds_DedupsApprovalAudits(t *testing.T) {
|
|
spec := FilterSpec{
|
|
Version: SpecVersion,
|
|
Sources: []DataSource{SourceApprovalRequest, SourceProjectEvent},
|
|
Predicates: &Predicates{
|
|
ProjectEvent: &ProjectEventPredicates{
|
|
EventTypes: []string{
|
|
"deadline_created",
|
|
"deadline_approval_requested",
|
|
"appointment_approval_approved",
|
|
"approval_decided",
|
|
"note_created",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
got := allowedProjectEventKinds(spec)
|
|
if slices.Contains(got, "deadline_approval_requested") {
|
|
t.Errorf("must drop deadline_approval_requested, got %v", got)
|
|
}
|
|
if slices.Contains(got, "appointment_approval_approved") {
|
|
t.Errorf("must drop appointment_approval_approved, got %v", got)
|
|
}
|
|
if slices.Contains(got, "approval_decided") {
|
|
t.Errorf("must drop approval_decided, got %v", got)
|
|
}
|
|
if !slices.Contains(got, "deadline_created") {
|
|
t.Errorf("must keep deadline_created, got %v", got)
|
|
}
|
|
if !slices.Contains(got, "note_created") {
|
|
t.Errorf("must keep note_created, got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestAllowedProjectEventKinds_NoDedupWhenApprovalsAbsent(t *testing.T) {
|
|
spec := FilterSpec{
|
|
Version: SpecVersion,
|
|
Sources: []DataSource{SourceProjectEvent},
|
|
Predicates: &Predicates{
|
|
ProjectEvent: &ProjectEventPredicates{
|
|
EventTypes: []string{
|
|
"deadline_created",
|
|
"deadline_approval_requested",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
got := allowedProjectEventKinds(spec)
|
|
if !slices.Contains(got, "deadline_approval_requested") {
|
|
t.Errorf("Verlauf-style spec without approvals source should keep audit kinds, got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestAllowedProjectEventKinds_NilWhenNoPredicateAndNoDedup(t *testing.T) {
|
|
spec := FilterSpec{
|
|
Version: SpecVersion,
|
|
Sources: []DataSource{SourceProjectEvent},
|
|
}
|
|
if got := allowedProjectEventKinds(spec); got != nil {
|
|
t.Errorf("expected nil (all kinds), got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestAllowedProjectEventKinds_FillsImplicitListWhenDedup(t *testing.T) {
|
|
// When approvals are in sources but the caller didn't explicitly
|
|
// narrow EventTypes, the helper must materialise the curated set
|
|
// minus audit duplicates so the WHERE clause filters them out.
|
|
spec := FilterSpec{
|
|
Version: SpecVersion,
|
|
Sources: []DataSource{SourceApprovalRequest, SourceProjectEvent},
|
|
}
|
|
got := allowedProjectEventKinds(spec)
|
|
if got == nil {
|
|
t.Fatal("expected explicit kind list, got nil")
|
|
}
|
|
for _, k := range got {
|
|
if isApprovalAuditKind(k) {
|
|
t.Errorf("audit kind %q leaked through dedup", k)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIsApprovalAuditKind(t *testing.T) {
|
|
cases := map[string]bool{
|
|
"deadline_approval_requested": true,
|
|
"appointment_approval_approved": true,
|
|
"appointment_approval_rejected": true,
|
|
"deadline_approval_changes_suggested": true,
|
|
"approval_decided": true,
|
|
"deadline_created": false,
|
|
"note_created": false,
|
|
"our_side_changed": false,
|
|
"project_archived": false,
|
|
}
|
|
for kind, want := range cases {
|
|
if got := isApprovalAuditKind(kind); got != want {
|
|
t.Errorf("isApprovalAuditKind(%q) = %v, want %v", kind, got, want)
|
|
}
|
|
}
|
|
}
|