package notifications import ( "context" "testing" "time" "switchboard-core/models" ) // mockNotifStore implements store.NotificationStore for testing. type mockNotifStore struct { items []models.Notification } func (m *mockNotifStore) Create(_ context.Context, n *models.Notification) error { n.ID = "mock-id" n.CreatedAt = time.Now() m.items = append(m.items, *n) return nil } func (m *mockNotifStore) ListByUser(_ context.Context, _ string, _, _ int, _ bool) ([]models.Notification, int, error) { return m.items, len(m.items), nil } func (m *mockNotifStore) MarkRead(_ context.Context, _, _ string) error { return nil } func (m *mockNotifStore) MarkAllRead(_ context.Context, _ string) error { return nil } func (m *mockNotifStore) Delete(_ context.Context, _, _ string) error { return nil } func (m *mockNotifStore) UnreadCount(_ context.Context, _ string) (int, error) { return len(m.items), nil } func (m *mockNotifStore) DeleteOlderThan(_ context.Context, _ time.Time) (int64, error) { return 0, nil } // newTestService creates a Service with a mock store for testing sources. func newTestService() (*Service, *mockNotifStore) { ms := &mockNotifStore{} svc := NewService(ms, nil) // no hub return svc, ms } // ── NotifyKBReady ───────────────────────── func TestNotifyKBReady(t *testing.T) { svc, ms := newTestService() NotifyKBReady(svc, "user1", "kb1", "My KB", 42) if len(ms.items) != 1 { t.Fatalf("expected 1 notification, got %d", len(ms.items)) } n := ms.items[0] if n.UserID != "user1" { t.Errorf("user_id: got %q, want %q", n.UserID, "user1") } if n.Type != models.NotifTypeKBReady { t.Errorf("type: got %q, want %q", n.Type, models.NotifTypeKBReady) } if n.ResourceType != models.ResourceTypeKnowledgeBase { t.Errorf("resource_type: got %q, want %q", n.ResourceType, models.ResourceTypeKnowledgeBase) } if n.ResourceID != "kb1" { t.Errorf("resource_id: got %q, want %q", n.ResourceID, "kb1") } } func TestNotifyKBReady_NilService(t *testing.T) { // Should not panic NotifyKBReady(nil, "user1", "kb1", "My KB", 10) } // ── NotifyKBError ───────────────────────── func TestNotifyKBError(t *testing.T) { svc, ms := newTestService() NotifyKBError(svc, "user1", "kb2", "Bad KB", "chunk failed") if len(ms.items) != 1 { t.Fatalf("expected 1 notification, got %d", len(ms.items)) } n := ms.items[0] if n.Type != models.NotifTypeKBError { t.Errorf("type: got %q, want %q", n.Type, models.NotifTypeKBError) } if n.Body != "chunk failed" { t.Errorf("body: got %q, want %q", n.Body, "chunk failed") } } // ── NotifyGroupMemberAdded ──────────────── func TestNotifyGroupMemberAdded(t *testing.T) { svc, ms := newTestService() NotifyGroupMemberAdded(svc, "user1", "grp1", "Engineering") if len(ms.items) != 1 { t.Fatalf("expected 1 notification, got %d", len(ms.items)) } n := ms.items[0] if n.Type != models.NotifTypeGrantChanged { t.Errorf("type: got %q, want %q", n.Type, models.NotifTypeGrantChanged) } if n.ResourceType != models.ResourceTypeGroup { t.Errorf("resource_type: got %q, want %q", n.ResourceType, models.ResourceTypeGroup) } if n.ResourceID != "grp1" { t.Errorf("resource_id: got %q, want %q", n.ResourceID, "grp1") } } // ── NotifyGroupMemberRemoved ────────────── func TestNotifyGroupMemberRemoved(t *testing.T) { svc, ms := newTestService() NotifyGroupMemberRemoved(svc, "user1", "grp1", "Engineering") if len(ms.items) != 1 { t.Fatalf("expected 1 notification, got %d", len(ms.items)) } n := ms.items[0] if n.Type != models.NotifTypeGrantChanged { t.Errorf("type: got %q", n.Type) } } // ── NotifyMemoryExtracted ───────────────── func TestNotifyMemoryExtracted(t *testing.T) { svc, ms := newTestService() NotifyMemoryExtracted(svc, "user1", "chan1", 5) if len(ms.items) != 1 { t.Fatalf("expected 1 notification, got %d", len(ms.items)) } n := ms.items[0] if n.Type != models.NotifTypeMemoryExtracted { t.Errorf("type: got %q, want %q", n.Type, models.NotifTypeMemoryExtracted) } if n.Title != "5 new memories extracted" { t.Errorf("title: got %q", n.Title) } if n.ResourceType != models.ResourceTypeChannel { t.Errorf("resource_type: got %q", n.ResourceType) } if n.ResourceID != "chan1" { t.Errorf("resource_id: got %q", n.ResourceID) } } func TestNotifyMemoryExtracted_Singular(t *testing.T) { svc, ms := newTestService() NotifyMemoryExtracted(svc, "user1", "chan1", 1) if len(ms.items) != 1 { t.Fatalf("expected 1 notification, got %d", len(ms.items)) } if ms.items[0].Title != "1 new memory extracted" { t.Errorf("singular title: got %q", ms.items[0].Title) } } func TestNotifyMemoryExtracted_NilService(t *testing.T) { NotifyMemoryExtracted(nil, "user1", "chan1", 3) // should not panic } // ── NotifyUserMentioned ─────────────────── func TestNotifyUserMentioned(t *testing.T) { svc, ms := newTestService() NotifyUserMentioned(svc, "mentioned-user", "chan1", "from-user", "hey @you check this") if len(ms.items) != 1 { t.Fatalf("expected 1 notification, got %d", len(ms.items)) } n := ms.items[0] if n.UserID != "mentioned-user" { t.Errorf("user_id: got %q", n.UserID) } if n.Type != models.NotifTypeUserMentioned { t.Errorf("type: got %q, want %q", n.Type, models.NotifTypeUserMentioned) } if n.Body != "hey @you check this" { t.Errorf("body: got %q", n.Body) } if n.ResourceType != models.ResourceTypeChannel { t.Errorf("resource_type: got %q", n.ResourceType) } if n.ResourceID != "chan1" { t.Errorf("resource_id: got %q", n.ResourceID) } } func TestNotifyUserMentioned_NilService(t *testing.T) { NotifyUserMentioned(nil, "u1", "c1", "u2", "hello") // should not panic } // ── NotifyWorkflowClaimed ───────────────── func TestNotifyWorkflowClaimed(t *testing.T) { svc, ms := newTestService() NotifyWorkflowClaimed(svc, "claimer1", "assign1", "chan1") if len(ms.items) != 1 { t.Fatalf("expected 1 notification, got %d", len(ms.items)) } n := ms.items[0] if n.UserID != "claimer1" { t.Errorf("user_id: got %q", n.UserID) } if n.Type != models.NotifTypeWorkflowClaimed { t.Errorf("type: got %q, want %q", n.Type, models.NotifTypeWorkflowClaimed) } if n.ResourceType != models.ResourceTypeChannel { t.Errorf("resource_type: got %q", n.ResourceType) } if n.ResourceID != "chan1" { t.Errorf("resource_id: got %q", n.ResourceID) } } func TestNotifyWorkflowClaimed_NilService(t *testing.T) { NotifyWorkflowClaimed(nil, "u1", "a1", "c1") // should not panic } // ── NotifyMany ──────────────────────────── func TestNotifyMany(t *testing.T) { svc, ms := newTestService() template := models.Notification{ Type: "test.broadcast", Title: "Hello everyone", } svc.NotifyMany(context.Background(), []string{"u1", "u2", "u3"}, template) if len(ms.items) != 3 { t.Fatalf("expected 3 notifications, got %d", len(ms.items)) } seen := map[string]bool{} for _, n := range ms.items { seen[n.UserID] = true if n.Type != "test.broadcast" { t.Errorf("type: got %q", n.Type) } } for _, uid := range []string{"u1", "u2", "u3"} { if !seen[uid] { t.Errorf("missing notification for %s", uid) } } } // ── Email subject lines for new types ───── func TestSubjectForNotification_NewTypes(t *testing.T) { tests := []struct { notifType string title string wantPfx string }{ {"memory.extracted", "3 new memories", "[Test] "}, {"user.mentioned", "You were mentioned", "[Test] "}, {"workflow.claimed", "Assignment claimed", "[Test] "}, {"task.completed", "Task done", "[Test] "}, {"task.budget_exceeded", "Budget hit", "[Test] "}, } for _, tt := range tests { n := &models.Notification{Type: tt.notifType, Title: tt.title} got := SubjectForNotification(n, "Test") if got == "" { t.Errorf("SubjectForNotification(%q) returned empty", tt.notifType) } // All should at least have the instance prefix if len(got) < len(tt.wantPfx) { t.Errorf("SubjectForNotification(%q) too short: %q", tt.notifType, got) } } }