All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
676 lines
19 KiB
Go
676 lines
19 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"armature/config"
|
|
"armature/database"
|
|
"armature/middleware"
|
|
"armature/models"
|
|
"armature/notifications"
|
|
"armature/store"
|
|
postgres "armature/store/postgres"
|
|
sqlite "armature/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
|
|
admin := api.Group("/admin")
|
|
admin.Use(middleware.Auth(cfg, stores.Users, userCache))
|
|
admin.Use(middleware.RequireAdmin(stores))
|
|
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 is_active = true WHERE id = $1"), adminID)
|
|
database.SeedAdminsGroupMember(t, 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 ────────────
|
|
|
|
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
|
|
}
|