package handlers import ( "context" "encoding/json" "net/http" "testing" "github.com/gin-gonic/gin" "switchboard-core/config" "switchboard-core/database" "switchboard-core/middleware" "switchboard-core/models" "switchboard-core/notifications" "switchboard-core/store" postgres "switchboard-core/store/postgres" sqlite "switchboard-core/store/sqlite" ) // ── Notification Test Harness ────────────── type notifHarness struct { *testHarness stores store.Stores adminToken string adminID string userToken string userID string } func setupNotifHarness(t *testing.T) *notifHarness { t.Helper() database.RequireTestDB(t) database.TruncateAll(t) cfg := &config.Config{ JWTSecret: testJWTSecret, BasePath: "", } var stores store.Stores if database.IsSQLite() { stores = sqlite.NewStores(database.TestDB) } else { stores = postgres.NewStores(database.TestDB) } userCache := middleware.NewUserStatusCache() r := gin.New() api := r.Group("/api/v1") protected := api.Group("") protected.Use(middleware.Auth(cfg, stores.Users, userCache)) notifH := NewNotificationHandler(stores, nil) // no hub in tests protected.GET("/notifications", notifH.List) protected.GET("/notifications/unread-count", notifH.UnreadCount) protected.PATCH("/notifications/:id/read", notifH.MarkRead) protected.POST("/notifications/mark-all-read", notifH.MarkAllRead) protected.DELETE("/notifications/:id", notifH.Delete) protected.GET("/notifications/preferences", notifH.ListPreferences) protected.PUT("/notifications/preferences/:type", notifH.SetPreference) protected.DELETE("/notifications/preferences/:type", notifH.DeletePreference) // Admin broadcast route (v0.28.6) admin := api.Group("/admin") admin.Use(middleware.Auth(cfg, stores.Users, userCache)) admin.Use(middleware.RequireAdmin()) admin.POST("/notifications/broadcast", notifH.Broadcast) // Set up notification service singleton for Broadcast handler notifSvc := notifications.NewService(stores.Notifications, nil) notifications.SetDefault(notifSvc) // Seed admin adminID := database.SeedTestUser(t, "notif-admin", "notif-admin@test.com") database.TestDB.Exec(dialectSQL("UPDATE users SET role = 'admin', is_active = true WHERE id = $1"), adminID) adminToken := makeToken(adminID, "notif-admin@test.com", "admin") // Seed regular user userID := database.SeedTestUser(t, "notif-user", "notif-user@test.com") database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID) userToken := makeToken(userID, "notif-user@test.com", "user") return ¬ifHarness{ testHarness: &testHarness{router: r, t: t}, stores: stores, adminToken: adminToken, adminID: adminID, userToken: userToken, userID: userID, } } // seedNotification inserts a notification directly via the store. func (h *notifHarness) seedNotification(userID, notifType, title string) string { h.t.Helper() n := &models.Notification{ UserID: userID, Type: notifType, Title: title, } if err := h.stores.Notifications.Create(context.Background(), n); err != nil { h.t.Fatalf("seedNotification: %v", err) } return n.ID } // ── GET /notifications ───────────────────── func TestNotifications_List_Empty(t *testing.T) { h := setupNotifHarness(t) resp := h.request("GET", "/api/v1/notifications", h.userToken, nil) if resp.Code != http.StatusOK { t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) } var body struct { Data []json.RawMessage `json:"data"` Total int `json:"total"` Limit int `json:"limit"` Offset int `json:"offset"` } json.Unmarshal(resp.Body.Bytes(), &body) if body.Data == nil { t.Error("data should be [] not null") } if len(body.Data) != 0 { t.Errorf("expected 0 items, got %d", len(body.Data)) } if body.Total != 0 { t.Errorf("total: got %d, want 0", body.Total) } if body.Limit != 20 { t.Errorf("default limit: got %d, want 20", body.Limit) } } func TestNotifications_List_Pagination(t *testing.T) { h := setupNotifHarness(t) // Seed 5 notifications for i := 0; i < 5; i++ { h.seedNotification(h.userID, "test.type", "Test Notification") } resp := h.request("GET", "/api/v1/notifications?limit=2&offset=0", h.userToken, nil) if resp.Code != http.StatusOK { t.Fatalf("got %d", resp.Code) } var body struct { Data []json.RawMessage `json:"data"` Total int `json:"total"` Limit int `json:"limit"` } json.Unmarshal(resp.Body.Bytes(), &body) if len(body.Data) != 2 { t.Errorf("items: got %d, want 2", len(body.Data)) } if body.Total != 5 { t.Errorf("total: got %d, want 5", body.Total) } if body.Limit != 2 { t.Errorf("limit: got %d, want 2", body.Limit) } } func TestNotifications_List_UnreadOnly(t *testing.T) { h := setupNotifHarness(t) id1 := h.seedNotification(h.userID, "test.type", "Unread One") h.seedNotification(h.userID, "test.type", "Unread Two") _ = id1 // Mark one as read h.stores.Notifications.MarkRead(context.Background(), id1, h.userID) resp := h.request("GET", "/api/v1/notifications?unread_only=true", h.userToken, nil) if resp.Code != http.StatusOK { t.Fatalf("got %d", resp.Code) } var body struct { Data []json.RawMessage `json:"data"` Total int `json:"total"` } json.Unmarshal(resp.Body.Bytes(), &body) if len(body.Data) != 1 { t.Errorf("unread items: got %d, want 1", len(body.Data)) } if body.Total != 1 { t.Errorf("total unread: got %d, want 1", body.Total) } } func TestNotifications_List_UserIsolation(t *testing.T) { h := setupNotifHarness(t) // Seed notifications for admin, not user h.seedNotification(h.adminID, "test.type", "Admin Only") resp := h.request("GET", "/api/v1/notifications", h.userToken, nil) if resp.Code != http.StatusOK { t.Fatalf("got %d", resp.Code) } var body struct { Data []json.RawMessage `json:"data"` } json.Unmarshal(resp.Body.Bytes(), &body) if len(body.Data) != 0 { t.Errorf("user should not see admin's notifications, got %d", len(body.Data)) } } // ── GET /notifications/unread-count ──────── func TestNotifications_UnreadCount(t *testing.T) { h := setupNotifHarness(t) h.seedNotification(h.userID, "test.type", "N1") id2 := h.seedNotification(h.userID, "test.type", "N2") h.seedNotification(h.userID, "test.type", "N3") // Mark one as read h.stores.Notifications.MarkRead(context.Background(), id2, h.userID) resp := h.request("GET", "/api/v1/notifications/unread-count", h.userToken, nil) if resp.Code != http.StatusOK { t.Fatalf("got %d", resp.Code) } var body struct { Count int `json:"count"` } json.Unmarshal(resp.Body.Bytes(), &body) if body.Count != 2 { t.Errorf("unread count: got %d, want 2", body.Count) } } // ── PATCH /notifications/:id/read ───────── func TestNotifications_MarkRead(t *testing.T) { h := setupNotifHarness(t) id := h.seedNotification(h.userID, "test.type", "Read Me") resp := h.request("PATCH", "/api/v1/notifications/"+id+"/read", h.userToken, nil) if resp.Code != http.StatusOK { t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) } // Verify via unread-count resp = h.request("GET", "/api/v1/notifications/unread-count", h.userToken, nil) var body struct { Count int `json:"count"` } json.Unmarshal(resp.Body.Bytes(), &body) if body.Count != 0 { t.Errorf("unread count after mark read: got %d, want 0", body.Count) } } func TestNotifications_MarkRead_WrongUser(t *testing.T) { h := setupNotifHarness(t) // Notification belongs to admin id := h.seedNotification(h.adminID, "test.type", "Admin's") // User tries to mark it read resp := h.request("PATCH", "/api/v1/notifications/"+id+"/read", h.userToken, nil) if resp.Code != http.StatusNotFound { t.Errorf("cross-user mark read: got %d, want 404", resp.Code) } } func TestNotifications_MarkRead_NotFound(t *testing.T) { h := setupNotifHarness(t) resp := h.request("PATCH", "/api/v1/notifications/00000000-0000-0000-0000-000000000000/read", h.userToken, nil) if resp.Code != http.StatusNotFound { t.Errorf("non-existent: got %d, want 404", resp.Code) } } // ── POST /notifications/mark-all-read ───── func TestNotifications_MarkAllRead(t *testing.T) { h := setupNotifHarness(t) h.seedNotification(h.userID, "test.type", "N1") h.seedNotification(h.userID, "test.type", "N2") h.seedNotification(h.userID, "test.type", "N3") resp := h.request("POST", "/api/v1/notifications/mark-all-read", h.userToken, nil) if resp.Code != http.StatusOK { t.Fatalf("got %d", resp.Code) } // Verify resp = h.request("GET", "/api/v1/notifications/unread-count", h.userToken, nil) var body struct { Count int `json:"count"` } json.Unmarshal(resp.Body.Bytes(), &body) if body.Count != 0 { t.Errorf("unread count after mark-all-read: got %d, want 0", body.Count) } } func TestNotifications_MarkAllRead_IsolatesUsers(t *testing.T) { h := setupNotifHarness(t) // Both users have notifications h.seedNotification(h.userID, "test.type", "User's") h.seedNotification(h.adminID, "test.type", "Admin's") // User marks all read h.request("POST", "/api/v1/notifications/mark-all-read", h.userToken, nil) // Admin should still have unread resp := h.request("GET", "/api/v1/notifications/unread-count", h.adminToken, nil) var body struct { Count int `json:"count"` } json.Unmarshal(resp.Body.Bytes(), &body) if body.Count != 1 { t.Errorf("admin unread count: got %d, want 1", body.Count) } } // ── DELETE /notifications/:id ───────────── func TestNotifications_Delete(t *testing.T) { h := setupNotifHarness(t) id := h.seedNotification(h.userID, "test.type", "Delete Me") resp := h.request("DELETE", "/api/v1/notifications/"+id, h.userToken, nil) if resp.Code != http.StatusOK { t.Fatalf("got %d", resp.Code) } // Verify gone resp = h.request("GET", "/api/v1/notifications", h.userToken, nil) var body struct { Data []json.RawMessage `json:"data"` } json.Unmarshal(resp.Body.Bytes(), &body) if len(body.Data) != 0 { t.Errorf("notification should be deleted, got %d", len(body.Data)) } } func TestNotifications_Delete_WrongUser(t *testing.T) { h := setupNotifHarness(t) id := h.seedNotification(h.adminID, "test.type", "Admin's") resp := h.request("DELETE", "/api/v1/notifications/"+id, h.userToken, nil) if resp.Code != http.StatusNotFound { t.Errorf("cross-user delete: got %d, want 404", resp.Code) } } func TestNotifications_Delete_NotFound(t *testing.T) { h := setupNotifHarness(t) resp := h.request("DELETE", "/api/v1/notifications/00000000-0000-0000-0000-000000000000", h.userToken, nil) if resp.Code != http.StatusNotFound { t.Errorf("non-existent: got %d, want 404", resp.Code) } } // ── GET /notifications/preferences ──────── func TestNotifications_Preferences_Empty(t *testing.T) { h := setupNotifHarness(t) resp := h.request("GET", "/api/v1/notifications/preferences", h.userToken, nil) if resp.Code != http.StatusOK { t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) } var body struct { Data []json.RawMessage `json:"data"` } if err := json.Unmarshal(resp.Body.Bytes(), &body); err != nil { t.Fatalf("decode: %v, raw: %s", err, resp.Body.String()) } if body.Data == nil { t.Error("data should be [] not null") } if len(body.Data) != 0 { t.Errorf("expected empty prefs, got %d", len(body.Data)) } } func TestNotifications_Preferences_Envelope(t *testing.T) { h := setupNotifHarness(t) // Verify response is wrapped in {"data": []} resp := h.request("GET", "/api/v1/notifications/preferences", h.userToken, nil) var raw map[string]json.RawMessage json.Unmarshal(resp.Body.Bytes(), &raw) if _, ok := raw["data"]; !ok { t.Errorf("response must have 'data' key, got keys: %v, raw: %s", keys(raw), resp.Body.String()) } } // ── PUT /notifications/preferences/:type ── func TestNotifications_SetPreference(t *testing.T) { h := setupNotifHarness(t) resp := h.request("PUT", "/api/v1/notifications/preferences/kb.ready", h.userToken, map[string]interface{}{ "in_app": true, "email": true, }) if resp.Code != http.StatusOK { t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) } var pref models.NotificationPreference json.Unmarshal(resp.Body.Bytes(), &pref) if !pref.InApp || !pref.Email { t.Errorf("expected in_app=true email=true, got %+v", pref) } // Verify via list resp = h.request("GET", "/api/v1/notifications/preferences", h.userToken, nil) var body struct { Data []models.NotificationPreference `json:"data"` } json.Unmarshal(resp.Body.Bytes(), &body) if len(body.Data) != 1 { t.Fatalf("prefs count: got %d, want 1", len(body.Data)) } if body.Data[0].Type != "kb.ready" { t.Errorf("type: got %q, want %q", body.Data[0].Type, "kb.ready") } } func TestNotifications_SetPreference_PartialUpdate(t *testing.T) { h := setupNotifHarness(t) // Set both h.request("PUT", "/api/v1/notifications/preferences/kb.ready", h.userToken, map[string]interface{}{ "in_app": true, "email": true, }) // Partial update: only change email resp := h.request("PUT", "/api/v1/notifications/preferences/kb.ready", h.userToken, map[string]interface{}{ "email": false, }) if resp.Code != http.StatusOK { t.Fatalf("partial update: got %d", resp.Code) } var pref models.NotificationPreference json.Unmarshal(resp.Body.Bytes(), &pref) if !pref.InApp { t.Error("in_app should remain true after partial update") } if pref.Email { t.Error("email should be false after partial update") } } func TestNotifications_SetPreference_Wildcard(t *testing.T) { h := setupNotifHarness(t) resp := h.request("PUT", "/api/v1/notifications/preferences/*", h.userToken, map[string]interface{}{ "in_app": false, "email": true, }) if resp.Code != http.StatusOK { t.Fatalf("wildcard pref: got %d, body: %s", resp.Code, resp.Body.String()) } var pref models.NotificationPreference json.Unmarshal(resp.Body.Bytes(), &pref) if pref.Type != "*" { t.Errorf("type: got %q, want %q", pref.Type, "*") } } // ── DELETE /notifications/preferences/:type ── func TestNotifications_DeletePreference(t *testing.T) { h := setupNotifHarness(t) // Create then delete h.request("PUT", "/api/v1/notifications/preferences/kb.ready", h.userToken, map[string]interface{}{ "email": true, }) resp := h.request("DELETE", "/api/v1/notifications/preferences/kb.ready", h.userToken, nil) if resp.Code != http.StatusOK { t.Fatalf("delete pref: got %d", resp.Code) } // Verify gone resp = h.request("GET", "/api/v1/notifications/preferences", h.userToken, nil) var body struct { Data []models.NotificationPreference `json:"data"` } json.Unmarshal(resp.Body.Bytes(), &body) if len(body.Data) != 0 { t.Errorf("prefs should be empty after delete, got %d", len(body.Data)) } } func TestNotifications_DeletePreference_Idempotent(t *testing.T) { h := setupNotifHarness(t) // Delete non-existent preference should still return 200 (idempotent) resp := h.request("DELETE", "/api/v1/notifications/preferences/nonexistent.type", h.userToken, nil) if resp.Code != http.StatusOK { t.Errorf("idempotent delete: got %d, want 200", resp.Code) } } // ── Auth enforcement ────────────────────── func TestNotifications_Unauthenticated(t *testing.T) { h := setupNotifHarness(t) endpoints := []struct { method string path string }{ {"GET", "/api/v1/notifications"}, {"GET", "/api/v1/notifications/unread-count"}, {"PATCH", "/api/v1/notifications/fake-id/read"}, {"POST", "/api/v1/notifications/mark-all-read"}, {"DELETE", "/api/v1/notifications/fake-id"}, {"GET", "/api/v1/notifications/preferences"}, {"PUT", "/api/v1/notifications/preferences/test"}, {"DELETE", "/api/v1/notifications/preferences/test"}, } for _, e := range endpoints { resp := h.request(e.method, e.path, "", nil) if resp.Code != http.StatusUnauthorized { t.Errorf("%s %s without token: got %d, want 401", e.method, e.path, resp.Code) } } } // ── Admin Broadcast (v0.28.6) ──────────── func TestBroadcast_Success(t *testing.T) { h := setupNotifHarness(t) resp := h.request("POST", "/api/v1/admin/notifications/broadcast", h.adminToken, map[string]interface{}{ "title": "Maintenance Tonight", "message": "Servers will be down from 2-4 AM.", "level": "warning", }) if resp.Code != http.StatusOK { t.Fatalf("broadcast: got %d, body: %s", resp.Code, resp.Body.String()) } var body map[string]interface{} json.NewDecoder(resp.Body).Decode(&body) if body["message"] != "broadcast sent" { t.Errorf("expected 'broadcast sent', got %v", body["message"]) } count := body["count"].(float64) if count < 2 { t.Errorf("expected at least 2 recipients (admin+user), got %v", count) } // Verify notification created for regular user resp = h.request("GET", "/api/v1/notifications", h.userToken, nil) if resp.Code != http.StatusOK { t.Fatalf("list notifications: got %d", resp.Code) } var listBody map[string]interface{} json.NewDecoder(resp.Body).Decode(&listBody) items := listBody["data"].([]interface{}) if len(items) == 0 { t.Fatal("user should have received the broadcast notification") } n := items[0].(map[string]interface{}) if n["type"] != "system.announcement" { t.Errorf("notification type: got %v, want system.announcement", n["type"]) } if n["title"] != "Maintenance Tonight" { t.Errorf("notification title: got %v", n["title"]) } } func TestBroadcast_NonAdmin403(t *testing.T) { h := setupNotifHarness(t) resp := h.request("POST", "/api/v1/admin/notifications/broadcast", h.userToken, map[string]interface{}{ "title": "Unauthorized", "message": "Should fail.", }) if resp.Code != http.StatusForbidden { t.Fatalf("non-admin broadcast: want 403, got %d", resp.Code) } } func TestBroadcast_MissingTitle(t *testing.T) { h := setupNotifHarness(t) resp := h.request("POST", "/api/v1/admin/notifications/broadcast", h.adminToken, map[string]interface{}{ "message": "No title.", }) if resp.Code != http.StatusBadRequest { t.Fatalf("missing title: want 400, got %d", resp.Code) } } func TestBroadcast_MissingMessage(t *testing.T) { h := setupNotifHarness(t) resp := h.request("POST", "/api/v1/admin/notifications/broadcast", h.adminToken, map[string]interface{}{ "title": "No message.", }) if resp.Code != http.StatusBadRequest { t.Fatalf("missing message: want 400, got %d", resp.Code) } } func TestBroadcast_InvalidLevel(t *testing.T) { h := setupNotifHarness(t) resp := h.request("POST", "/api/v1/admin/notifications/broadcast", h.adminToken, map[string]interface{}{ "title": "Bad level", "message": "Test", "level": "extreme", }) if resp.Code != http.StatusBadRequest { t.Fatalf("invalid level: want 400, got %d", resp.Code) } } func TestBroadcast_DefaultLevel(t *testing.T) { h := setupNotifHarness(t) // Omit level — should default to info and succeed resp := h.request("POST", "/api/v1/admin/notifications/broadcast", h.adminToken, map[string]interface{}{ "title": "Info broadcast", "message": "Default level test.", }) if resp.Code != http.StatusOK { t.Fatalf("default level: want 200, got %d, body: %s", resp.Code, resp.Body.String()) } } // ── helpers ──────────────────────────────── func keys(m map[string]json.RawMessage) []string { var ks []string for k := range m { ks = append(ks, k) } return ks }