Files
Crussell/backend/handlers/notifications/notifications_test.go
T
popertotsandSisyphus e4b9003439 refactor(handlers): migrate remaining backend handlers to clock.Now() and transaction patterns
Apply clock.Now() migration, transaction wrapping, and minor refactors across admin, scheduling, today, user, auth handler, notifications, webhooks, services, portfolio, ratelimit, testutils, and main.go.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-24 23:43:50 +01:00

707 lines
22 KiB
Go

//go:build test
// +build test
package notifications
// Package notifications contains tests for admin notification endpoints.
//
// Test Coverage:
// - GetNotifications: GET /api/admin/notifications - List unacknowledged notifications
// - AcknowledgeNotification: POST /api/admin/notifications/{id}/acknowledge - Acknowledge a notification
//
// Features tested:
// - Pagination (page, per_page)
// - Filtering by reason
// - Acknowledging notifications (idempotent, not found cases)
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"crussell/clock"
"crussell/testutils"
"crussell/mw"
"crussell/testutils/fixtures"
"github.com/go-chi/chi/v5"
)
// makeAdminRequest creates a request with admin context
func makeAdminRequest(handler http.Handler, method, path string, body interface{}, ctx context.Context) *httptest.ResponseRecorder {
return makeRequestWithContext(handler, method, path, body, "admin001", "admin", ctx)
}
// makeUserRequest creates a request with regular user context
func makeUserRequest(handler http.Handler, method, path string, body interface{}, ctx context.Context) *httptest.ResponseRecorder {
return makeRequestWithContext(handler, method, path, body, "user001", "verified_email", ctx)
}
// makeRequestWithContext creates a request with specific user context
func makeRequestWithContext(handler http.Handler, method, path string, body interface{}, userID, role string, 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)
}
// Set up chi routing context for path params
rctx := chi.NewRouteContext()
if id, paramName := extractIDFromPath(path); id != "" {
rctx.URLParams.Add(paramName, id)
}
ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx)
ctx = context.WithValue(ctx, mw.UserIDKey, userID)
ctx = context.WithValue(ctx, mw.UserRoleKey, role)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
return w
}
// extractIDFromPath extracts the ID from URL paths
func extractIDFromPath(path string) (string, string) {
prefix := "/api/admin/notifications/"
if len(path) > len(prefix) && path[:len(prefix)] == prefix {
// Extract ID after prefix, up to next / or end
remainder := path[len(prefix):]
for i, c := range remainder {
if c == '/' {
return remainder[:i], "id"
}
}
return remainder, "id"
}
return "", ""
}
// =============================================================================
// GetNotifications Tests
// =============================================================================
// TestNotifications_List tests that an admin can list all unacknowledged notifications
func TestNotifications_List(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
// Create test user for notification reference
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 ('Test', 'User', 'test@test.com', '+1234567890', '1990-01-01', 'hash', 'verified_email', 'email')
RETURNING id
`).Scan(&userID)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
// Create a notification
var notificationID string
err = tx.QueryRow(ctx, `
INSERT INTO admin_notifications (reason, user_id)
VALUES ('pending_booking', $1)
RETURNING id
`, userID).Scan(&notificationID)
if err != nil {
t.Fatalf("failed to create notification: %v", err)
}
handler := http.HandlerFunc(GetNotifications)
w := makeAdminRequest(handler, "GET", "/api/admin/notifications", nil, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var resp AdminNotificationListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if len(resp.Notifications) != 1 {
t.Errorf("expected 1 notification, got %d", len(resp.Notifications))
}
if len(resp.Notifications) > 0 {
if resp.Notifications[0].Reason != "pending_booking" {
t.Errorf("expected reason 'pending_booking', got %s", resp.Notifications[0].Reason)
}
if resp.Notifications[0].ID != notificationID {
t.Errorf("expected notification ID %s, got %s", notificationID, resp.Notifications[0].ID)
}
}
}
// TestNotifications_ListEmpty tests that an empty list is returned when no notifications exist
func TestNotifications_ListEmpty(t *testing.T) {
t.Parallel()
ctx, _ := testutils.SetupTestTx(t)
handler := http.HandlerFunc(GetNotifications)
w := makeAdminRequest(handler, "GET", "/api/admin/notifications", nil, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var resp AdminNotificationListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if len(resp.Notifications) != 0 {
t.Errorf("expected 0 notifications, got %d", len(resp.Notifications))
}
// Total should be 0
if resp.Total != 0 {
t.Errorf("expected total 0, got %d", resp.Total)
}
}
// TestNotifications_ListFilterByReason tests that notifications can be filtered by reason
func TestNotifications_ListFilterByReason(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
// Create test 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 ('Test', 'User', 'test@test.com', '+1234567890', '1990-01-01', 'hash', 'verified_email', 'email')
RETURNING id
`).Scan(&userID)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
// Create notifications with different reasons
reasons := []string{"pending_booking", "cancelled_booking", "edit_requested"}
for _, reason := range reasons {
_, err := tx.Exec(ctx, `
INSERT INTO admin_notifications (reason, user_id)
VALUES ($1, $2)
`, reason, userID)
if err != nil {
t.Fatalf("failed to create notification: %v", err)
}
}
handler := http.HandlerFunc(GetNotifications)
// Filter by pending_booking
w := makeAdminRequest(handler, "GET", "/api/admin/notifications?reason=pending_booking", nil, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var resp AdminNotificationListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if len(resp.Notifications) != 1 {
t.Errorf("expected 1 notification with reason=pending_booking, got %d", len(resp.Notifications))
}
if len(resp.Notifications) > 0 && resp.Notifications[0].Reason != "pending_booking" {
t.Errorf("expected reason 'pending_booking', got %s", resp.Notifications[0].Reason)
}
}
// TestNotifications_ListPagination tests that pagination works correctly
func TestNotifications_ListPagination(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
// Create test 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 ('Test', 'User', 'test@test.com', '+1234567890', '1990-01-01', 'hash', 'verified_email', 'email')
RETURNING id
`).Scan(&userID)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
// Create 25 notifications
for i := 0; i < 25; i++ {
_, err := tx.Exec(ctx, `
INSERT INTO admin_notifications (reason, user_id)
VALUES ('pending_booking', $1)
`, userID)
if err != nil {
t.Fatalf("failed to create notification: %v", err)
}
}
handler := http.HandlerFunc(GetNotifications)
// Get first page (default 20 items)
w := makeAdminRequest(handler, "GET", "/api/admin/notifications?page=1&per_page=10", nil, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var resp AdminNotificationListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if len(resp.Notifications) != 10 {
t.Errorf("expected 10 notifications on page 1, got %d", len(resp.Notifications))
}
if resp.Total != 25 {
t.Errorf("expected total 25, got %d", resp.Total)
}
}
// TestNotifications_ListExcludesAcknowledged tests that acknowledged notifications are not returned
func TestNotifications_ListExcludesAcknowledged(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
// Create test 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 ('Test', 'User', 'test@test.com', '+1234567890', '1990-01-01', 'hash', 'verified_email', 'email')
RETURNING id
`).Scan(&userID)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
// Create acknowledged notification
_, err = tx.Exec(ctx, `
INSERT INTO admin_notifications (reason, user_id, acknowledged_at)
VALUES ('pending_booking', $1, NOW())
`, userID)
if err != nil {
t.Fatalf("failed to create acknowledged notification: %v", err)
}
// Create unacknowledged notification
var unackID string
err = tx.QueryRow(ctx, `
INSERT INTO admin_notifications (reason, user_id)
VALUES ('cancelled_booking', $1)
RETURNING id
`, userID).Scan(&unackID)
if err != nil {
t.Fatalf("failed to create unacknowledged notification: %v", err)
}
handler := http.HandlerFunc(GetNotifications)
w := makeAdminRequest(handler, "GET", "/api/admin/notifications", nil, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var resp AdminNotificationListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
// Should only return the unacknowledged notification
if len(resp.Notifications) != 1 {
t.Errorf("expected 1 unacknowledged notification, got %d", len(resp.Notifications))
}
if len(resp.Notifications) > 0 && resp.Notifications[0].ID != unackID {
t.Errorf("expected unacknowledged notification ID %s, got %s", unackID, resp.Notifications[0].ID)
}
}
// =============================================================================
// AcknowledgeNotification Tests
// =============================================================================
// TestNotifications_Acknowledge tests that an admin can acknowledge a notification
func TestNotifications_Acknowledge(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
// Create test 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 ('Test', 'User', 'test@test.com', '+1234567890', '1990-01-01', 'hash', 'verified_email', 'email')
RETURNING id
`).Scan(&userID)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
// Create notification
var notificationID string
err = tx.QueryRow(ctx, `
INSERT INTO admin_notifications (reason, user_id)
VALUES ('pending_booking', $1)
RETURNING id
`, userID).Scan(&notificationID)
if err != nil {
t.Fatalf("failed to create notification: %v", err)
}
handler := http.HandlerFunc(AcknowledgeNotification)
w := makeAdminRequest(handler, "POST", fmt.Sprintf("/api/admin/notifications/%s/acknowledge", notificationID), nil, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
// Verify the notification is acknowledged
var acknowledgedAt *time.Time
err = tx.QueryRow(ctx, `
SELECT acknowledged_at FROM admin_notifications WHERE id = $1
`, notificationID).Scan(&acknowledgedAt)
if err != nil {
t.Fatalf("failed to query notification: %v", err)
}
if acknowledgedAt == nil {
t.Error("expected acknowledged_at to be set, got nil")
}
}
// TestNotifications_AcknowledgeNotFound tests that acknowledging a non-existent notification returns 404
func TestNotifications_AcknowledgeNotFound(t *testing.T) {
t.Parallel()
ctx, _ := testutils.SetupTestTx(t)
handler := http.HandlerFunc(AcknowledgeNotification)
w := makeAdminRequest(handler, "POST", "/api/admin/notifications/ffffffffffff/acknowledge", nil, ctx)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404 for non-existent notification, got %d", w.Code)
}
}
// TestNotifications_AcknowledgeAlreadyAcknowledged tests that acknowledging an already-acknowledged notification returns 404
func TestNotifications_AcknowledgeAlreadyAcknowledged(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
// Create test 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 ('Test', 'User', 'test@test.com', '+1234567890', '1990-01-01', 'hash', 'verified_email', 'email')
RETURNING id
`).Scan(&userID)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
// Create already-acknowledged notification
var notificationID string
err = tx.QueryRow(ctx, `
INSERT INTO admin_notifications (reason, user_id, acknowledged_at)
VALUES ('pending_booking', $1, NOW())
RETURNING id
`, userID).Scan(&notificationID)
if err != nil {
t.Fatalf("failed to create notification: %v", err)
}
handler := http.HandlerFunc(AcknowledgeNotification)
w := makeAdminRequest(handler, "POST", fmt.Sprintf("/api/admin/notifications/%s/acknowledge", notificationID), nil, ctx)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404 for already acknowledged notification, got %d", w.Code)
}
}
// TestNotifications_AcknowledgeInvalidID tests that invalid notification IDs are handled
func TestNotifications_AcknowledgeInvalidID(t *testing.T) {
t.Parallel()
_, _ = testutils.SetupTestTx(t)
handler := http.HandlerFunc(AcknowledgeNotification)
tests := []struct {
name string
id string
expectCode int
}{
{"non_numeric_id", "abc", http.StatusBadRequest}, // Invalid format
{"negative_id", "-1", http.StatusBadRequest}, // Invalid (non-positive)
{"zero_id", "0", http.StatusBadRequest}, // Invalid (non-positive)
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create a custom request to test invalid ID handling
req := httptest.NewRequest("POST", "/api/admin/notifications/"+tt.id+"/acknowledge", nil)
// Set up chi routing context with the invalid ID
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", tt.id)
ctx := context.WithValue(req.Context(), 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)
if w.Code != tt.expectCode {
t.Errorf("expected status %d for ID '%s', got %d", tt.expectCode, tt.id, w.Code)
}
})
}
}
// TestNotifications_AcknowledgeMissingID tests that missing ID returns 400
func TestNotifications_AcknowledgeMissingID(t *testing.T) {
t.Parallel()
_, _ = testutils.SetupTestTx(t)
// Create a custom request with no ID in path
req := httptest.NewRequest("POST", "/api/admin/notifications//acknowledge", nil)
// Set up chi routing context without ID
rctx := chi.NewRouteContext()
ctx := context.WithValue(req.Context(), 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 := http.HandlerFunc(AcknowledgeNotification)
handler.ServeHTTP(w, req)
// Should return 400 for missing ID
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400 for missing ID, got %d", w.Code)
}
}
// =============================================================================
// Notification with Booking Reference Tests
// =============================================================================
// TestNotifications_WithBookingReference tests that notifications include booking_id when applicable
func TestNotifications_WithBookingReference(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
// Create test 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 ('Test', 'User', 'test@test.com', '+1234567890', '1990-01-01', 'hash', 'verified_email', 'email')
RETURNING id
`).Scan(&userID)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
// 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 booking
var bookingID string
err = tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status)
VALUES ($1, NOW() + INTERVAL '1 day', 'pending')
RETURNING id
`, userID).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Create notification with booking reference
var notificationID string
err = tx.QueryRow(ctx, `
INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ('pending_booking', $1, $2)
RETURNING id
`, bookingID, userID).Scan(&notificationID)
if err != nil {
t.Fatalf("failed to create notification: %v", err)
}
handler := http.HandlerFunc(GetNotifications)
w := makeAdminRequest(handler, "GET", "/api/admin/notifications", nil, ctx)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var resp AdminNotificationListResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if len(resp.Notifications) != 1 {
t.Fatalf("expected 1 notification, got %d", len(resp.Notifications))
}
// Note: booking_id in DB is stored as bigint, but our struct uses int
// The booking_id should be present
if resp.Notifications[0].BookingID == nil {
t.Error("expected booking_id to be set, got nil")
}
}
// =============================================================================
// AcknowledgePendingBookingNotification Tests
// =============================================================================
func TestAcknowledgePendingBookingNotification_Success(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, clock.Now().Add(24*time.Hour))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Create a pending notification for this booking
_, err = tx.Exec(ctx, `
INSERT INTO admin_notifications (booking_id, reason, acknowledged_at)
VALUES ($1, 'pending_booking', NULL)
`, bookingID)
if err != nil {
t.Fatalf("failed to create notification: %v", err)
}
err = AcknowledgePendingBookingNotification(tx, ctx, bookingID)
if err != nil {
t.Fatalf("AcknowledgePendingBookingNotification failed: %v", err)
}
// Verify notification was acknowledged
var acknowledgedAt *time.Time
err = tx.QueryRow(ctx,
"SELECT acknowledged_at FROM admin_notifications WHERE booking_id = $1 AND reason = 'pending_booking'",
bookingID).Scan(&acknowledgedAt)
if err != nil {
t.Fatalf("failed to query notification: %v", err)
}
if acknowledgedAt == nil {
t.Error("expected acknowledged_at to be set, got nil")
}
}
func TestAcknowledgePendingBookingNotification_Idempotent(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, clock.Now().Add(24*time.Hour))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Notification already acknowledged
_, err = tx.Exec(ctx, `
INSERT INTO admin_notifications (booking_id, reason, acknowledged_at)
VALUES ($1, 'pending_booking', NOW())
`, bookingID)
if err != nil {
t.Fatalf("failed to create acknowledged notification: %v", err)
}
// Calling again on already acknowledged should not error
err = AcknowledgePendingBookingNotification(tx, ctx, bookingID)
if err != nil {
t.Fatalf("AcknowledgePendingBookingNotification should not error when already acknowledged: %v", err)
}
}
func TestAcknowledgePendingBookingNotification_NoNotification(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, clock.Now().Add(24*time.Hour))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// No notification exists - should not error
err = AcknowledgePendingBookingNotification(tx, ctx, bookingID)
if err != nil {
t.Fatalf("AcknowledgePendingBookingNotification should not error when no notification exists: %v", err)
}
}
func TestAcknowledgePendingBookingNotification_NonTxCaller(t *testing.T) {
t.Parallel()
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, clock.Now().Add(24*time.Hour))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Call with a plain struct (not a tx) - should log warning and not error
err = AcknowledgePendingBookingNotification("not-a-tx", ctx, bookingID)
if err != nil {
t.Errorf("expected no error for non-tx caller, got: %v", err)
}
}