//go:build test package notifications import ( "bytes" "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "net/url" "testing" "time" "crussell/db" "crussell/internal/adminnotify" "crussell/mw" "crussell/testutils" "github.com/go-chi/chi/v5" ) func makeExtendedAdminRequest(handler http.Handler, method, path string, body interface{}, ctx context.Context) *httptest.ResponseRecorder { var req *http.Request if body != nil { bodyBytes, _ := json.Marshal(body) req = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes)) req.Header.Set("Content-Type", "application/json") } else { req = httptest.NewRequest(method, path, nil) } rctx := chi.NewRouteContext() if id, _ := extractIDFromPath(path); id != "" { rctx.URLParams.Add("id", id) } ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx) ctx = context.WithValue(ctx, mw.UserIDKey, "admin001") ctx = context.WithValue(ctx, mw.UserRoleKey, "admin") req = req.WithContext(ctx) w := httptest.NewRecorder() handler.ServeHTTP(w, req) return w } func createTestUser(t *testing.T, ctx context.Context, q db.Querier) string { t.Helper() var userID string err := q.QueryRow(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Test', 'User', 'test@test.com', '+447700900000', '1990-01-01', 'hash', 'verified_email', 'email') RETURNING id `).Scan(&userID) if err != nil { t.Fatalf("failed to create user: %v", err) } return userID } func createNotification(t *testing.T, ctx context.Context, q db.Querier, reason, userID string, acknowledged bool) string { t.Helper() var notificationID string if userID == "" { query := `INSERT INTO admin_notifications (reason) VALUES ($1) RETURNING id` if acknowledged { query = `INSERT INTO admin_notifications (reason, acknowledged_at) VALUES ($1, NOW()) RETURNING id` } err := q.QueryRow(ctx, query, reason).Scan(¬ificationID) if err != nil { t.Fatalf("failed to create notification: %v", err) } } else { query := `INSERT INTO admin_notifications (reason, user_id) VALUES ($1, $2) RETURNING id` if acknowledged { query = `INSERT INTO admin_notifications (reason, user_id, acknowledged_at) VALUES ($1, $2, NOW()) RETURNING id` } err := q.QueryRow(ctx, query, reason, userID).Scan(¬ificationID) if err != nil { t.Fatalf("failed to create notification: %v", err) } } return notificationID } // ============================================================================= // include_acknowledged param tests // ============================================================================= func TestNotifications_IncludeAcknowledged_Default(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID := createTestUser(t, ctx, tx) createNotification(t, ctx, tx, "pending_booking", userID, false) createNotification(t, ctx, tx, "cancelled_booking", userID, true) handler := http.HandlerFunc(GetNotifications) w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil, ctx) var resp AdminNotificationListResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal: %v", err) } if len(resp.Notifications) != 1 { t.Errorf("expected 1 unacknowledged notification, got %d", len(resp.Notifications)) } } func TestNotifications_IncludeAcknowledged_True(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID := createTestUser(t, ctx, tx) createNotification(t, ctx, tx, "pending_booking", userID, false) createNotification(t, ctx, tx, "cancelled_booking", userID, true) handler := http.HandlerFunc(GetNotifications) w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications?include_acknowledged=true", nil, ctx) var resp AdminNotificationListResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal: %v", err) } if len(resp.Notifications) != 2 { t.Errorf("expected 2 notifications with include_acknowledged=true, got %d", len(resp.Notifications)) } } func TestNotifications_IncludeAcknowledged_ResponseHasAcknowledgedAt(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID := createTestUser(t, ctx, tx) createNotification(t, ctx, tx, "pending_booking", userID, true) handler := http.HandlerFunc(GetNotifications) w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications?include_acknowledged=true", nil, ctx) var resp AdminNotificationListResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal: %v", err) } if len(resp.Notifications) != 1 { t.Fatalf("expected 1 notification, got %d", len(resp.Notifications)) } if resp.Notifications[0].AcknowledgedAt == nil { t.Error("expected acknowledged_at to be set for acknowledged notification") } } // ============================================================================= // Priority ordering tests // ============================================================================= func TestNotifications_PriorityOrdering(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID := createTestUser(t, ctx, tx) // Create notifications in reverse priority order reasons := []string{ "1_week_no_pay", "affiliate_claim", "pending_booking", "cancelled_booking", } for _, reason := range reasons { createNotification(t, ctx, tx, reason, userID, false) } handler := http.HandlerFunc(GetNotifications) w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil, ctx) var resp AdminNotificationListResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal: %v", err) } if len(resp.Notifications) != 4 { t.Fatalf("expected 4 notifications, got %d", len(resp.Notifications)) } // Verify priority order: pending_booking(1) > cancelled_booking(3) > affiliate_claim(6) > 1_week_no_pay(10) expectedOrder := []string{"pending_booking", "cancelled_booking", "affiliate_claim", "1_week_no_pay"} for i, expected := range expectedOrder { if resp.Notifications[i].Reason != expected { t.Errorf("position %d: expected %s, got %s", i, expected, resp.Notifications[i].Reason) } } } func TestNotifications_PriorityOrdering_OldestFirstWithinPriority(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID := createTestUser(t, ctx, tx) // Create two pending_booking notifications (created_at is NOW() so both get same tx timestamp; id ASC breaks ties) createNotification(t, ctx, tx, "pending_booking", userID, false) createNotification(t, ctx, tx, "pending_booking", userID, false) handler := http.HandlerFunc(GetNotifications) w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil, ctx) var resp AdminNotificationListResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal: %v", err) } if len(resp.Notifications) != 2 { t.Fatalf("expected 2 notifications, got %d", len(resp.Notifications)) } // Oldest first within same priority if resp.Notifications[0].CreatedAt.After(resp.Notifications[1].CreatedAt) { t.Error("expected oldest notification first within same priority level") } } func TestNotifications_AllNotifications_NewestFirst(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID := createTestUser(t, ctx, tx) createNotification(t, ctx, tx, "pending_booking", userID, false) createNotification(t, ctx, tx, "cancelled_booking", userID, true) handler := http.HandlerFunc(GetNotifications) w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications?include_acknowledged=true", nil, ctx) var resp AdminNotificationListResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal: %v", err) } if len(resp.Notifications) != 2 { t.Fatalf("expected 2 notifications, got %d", len(resp.Notifications)) } // Newest first when include_acknowledged=true if resp.Notifications[0].CreatedAt.Before(resp.Notifications[1].CreatedAt) { t.Error("expected newest notification first when include_acknowledged=true") } } // ============================================================================= // GetUnreadCount tests // ============================================================================= func TestNotifications_UnreadCount(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID := createTestUser(t, ctx, tx) createNotification(t, ctx, tx, "pending_booking", userID, false) createNotification(t, ctx, tx, "cancelled_booking", userID, false) createNotification(t, ctx, tx, "affiliate_claim", userID, true) handler := http.HandlerFunc(GetUnreadCount) w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications/unread-count", nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d", w.Code) } var resp map[string]int if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal: %v", err) } if resp["count"] != 2 { t.Errorf("expected count 2, got %d", resp["count"]) } } func TestNotifications_UnreadCount_Zero(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID := createTestUser(t, ctx, tx) createNotification(t, ctx, tx, "pending_booking", userID, true) handler := http.HandlerFunc(GetUnreadCount) w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications/unread-count", nil, ctx) var resp map[string]int if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal: %v", err) } if resp["count"] != 0 { t.Errorf("expected count 0, got %d", resp["count"]) } } func TestNotifications_UnreadCount_Empty(t *testing.T) { t.Parallel() ctx, _ := testutils.SetupTestTx(t) handler := http.HandlerFunc(GetUnreadCount) w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications/unread-count", nil, ctx) var resp map[string]int if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal: %v", err) } if resp["count"] != 0 { t.Errorf("expected count 0, got %d", resp["count"]) } } // ============================================================================= // New notification reason tests // ============================================================================= func TestNotifications_NewBookingReason(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID := createTestUser(t, ctx, tx) createNotification(t, ctx, tx, "new_booking", userID, false) handler := http.HandlerFunc(GetNotifications) w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil, ctx) var resp AdminNotificationListResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal: %v", err) } if len(resp.Notifications) != 1 { t.Fatalf("expected 1 notification, got %d", len(resp.Notifications)) } if resp.Notifications[0].Reason != "new_booking" { t.Errorf("expected reason new_booking, got %s", resp.Notifications[0].Reason) } } func TestNotifications_EditRequestedReason(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID := createTestUser(t, ctx, tx) createNotification(t, ctx, tx, "edit_requested", userID, false) handler := http.HandlerFunc(GetNotifications) w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil, ctx) var resp AdminNotificationListResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal: %v", err) } if len(resp.Notifications) != 1 { t.Fatalf("expected 1 notification, got %d", len(resp.Notifications)) } if resp.Notifications[0].Reason != "edit_requested" { t.Errorf("expected reason edit_requested, got %s", resp.Notifications[0].Reason) } } func TestNotifications_Priority_NewBookingBelowPendingBooking(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID := createTestUser(t, ctx, tx) createNotification(t, ctx, tx, "new_booking", userID, false) createNotification(t, ctx, tx, "pending_booking", userID, false) handler := http.HandlerFunc(GetNotifications) w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil, ctx) var resp AdminNotificationListResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal: %v", err) } if len(resp.Notifications) != 2 { t.Fatalf("expected 2 notifications, got %d", len(resp.Notifications)) } if resp.Notifications[0].Reason != "pending_booking" { t.Errorf("expected pending_booking first, got %s", resp.Notifications[0].Reason) } } // ============================================================================= // Combined param tests // ============================================================================= func TestNotifications_IncludeAcknowledgedWithReasonFilter(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID := createTestUser(t, ctx, tx) createNotification(t, ctx, tx, "pending_booking", userID, false) createNotification(t, ctx, tx, "pending_booking", userID, true) createNotification(t, ctx, tx, "cancelled_booking", userID, false) handler := http.HandlerFunc(GetNotifications) w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications?include_acknowledged=true&reason=pending_booking", nil, ctx) var resp AdminNotificationListResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal: %v", err) } if len(resp.Notifications) != 2 { t.Errorf("expected 2 pending_booking notifications, got %d", len(resp.Notifications)) } } // ============================================================================= // Response enrichment tests (user_name, booking_start_time) // ============================================================================= func TestNotifications_ResponseEnriched_WithUserAndBooking(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) // Create a service var serviceID string err := tx.QueryRow(ctx, ` INSERT INTO services (name, description, price, duration_minutes, is_active) VALUES ('Manicure', 'Test service', 25.00, 30, true) RETURNING id `).Scan(&serviceID) if err != nil { t.Fatalf("failed to create service: %v", err) } // Create a user var userID string err = tx.QueryRow(ctx, ` INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type) VALUES ('Alice', 'Smith', 'alice@test.com', '+447700900001', '1990-01-01', 'hash', 'verified_email', 'email') RETURNING id `).Scan(&userID) if err != nil { t.Fatalf("failed to create user: %v", err) } // Create a booking var bookingID string err = tx.QueryRow(ctx, ` INSERT INTO bookings (user_id, start_time, status) VALUES ($1, NOW() + INTERVAL '3 days', 'pending') RETURNING id `, userID).Scan(&bookingID) if err != nil { t.Fatalf("failed to create booking: %v", err) } // Create notification with both user_id and booking_id createNotificationWithBooking(t, ctx, tx, "pending_booking", userID, bookingID, false) handler := http.HandlerFunc(GetNotifications) w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil, ctx) var resp AdminNotificationListResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal: %v", err) } if len(resp.Notifications) != 1 { t.Fatalf("expected 1 notification, got %d", len(resp.Notifications)) } n := resp.Notifications[0] if n.UserName == nil || *n.UserName != "Alice Smith" { t.Errorf("expected user_name 'Alice Smith', got %v", n.UserName) } if n.BookingStartTime == nil { t.Error("expected booking_start_time to be set") } } func TestNotifications_ResponseEnriched_NoUserOrBooking(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) // Create notification without user_id or booking_id createNotification(t, ctx, tx, "1_week_no_pay", "", false) handler := http.HandlerFunc(GetNotifications) w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil, ctx) var resp AdminNotificationListResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal: %v", err) } if len(resp.Notifications) != 1 { t.Fatalf("expected 1 notification, got %d", len(resp.Notifications)) } n := resp.Notifications[0] if n.UserName != nil { t.Errorf("expected user_name to be nil, got %v", n.UserName) } if n.BookingStartTime != nil { t.Errorf("expected booking_start_time to be nil, got %v", n.BookingStartTime) } } // ============================================================================= // Acknowledge notification tests (extended) // ============================================================================= func TestNotifications_Acknowledge_ViaExtendedHandler(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID := createTestUser(t, ctx, tx) notifID := createNotification(t, ctx, tx, "pending_booking", userID, false) handler := http.HandlerFunc(AcknowledgeNotification) w := makeExtendedAdminRequest(handler, "POST", fmt.Sprintf("/api/admin/notifications/%s/acknowledge", notifID), nil, ctx) if w.Code != http.StatusOK { t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String()) } // Verify acknowledged var ackTime *time.Time err := tx.QueryRow(ctx, "SELECT acknowledged_at FROM admin_notifications WHERE id = $1", notifID).Scan(&ackTime) if err != nil { t.Fatalf("failed to query notification: %v", err) } if ackTime == nil { t.Error("expected acknowledged_at to be set") } } func TestNotifications_Acknowledge_AlreadyAcknowledged_Extended(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID := createTestUser(t, ctx, tx) notifID := createNotification(t, ctx, tx, "pending_booking", userID, true) handler := http.HandlerFunc(AcknowledgeNotification) w := makeExtendedAdminRequest(handler, "POST", fmt.Sprintf("/api/admin/notifications/%s/acknowledge", notifID), nil, ctx) if w.Code != http.StatusNotFound { t.Errorf("expected status 404 for already acknowledged, got %d", w.Code) } } // createNotificationWithBooking creates a notification with both user_id and booking_id func createNotificationWithBooking(t *testing.T, ctx context.Context, q db.Querier, reason, userID, bookingID string, acknowledged bool) string { t.Helper() var notificationID string query := `INSERT INTO admin_notifications (reason, user_id, booking_id) VALUES ($1, $2, $3) RETURNING id` if acknowledged { query = `INSERT INTO admin_notifications (reason, user_id, booking_id, acknowledged_at) VALUES ($1, $2, $3, NOW()) RETURNING id` } err := q.QueryRow(ctx, query, reason, userID, bookingID).Scan(¬ificationID) if err != nil { t.Fatalf("failed to create notification: %v", err) } return notificationID } // ============================================================================= // Error path tests // ============================================================================= func TestGetUnreadCount_QueryError(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() req := httptest.NewRequest("GET", "/api/admin/notifications/unread-count", nil) req = req.WithContext(ctx) w := httptest.NewRecorder() GetUnreadCount(w, req) if w.Code != http.StatusInternalServerError { t.Errorf("expected 500, got %d. body: %s", w.Code, w.Body.String()) } } func TestNotifications_QueryError(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() handler := http.HandlerFunc(GetNotifications) req := httptest.NewRequest("GET", "/api/admin/notifications", nil) req = req.WithContext(ctx) w := httptest.NewRecorder() handler.ServeHTTP(w, req) if w.Code != http.StatusInternalServerError { t.Errorf("expected 500, got %d. body: %s", w.Code, w.Body.String()) } } func TestNotifications_InvalidCursor(t *testing.T) { handler := http.HandlerFunc(GetNotifications) req := httptest.NewRequest("GET", "/api/admin/notifications?cursor=invalid", nil) req = req.WithContext(context.Background()) w := httptest.NewRecorder() handler.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Errorf("expected 400 for invalid cursor, got %d. body: %s", w.Code, w.Body.String()) } } func TestNotifications_CursorPagination(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID := createTestUser(t, ctx, tx) for i := 0; i < 25; i++ { createNotification(t, ctx, tx, "pending_booking", userID, false) } handler := http.HandlerFunc(GetNotifications) w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications?include_acknowledged=true&per_page=20", nil, ctx) var firstPage AdminNotificationListResponse if err := json.Unmarshal(w.Body.Bytes(), &firstPage); err != nil { t.Fatalf("failed to unmarshal first page: %v", err) } if len(firstPage.Notifications) != 20 { t.Errorf("expected 20 notifications on first page, got %d", len(firstPage.Notifications)) } if firstPage.NextCursor == nil { t.Fatal("expected next_cursor to be set on first page") } firstPageIDs := make(map[string]bool) for _, n := range firstPage.Notifications { firstPageIDs[n.ID] = true } cursor := url.QueryEscape(*firstPage.NextCursor) w2 := makeExtendedAdminRequest(handler, "GET", fmt.Sprintf("/api/admin/notifications?include_acknowledged=true&per_page=20&cursor=%s", cursor), nil, ctx) var secondPage AdminNotificationListResponse if err := json.Unmarshal(w2.Body.Bytes(), &secondPage); err != nil { t.Fatalf("failed to unmarshal second page: %v", err) } if len(secondPage.Notifications) != 5 { t.Errorf("expected 5 notifications on second page, got %d", len(secondPage.Notifications)) } if secondPage.NextCursor != nil { t.Errorf("expected next_cursor to be nil on last page, got %v", *secondPage.NextCursor) } for _, n := range secondPage.Notifications { if firstPageIDs[n.ID] { t.Errorf("found duplicate notification ID %s on second page", n.ID) } } } func TestAcknowledgeNotification_BeginError(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() req := httptest.NewRequest("POST", "/api/admin/notifications/aaaaaaaaaaaa/acknowledge", nil) rctx := chi.NewRouteContext() rctx.URLParams.Add("id", "aaaaaaaaaaaa") ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx) ctx = context.WithValue(ctx, mw.UserIDKey, "admin001") ctx = context.WithValue(ctx, mw.UserRoleKey, "admin") req = req.WithContext(ctx) w := httptest.NewRecorder() AcknowledgeNotification(w, req) if w.Code != http.StatusInternalServerError { t.Errorf("expected 500, got %d. body: %s", w.Code, w.Body.String()) } } // ============================================================================= // Money-critical priority ordering (FIX 2) // ============================================================================= // TestNotifications_Priority_MoneyCriticalFirst pins FIX 2: money-critical // reasons (webhook/sweep 'critical_payment_log', 'refund_failed', // 'refresh_token_reuse', gift-card events) must sort ABOVE routine // notifications in the unacknowledged feed so the operator's only pager // surfaces money/security events first. The money rows are inserted LAST to // prove the priority CASE — not insertion order — drives the sort. func TestNotifications_Priority_MoneyCriticalFirst(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID := createTestUser(t, ctx, tx) moneyReasons := []string{ "critical_payment_log", "refund_failed", "refresh_token_reuse", "gift_card_purchased_for_friend", } routineReasons := []string{ "pending_booking", "cancelled_booking", "late_cancellation", "deposit_paid", "affiliate_claim", "edit_requested", "new_booking", "1_month_no_pay", "1_week_no_pay", "rescheduled_booking", "edit_request", "deposit_not_paid_by_deadline", "default_hours_changed", } moneySet := make(map[string]bool, len(moneyReasons)) for _, r := range moneyReasons { moneySet[r] = true } for _, reason := range routineReasons { createNotification(t, ctx, tx, reason, userID, false) } for _, reason := range moneyReasons { createNotification(t, ctx, tx, reason, userID, false) } handler := http.HandlerFunc(GetNotifications) w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil, ctx) var resp AdminNotificationListResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal: %v", err) } if len(resp.Notifications) != len(moneyReasons)+len(routineReasons) { t.Fatalf("expected %d notifications, got %d", len(moneyReasons)+len(routineReasons), len(resp.Notifications)) } // Every money-critical reason must appear strictly before the first routine // reason; each money reason must be present. lastMoney := -1 firstRoutine := len(resp.Notifications) for i, n := range resp.Notifications { if moneySet[n.Reason] { lastMoney = i } else if firstRoutine == len(resp.Notifications) { firstRoutine = i } } if lastMoney == -1 { t.Fatal("expected at least one money-critical reason in the response") } if lastMoney > firstRoutine { t.Errorf("money-critical reasons must sort above routine notifications: last money position %d, first routine position %d", lastMoney, firstRoutine) } } // ============================================================================= // Flood-cap suppression count in the response (FIX 3a) // ============================================================================= // TestNotifications_Response_SuppressedCount pins FIX 3a: when the flood cap // has suppressed alerts, GET /api/admin/notifications exposes the per-reason // "suppressed this cycle" count, and acknowledging the queue back below the cap // resets it to zero. func TestNotifications_Response_SuppressedCount(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID := createTestUser(t, ctx, tx) handler := http.HandlerFunc(GetNotifications) // No suppressions yet → the response reports zero. w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil, ctx) var resp AdminNotificationListResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal: %v", err) } if resp.Suppressed != 0 { t.Errorf("expected suppressed 0 before any cap hit, got %d", resp.Suppressed) } if len(resp.SuppressedDetails) != 0 { t.Errorf("expected no suppressed_details before any cap hit, got %d", len(resp.SuppressedDetails)) } // Fill the critical_payment_log queue to the cap, then hit the cap twice the // way an insert site would (pre-check records each suppression). for i := 0; i < adminnotify.MaxUnacknowledgedCriticalLogs; i++ { if _, err := tx.Exec(ctx, ` INSERT INTO admin_notifications (reason, user_id, created_at) VALUES ('critical_payment_log', $1, NOW()) `, userID); err != nil { t.Fatalf("failed to fill the queue to the cap: %v", err) } } if !adminnotify.CriticalLogsCapExceeded(ctx, db.Conn, "critical_payment_log") { t.Fatal("expected the queue to be at the cap") } if !adminnotify.CriticalLogsCapExceeded(ctx, db.Conn, "critical_payment_log") { t.Fatal("expected the queue to stay at the cap") } w = makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil, ctx) if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal: %v", err) } if resp.Suppressed != 2 { t.Errorf("expected suppressed 2, got %d", resp.Suppressed) } if len(resp.SuppressedDetails) != 1 { t.Fatalf("expected 1 suppressed_details entry, got %d", len(resp.SuppressedDetails)) } if resp.SuppressedDetails[0].Reason != "critical_payment_log" { t.Errorf("expected suppressed_details reason critical_payment_log, got %s", resp.SuppressedDetails[0].Reason) } if resp.SuppressedDetails[0].SuppressedCount != 2 { t.Errorf("expected suppressed_details count 2, got %d", resp.SuppressedDetails[0].SuppressedCount) } // Acknowledging the queue below the cap resets the visible count. if _, err := tx.Exec(ctx, "UPDATE admin_notifications SET acknowledged_at = NOW() WHERE reason = 'critical_payment_log'"); err != nil { t.Fatalf("failed to acknowledge the queue: %v", err) } w = makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil, ctx) if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal: %v", err) } if resp.Suppressed != 0 { t.Errorf("expected suppressed 0 after the queue was worked down, got %d", resp.Suppressed) } } // TestNotifications_Response_CarriesEventDetail pins FIX 3b: the GET endpoint // surfaces the money-critical event detail columns (amount, square_id, // description) when the insert site has populated them. func TestNotifications_Response_CarriesEventDetail(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) var notificationID string err := tx.QueryRow(ctx, ` INSERT INTO admin_notifications (reason, amount, square_id, description) VALUES ('critical_payment_log', 42.50, 'sq_dispute_123', 'Dispute received for charge 42.50') RETURNING id `).Scan(¬ificationID) if err != nil { t.Fatalf("failed to create notification with event detail: %v", err) } handler := http.HandlerFunc(GetNotifications) w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil, ctx) var resp AdminNotificationListResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal: %v", err) } if len(resp.Notifications) != 1 { t.Fatalf("expected 1 notification, got %d", len(resp.Notifications)) } n := resp.Notifications[0] if n.Amount == nil || *n.Amount != 42.50 { t.Errorf("expected amount 42.50, got %v", n.Amount) } if n.SquareID == nil || *n.SquareID != "sq_dispute_123" { t.Errorf("expected square_id sq_dispute_123, got %v", n.SquareID) } if n.Description == nil || *n.Description != "Dispute received for charge 42.50" { t.Errorf("expected description populated, got %v", n.Description) } }