//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" "os" "testing" "time" "crussell/db" "crussell/mw" "crussell/testutils/jwt" "crussell/testutils/testdb" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgxpool" ) func TestMain(m *testing.M) { pool, _ := testdb.NewPool("") testdb.Migrate(&testing.T{}, pool) db.DB = pool jwt.Init() code := m.Run() pool.Close() os.Exit(code) } func resetTestData(t *testing.T) { t.Helper() testdb.TruncateTables(t, db.DB) } // makeAdminRequest creates a request with admin context func makeAdminRequest(handler http.Handler, method, path string, body interface{}) *httptest.ResponseRecorder { return makeRequestWithContext(handler, method, path, body, "admin001", "admin") } // makeUserRequest creates a request with regular user context func makeUserRequest(handler http.Handler, method, path string, body interface{}) *httptest.ResponseRecorder { return makeRequestWithContext(handler, method, path, body, "user001", "verified_email") } // makeRequestWithContext creates a request with specific user context func makeRequestWithContext(handler http.Handler, method, path string, body interface{}, userID, role string) *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(req.Context(), 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) { resetTestData(t) // Create test user for notification reference var userID string err := db.DB.QueryRow(context.Background(), ` 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 int err = db.DB.QueryRow(context.Background(), ` INSERT INTO admin_notifications (reason, user_id) VALUES ('pending_booking', $1) RETURNING id `, userID).Scan(¬ificationID) if err != nil { t.Fatalf("failed to create notification: %v", err) } handler := http.HandlerFunc(GetNotifications) w := makeAdminRequest(handler, "GET", "/api/admin/notifications", nil) 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 %d, got %d", notificationID, resp.Notifications[0].ID) } } } // TestNotifications_ListEmpty tests that an empty list is returned when no notifications exist func TestNotifications_ListEmpty(t *testing.T) { resetTestData(t) handler := http.HandlerFunc(GetNotifications) w := makeAdminRequest(handler, "GET", "/api/admin/notifications", nil) 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) { resetTestData(t) // Create test user var userID string err := db.DB.QueryRow(context.Background(), ` 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 := db.DB.Exec(context.Background(), ` 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) 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) { resetTestData(t) // Create test user var userID string err := db.DB.QueryRow(context.Background(), ` 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 := db.DB.Exec(context.Background(), ` 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) 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) } if resp.Page != 1 { t.Errorf("expected page 1, got %d", resp.Page) } if resp.PerPage != 10 { t.Errorf("expected per_page 10, got %d", resp.PerPage) } } // TestNotifications_ListExcludesAcknowledged tests that acknowledged notifications are not returned func TestNotifications_ListExcludesAcknowledged(t *testing.T) { resetTestData(t) // Create test user var userID string err := db.DB.QueryRow(context.Background(), ` 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 = db.DB.Exec(context.Background(), ` 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 int err = db.DB.QueryRow(context.Background(), ` 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) 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 %d, got %d", unackID, resp.Notifications[0].ID) } } // ============================================================================= // AcknowledgeNotification Tests // ============================================================================= // TestNotifications_Acknowledge tests that an admin can acknowledge a notification func TestNotifications_Acknowledge(t *testing.T) { resetTestData(t) // Create test user var userID string err := db.DB.QueryRow(context.Background(), ` 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 int err = db.DB.QueryRow(context.Background(), ` INSERT INTO admin_notifications (reason, user_id) VALUES ('pending_booking', $1) RETURNING id `, userID).Scan(¬ificationID) if err != nil { t.Fatalf("failed to create notification: %v", err) } handler := http.HandlerFunc(AcknowledgeNotification) w := makeAdminRequest(handler, "POST", fmt.Sprintf("/api/admin/notifications/%d/acknowledge", notificationID), nil) 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 = db.DB.QueryRow(context.Background(), ` 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) { resetTestData(t) handler := http.HandlerFunc(AcknowledgeNotification) w := makeAdminRequest(handler, "POST", "/api/admin/notifications/99999/acknowledge", nil) 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) { resetTestData(t) // Create test user var userID string err := db.DB.QueryRow(context.Background(), ` 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 int err = db.DB.QueryRow(context.Background(), ` INSERT INTO admin_notifications (reason, user_id, acknowledged_at) VALUES ('pending_booking', $1, NOW()) RETURNING id `, userID).Scan(¬ificationID) if err != nil { t.Fatalf("failed to create notification: %v", err) } handler := http.HandlerFunc(AcknowledgeNotification) w := makeAdminRequest(handler, "POST", fmt.Sprintf("/api/admin/notifications/%d/acknowledge", notificationID), nil) 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) { resetTestData(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.StatusNotFound}, // Valid int, not found {"zero_id", "0", http.StatusNotFound}, // Valid int, not found } 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) { resetTestData(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) { resetTestData(t) // Create test user var userID string err := db.DB.QueryRow(context.Background(), ` 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 = db.DB.QueryRow(context.Background(), ` 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 = db.DB.QueryRow(context.Background(), ` 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 int err = db.DB.QueryRow(context.Background(), ` INSERT INTO admin_notifications (reason, booking_id, user_id) VALUES ('pending_booking', $1, $2) RETURNING id `, bookingID, userID).Scan(¬ificationID) if err != nil { t.Fatalf("failed to create notification: %v", err) } handler := http.HandlerFunc(GetNotifications) w := makeAdminRequest(handler, "GET", "/api/admin/notifications", nil) 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") } } // Ensure test compilation - import pgxpool to avoid unused import var _ = func() *pgxpool.Pool { return nil }