- Add TestMain to all 10 test packages (schema DROP+CREATE runs once per package) - Convert per-test setupTestDB to resetTestData (TRUNCATE only, ~60% faster) - Add 3 missing tables to TruncateTables (booking_edit_requests, exceptional_group_applications, business_settings) - Remove dead truncateDiscountTables helper - Consolidate discount_test.go into package bookings (was external test package) - Update testutils.SetupTestDB to truncate-only - Fix unused imports across user, bookings, and handlers packages - Verify: 286 passing, 2 skipped, 0 failures with -count=2 (no state leakage)
1248 lines
43 KiB
Go
1248 lines
43 KiB
Go
//go:build test
|
|
// +build test
|
|
|
|
package scheduling
|
|
|
|
// Package scheduling contains tests for time blockers CRUD handlers.
|
|
//
|
|
// Test Coverage:
|
|
// - ListTimeBlockers: GET /api/admin/time-blockers - List all blockers
|
|
// - CreateTimeBlocker: POST /api/admin/time-blockers - Create blocker (admin)
|
|
// - DeleteTimeBlocker: DELETE /api/admin/time-blockers/{id} - Delete blocker (admin)
|
|
// - CheckTimeBlockerOverlap: Helper to check for overlapping blockers
|
|
// - GetTimeBlockersInRange: Helper to get blockers in date range
|
|
//
|
|
// Authentication: Create/Delete endpoints require admin role.
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"crussell/db"
|
|
"crussell/mw"
|
|
"crussell/testutils/fixtures"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
func makeTimeBlockerRequest(handler http.HandlerFunc, 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)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
return w
|
|
}
|
|
|
|
func makeTimeBlockerAuthRequest(handler http.HandlerFunc, 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)
|
|
}
|
|
|
|
// Add admin context (no user ID - created_by will be NULL)
|
|
ctx := context.WithValue(req.Context(), mw.UserRoleKey, "admin")
|
|
req = req.WithContext(ctx)
|
|
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
return w
|
|
}
|
|
|
|
// --- Tests for ListTimeBlockers ---
|
|
|
|
// TestTimeBlockers_List verifies that all time blockers can be listed.
|
|
// Returns 200 OK with an array of blockers.
|
|
func TestTimeBlockers_List(t *testing.T) {
|
|
resetTestData(t)
|
|
|
|
ukLocation, _ := time.LoadLocation("Europe/London")
|
|
blockerTime1 := time.Now().In(ukLocation).Add(7 * 24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
|
|
blockerTime2 := time.Now().In(ukLocation).Add(8 * 24 * time.Hour).Truncate(24 * time.Hour).Add(14 * time.Hour)
|
|
|
|
_, err := db.DB.Exec(context.Background(), `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
|
|
VALUES ($1, 60, 'Blocker 1', NULL),
|
|
($2, 30, 'Blocker 2', NULL)
|
|
`, blockerTime1, blockerTime2)
|
|
if err != nil {
|
|
t.Fatalf("failed to create time blockers: %v", err)
|
|
}
|
|
|
|
handler := http.HandlerFunc(ListTimeBlockers)
|
|
w := makeTimeBlockerRequest(handler, "GET", "/api/admin/time-blockers", nil)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response []TimeBlocker
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
if len(response) != 2 {
|
|
t.Errorf("expected 2 blockers, got %d", len(response))
|
|
}
|
|
}
|
|
|
|
// TestTimeBlockers_ListWithDateFilter verifies that time blockers can be
|
|
// filtered by start/end query parameters.
|
|
func TestTimeBlockers_ListWithDateFilter(t *testing.T) {
|
|
resetTestData(t)
|
|
|
|
ukLocation, _ := time.LoadLocation("Europe/London")
|
|
// Create blockers on different dates
|
|
blockerTime1 := time.Date(2026, 3, 10, 10, 0, 0, 0, ukLocation) // In range
|
|
blockerTime2 := time.Date(2026, 3, 15, 14, 0, 0, 0, ukLocation) // Out of range
|
|
blockerTime3 := time.Date(2026, 3, 12, 9, 0, 0, 0, ukLocation) // In range
|
|
|
|
_, err := db.DB.Exec(context.Background(), `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
|
|
VALUES ($1, 60, 'In Range 1', NULL),
|
|
($2, 30, 'Out of Range', NULL),
|
|
($3, 45, 'In Range 2', NULL)
|
|
`, blockerTime1, blockerTime2, blockerTime3)
|
|
if err != nil {
|
|
t.Fatalf("failed to create time blockers: %v", err)
|
|
}
|
|
handler := http.HandlerFunc(ListTimeBlockers)
|
|
w := makeTimeBlockerRequest(handler, "GET", "/api/admin/time-blockers?start=2026-03-10&end=2026-03-13", nil)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response []TimeBlocker
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
// Should return only blockers within the date range (2026-03-10 to 2026-03-13)
|
|
if len(response) != 2 {
|
|
t.Errorf("expected 2 blockers in range, got %d", len(response))
|
|
}
|
|
}
|
|
|
|
// --- Tests for CreateTimeBlocker ---
|
|
|
|
// TestTimeBlockers_Create verifies that an admin can create a new time blocker.
|
|
func TestTimeBlockers_Create(t *testing.T) {
|
|
resetTestData(t)
|
|
|
|
ukLocation, _ := time.LoadLocation("Europe/London")
|
|
blockerTime := time.Date(2026, 3, 20, 10, 0, 0, 0, ukLocation)
|
|
|
|
reqBody := CreateTimeBlockerRequest{
|
|
StartTime: blockerTime,
|
|
DurationMinutes: 60,
|
|
Description: "Test blocker",
|
|
}
|
|
|
|
handler := http.HandlerFunc(CreateTimeBlocker)
|
|
w := makeTimeBlockerAuthRequest(handler, "POST", "/api/admin/time-blockers", reqBody)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var response TimeBlocker
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("failed to unmarshal response: %v", err)
|
|
}
|
|
|
|
if response.DurationMinutes != 60 {
|
|
t.Errorf("expected duration 60, got %d", response.DurationMinutes)
|
|
}
|
|
|
|
if response.Description != "Test blocker" {
|
|
t.Errorf("expected description 'Test blocker', got %s", response.Description)
|
|
}
|
|
|
|
// Verify it exists in DB
|
|
var count int
|
|
err := db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM time_blockers WHERE id = $1`, response.ID).Scan(&count)
|
|
if err != nil {
|
|
t.Fatalf("failed to verify blocker in DB: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Error("expected blocker to exist in DB")
|
|
}
|
|
}
|
|
|
|
// TestTimeBlockers_Create_ValidationErrors verifies that missing or invalid
|
|
// fields result in 400 Bad Request.
|
|
func TestTimeBlockers_Create_ValidationErrors(t *testing.T) {
|
|
resetTestData(t)
|
|
|
|
handler := http.HandlerFunc(CreateTimeBlocker)
|
|
|
|
// Test missing start_time
|
|
reqBody1 := map[string]interface{}{
|
|
"duration_minutes": 60,
|
|
"description": "Test",
|
|
}
|
|
w := makeTimeBlockerAuthRequest(handler, "POST", "/api/admin/time-blockers", reqBody1)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400 for missing start_time, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Test missing duration_minutes
|
|
ukLocation, _ := time.LoadLocation("Europe/London")
|
|
blockerTime := time.Date(2026, 3, 20, 10, 0, 0, 0, ukLocation)
|
|
reqBody2 := map[string]interface{}{
|
|
"start_time": blockerTime,
|
|
"description": "Test",
|
|
}
|
|
w = makeTimeBlockerAuthRequest(handler, "POST", "/api/admin/time-blockers", reqBody2)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400 for missing duration_minutes, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Test invalid duration_minutes (zero)
|
|
reqBody3 := map[string]interface{}{
|
|
"start_time": blockerTime,
|
|
"duration_minutes": 0,
|
|
"description": "Test",
|
|
}
|
|
w = makeTimeBlockerAuthRequest(handler, "POST", "/api/admin/time-blockers", reqBody3)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400 for zero duration_minutes, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Test invalid duration_minutes (negative)
|
|
reqBody4 := map[string]interface{}{
|
|
"start_time": blockerTime,
|
|
"duration_minutes": -10,
|
|
"description": "Test",
|
|
}
|
|
w = makeTimeBlockerAuthRequest(handler, "POST", "/api/admin/time-blockers", reqBody4)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400 for negative duration_minutes, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// --- Tests for DeleteTimeBlocker ---
|
|
|
|
// TestTimeBlockers_Delete verifies that an admin can delete a time blocker.
|
|
func TestTimeBlockers_Delete(t *testing.T) {
|
|
resetTestData(t)
|
|
|
|
ukLocation, _ := time.LoadLocation("Europe/London")
|
|
blockerTime := time.Date(2026, 3, 25, 10, 0, 0, 0, ukLocation)
|
|
|
|
// Create a blocker to delete
|
|
var blockerID string
|
|
err := db.DB.QueryRow(context.Background(), `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
|
|
VALUES ($1, 60, 'To be deleted', NULL)
|
|
RETURNING id
|
|
`, blockerTime).Scan(&blockerID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create blocker: %v", err)
|
|
}
|
|
|
|
// Set up chi router for URL param
|
|
r := chi.NewRouter()
|
|
r.Delete("/api/admin/time-blockers/{id}", DeleteTimeBlocker)
|
|
|
|
// Create request with chi context
|
|
req := httptest.NewRequest("DELETE", "/api/admin/time-blockers/"+blockerID, nil)
|
|
ctx := context.WithValue(req.Context(), mw.UserIDKey, "admin001")
|
|
ctx = context.WithValue(ctx, mw.UserRoleKey, "admin")
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("id", blockerID)
|
|
ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx)
|
|
req = req.WithContext(ctx)
|
|
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusNoContent {
|
|
t.Errorf("expected status 204, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Verify blocker was deleted
|
|
var count int
|
|
err = db.DB.QueryRow(context.Background(), `SELECT COUNT(*) FROM time_blockers WHERE id = $1`, blockerID).Scan(&count)
|
|
if err != nil {
|
|
t.Fatalf("failed to check blocker: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Error("expected blocker to be deleted from DB")
|
|
}
|
|
}
|
|
|
|
// TestTimeBlockers_Delete_NotFound verifies that attempting to delete a
|
|
// non-existent blocker returns 404 Not Found.
|
|
func TestTimeBlockers_Delete_NotFound(t *testing.T) {
|
|
resetTestData(t)
|
|
|
|
// Set up chi router for URL param
|
|
r := chi.NewRouter()
|
|
r.Delete("/api/admin/time-blockers/{id}", DeleteTimeBlocker)
|
|
|
|
// Create request with non-existent ID
|
|
req := httptest.NewRequest("DELETE", "/api/admin/time-blockers/nonexistent-id", nil)
|
|
ctx := context.WithValue(req.Context(), mw.UserIDKey, "admin001")
|
|
ctx = context.WithValue(ctx, mw.UserRoleKey, "admin")
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("id", "nonexistent-id")
|
|
ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx)
|
|
req = req.WithContext(ctx)
|
|
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected status 404, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// --- Tests for CheckTimeBlockerOverlap ---
|
|
|
|
// TestCheckTimeBlockerOverlap verifies that the overlap detection function
|
|
// correctly identifies overlapping time ranges.
|
|
func TestCheckTimeBlockerOverlap(t *testing.T) {
|
|
resetTestData(t)
|
|
|
|
ukLocation, _ := time.LoadLocation("Europe/London")
|
|
// Create blocker for 10:00-11:00 (60 minutes)
|
|
blockerTime := time.Date(2026, 3, 15, 10, 0, 0, 0, ukLocation)
|
|
|
|
_, err := db.DB.Exec(context.Background(), `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
|
|
VALUES ($1, 60, 'Existing blocker', NULL)
|
|
`, blockerTime)
|
|
if err != nil {
|
|
t.Fatalf("failed to create blocker: %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Test case 1: Exact overlap (10:00-11:00)
|
|
hasOverlap, desc, err := CheckTimeBlockerOverlap(ctx,
|
|
time.Date(2026, 3, 15, 10, 0, 0, 0, ukLocation),
|
|
time.Date(2026, 3, 15, 11, 0, 0, 0, ukLocation))
|
|
if err != nil {
|
|
t.Fatalf("CheckTimeBlockerOverlap failed: %v", err)
|
|
}
|
|
if !hasOverlap {
|
|
t.Error("expected overlap for exact match booking 10:00-11:00")
|
|
}
|
|
if desc != "Existing blocker" {
|
|
t.Errorf("expected description 'Existing blocker', got %s", desc)
|
|
}
|
|
|
|
// Test case 2: No overlap (09:00-10:00 - ends exactly when blocker starts)
|
|
hasOverlap, _, err = CheckTimeBlockerOverlap(ctx,
|
|
time.Date(2026, 3, 15, 9, 0, 0, 0, ukLocation),
|
|
time.Date(2026, 3, 15, 10, 0, 0, 0, ukLocation))
|
|
if err != nil {
|
|
t.Fatalf("CheckTimeBlockerOverlap failed: %v", err)
|
|
}
|
|
if hasOverlap {
|
|
t.Error("expected no overlap for booking 09:00-10:00 (ends exactly when blocker starts)")
|
|
}
|
|
|
|
// Test case 3: Partial overlap (10:30-11:30 - starts during blocker)
|
|
hasOverlap, _, err = CheckTimeBlockerOverlap(ctx,
|
|
time.Date(2026, 3, 15, 10, 30, 0, 0, ukLocation),
|
|
time.Date(2026, 3, 15, 11, 30, 0, 0, ukLocation))
|
|
if err != nil {
|
|
t.Fatalf("CheckTimeBlockerOverlap failed: %v", err)
|
|
}
|
|
if !hasOverlap {
|
|
t.Error("expected overlap for partial overlap booking 10:30-11:30")
|
|
}
|
|
|
|
// Test case 4: Partial overlap (09:30-10:30 - ends during blocker)
|
|
hasOverlap, _, err = CheckTimeBlockerOverlap(ctx,
|
|
time.Date(2026, 3, 15, 9, 30, 0, 0, ukLocation),
|
|
time.Date(2026, 3, 15, 10, 30, 0, 0, ukLocation))
|
|
if err != nil {
|
|
t.Fatalf("CheckTimeBlockerOverlap failed: %v", err)
|
|
}
|
|
if !hasOverlap {
|
|
t.Error("expected overlap for partial overlap booking 09:30-10:30")
|
|
}
|
|
|
|
// Test case 5: No overlap (completely before blocker)
|
|
hasOverlap, _, err = CheckTimeBlockerOverlap(ctx,
|
|
time.Date(2026, 3, 15, 8, 0, 0, 0, ukLocation),
|
|
time.Date(2026, 3, 15, 9, 0, 0, 0, ukLocation))
|
|
if err != nil {
|
|
t.Fatalf("CheckTimeBlockerOverlap failed: %v", err)
|
|
}
|
|
if hasOverlap {
|
|
t.Error("expected no overlap for booking completely before blocker")
|
|
}
|
|
|
|
// Test case 6: No overlap (completely after blocker)
|
|
hasOverlap, _, err = CheckTimeBlockerOverlap(ctx,
|
|
time.Date(2026, 3, 15, 14, 0, 0, 0, ukLocation),
|
|
time.Date(2026, 3, 15, 15, 0, 0, 0, ukLocation))
|
|
if err != nil {
|
|
t.Fatalf("CheckTimeBlockerOverlap failed: %v", err)
|
|
}
|
|
if hasOverlap {
|
|
t.Error("expected no overlap for booking completely after blocker")
|
|
}
|
|
}
|
|
|
|
// --- Tests for GetTimeBlockersInRange ---
|
|
|
|
// TestGetTimeBlockersInRange verifies that blockers can be retrieved
|
|
// for a specific date range.
|
|
func TestGetTimeBlockersInRange(t *testing.T) {
|
|
resetTestData(t)
|
|
|
|
ukLocation, _ := time.LoadLocation("Europe/London")
|
|
// Create blockers on different dates
|
|
blocker1 := time.Date(2026, 3, 10, 10, 0, 0, 0, ukLocation)
|
|
blocker2 := time.Date(2026, 3, 15, 14, 0, 0, 0, ukLocation)
|
|
blocker3 := time.Date(2026, 3, 20, 9, 0, 0, 0, ukLocation)
|
|
|
|
_, err := db.DB.Exec(context.Background(), `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
|
|
VALUES ($1, 60, 'Day 10', NULL),
|
|
($2, 30, 'Day 15', NULL),
|
|
($3, 45, 'Day 20', NULL)
|
|
`, blocker1, blocker2, blocker3)
|
|
if err != nil {
|
|
t.Fatalf("failed to create blockers: %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Query range that includes blocker1 and blocker2 but not blocker3
|
|
start := time.Date(2026, 3, 1, 0, 0, 0, 0, ukLocation)
|
|
end := time.Date(2026, 3, 16, 23, 59, 59, 0, ukLocation)
|
|
|
|
blockers, err := GetTimeBlockersInRange(ctx, start, end)
|
|
if err != nil {
|
|
t.Fatalf("GetTimeBlockersInRange failed: %v", err)
|
|
}
|
|
|
|
// Should return 2 blockers (March 10 and March 15)
|
|
if len(blockers) != 2 {
|
|
t.Errorf("expected 2 blockers in range, got %d", len(blockers))
|
|
}
|
|
|
|
// Verify correct blockers returned
|
|
found := make(map[string]bool)
|
|
for _, b := range blockers {
|
|
found[b.Description] = true
|
|
}
|
|
|
|
if !found["Day 10"] {
|
|
t.Error("expected blocker 'Day 10' in range")
|
|
}
|
|
if !found["Day 15"] {
|
|
t.Error("expected blocker 'Day 15' in range")
|
|
}
|
|
if found["Day 20"] {
|
|
t.Error("did not expect blocker 'Day 20' in range (March 20)")
|
|
}
|
|
}
|
|
|
|
// TestGetTimeBlockersInRange_Empty verifies that an empty array is
|
|
// returned when no blockers exist in the range.
|
|
func TestGetTimeBlockersInRange_Empty(t *testing.T) {
|
|
resetTestData(t)
|
|
|
|
ukLocation, _ := time.LoadLocation("Europe/London")
|
|
|
|
// Create a blocker
|
|
blockerTime := time.Date(2026, 3, 15, 10, 0, 0, 0, ukLocation)
|
|
_, err := db.DB.Exec(context.Background(), `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
|
|
VALUES ($1, 60, 'March 15', NULL)
|
|
`, blockerTime)
|
|
if err != nil {
|
|
t.Fatalf("failed to create blocker: %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Query range with no blockers
|
|
start := time.Date(2026, 4, 1, 0, 0, 0, 0, ukLocation)
|
|
end := time.Date(2026, 4, 30, 23, 59, 59, 0, ukLocation)
|
|
|
|
blockers, err := GetTimeBlockersInRange(ctx, start, end)
|
|
if err != nil {
|
|
t.Fatalf("GetTimeBlockersInRange failed: %v", err)
|
|
}
|
|
|
|
if len(blockers) != 0 {
|
|
t.Errorf("expected 0 blockers in range, got %d", len(blockers))
|
|
}
|
|
}
|
|
|
|
// TestGetTimeBlockersInRange_IncludesRecurring verifies that recurring blockers
|
|
// are expanded to actual occurrences within the query range.
|
|
func TestGetTimeBlockersInRange_IncludesRecurring(t *testing.T) {
|
|
resetTestData(t)
|
|
|
|
ukLocation, _ := time.LoadLocation("Europe/London")
|
|
// Create one-off blocker for March 15
|
|
oneOffTime := time.Date(2026, 3, 15, 10, 0, 0, 0, ukLocation)
|
|
// Cron: every Monday at 10:00 (0 10 * * 1)
|
|
cronExpr := "0 10 * * 1"
|
|
|
|
_, err := db.DB.Exec(context.Background(), `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, cron_expression, created_by)
|
|
VALUES ($1, 60, 'One-off', NULL, NULL),
|
|
($2, 60, 'Recurring Monday', $3, NULL)
|
|
`, oneOffTime, oneOffTime, cronExpr)
|
|
if err != nil {
|
|
t.Fatalf("failed to create blockers: %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Query range: March 1-31, 2026
|
|
start := time.Date(2026, 3, 1, 0, 0, 0, 0, ukLocation)
|
|
end := time.Date(2026, 3, 31, 23, 59, 59, 0, ukLocation)
|
|
|
|
blockers, err := GetTimeBlockersInRange(ctx, start, end)
|
|
if err != nil {
|
|
t.Fatalf("GetTimeBlockersInRange failed: %v", err)
|
|
}
|
|
|
|
// March 2026 has 5 Mondays: 2nd, 9th, 16th, 23rd, 30th
|
|
// So we expect: 1 one-off + 5 recurring occurrences = 6 total
|
|
if len(blockers) != 6 {
|
|
t.Errorf("expected 6 blockers (1 one-off + 5 recurring), got %d", len(blockers))
|
|
for i, b := range blockers {
|
|
t.Logf("Blocker %d: %s at %v", i, b.Description, b.StartTime)
|
|
}
|
|
}
|
|
|
|
// Verify we have the one-off
|
|
foundOneOff := false
|
|
foundRecurring := 0
|
|
for _, b := range blockers {
|
|
if b.Description == "One-off" {
|
|
foundOneOff = true
|
|
}
|
|
if b.Description == "Recurring Monday" {
|
|
foundRecurring++
|
|
}
|
|
}
|
|
|
|
if !foundOneOff {
|
|
t.Error("expected to find one-off blocker")
|
|
}
|
|
if foundRecurring != 5 {
|
|
t.Errorf("expected 5 recurring Monday occurrences, got %d", foundRecurring)
|
|
}
|
|
}
|
|
|
|
// TestCleanupOldReservations verifies that reservation blockers older than 1 hour
|
|
// are automatically deleted, while recent ones are kept.
|
|
func TestCleanupOldReservations(t *testing.T) {
|
|
resetTestData(t)
|
|
|
|
ctx := context.Background()
|
|
ukLocation, _ := time.LoadLocation("Europe/London")
|
|
|
|
// Create fixture users for the test
|
|
oldUserID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create old user: %v", err)
|
|
}
|
|
|
|
recentUserID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create recent user: %v", err)
|
|
}
|
|
|
|
// Create old reservation (> 1 hour old)
|
|
oldTime := time.Now().Add(-2 * time.Hour).In(ukLocation)
|
|
_, err = db.DB.Exec(ctx, `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
|
|
VALUES ($1, 60, $2, $3)`, oldTime, fmt.Sprintf("RESERVATION:user:%s:%d", oldUserID, time.Now().UnixNano()), oldUserID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create old reservation: %v", err)
|
|
}
|
|
|
|
// Set old created_at to make it eligible for cleanup (> 1 hour old)
|
|
_, err = db.DB.Exec(ctx, `UPDATE time_blockers SET created_at = $1 WHERE description LIKE 'RESERVATION:user:%' AND start_time = $2`, time.Now().Add(-2*time.Hour), oldTime)
|
|
if err != nil {
|
|
t.Fatalf("failed to update old reservation created_at: %v", err)
|
|
}
|
|
|
|
// Create recent reservation (< 1 hour old)
|
|
recentTime := time.Now().Add(-30 * time.Minute).In(ukLocation)
|
|
_, err = db.DB.Exec(ctx, `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
|
|
VALUES ($1, 60, $2, $3)`, recentTime, fmt.Sprintf("RESERVATION:user:%s:%d", recentUserID, time.Now().UnixNano()), recentUserID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create recent reservation: %v", err)
|
|
}
|
|
|
|
// Set recent created_at to recent (< 1 hour old) so it's NOT deleted
|
|
_, err = db.DB.Exec(ctx, `UPDATE time_blockers SET created_at = $1 WHERE description LIKE 'RESERVATION:user:%' AND start_time = $2`, time.Now().Add(-30*time.Minute), recentTime)
|
|
if err != nil {
|
|
t.Fatalf("failed to update recent reservation created_at: %v", err)
|
|
}
|
|
|
|
// Create non-reservation blocker (should never be deleted)
|
|
nonResTime := time.Now().Add(-2 * time.Hour).In(ukLocation)
|
|
_, err = db.DB.Exec(ctx, `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
|
|
VALUES ($1, 60, $2, NULL)`, nonResTime, "Admin Blocked Time")
|
|
if err != nil {
|
|
t.Fatalf("failed to create non-reservation blocker: %v", err)
|
|
}
|
|
|
|
// Verify we have 3 blockers before cleanup
|
|
var countBefore int
|
|
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers").Scan(&countBefore)
|
|
if err != nil {
|
|
t.Fatalf("failed to count blockers before cleanup: %v", err)
|
|
}
|
|
if countBefore != 3 {
|
|
t.Errorf("expected 3 blockers before cleanup, got %d", countBefore)
|
|
}
|
|
|
|
// Run cleanup
|
|
err = CleanupOldReservations(ctx)
|
|
if err != nil {
|
|
t.Fatalf("CleanupOldReservations failed: %v", err)
|
|
}
|
|
|
|
// Verify old reservation was deleted
|
|
var oldCount int
|
|
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:%' AND start_time = $1", oldTime).Scan(&oldCount)
|
|
if err == nil && oldCount > 0 {
|
|
t.Error("expected old reservation to be deleted")
|
|
}
|
|
|
|
// Verify recent reservation still exists
|
|
var recentCount int
|
|
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:%' AND start_time = $1", recentTime).Scan(&recentCount)
|
|
if err != nil || recentCount == 0 {
|
|
t.Error("expected recent reservation to still exist")
|
|
}
|
|
|
|
// Verify non-reservation blocker still exists
|
|
var nonResCount int
|
|
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'Admin Blocked Time'").Scan(&nonResCount)
|
|
if err != nil || nonResCount == 0 {
|
|
t.Error("expected non-reservation blocker to still exist")
|
|
}
|
|
|
|
// Verify final count (should be 2: recent reservation + non-reservation blocker)
|
|
var countAfter int
|
|
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers").Scan(&countAfter)
|
|
if err != nil {
|
|
t.Fatalf("failed to count blockers after cleanup: %v", err)
|
|
}
|
|
if countAfter != 2 {
|
|
t.Errorf("expected 2 blockers after cleanup (1 old deletion), got %d", countAfter)
|
|
}
|
|
}
|
|
|
|
// --- Tests for CleanupOldReservations (Admin Walk-In) ---
|
|
|
|
// TestCleanupOldReservations_AdminWalkIn verifies that admin walk-in reservations
|
|
// older than 15 minutes are deleted, while recent ones are preserved.
|
|
func TestCleanupOldReservations_AdminWalkIn(t *testing.T) {
|
|
resetTestData(t)
|
|
|
|
ctx := context.Background()
|
|
ukLocation, _ := time.LoadLocation("Europe/London")
|
|
|
|
// Create old walk-in reservation (>15 min old)
|
|
oldTime := time.Now().Add(-16 * time.Minute).In(ukLocation)
|
|
_, err := db.DB.Exec(ctx, `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
|
VALUES ($1, 60, 'RESERVATION:admin:walkin:guest:123', $2)
|
|
`, oldTime, time.Now().Add(-16*time.Minute))
|
|
if err != nil {
|
|
t.Fatalf("failed to create old walk-in reservation: %v", err)
|
|
}
|
|
|
|
// Create recent walk-in reservation (<15 min old)
|
|
recentTime := time.Now().Add(-14 * time.Minute).In(ukLocation)
|
|
_, err = db.DB.Exec(ctx, `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
|
VALUES ($1, 60, 'RESERVATION:admin:walkin:guest:456', $2)
|
|
`, recentTime, time.Now().Add(-14*time.Minute))
|
|
if err != nil {
|
|
t.Fatalf("failed to create recent walk-in reservation: %v", err)
|
|
}
|
|
|
|
// Run cleanup
|
|
err = CleanupOldReservations(ctx)
|
|
if err != nil {
|
|
t.Fatalf("CleanupOldReservations failed: %v", err)
|
|
}
|
|
|
|
// Verify old reservation was deleted
|
|
var oldCount int
|
|
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:walkin:guest:123'").Scan(&oldCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to check old reservation: %v", err)
|
|
}
|
|
if oldCount != 0 {
|
|
t.Error("expected old walk-in reservation (16 min) to be deleted")
|
|
}
|
|
|
|
// Verify recent reservation still exists
|
|
var recentCount int
|
|
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:walkin:guest:456'").Scan(&recentCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to check recent reservation: %v", err)
|
|
}
|
|
if recentCount != 1 {
|
|
t.Error("expected recent walk-in reservation (14 min) to be preserved")
|
|
}
|
|
}
|
|
|
|
// --- Tests for CleanupOldReservations (Admin Call-In) ---
|
|
|
|
// TestCleanupOldReservations_AdminCallIn verifies that admin call-in reservations
|
|
// older than 15 minutes are deleted, while recent ones are preserved.
|
|
func TestCleanupOldReservations_AdminCallIn(t *testing.T) {
|
|
resetTestData(t)
|
|
|
|
ctx := context.Background()
|
|
ukLocation, _ := time.LoadLocation("Europe/London")
|
|
|
|
// Create old call-in reservation (>15 min old)
|
|
oldTime := time.Now().Add(-16 * time.Minute).In(ukLocation)
|
|
_, err := db.DB.Exec(ctx, `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
|
VALUES ($1, 60, 'RESERVATION:admin:callin:guest:123', $2)
|
|
`, oldTime, time.Now().Add(-16*time.Minute))
|
|
if err != nil {
|
|
t.Fatalf("failed to create old call-in reservation: %v", err)
|
|
}
|
|
|
|
// Create recent call-in reservation (<15 min old)
|
|
recentTime := time.Now().Add(-14 * time.Minute).In(ukLocation)
|
|
_, err = db.DB.Exec(ctx, `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
|
VALUES ($1, 60, 'RESERVATION:admin:callin:guest:456', $2)
|
|
`, recentTime, time.Now().Add(-14*time.Minute))
|
|
if err != nil {
|
|
t.Fatalf("failed to create recent call-in reservation: %v", err)
|
|
}
|
|
|
|
// Run cleanup
|
|
err = CleanupOldReservations(ctx)
|
|
if err != nil {
|
|
t.Fatalf("CleanupOldReservations failed: %v", err)
|
|
}
|
|
|
|
// Verify old reservation was deleted
|
|
var oldCount int
|
|
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:callin:guest:123'").Scan(&oldCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to check old reservation: %v", err)
|
|
}
|
|
if oldCount != 0 {
|
|
t.Error("expected old call-in reservation (16 min) to be deleted")
|
|
}
|
|
|
|
// Verify recent reservation still exists
|
|
var recentCount int
|
|
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:callin:guest:456'").Scan(&recentCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to check recent reservation: %v", err)
|
|
}
|
|
if recentCount != 1 {
|
|
t.Error("expected recent call-in reservation (14 min) to be preserved")
|
|
}
|
|
}
|
|
|
|
// --- Tests for CleanupOldReservations (Mixed Types) ---
|
|
|
|
// TestCleanupOldReservations_MixedTypes verifies that cleanup correctly handles
|
|
// all reservation types with their respective TTLs.
|
|
func TestCleanupOldReservations_MixedTypes(t *testing.T) {
|
|
resetTestData(t)
|
|
|
|
ctx := context.Background()
|
|
ukLocation, _ := time.LoadLocation("Europe/London")
|
|
|
|
// Create old user reservation (>1 hour old)
|
|
oldUserTime := time.Now().Add(-2 * time.Hour).In(ukLocation)
|
|
_, err := db.DB.Exec(ctx, `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
|
VALUES ($1, 60, 'RESERVATION:user:old', $2)
|
|
`, oldUserTime, time.Now().Add(-2*time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("failed to create old user reservation: %v", err)
|
|
}
|
|
|
|
// Create recent user reservation (<1 hour old)
|
|
recentUserTime := time.Now().Add(-30 * time.Minute).In(ukLocation)
|
|
_, err = db.DB.Exec(ctx, `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
|
VALUES ($1, 60, 'RESERVATION:user:recent', $2)
|
|
`, recentUserTime, time.Now().Add(-30*time.Minute))
|
|
if err != nil {
|
|
t.Fatalf("failed to create recent user reservation: %v", err)
|
|
}
|
|
|
|
// Create old anon reservation (>10 min old)
|
|
oldAnonTime := time.Now().Add(-15 * time.Minute).In(ukLocation)
|
|
_, err = db.DB.Exec(ctx, `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
|
VALUES ($1, 60, 'RESERVATION:anon:old', $2)
|
|
`, oldAnonTime, time.Now().Add(-15*time.Minute))
|
|
if err != nil {
|
|
t.Fatalf("failed to create old anon reservation: %v", err)
|
|
}
|
|
|
|
// Create recent anon reservation (<10 min old)
|
|
recentAnonTime := time.Now().Add(-5 * time.Minute).In(ukLocation)
|
|
_, err = db.DB.Exec(ctx, `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
|
VALUES ($1, 60, 'RESERVATION:anon:recent', $2)
|
|
`, recentAnonTime, time.Now().Add(-5*time.Minute))
|
|
if err != nil {
|
|
t.Fatalf("failed to create recent anon reservation: %v", err)
|
|
}
|
|
|
|
// Create old admin walk-in reservation (>15 min old)
|
|
oldWalkinTime := time.Now().Add(-20 * time.Minute).In(ukLocation)
|
|
_, err = db.DB.Exec(ctx, `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
|
VALUES ($1, 60, 'RESERVATION:admin:walkin:old', $2)
|
|
`, oldWalkinTime, time.Now().Add(-20*time.Minute))
|
|
if err != nil {
|
|
t.Fatalf("failed to create old walk-in reservation: %v", err)
|
|
}
|
|
|
|
// Create recent admin walk-in reservation (<15 min old)
|
|
recentWalkinTime := time.Now().Add(-10 * time.Minute).In(ukLocation)
|
|
_, err = db.DB.Exec(ctx, `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
|
VALUES ($1, 60, 'RESERVATION:admin:walkin:recent', $2)
|
|
`, recentWalkinTime, time.Now().Add(-10*time.Minute))
|
|
if err != nil {
|
|
t.Fatalf("failed to create recent walk-in reservation: %v", err)
|
|
}
|
|
|
|
// Create old admin call-in reservation (>15 min old)
|
|
oldCallinTime := time.Now().Add(-20 * time.Minute).In(ukLocation)
|
|
_, err = db.DB.Exec(ctx, `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
|
VALUES ($1, 60, 'RESERVATION:admin:callin:old', $2)
|
|
`, oldCallinTime, time.Now().Add(-20*time.Minute))
|
|
if err != nil {
|
|
t.Fatalf("failed to create old call-in reservation: %v", err)
|
|
}
|
|
|
|
// Create recent admin call-in reservation (<15 min old)
|
|
recentCallinTime := time.Now().Add(-10 * time.Minute).In(ukLocation)
|
|
_, err = db.DB.Exec(ctx, `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
|
VALUES ($1, 60, 'RESERVATION:admin:callin:recent', $2)
|
|
`, recentCallinTime, time.Now().Add(-10*time.Minute))
|
|
if err != nil {
|
|
t.Fatalf("failed to create recent call-in reservation: %v", err)
|
|
}
|
|
|
|
// Verify we have 8 reservations before cleanup
|
|
var countBefore int
|
|
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:%'").Scan(&countBefore)
|
|
if err != nil {
|
|
t.Fatalf("failed to count reservations before cleanup: %v", err)
|
|
}
|
|
if countBefore != 8 {
|
|
t.Errorf("expected 8 reservations before cleanup, got %d", countBefore)
|
|
}
|
|
|
|
// Run cleanup
|
|
err = CleanupOldReservations(ctx)
|
|
if err != nil {
|
|
t.Fatalf("CleanupOldReservations failed: %v", err)
|
|
}
|
|
|
|
// Verify old reservations were deleted (4 old ones)
|
|
var oldUserCount, oldAnonCount, oldWalkinCount, oldCallinCount int
|
|
db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:user:old'").Scan(&oldUserCount)
|
|
db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:anon:old'").Scan(&oldAnonCount)
|
|
db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:walkin:old'").Scan(&oldWalkinCount)
|
|
db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:callin:old'").Scan(&oldCallinCount)
|
|
|
|
if oldUserCount != 0 {
|
|
t.Error("expected old user reservation to be deleted")
|
|
}
|
|
if oldAnonCount != 0 {
|
|
t.Error("expected old anon reservation to be deleted")
|
|
}
|
|
if oldWalkinCount != 0 {
|
|
t.Error("expected old walk-in reservation to be deleted")
|
|
}
|
|
if oldCallinCount != 0 {
|
|
t.Error("expected old call-in reservation to be deleted")
|
|
}
|
|
|
|
// Verify recent reservations still exist (4 recent ones)
|
|
var recentUserCount, recentAnonCount, recentWalkinCount, recentCallinCount int
|
|
db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:user:recent'").Scan(&recentUserCount)
|
|
db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:anon:recent'").Scan(&recentAnonCount)
|
|
db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:walkin:recent'").Scan(&recentWalkinCount)
|
|
db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:callin:recent'").Scan(&recentCallinCount)
|
|
|
|
if recentUserCount != 1 {
|
|
t.Error("expected recent user reservation to be preserved")
|
|
}
|
|
if recentAnonCount != 1 {
|
|
t.Error("expected recent anon reservation to be preserved")
|
|
}
|
|
if recentWalkinCount != 1 {
|
|
t.Error("expected recent walk-in reservation to be preserved")
|
|
}
|
|
if recentCallinCount != 1 {
|
|
t.Error("expected recent call-in reservation to be preserved")
|
|
}
|
|
}
|
|
|
|
// --- Tests for GetTimeBlockersInRange (Excludes Reservations) ---
|
|
|
|
// TestGetTimeBlockersInRange_ExcludesReservations verifies that reservation
|
|
// blockers are excluded from the results.
|
|
func TestGetTimeBlockersInRange_ExcludesReservations(t *testing.T) {
|
|
resetTestData(t)
|
|
|
|
ctx := context.Background()
|
|
ukLocation, _ := time.LoadLocation("Europe/London")
|
|
|
|
// Create a regular blocker for tomorrow at 10:00
|
|
tomorrow := time.Now().Add(24 * time.Hour).In(ukLocation)
|
|
blockerTime := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 10, 0, 0, 0, ukLocation)
|
|
_, err := db.DB.Exec(ctx, `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
|
|
VALUES ($1, 60, 'Staff meeting', NULL)
|
|
`, blockerTime)
|
|
if err != nil {
|
|
t.Fatalf("failed to create regular blocker: %v", err)
|
|
}
|
|
|
|
// Create a reservation for tomorrow at 11:00
|
|
reservationTime := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 11, 0, 0, 0, ukLocation)
|
|
_, err = db.DB.Exec(ctx, `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
|
|
VALUES ($1, 60, 'RESERVATION:user:abc:123', NULL)
|
|
`, reservationTime)
|
|
if err != nil {
|
|
t.Fatalf("failed to create reservation: %v", err)
|
|
}
|
|
|
|
// Query range covering both times
|
|
start := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 0, 0, 0, 0, ukLocation)
|
|
end := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 23, 59, 59, 0, ukLocation)
|
|
|
|
blockers, err := GetTimeBlockersInRange(ctx, start, end)
|
|
if err != nil {
|
|
t.Fatalf("GetTimeBlockersInRange failed: %v", err)
|
|
}
|
|
|
|
// Should return only 1 blocker (the regular one, not the reservation)
|
|
if len(blockers) != 1 {
|
|
t.Errorf("expected 1 blocker, got %d", len(blockers))
|
|
}
|
|
|
|
// Verify the blocker is "Staff meeting"
|
|
if len(blockers) > 0 && blockers[0].Description != "Staff meeting" {
|
|
t.Errorf("expected blocker 'Staff meeting', got '%s'", blockers[0].Description)
|
|
}
|
|
}
|
|
|
|
// --- Tests for AnonymizeStaleGuestAccounts ---
|
|
|
|
// TestAnonymizeStaleGuestAccounts_Exactly6Months verifies that a guest with
|
|
// a booking exactly 6 months ago is anonymized.
|
|
func TestAnonymizeStaleGuestAccounts_Exactly6Months(t *testing.T) {
|
|
resetTestData(t)
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create guest user
|
|
guestID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create guest user: %v", err)
|
|
}
|
|
|
|
// Set account_role to guest
|
|
_, err = db.DB.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guestID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set guest role: %v", err)
|
|
}
|
|
|
|
// Create booking with start_time exactly 6 months ago
|
|
_, err = db.DB.Exec(ctx, `
|
|
INSERT INTO bookings (user_id, start_time, status, deposit_required)
|
|
VALUES ($1, NOW() - INTERVAL '6 months', 'completed', false)
|
|
`, guestID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
// Run anonymization
|
|
err = AnonymizeStaleGuestAccounts(ctx)
|
|
if err != nil {
|
|
t.Fatalf("AnonymizeStaleGuestAccounts failed: %v", err)
|
|
}
|
|
|
|
// Verify guest was anonymized
|
|
var firstName, lastName, email string
|
|
err = db.DB.QueryRow(ctx, `SELECT n_first_name, n_last_name, email FROM users WHERE id = $1`, guestID).Scan(&firstName, &lastName, &email)
|
|
if err != nil {
|
|
t.Fatalf("failed to query anonymized user: %v", err)
|
|
}
|
|
|
|
if firstName != "Guest" {
|
|
t.Errorf("expected first_name 'Guest', got '%s'", firstName)
|
|
}
|
|
if lastName != "Anonymized" {
|
|
t.Errorf("expected last_name 'Anonymized', got '%s'", lastName)
|
|
}
|
|
if !strings.HasPrefix(email, "anon-") {
|
|
t.Errorf("expected email to start with 'anon-', got '%s'", email)
|
|
}
|
|
}
|
|
|
|
// TestAnonymizeStaleGuestAccounts_ActiveBooking_Skipped verifies that a guest
|
|
// with an active (future) booking is NOT anonymized even if they have a past booking.
|
|
func TestAnonymizeStaleGuestAccounts_ActiveBooking_Skipped(t *testing.T) {
|
|
resetTestData(t)
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create guest user
|
|
guestID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create guest user: %v", err)
|
|
}
|
|
|
|
// Set account_role to guest
|
|
_, err = db.DB.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guestID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set guest role: %v", err)
|
|
}
|
|
|
|
// Create past booking (7 months ago)
|
|
_, err = db.DB.Exec(ctx, `
|
|
INSERT INTO bookings (user_id, start_time, status, deposit_required)
|
|
VALUES ($1, NOW() - INTERVAL '7 months', 'completed', false)
|
|
`, guestID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create past booking: %v", err)
|
|
}
|
|
|
|
// Create active booking (tomorrow)
|
|
tomorrow := time.Now().Add(24 * time.Hour)
|
|
_, err = db.DB.Exec(ctx, `
|
|
INSERT INTO bookings (user_id, start_time, status, deposit_required)
|
|
VALUES ($1, $2, 'confirmed', false)
|
|
`, guestID, tomorrow)
|
|
if err != nil {
|
|
t.Fatalf("failed to create active booking: %v", err)
|
|
}
|
|
|
|
// Run anonymization
|
|
err = AnonymizeStaleGuestAccounts(ctx)
|
|
if err != nil {
|
|
t.Fatalf("AnonymizeStaleGuestAccounts failed: %v", err)
|
|
}
|
|
|
|
// Verify guest was NOT anonymized
|
|
var firstName string
|
|
err = db.DB.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, guestID).Scan(&firstName)
|
|
if err != nil {
|
|
t.Fatalf("failed to query user: %v", err)
|
|
}
|
|
|
|
// The first name should NOT be "Guest" (it should retain original name)
|
|
if firstName == "Guest" {
|
|
t.Error("expected guest with active booking to NOT be anonymized")
|
|
}
|
|
}
|
|
|
|
// TestAnonymizeStaleGuestAccounts_NoBookings verifies that a guest with
|
|
// no bookings is NOT anonymized.
|
|
func TestAnonymizeStaleGuestAccounts_NoBookings(t *testing.T) {
|
|
resetTestData(t)
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create guest user with no bookings
|
|
guestID, err := fixtures.CreateTestUser(db.DB)
|
|
if err != nil {
|
|
t.Fatalf("failed to create guest user: %v", err)
|
|
}
|
|
|
|
// Set account_role to guest
|
|
_, err = db.DB.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guestID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set guest role: %v", err)
|
|
}
|
|
|
|
// Run anonymization
|
|
err = AnonymizeStaleGuestAccounts(ctx)
|
|
if err != nil {
|
|
t.Fatalf("AnonymizeStaleGuestAccounts failed: %v", err)
|
|
}
|
|
|
|
// Verify guest was NOT anonymized
|
|
var firstName string
|
|
err = db.DB.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, guestID).Scan(&firstName)
|
|
if err != nil {
|
|
t.Fatalf("failed to query user: %v", err)
|
|
}
|
|
|
|
// The first name should NOT be "Guest" (it should retain original name)
|
|
if firstName == "Guest" {
|
|
t.Error("expected guest with no bookings to NOT be anonymized")
|
|
}
|
|
}
|
|
|
|
// Ensure bytes is used to avoid unused import error
|
|
var _ = bytes.Buffer{}
|
|
|
|
func TestAnonymizeStaleGuestAccounts(t *testing.T) {
|
|
resetTestData(t)
|
|
|
|
ctx := context.Background()
|
|
|
|
// Guest 1: last booking 7 months ago — should be anonymized
|
|
guest1ID, _ := fixtures.CreateTestUser(db.DB)
|
|
db.DB.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guest1ID)
|
|
db.DB.Exec(ctx, `
|
|
INSERT INTO bookings (user_id, start_time, status, deposit_required)
|
|
VALUES ($1, NOW() - INTERVAL '7 months', 'completed', false)
|
|
`, guest1ID)
|
|
|
|
// Guest 2: last booking 3 months ago — should NOT be anonymized
|
|
guest2ID, _ := fixtures.CreateTestUser(db.DB)
|
|
db.DB.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guest2ID)
|
|
db.DB.Exec(ctx, `
|
|
INSERT INTO bookings (user_id, start_time, status, deposit_required)
|
|
VALUES ($1, NOW() - INTERVAL '3 months', 'completed', false)
|
|
`, guest2ID)
|
|
|
|
// Guest 3: has a pending booking — should NOT be anonymized (regardless of booking age)
|
|
guest3ID, _ := fixtures.CreateTestUser(db.DB)
|
|
db.DB.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guest3ID)
|
|
db.DB.Exec(ctx, `
|
|
INSERT INTO bookings (user_id, start_time, status, deposit_required)
|
|
VALUES ($1, NOW() + INTERVAL '2 days', 'pending', false)
|
|
`, guest3ID)
|
|
|
|
// Run anonymization
|
|
err := AnonymizeStaleGuestAccounts(ctx)
|
|
if err != nil {
|
|
t.Fatalf("AnonymizeStaleGuestAccounts failed: %v", err)
|
|
}
|
|
|
|
// Guest 1 should be anonymized
|
|
var g1Name string
|
|
db.DB.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, guest1ID).Scan(&g1Name)
|
|
if g1Name != "Guest" {
|
|
t.Errorf("expected guest 1 to be anonymized, got first_name='%s'", g1Name)
|
|
}
|
|
|
|
// Guest 2 should NOT be anonymized
|
|
var g2Name string
|
|
db.DB.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, guest2ID).Scan(&g2Name)
|
|
if g2Name == "Guest" {
|
|
t.Error("expected guest 2 to NOT be anonymized (booking too recent)")
|
|
}
|
|
|
|
// Guest 3 should NOT be anonymized (has pending booking)
|
|
var g3Name string
|
|
db.DB.QueryRow(ctx, `SELECT n_first_name FROM users WHERE id = $1`, guest3ID).Scan(&g3Name)
|
|
if g3Name == "Guest" {
|
|
t.Error("expected guest 3 to NOT be anonymized (has pending booking)")
|
|
}
|
|
|
|
// Verify guest 1's email was anonymized
|
|
var g1Email string
|
|
db.DB.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, guest1ID).Scan(&g1Email)
|
|
if !strings.HasPrefix(g1Email, "anon-") {
|
|
t.Errorf("expected guest 1 email to start with 'anon-', got '%s'", g1Email)
|
|
}
|
|
}
|
|
|
|
// --- Tests for CleanupOldReservations (Edit Request) ---
|
|
|
|
// TestCleanupOldReservations_EditRequest verifies that edit request reservations
|
|
// older than 24 hours are deleted, while recent ones are preserved.
|
|
func TestCleanupOldReservations_EditRequest(t *testing.T) {
|
|
resetTestData(t)
|
|
|
|
ctx := context.Background()
|
|
ukLocation, _ := time.LoadLocation("Europe/London")
|
|
|
|
// Create old edit_request reservation (>24 hours old)
|
|
oldTime := time.Now().Add(-25 * time.Hour).In(ukLocation)
|
|
_, err := db.DB.Exec(ctx, `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
|
VALUES ($1, 60, 'RESERVATION:edit_request:bk123', $2)
|
|
`, oldTime, time.Now().Add(-25*time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("failed to create old edit_request reservation: %v", err)
|
|
}
|
|
|
|
// Create recent edit_request reservation (<24 hours old)
|
|
recentTime := time.Now().Add(-12 * time.Hour).In(ukLocation)
|
|
_, err = db.DB.Exec(ctx, `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
|
VALUES ($1, 60, 'RESERVATION:edit_request:bk456', $2)
|
|
`, recentTime, time.Now().Add(-12*time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("failed to create recent edit_request reservation: %v", err)
|
|
}
|
|
|
|
// Run cleanup
|
|
err = CleanupOldReservations(ctx)
|
|
if err != nil {
|
|
t.Fatalf("CleanupOldReservations failed: %v", err)
|
|
}
|
|
|
|
// Verify old reservation was deleted
|
|
var oldCount int
|
|
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:edit_request:bk123'").Scan(&oldCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to check old reservation: %v", err)
|
|
}
|
|
if oldCount != 0 {
|
|
t.Error("expected old edit_request reservation (25h) to be deleted")
|
|
}
|
|
|
|
// Verify recent reservation still exists
|
|
var recentCount int
|
|
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:edit_request:bk456'").Scan(&recentCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to check recent reservation: %v", err)
|
|
}
|
|
if recentCount != 1 {
|
|
t.Error("expected recent edit_request reservation (12h) to be preserved")
|
|
}
|
|
}
|