- go.mod module name
- All 714 import references across 289 Go files
- VERSION: 0.1.0
- CI DB names: switchboard_core_{ci,dev,test}
- Docker image: gobha/switchboard-core
- Test fixtures: JWT issuer, repo names
- .env.example, docker-compose container name
- Compiles clean (go build exit 0)
289 lines
8.2 KiB
Go
289 lines
8.2 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|