Substrate changes that turn /inbox from approvals-only into the unified notification surface m asked for. - Migration 126: paliad.users.inbox_seen_at (high-watermark read cursor; pending approval_requests bypass it per design §3). - KnownProjectEventKinds gains note_created, our_side_changed, deadline_updated/deleted, deadlines_imported. New InboxProjectEventKinds curated subset (head's Q1=A lock). - InboxSystemView spans [approval_request, project_event]; defaults to past 30 days, newest first, row_action="inbox". - view_service.allowedProjectEventKinds drops *_approval_* audits when ApprovalRequest is also in spec.Sources (no double-count). - RunSpec resolves the caller's inbox_seen_at once and threads it through viewSpecBounds; runProjectEvents excludes self-authored events and rows older than the cursor when unread_only is set. Decided approval_requests follow the cursor; pending always survives. - ApprovalService.UnseenInboxCountForUser (unified badge count) + MarkInboxSeen + InboxSeenAt service methods. - GET /api/inbox/count returns the unified count; new POST /api/inbox/mark-all-seen advances the cursor (optional up_to=). Tests cover the InboxSystemView shape, the audit-dedup helper, the isApprovalAuditKind matcher, and the no-narrow-no-approvals nil path.
111 lines
3.4 KiB
Go
111 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()
|
|
preds := sv.Filter.Predicates[SourceProjectEvent]
|
|
if preds.ProjectEvent == nil {
|
|
t.Fatal("InboxSystemView must narrow project_event predicates")
|
|
}
|
|
got := preds.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)
|
|
}
|
|
}
|