Two-tier notification system: new_booking (all public bookings) + pending_booking (notes/today). Priority-sorted queue, unread count polling, enriched responses with user_name/booking_start_time. Fix critical bug: edit_requested cleanup was broken (wrong reason string in 3 handlers). Add 15 new tests covering priority ordering, enrichment, and notification creation flows. Update Admin Manual, Technical Manual, and gap backlog docs.
560 lines
18 KiB
Go
560 lines
18 KiB
Go
//go:build test
|
|
// +build test
|
|
|
|
package notifications
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"crussell/db"
|
|
"crussell/mw"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
func makeExtendedAdminRequest(handler http.Handler, method, path string, body interface{}) *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(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)
|
|
return w
|
|
}
|
|
|
|
func createTestUser(t *testing.T) string {
|
|
t.Helper()
|
|
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', '+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, reason, userID string, acknowledged bool) int {
|
|
t.Helper()
|
|
var notificationID int
|
|
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 := db.DB.QueryRow(context.Background(), 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 := db.DB.QueryRow(context.Background(), 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) {
|
|
resetTestData(t)
|
|
userID := createTestUser(t)
|
|
|
|
createNotification(t, "pending_booking", userID, false)
|
|
createNotification(t, "cancelled_booking", userID, true)
|
|
|
|
handler := http.HandlerFunc(GetNotifications)
|
|
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil)
|
|
|
|
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) {
|
|
resetTestData(t)
|
|
userID := createTestUser(t)
|
|
|
|
createNotification(t, "pending_booking", userID, false)
|
|
createNotification(t, "cancelled_booking", userID, true)
|
|
|
|
handler := http.HandlerFunc(GetNotifications)
|
|
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications?include_acknowledged=true", nil)
|
|
|
|
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) {
|
|
resetTestData(t)
|
|
userID := createTestUser(t)
|
|
|
|
createNotification(t, "pending_booking", userID, true)
|
|
|
|
handler := http.HandlerFunc(GetNotifications)
|
|
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications?include_acknowledged=true", nil)
|
|
|
|
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) {
|
|
resetTestData(t)
|
|
userID := createTestUser(t)
|
|
|
|
// Create notifications in reverse priority order
|
|
reasons := []string{
|
|
"1_week_no_pay",
|
|
"affiliate_claim",
|
|
"pending_booking",
|
|
"cancelled_booking",
|
|
}
|
|
for _, reason := range reasons {
|
|
createNotification(t, reason, userID, false)
|
|
}
|
|
|
|
handler := http.HandlerFunc(GetNotifications)
|
|
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil)
|
|
|
|
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) {
|
|
resetTestData(t)
|
|
userID := createTestUser(t)
|
|
|
|
// Create two pending_booking notifications with a time gap
|
|
createNotification(t, "pending_booking", userID, false)
|
|
time.Sleep(10 * time.Millisecond)
|
|
createNotification(t, "pending_booking", userID, false)
|
|
|
|
handler := http.HandlerFunc(GetNotifications)
|
|
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil)
|
|
|
|
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) {
|
|
resetTestData(t)
|
|
userID := createTestUser(t)
|
|
|
|
createNotification(t, "pending_booking", userID, false)
|
|
time.Sleep(10 * time.Millisecond)
|
|
createNotification(t, "cancelled_booking", userID, true)
|
|
|
|
handler := http.HandlerFunc(GetNotifications)
|
|
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications?include_acknowledged=true", nil)
|
|
|
|
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) {
|
|
resetTestData(t)
|
|
userID := createTestUser(t)
|
|
|
|
createNotification(t, "pending_booking", userID, false)
|
|
createNotification(t, "cancelled_booking", userID, false)
|
|
createNotification(t, "affiliate_claim", userID, true)
|
|
|
|
handler := http.HandlerFunc(GetUnreadCount)
|
|
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications/unread-count", nil)
|
|
|
|
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) {
|
|
resetTestData(t)
|
|
userID := createTestUser(t)
|
|
|
|
createNotification(t, "pending_booking", userID, true)
|
|
|
|
handler := http.HandlerFunc(GetUnreadCount)
|
|
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications/unread-count", nil)
|
|
|
|
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) {
|
|
resetTestData(t)
|
|
|
|
handler := http.HandlerFunc(GetUnreadCount)
|
|
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications/unread-count", nil)
|
|
|
|
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) {
|
|
resetTestData(t)
|
|
userID := createTestUser(t)
|
|
|
|
createNotification(t, "new_booking", userID, false)
|
|
|
|
handler := http.HandlerFunc(GetNotifications)
|
|
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil)
|
|
|
|
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) {
|
|
resetTestData(t)
|
|
userID := createTestUser(t)
|
|
|
|
createNotification(t, "edit_requested", userID, false)
|
|
|
|
handler := http.HandlerFunc(GetNotifications)
|
|
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil)
|
|
|
|
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) {
|
|
resetTestData(t)
|
|
userID := createTestUser(t)
|
|
|
|
createNotification(t, "new_booking", userID, false)
|
|
createNotification(t, "pending_booking", userID, false)
|
|
|
|
handler := http.HandlerFunc(GetNotifications)
|
|
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil)
|
|
|
|
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) {
|
|
resetTestData(t)
|
|
userID := createTestUser(t)
|
|
|
|
createNotification(t, "pending_booking", userID, false)
|
|
createNotification(t, "pending_booking", userID, true)
|
|
createNotification(t, "cancelled_booking", userID, false)
|
|
|
|
handler := http.HandlerFunc(GetNotifications)
|
|
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications?include_acknowledged=true&reason=pending_booking", nil)
|
|
|
|
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) {
|
|
resetTestData(t)
|
|
|
|
// 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 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 ('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 = db.DB.QueryRow(context.Background(), `
|
|
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, "pending_booking", userID, bookingID, false)
|
|
|
|
handler := http.HandlerFunc(GetNotifications)
|
|
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil)
|
|
|
|
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) {
|
|
resetTestData(t)
|
|
|
|
// Create notification without user_id or booking_id
|
|
createNotification(t, "1_week_no_pay", "", false)
|
|
|
|
handler := http.HandlerFunc(GetNotifications)
|
|
w := makeExtendedAdminRequest(handler, "GET", "/api/admin/notifications", nil)
|
|
|
|
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) {
|
|
resetTestData(t)
|
|
userID := createTestUser(t)
|
|
|
|
notifID := createNotification(t, "pending_booking", userID, false)
|
|
|
|
handler := http.HandlerFunc(AcknowledgeNotification)
|
|
w := makeExtendedAdminRequest(handler, "POST", fmt.Sprintf("/api/admin/notifications/%d/acknowledge", notifID), nil)
|
|
|
|
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 := db.DB.QueryRow(context.Background(),
|
|
"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) {
|
|
resetTestData(t)
|
|
userID := createTestUser(t)
|
|
|
|
notifID := createNotification(t, "pending_booking", userID, true)
|
|
|
|
handler := http.HandlerFunc(AcknowledgeNotification)
|
|
w := makeExtendedAdminRequest(handler, "POST", fmt.Sprintf("/api/admin/notifications/%d/acknowledge", notifID), nil)
|
|
|
|
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, reason, userID, bookingID string, acknowledged bool) int {
|
|
t.Helper()
|
|
var notificationID int
|
|
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 := db.DB.QueryRow(context.Background(), query, reason, userID, bookingID).Scan(¬ificationID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create notification: %v", err)
|
|
}
|
|
return notificationID
|
|
}
|
|
|
|
// Ensure test compilation
|
|
var _ = func() *pgxpool.Pool { return nil }
|