This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/notification_test.go
2026-03-19 18:50:27 +00:00

675 lines
19 KiB
Go

package handlers
import (
"context"
"encoding/json"
"net/http"
"testing"
"github.com/gin-gonic/gin"
"chat-switchboard/config"
"chat-switchboard/database"
"chat-switchboard/middleware"
"chat-switchboard/models"
"chat-switchboard/notifications"
"chat-switchboard/store"
postgres "chat-switchboard/store/postgres"
sqlite "chat-switchboard/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 &notifHarness{
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
}