Files
Crussell/backend/handlers/scheduling/time_blockers_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

2941 lines
94 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"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"crussell/clock"
"crussell/mw"
"crussell/testutils/fixtures"
"github.com/go-chi/chi/v5"
)
func makeTimeBlockerRequest(handler http.HandlerFunc, method, path string, body interface{}, ctx context.Context) *httptest.ResponseRecorder {
var req *http.Request
if body != nil {
bodyBytes, _ := json.Marshal(body)
req = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, path, nil)
}
req = req.WithContext(ctx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
return w
}
func makeTimeBlockerAuthRequest(handler http.HandlerFunc, method, path string, body interface{}, ctx context.Context) *httptest.ResponseRecorder {
var req *http.Request
if body != nil {
bodyBytes, _ := json.Marshal(body)
req = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, path, nil)
}
// Layer admin context on top of test transaction context
chiCtx := context.WithValue(ctx, mw.UserRoleKey, "admin")
req = req.WithContext(chiCtx)
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) {
t.Parallel()
ctx, tx := resetTestData(t)
blockerTime1 := clock.Now().Add(7 * 24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
blockerTime2 := clock.Now().Add(8 * 24 * time.Hour).Truncate(24 * time.Hour).Add(14 * time.Hour)
_, err := tx.Exec(ctx, `
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, ctx)
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) {
t.Parallel()
ctx, tx := resetTestData(t)
// Create blockers on different dates
blockerTime1 := time.Date(2026, 3, 10, 10, 0, 0, 0, time.UTC) // In range
blockerTime2 := time.Date(2026, 3, 15, 14, 0, 0, 0, time.UTC) // Out of range
blockerTime3 := time.Date(2026, 3, 12, 9, 0, 0, 0, time.UTC) // In range
_, err := tx.Exec(ctx, `
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, ctx)
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) {
t.Parallel()
ctx, tx := resetTestData(t)
blockerTime := time.Date(2026, 3, 20, 10, 0, 0, 0, time.UTC)
reqBody := CreateTimeBlockerRequest{
StartTime: blockerTime,
DurationMinutes: 60,
Description: "Test blocker",
}
handler := http.HandlerFunc(CreateTimeBlocker)
w := makeTimeBlockerAuthRequest(handler, "POST", "/api/admin/time-blockers", reqBody, ctx)
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 := tx.QueryRow(ctx, `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) {
t.Parallel()
ctx, _ := 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, ctx)
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
blockerTime := time.Date(2026, 3, 20, 10, 0, 0, 0, time.UTC)
reqBody2 := map[string]interface{}{
"start_time": blockerTime,
"description": "Test",
}
w = makeTimeBlockerAuthRequest(handler, "POST", "/api/admin/time-blockers", reqBody2, ctx)
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, ctx)
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, ctx)
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) {
t.Parallel()
ctx, tx := resetTestData(t)
blockerTime := time.Date(2026, 3, 25, 10, 0, 0, 0, time.UTC)
// Create a blocker to delete
var blockerID string
err := tx.QueryRow(ctx, `
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)
reqCtx := context.WithValue(ctx, mw.UserIDKey, "admin001")
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin")
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", blockerID)
reqCtx = context.WithValue(reqCtx, chi.RouteCtxKey, rctx)
req = req.WithContext(reqCtx)
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 = tx.QueryRow(ctx, `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) {
t.Parallel()
ctx, _ := 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)
reqCtx := context.WithValue(ctx, mw.UserIDKey, "admin001")
reqCtx = context.WithValue(reqCtx, mw.UserRoleKey, "admin")
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", "nonexistent-id")
reqCtx = context.WithValue(reqCtx, chi.RouteCtxKey, rctx)
req = req.WithContext(reqCtx)
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) {
t.Parallel()
ctx, tx := resetTestData(t)
// Create blocker for 10:00-11:00 (60 minutes)
blockerTime := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC)
_, err := tx.Exec(ctx, `
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)
}
// Test case 1: Exact overlap (10:00-11:00)
hasOverlap, desc, err := CheckTimeBlockerOverlap(ctx,
time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC),
time.Date(2026, 3, 15, 11, 0, 0, 0, time.UTC))
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, time.UTC),
time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC))
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, time.UTC),
time.Date(2026, 3, 15, 11, 30, 0, 0, time.UTC))
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, time.UTC),
time.Date(2026, 3, 15, 10, 30, 0, 0, time.UTC))
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, time.UTC),
time.Date(2026, 3, 15, 9, 0, 0, 0, time.UTC))
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, time.UTC),
time.Date(2026, 3, 15, 15, 0, 0, 0, time.UTC))
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) {
t.Parallel()
ctx, tx := resetTestData(t)
// Create blockers on different dates
blocker1 := time.Date(2026, 3, 10, 10, 0, 0, 0, time.UTC)
blocker2 := time.Date(2026, 3, 15, 14, 0, 0, 0, time.UTC)
blocker3 := time.Date(2026, 3, 20, 9, 0, 0, 0, time.UTC)
_, err := tx.Exec(ctx, `
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)
}
// Query range that includes blocker1 and blocker2 but not blocker3
start := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2026, 3, 16, 23, 59, 59, 0, time.UTC)
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) {
t.Parallel()
ctx, tx := resetTestData(t)
// Create a blocker
blockerTime := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC)
_, err := tx.Exec(ctx, `
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)
}
// Query range with no blockers
start := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2026, 4, 30, 23, 59, 59, 0, time.UTC)
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) {
t.Parallel()
ctx, tx := resetTestData(t)
// Create one-off blocker for March 15
oneOffTime := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC)
// Cron: every Monday at 10:00 (0 10 * * 1)
cronExpr := "0 10 * * 1"
_, err := tx.Exec(ctx, `
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)
}
// Query range: March 1-31, 2026
start := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2026, 3, 31, 23, 59, 59, 0, time.UTC)
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) {
t.Parallel()
ctx, tx := resetTestData(t)
// Create fixture users for the test
oldUserID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create old user: %v", err)
}
recentUserID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create recent user: %v", err)
}
// Create old reservation (> 1 hour old)
oldTime := clock.Now().Add(-2 * time.Hour)
_, err = tx.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, clock.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 = tx.Exec(ctx, `UPDATE time_blockers SET created_at = $1 WHERE description LIKE 'RESERVATION:user:%' AND start_time = $2`, clock.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 := clock.Now().Add(-30 * time.Minute)
_, err = tx.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, clock.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 = tx.Exec(ctx, `UPDATE time_blockers SET created_at = $1 WHERE description LIKE 'RESERVATION:user:%' AND start_time = $2`, clock.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 := clock.Now().Add(-2 * time.Hour)
_, err = tx.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 = tx.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 = tx.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 = tx.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 = tx.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 = tx.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) {
t.Parallel()
ctx, tx := resetTestData(t)
// Create old walk-in reservation (>15 min old)
oldTime := clock.Now().Add(-16 * time.Minute)
_, err := tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
VALUES ($1, 60, 'RESERVATION:admin:walkin:guest:123', $2)
`, oldTime, clock.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 := clock.Now().Add(-14 * time.Minute)
_, err = tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
VALUES ($1, 60, 'RESERVATION:admin:walkin:guest:456', $2)
`, recentTime, clock.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 = tx.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 = tx.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) {
t.Parallel()
ctx, tx := resetTestData(t)
// Create old call-in reservation (>15 min old)
oldTime := clock.Now().Add(-16 * time.Minute)
_, err := tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
VALUES ($1, 60, 'RESERVATION:admin:callin:guest:123', $2)
`, oldTime, clock.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 := clock.Now().Add(-14 * time.Minute)
_, err = tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
VALUES ($1, 60, 'RESERVATION:admin:callin:guest:456', $2)
`, recentTime, clock.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 = tx.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 = tx.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) {
t.Parallel()
ctx, tx := resetTestData(t)
// Create old user reservation (>1 hour old)
oldUserTime := clock.Now().Add(-2 * time.Hour)
_, err := tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
VALUES ($1, 60, 'RESERVATION:user:old', $2)
`, oldUserTime, clock.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 := clock.Now().Add(-30 * time.Minute)
_, err = tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
VALUES ($1, 60, 'RESERVATION:user:recent', $2)
`, recentUserTime, clock.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 := clock.Now().Add(-15 * time.Minute)
_, err = tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
VALUES ($1, 60, 'RESERVATION:anon:old', $2)
`, oldAnonTime, clock.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 := clock.Now().Add(-5 * time.Minute)
_, err = tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
VALUES ($1, 60, 'RESERVATION:anon:recent', $2)
`, recentAnonTime, clock.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 := clock.Now().Add(-20 * time.Minute)
_, err = tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
VALUES ($1, 60, 'RESERVATION:admin:walkin:old', $2)
`, oldWalkinTime, clock.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 := clock.Now().Add(-10 * time.Minute)
_, err = tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
VALUES ($1, 60, 'RESERVATION:admin:walkin:recent', $2)
`, recentWalkinTime, clock.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 := clock.Now().Add(-20 * time.Minute)
_, err = tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
VALUES ($1, 60, 'RESERVATION:admin:callin:old', $2)
`, oldCallinTime, clock.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 := clock.Now().Add(-10 * time.Minute)
_, err = tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
VALUES ($1, 60, 'RESERVATION:admin:callin:recent', $2)
`, recentCallinTime, clock.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 = tx.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
tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:user:old'").Scan(&oldUserCount)
tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:anon:old'").Scan(&oldAnonCount)
tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:walkin:old'").Scan(&oldWalkinCount)
tx.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
tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:user:recent'").Scan(&recentUserCount)
tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:anon:recent'").Scan(&recentAnonCount)
tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:admin:walkin:recent'").Scan(&recentWalkinCount)
tx.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_IncludesReservations verifies that reservation
// blockers ARE included in the results (needed for available-hours to correctly
// exclude reserved slots).
func TestGetTimeBlockersInRange_IncludesReservations(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
// Create a regular blocker for tomorrow at 10:00
tomorrow := clock.Now().Add(24 * time.Hour)
blockerTime := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 10, 0, 0, 0, time.UTC)
_, err := tx.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, time.UTC)
_, err = tx.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, time.UTC)
end := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 23, 59, 59, 0, time.UTC)
blockers, err := GetTimeBlockersInRange(ctx, start, end)
if err != nil {
t.Fatalf("GetTimeBlockersInRange failed: %v", err)
}
// Should return both blockers (regular + reservation)
if len(blockers) != 2 {
t.Errorf("expected 2 blockers (including reservation), got %d", len(blockers))
}
}
// --- Tests for AnonymizeStaleGuestAccounts ---
// TestAnonymizeStaleGuestAccounts_Exactly6Months verifies that a guest with
// a booking exactly 6 months ago is anonymized.
func TestAnonymizeStaleGuestAccounts_Exactly6Months(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
// Create guest user
guestID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create guest user: %v", err)
}
// Set account_role to guest
_, err = tx.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 more than 6 months ago
// (strictly less than NOW() - INTERVAL '6 months' per the SQL condition)
_, err = tx.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 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 = tx.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) {
t.Parallel()
ctx, tx := resetTestData(t)
// Create guest user
guestID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create guest user: %v", err)
}
// Set account_role to guest
_, err = tx.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 = tx.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 := clock.Now().Add(24 * time.Hour)
_, err = tx.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 = tx.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) {
t.Parallel()
ctx, tx := resetTestData(t)
// Create guest user with no bookings
guestID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create guest user: %v", err)
}
// Set account_role to guest
_, err = tx.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 = tx.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")
}
}
// --- Tests for CleanupExpiredFinancialRecords ---
// TestCleanupExpiredFinancialRecords_PaymentOlderThan7Years verifies that a
// payment older than 7 years is aggregated and deleted.
func TestCleanupExpiredFinancialRecords_PaymentOlderThan7Years(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(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.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
eightYearsAgo := clock.Now().AddDate(-8, 0, 0)
_, err = tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_at)
VALUES ($1, 'full', 'cash', 'completed', 50.00, $2)
`, bookingID, eightYearsAgo)
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
// Run cleanup
err = CleanupExpiredFinancialRecords(ctx)
if err != nil {
t.Fatalf("CleanupExpiredFinancialRecords failed: %v", err)
}
// Verify payment was deleted
var paymentCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&paymentCount)
if err != nil {
t.Fatalf("failed to count payments: %v", err)
}
if paymentCount != 0 {
t.Errorf("expected payment to be deleted, got %d payment(s)", paymentCount)
}
// Verify financial_aggregates has 1 row
var aggCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM financial_aggregates").Scan(&aggCount)
if err != nil {
t.Fatalf("failed to count aggregates: %v", err)
}
if aggCount != 1 {
t.Errorf("expected 1 financial_aggregates row, got %d", aggCount)
}
// Verify aggregate has correct month and total
expectedMonth := time.Date(eightYearsAgo.Year(), eightYearsAgo.Month(), 1, 0, 0, 0, 0, time.UTC)
var aggMonth time.Time
var totalPayments float64
err = tx.QueryRow(ctx, "SELECT month, total_payments FROM financial_aggregates").Scan(&aggMonth, &totalPayments)
if err != nil {
t.Fatalf("failed to query aggregate: %v", err)
}
if !aggMonth.Equal(expectedMonth) {
t.Errorf("expected month %s, got %s", expectedMonth.Format("2006-01-02"), aggMonth.Format("2006-01-02"))
}
if totalPayments != 50.00 {
t.Errorf("expected total_payments 50.00, got %.2f", totalPayments)
}
}
// TestCleanupExpiredFinancialRecords_AnonUserWithin1YearBuffer verifies that
// an anonymized user's payment is NOT deleted when the 1-year buffer hasn't
// elapsed, even if the payment is older than 7 years.
func TestCleanupExpiredFinancialRecords_AnonUserWithin1YearBuffer(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
guestID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
_, err = tx.Exec(ctx, `
UPDATE users
SET account_role = 'guest',
email = 'anon-' || id || '@anon.invalid',
updated_at = NOW() - INTERVAL '6 months'
WHERE id = $1
`, guestID)
if err != nil {
t.Fatalf("failed to anonymize user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, guestID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
fourYearsAgo := clock.Now().AddDate(-4, 0, 0)
var paymentID string
err = tx.QueryRow(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_at)
VALUES ($1, 'full', 'cash', 'completed', 75.00, $2)
RETURNING id
`, bookingID, fourYearsAgo).Scan(&paymentID)
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
// Run cleanup
err = CleanupExpiredFinancialRecords(ctx)
if err != nil {
t.Fatalf("CleanupExpiredFinancialRecords failed: %v", err)
}
// Verify payment still exists (neither 7yr nor 1yr conditions satisfied)
var paymentCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE id = $1", paymentID).Scan(&paymentCount)
if err != nil {
t.Fatalf("failed to count payments: %v", err)
}
if paymentCount != 1 {
t.Error("expected payment to still exist")
}
// Verify no aggregate was created
var aggCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM financial_aggregates").Scan(&aggCount)
if err != nil {
t.Fatalf("failed to count aggregates: %v", err)
}
if aggCount != 0 {
t.Errorf("expected no aggregates, got %d", aggCount)
}
}
// TestCleanupExpiredFinancialRecords_PaymentOlderThan9Years verifies that a
// payment older than 9 years is always deleted regardless of user status.
func TestCleanupExpiredFinancialRecords_PaymentOlderThan9Years(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(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.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Payment created 9 years ago
nineYearsAgo := clock.Now().AddDate(-9, 0, 0)
_, err = tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_at)
VALUES ($1, 'full', 'cash', 'completed', 60.00, $2)
`, bookingID, nineYearsAgo)
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
// Run cleanup
err = CleanupExpiredFinancialRecords(ctx)
if err != nil {
t.Fatalf("CleanupExpiredFinancialRecords failed: %v", err)
}
// Verify payment was deleted
var paymentCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&paymentCount)
if err != nil {
t.Fatalf("failed to count payments: %v", err)
}
if paymentCount != 0 {
t.Errorf("expected payment to be deleted, got %d payment(s)", paymentCount)
}
// Verify financial_aggregates has 1 row
var aggCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM financial_aggregates").Scan(&aggCount)
if err != nil {
t.Fatalf("failed to count aggregates: %v", err)
}
if aggCount != 1 {
t.Errorf("expected 1 financial_aggregates row, got %d", aggCount)
}
}
// TestCleanupExpiredFinancialRecords_AggregationCorrectTotals verifies that
// multiple payments in the same month are correctly aggregated by method and type.
func TestCleanupExpiredFinancialRecords_AggregationCorrectTotals(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(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.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// 3 payments 8 years ago, all in the same month
sameMonth := clock.Now().AddDate(-8, 0, 0)
_ = sameMonth // used for all payments
// Payment 1: £50 cash
_, err = tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, fees, created_at)
VALUES ($1, 'full', 'cash', 'completed', 50.00, 0, $2)
`, bookingID, sameMonth)
if err != nil {
t.Fatalf("failed to create cash payment: %v", err)
}
// Payment 2: £30 online_square with £2.50 fees
_, err = tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, fees, created_at)
VALUES ($1, 'full', 'online_square', 'completed', 30.00, 2.50, $2)
`, bookingID, sameMonth)
if err != nil {
t.Fatalf("failed to create online payment: %v", err)
}
// Payment 3: £20 in_person_card
_, err = tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, fees, created_at)
VALUES ($1, 'full', 'in_person_card', 'completed', 20.00, 0, $2)
`, bookingID, sameMonth)
if err != nil {
t.Fatalf("failed to create card payment: %v", err)
}
// Run cleanup
err = CleanupExpiredFinancialRecords(ctx)
if err != nil {
t.Fatalf("CleanupExpiredFinancialRecords failed: %v", err)
}
// Verify aggregate totals
var totalPayments, totalCash, totalOnline, totalInPerson, totalSquareFees float64
var bookingCount int
err = tx.QueryRow(ctx, `
SELECT total_payments, total_cash, total_online, total_in_person, total_square_fees, booking_count
FROM financial_aggregates
`).Scan(&totalPayments, &totalCash, &totalOnline, &totalInPerson, &totalSquareFees, &bookingCount)
if err != nil {
t.Fatalf("failed to query aggregate: %v", err)
}
if totalPayments != 100.00 {
t.Errorf("expected total_payments 100.00, got %.2f", totalPayments)
}
if totalCash != 50.00 {
t.Errorf("expected total_cash 50.00, got %.2f", totalCash)
}
if totalOnline != 30.00 {
t.Errorf("expected total_online 30.00, got %.2f", totalOnline)
}
if totalInPerson != 20.00 {
t.Errorf("expected total_in_person 20.00, got %.2f", totalInPerson)
}
if totalSquareFees != 2.50 {
t.Errorf("expected total_square_fees 2.50, got %.2f", totalSquareFees)
}
if bookingCount != 1 {
t.Errorf("expected booking_count 1, got %d", bookingCount)
}
}
// TestCleanupExpiredFinancialRecords_Idempotent verifies that running the
// cleanup function twice produces the same result (no double-counting).
func TestCleanupExpiredFinancialRecords_Idempotent(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(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.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Payment 8 years ago
eightYearsAgo := clock.Now().AddDate(-8, 0, 0)
_, err = tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_at)
VALUES ($1, 'full', 'cash', 'completed', 100.00, $2)
`, bookingID, eightYearsAgo)
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
// First run
err = CleanupExpiredFinancialRecords(ctx)
if err != nil {
t.Fatalf("first cleanup failed: %v", err)
}
// Capture aggregate values after first run
var aggCount1 int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM financial_aggregates").Scan(&aggCount1)
if err != nil {
t.Fatalf("failed to count aggregates: %v", err)
}
var totalPayments1 float64
var aggMonth1 time.Time
err = tx.QueryRow(ctx, "SELECT month, total_payments FROM financial_aggregates").Scan(&aggMonth1, &totalPayments1)
if err != nil {
t.Fatalf("failed to query aggregate after first run: %v", err)
}
// Second run
err = CleanupExpiredFinancialRecords(ctx)
if err != nil {
t.Fatalf("second cleanup failed: %v", err)
}
// Verify same aggregate count and values
var aggCount2 int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM financial_aggregates").Scan(&aggCount2)
if err != nil {
t.Fatalf("failed to count aggregates: %v", err)
}
if aggCount2 != aggCount1 {
t.Errorf("expected %d aggregates after second run, got %d", aggCount1, aggCount2)
}
var totalPayments2 float64
var aggMonth2 time.Time
err = tx.QueryRow(ctx, "SELECT month, total_payments FROM financial_aggregates").Scan(&aggMonth2, &totalPayments2)
if err != nil {
t.Fatalf("failed to query aggregate after second run: %v", err)
}
if !aggMonth2.Equal(aggMonth1) {
t.Errorf("expected month %s, got %s", aggMonth1.Format("2006-01-02"), aggMonth2.Format("2006-01-02"))
}
if totalPayments2 != totalPayments1 {
t.Errorf("expected total_payments %.2f after second run, got %.2f", totalPayments1, totalPayments2)
}
// Verify payments are still deleted
var paymentCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&paymentCount)
if err != nil {
t.Fatalf("failed to count payments: %v", err)
}
if paymentCount != 0 {
t.Errorf("expected payments to be deleted, got %d", paymentCount)
}
}
// TestCleanupExpiredFinancialRecords_ActiveUserWithin7Years verifies that
// a payment newer than 7 years for an active user is NOT deleted.
func TestCleanupExpiredFinancialRecords_ActiveUserWithin7Years(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(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.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Payment created 3 years ago (< 7 years)
threeYearsAgo := clock.Now().AddDate(-3, 0, 0)
var paymentID string
err = tx.QueryRow(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_at)
VALUES ($1, 'full', 'cash', 'completed', 40.00, $2)
RETURNING id
`, bookingID, threeYearsAgo).Scan(&paymentID)
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
// Run cleanup
err = CleanupExpiredFinancialRecords(ctx)
if err != nil {
t.Fatalf("CleanupExpiredFinancialRecords failed: %v", err)
}
// Verify payment still exists
var paymentCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE id = $1", paymentID).Scan(&paymentCount)
if err != nil {
t.Fatalf("failed to count payments: %v", err)
}
if paymentCount != 1 {
t.Error("expected payment to still exist")
}
// Verify no aggregate was created
var aggCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM financial_aggregates").Scan(&aggCount)
if err != nil {
t.Fatalf("failed to count aggregates: %v", err)
}
if aggCount != 0 {
t.Errorf("expected no aggregates, got %d", aggCount)
}
}
// TestCleanupExpiredFinancialRecords_AnonUserBothThresholdsElapsed verifies that
// an anonymized user's payment IS deleted when BOTH the 7-year rule AND the
// 1-year post-anonymization buffer have elapsed.
func TestCleanupExpiredFinancialRecords_AnonUserBothThresholdsElapsed(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
guestID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
// Anonymize user 2 years ago
_, err = tx.Exec(ctx, `
UPDATE users
SET account_role = 'guest',
email = 'anon-' || id || '@anon.invalid',
updated_at = NOW() - INTERVAL '2 years'
WHERE id = $1
`, guestID)
if err != nil {
t.Fatalf("failed to anonymize user: %v", err)
}
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBooking(tx, guestID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Payment created 8 years ago (past 7yr rule)
// User anonymized 2 years ago (past 1yr buffer)
// Both conditions met → should be deleted
eightYearsAgo := clock.Now().AddDate(-8, 0, 0)
var paymentID string
err = tx.QueryRow(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_at)
VALUES ($1, 'full', 'cash', 'completed', 75.00, $2)
RETURNING id
`, bookingID, eightYearsAgo).Scan(&paymentID)
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
// Run cleanup
err = CleanupExpiredFinancialRecords(ctx)
if err != nil {
t.Fatalf("CleanupExpiredFinancialRecords failed: %v", err)
}
// Verify payment was deleted
var paymentCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE id = $1", paymentID).Scan(&paymentCount)
if err != nil {
t.Fatalf("failed to count payments: %v", err)
}
if paymentCount != 0 {
t.Error("expected payment to be deleted (both 7yr and 1yr+ thresholds elapsed)")
}
// Verify aggregate was created
var aggCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM financial_aggregates").Scan(&aggCount)
if err != nil {
t.Fatalf("failed to count aggregates: %v", err)
}
if aggCount != 1 {
t.Errorf("expected 1 financial_aggregates row, got %d", aggCount)
}
}
// TestCleanupExpiredFinancialRecords_RefundAggregatedAndDeleted verifies that
// refunds are aggregated and deleted alongside their parent payment when
// retention expires.
func TestCleanupExpiredFinancialRecords_RefundAggregatedAndDeleted(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(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.CreateTestBooking(tx, userID, serviceID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Payment 8 years ago
eightYearsAgo := clock.Now().AddDate(-8, 0, 0)
var paymentID string
err = tx.QueryRow(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_at)
VALUES ($1, 'full', 'cash', 'completed', 100.00, $2)
RETURNING id
`, bookingID, eightYearsAgo).Scan(&paymentID)
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
// Refund 8 years ago (same month as payment)
var refundID string
err = tx.QueryRow(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, created_at)
VALUES ($1, $2, 30.00, 'completed', 'test refund', $3)
RETURNING id
`, paymentID, bookingID, eightYearsAgo).Scan(&refundID)
if err != nil {
t.Fatalf("failed to create refund: %v", err)
}
// Run cleanup
err = CleanupExpiredFinancialRecords(ctx)
if err != nil {
t.Fatalf("CleanupExpiredFinancialRecords failed: %v", err)
}
// Verify payment was deleted
var paymentCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE id = $1", paymentID).Scan(&paymentCount)
if err != nil {
t.Fatalf("failed to count payments: %v", err)
}
if paymentCount != 0 {
t.Error("expected payment to be deleted")
}
// Verify refund was deleted
var refundCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM refunds WHERE id = $1", refundID).Scan(&refundCount)
if err != nil {
t.Fatalf("failed to count refunds: %v", err)
}
if refundCount != 0 {
t.Error("expected refund to be deleted")
}
// Verify aggregate has both payment and refund totals
var totalPayments, totalRefunds float64
err = tx.QueryRow(ctx, "SELECT total_payments, total_refunds FROM financial_aggregates").Scan(&totalPayments, &totalRefunds)
if err != nil {
t.Fatalf("failed to query aggregate: %v", err)
}
if totalPayments != 100.00 {
t.Errorf("expected total_payments 100.00, got %.2f", totalPayments)
}
if totalRefunds != 30.00 {
t.Errorf("expected total_refunds 30.00, got %.2f", totalRefunds)
}
}
// Ensure bytes is used to avoid unused import error
var _ = bytes.Buffer{}
func TestAnonymizeStaleGuestAccounts(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
// Guest 1: last booking 7 months ago — should be anonymized
guest1ID, _ := fixtures.CreateTestUser(tx)
tx.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guest1ID)
tx.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(tx)
tx.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guest2ID)
tx.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(tx)
tx.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guest3ID)
tx.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
tx.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
tx.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
tx.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
tx.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) {
t.Parallel()
ctx, tx := resetTestData(t)
// Create old edit_request reservation (>24 hours old)
oldTime := clock.Now().Add(-25 * time.Hour)
_, err := tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
VALUES ($1, 60, 'RESERVATION:edit_request:bk123', $2)
`, oldTime, clock.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 := clock.Now().Add(-12 * time.Hour)
_, err = tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
VALUES ($1, 60, 'RESERVATION:edit_request:bk456', $2)
`, recentTime, clock.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 = tx.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 = tx.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")
}
}
// --- Tests for CleanupExpiredDeposits ---
func TestCleanupExpiredDeposits_ExpiredConfirmed(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
startTime := clock.Now().Add(12 * time.Hour)
var bookingID string
err = tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status, deposit_required)
VALUES ($1, $2, 'confirmed', true)
RETURNING id
`, userID, startTime).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description)
VALUES ($1, 60, $2)
`, startTime, "RESERVATION:user:"+userID+":bk123")
if err != nil {
t.Fatalf("failed to create reservation time blocker: %v", err)
}
err = CleanupExpiredDeposits(ctx)
if err != nil {
t.Fatalf("CleanupExpiredDeposits failed: %v", err)
}
var status string
err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status)
if err != nil {
t.Fatalf("failed to query booking: %v", err)
}
if status != "pending_release" {
t.Errorf("expected status 'pending_release', got '%s'", status)
}
var notifCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'deposit_not_paid_by_deadline'", bookingID).Scan(&notifCount)
if err != nil {
t.Fatalf("failed to query admin notifications: %v", err)
}
if notifCount != 1 {
t.Errorf("expected 1 deposit_not_paid_by_deadline notification, got %d", notifCount)
}
var tbCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", "RESERVATION:user:"+userID+":bk123").Scan(&tbCount)
if err != nil {
t.Fatalf("failed to query time blockers: %v", err)
}
if tbCount != 0 {
t.Error("expected reservation time blocker to be deleted")
}
}
func TestCleanupExpiredDeposits_ExpiredPending(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
startTime := clock.Now().Add(12 * time.Hour)
var bookingID string
err = tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status, deposit_required)
VALUES ($1, $2, 'pending', true)
RETURNING id
`, userID, startTime).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description)
VALUES ($1, 60, $2)
`, startTime, "RESERVATION:user:"+userID+":bk123")
if err != nil {
t.Fatalf("failed to create reservation time blocker: %v", err)
}
err = CleanupExpiredDeposits(ctx)
if err != nil {
t.Fatalf("CleanupExpiredDeposits failed: %v", err)
}
var status string
err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status)
if err != nil {
t.Fatalf("failed to query booking: %v", err)
}
if status != "pending_release" {
t.Errorf("expected status 'pending_release', got '%s'", status)
}
var notifCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM admin_notifications WHERE booking_id = $1 AND reason = 'deposit_not_paid_by_deadline'", bookingID).Scan(&notifCount)
if err != nil {
t.Fatalf("failed to query admin notifications: %v", err)
}
if notifCount != 1 {
t.Errorf("expected 1 deposit_not_paid_by_deadline notification, got %d", notifCount)
}
var tbCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", "RESERVATION:user:"+userID+":bk123").Scan(&tbCount)
if err != nil {
t.Fatalf("failed to query time blockers: %v", err)
}
if tbCount != 0 {
t.Error("expected reservation time blocker to be deleted")
}
}
// TestCleanupExpiredDeposits_PaidDepositPreserved verifies that a booking
// past its deposit deadline with completed payment is NOT cancelled.
func TestCleanupExpiredDeposits_PaidDepositPreserved(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
startTime := clock.Now().Add(12 * time.Hour)
var bookingID string
err = tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status, deposit_required)
VALUES ($1, $2, 'confirmed', true)
RETURNING id
`, userID, startTime).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Add completed payment
_, err = tx.Exec(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_at)
VALUES ($1, 'deposit', 'online_square', 'completed', 20.00, NOW() - INTERVAL '1 hour')
`, bookingID)
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
// Create reservation time blocker
_, err = tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description)
VALUES ($1, 60, $2)
`, startTime, "RESERVATION:user:"+userID+":bk123")
if err != nil {
t.Fatalf("failed to create reservation time blocker: %v", err)
}
// Run cleanup
err = CleanupExpiredDeposits(ctx)
if err != nil {
t.Fatalf("CleanupExpiredDeposits failed: %v", err)
}
// Verify status remains 'confirmed'
var status string
err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status)
if err != nil {
t.Fatalf("failed to query booking: %v", err)
}
if status != "confirmed" {
t.Errorf("expected status 'confirmed', got '%s'", status)
}
// Verify reservation time blocker remains
var tbCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", "RESERVATION:user:"+userID+":bk123").Scan(&tbCount)
if err != nil {
t.Fatalf("failed to query time blockers: %v", err)
}
if tbCount != 1 {
t.Error("expected reservation time blocker to still exist")
}
}
// TestCleanupExpiredDeposits_FutureDeadlinePreserved verifies that a booking
// with deposit deadline in the future (e.g. starting 48h from now, deadline is 24h from now)
// is NOT cancelled.
func TestCleanupExpiredDeposits_FutureDeadlinePreserved(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
startTime := clock.Now().Add(48 * time.Hour)
var bookingID string
err = tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status, deposit_required)
VALUES ($1, $2, 'confirmed', true)
RETURNING id
`, userID, startTime).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
// Create reservation time blocker
_, err = tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description)
VALUES ($1, 60, $2)
`, startTime, "RESERVATION:user:"+userID+":bk123")
if err != nil {
t.Fatalf("failed to create reservation time blocker: %v", err)
}
// Run cleanup
err = CleanupExpiredDeposits(ctx)
if err != nil {
t.Fatalf("CleanupExpiredDeposits failed: %v", err)
}
// Verify status remains 'confirmed'
var status string
err = tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status)
if err != nil {
t.Fatalf("failed to query booking: %v", err)
}
if status != "confirmed" {
t.Errorf("expected status 'confirmed', got '%s'", status)
}
// Verify reservation time blocker remains
var tbCount int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", "RESERVATION:user:"+userID+":bk123").Scan(&tbCount)
if err != nil {
t.Fatalf("failed to query time blockers: %v", err)
}
if tbCount != 1 {
t.Error("expected reservation time blocker to still exist")
}
}
// --- Tests for CleanupExpiredGiftCards ---
// TestCleanupExpiredGiftCards verifies that gift cards unused for 24+ months
// are expired: amount_remaining set to 0, moved to gift_card_expired_balances,
// and an 'expire' transaction is recorded.
func TestCleanupExpiredGiftCards(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
// Make account_id nullable for this test — CleanupExpiredGiftCards inserts NULL
// for unredeemed gift cards (no user account to reference).
_, err := tx.Exec(ctx, `ALTER TABLE gift_card_expired_balances ALTER COLUMN account_id DROP NOT NULL`)
if err != nil {
t.Fatalf("failed to alter gift_card_expired_balances: %v", err)
}
// Create expired gift card (unused for 25 months)
var expiredCardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, last_used_at)
VALUES (100.00, 50.00, NOW() - INTERVAL '25 months')
RETURNING id
`).Scan(&expiredCardID)
if err != nil {
t.Fatalf("failed to create expired gift card: %v", err)
}
// Run cleanup
err = CleanupExpiredGiftCards(ctx)
if err != nil {
t.Fatalf("CleanupExpiredGiftCards failed: %v", err)
}
// Verify expired card's amount_remaining is 0
var amountRemaining float64
err = tx.QueryRow(ctx, `SELECT amount_remaining FROM gift_cards WHERE id = $1`, expiredCardID).Scan(&amountRemaining)
if err != nil {
t.Fatalf("failed to query gift card: %v", err)
}
if amountRemaining != 0 {
t.Errorf("expected amount_remaining 0 for expired card, got %.2f", amountRemaining)
}
// Verify gift_card_expired_balances has a record (account_id IS NULL for gift cards)
var ebCount int
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM gift_card_expired_balances WHERE account_id IS NULL`).Scan(&ebCount)
if err != nil {
t.Fatalf("failed to count expired balances: %v", err)
}
if ebCount != 1 {
t.Errorf("expected 1 expired balance record, got %d", ebCount)
}
// Verify the expired balance amount
var originalBalance float64
err = tx.QueryRow(ctx, `SELECT original_balance FROM gift_card_expired_balances WHERE account_id IS NULL`).Scan(&originalBalance)
if err != nil {
t.Fatalf("failed to query expired balance: %v", err)
}
if originalBalance != 50.00 {
t.Errorf("expected original_balance 50.00, got %.2f", originalBalance)
}
// Verify gift_card_transactions has an 'expire' transaction
var txCount int
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM gift_card_transactions WHERE gift_card_id = $1 AND transaction_type = 'expire'`, expiredCardID).Scan(&txCount)
if err != nil {
t.Fatalf("failed to count transactions: %v", err)
}
if txCount != 1 {
t.Errorf("expected 1 expire transaction, got %d", txCount)
}
}
// TestCleanupExpiredGiftCards_SkipRecentlyUsed verifies that gift cards used
// recently (last_used_at = NOW()) are NOT expired.
func TestCleanupExpiredGiftCards_SkipRecentlyUsed(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
// Make account_id nullable for this test — CleanupExpiredGiftCards inserts NULL
// for unredeemed gift cards (no user account to reference).
_, err := tx.Exec(ctx, `ALTER TABLE gift_card_expired_balances ALTER COLUMN account_id DROP NOT NULL`)
if err != nil {
t.Fatalf("failed to alter gift_card_expired_balances: %v", err)
}
// Create recently used gift card
var recentCardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, last_used_at)
VALUES (100.00, 75.00, NOW())
RETURNING id
`).Scan(&recentCardID)
if err != nil {
t.Fatalf("failed to create recent gift card: %v", err)
}
// Run cleanup
err = CleanupExpiredGiftCards(ctx)
if err != nil {
t.Fatalf("CleanupExpiredGiftCards failed: %v", err)
}
// Verify recent card's amount_remaining unchanged
var amountRemaining float64
err = tx.QueryRow(ctx, `SELECT amount_remaining FROM gift_cards WHERE id = $1`, recentCardID).Scan(&amountRemaining)
if err != nil {
t.Fatalf("failed to query gift card: %v", err)
}
if amountRemaining != 75.00 {
t.Errorf("expected amount_remaining 75.00 for recent card, got %.2f", amountRemaining)
}
// Verify no expired balances created
var ebCount int
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM gift_card_expired_balances`).Scan(&ebCount)
if err != nil {
t.Fatalf("failed to count expired balances: %v", err)
}
if ebCount != 0 {
t.Errorf("expected 0 expired balance records, got %d", ebCount)
}
}
// TestCleanupExpiredGiftCards_SkipRedeemed verifies that gift cards already
// redeemed to an account are NOT expired (they're already claimed).
func TestCleanupExpiredGiftCards_SkipRedeemed(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
// Make account_id nullable for this test — CleanupExpiredGiftCards inserts NULL
// for unredeemed gift cards (no user account to reference). Even though this
// card is redeemed, the function may also match other cards; ensure schema allows it.
_, err := tx.Exec(ctx, `ALTER TABLE gift_card_expired_balances ALTER COLUMN account_id DROP NOT NULL`)
if err != nil {
t.Fatalf("failed to alter gift_card_expired_balances: %v", err)
}
// Create a user to be the redeemer
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
// Create redeemed gift card (redeemed_by IS NOT NULL) that is otherwise expired
var redeemedCardID string
err = tx.QueryRow(ctx, `
INSERT INTO gift_cards (total_funds_added, amount_remaining, redeemed_by, redeemed_at, last_used_at)
VALUES (100.00, 25.00, $1, NOW() - INTERVAL '25 months', NOW() - INTERVAL '25 months')
RETURNING id
`, userID).Scan(&redeemedCardID)
if err != nil {
t.Fatalf("failed to create redeemed gift card: %v", err)
}
// Run cleanup
err = CleanupExpiredGiftCards(ctx)
if err != nil {
t.Fatalf("CleanupExpiredGiftCards failed: %v", err)
}
// Verify redeemed card's amount_remaining unchanged
var amountRemaining float64
err = tx.QueryRow(ctx, `SELECT amount_remaining FROM gift_cards WHERE id = $1`, redeemedCardID).Scan(&amountRemaining)
if err != nil {
t.Fatalf("failed to query gift card: %v", err)
}
if amountRemaining != 25.00 {
t.Errorf("expected amount_remaining 25.00 for redeemed card, got %.2f", amountRemaining)
}
// Verify no expired balances created (redeemed cards are skipped)
var ebCount int
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM gift_card_expired_balances`).Scan(&ebCount)
if err != nil {
t.Fatalf("failed to count expired balances: %v", err)
}
if ebCount != 0 {
t.Errorf("expected 0 expired balance records, got %d", ebCount)
}
}
// --- Tests for CleanupIdleAccounts ---
// TestCleanupIdleAccounts_WithBalance verifies that an account idle for 5+ years
// with a balance is anonymized and the balance is moved to gift_card_expired_balances.
func TestCleanupIdleAccounts_WithBalance(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
// Create a user
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
// Set last_login_at to 6 years ago (past the 5yr threshold)
_, err = tx.Exec(ctx, `UPDATE users SET last_login_at = NOW() - INTERVAL '6 years' WHERE id = $1`, userID)
if err != nil {
t.Fatalf("failed to set last_login_at: %v", err)
}
// Create a gift card balance for this user
_, err = tx.Exec(ctx, `
INSERT INTO user_giftcard_balances (user_id, balance)
VALUES ($1, 150.00)
`, userID)
if err != nil {
t.Fatalf("failed to create user giftcard balance: %v", err)
}
// Run cleanup
err = CleanupIdleAccounts(ctx)
if err != nil {
t.Fatalf("CleanupIdleAccounts failed: %v", err)
}
// Verify balance was zeroed
var balance float64
err = tx.QueryRow(ctx, `SELECT balance FROM user_giftcard_balances WHERE user_id = $1`, userID).Scan(&balance)
if err != nil {
t.Fatalf("failed to query balance: %v", err)
}
if balance != 0 {
t.Errorf("expected balance 0, got %.2f", balance)
}
// Verify gift_card_expired_balances has a record with the correct amount
var originalBalance float64
var ebCount int
err = tx.QueryRow(ctx, `SELECT COUNT(*), COALESCE(SUM(original_balance), 0) FROM gift_card_expired_balances WHERE account_id = $1`, userID).Scan(&ebCount, &originalBalance)
if err != nil {
t.Fatalf("failed to query expired balances: %v", err)
}
if ebCount != 1 {
t.Errorf("expected 1 expired balance record, got %d", ebCount)
}
if originalBalance != 150.00 {
t.Errorf("expected original_balance 150.00, got %.2f", originalBalance)
}
// Verify user was anonymized
var email string
err = tx.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, userID).Scan(&email)
if err != nil {
t.Fatalf("failed to query user email: %v", err)
}
if !strings.Contains(email, "deleted+") || !strings.HasSuffix(email, "@deleted.invalid") {
t.Errorf("expected anonymized email 'deleted+%s@deleted.invalid', got '%s'", userID, email)
}
}
// TestCleanupIdleAccounts_NoBalance verifies that an account idle for 2+ years
// with no balance is anonymized.
func TestCleanupIdleAccounts_NoBalance(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
// Create a user
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
// Set last_login_at to 3 years ago (past the 2yr threshold)
_, err = tx.Exec(ctx, `UPDATE users SET last_login_at = NOW() - INTERVAL '3 years' WHERE id = $1`, userID)
if err != nil {
t.Fatalf("failed to set last_login_at: %v", err)
}
// Run cleanup
err = CleanupIdleAccounts(ctx)
if err != nil {
t.Fatalf("CleanupIdleAccounts failed: %v", err)
}
// Verify user was anonymized
var email string
err = tx.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, userID).Scan(&email)
if err != nil {
t.Fatalf("failed to query user email: %v", err)
}
if !strings.Contains(email, "deleted+") || !strings.HasSuffix(email, "@deleted.invalid") {
t.Errorf("expected anonymized email 'deleted+%s@deleted.invalid', got '%s'", userID, email)
}
}
// TestCleanupIdleAccounts_SkipActive verifies that recently active accounts
// are NOT anonymized.
func TestCleanupIdleAccounts_SkipActive(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
// Create a user with recent last_login
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
// Set last_login_at to NOW() (active account)
_, err = tx.Exec(ctx, `UPDATE users SET last_login_at = NOW() WHERE id = $1`, userID)
if err != nil {
t.Fatalf("failed to set last_login_at: %v", err)
}
// Run cleanup
err = CleanupIdleAccounts(ctx)
if err != nil {
t.Fatalf("CleanupIdleAccounts failed: %v", err)
}
// Verify user was NOT anonymized
var email string
err = tx.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, userID).Scan(&email)
if err != nil {
t.Fatalf("failed to query user email: %v", err)
}
if strings.Contains(email, "deleted+") {
t.Errorf("expected active user to NOT be anonymized, got email '%s'", email)
}
}
// TestCleanupIdleAccounts_SkipAdminGuest verifies that admin and guest accounts
// are NOT anonymized regardless of inactivity.
func TestCleanupIdleAccounts_SkipAdminGuest(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
// Create admin user with old last_login
adminID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create admin user: %v", err)
}
_, err = tx.Exec(ctx, `UPDATE users SET account_role = 'admin', last_login_at = NOW() - INTERVAL '10 years' WHERE id = $1`, adminID)
if err != nil {
t.Fatalf("failed to set admin role and last_login: %v", err)
}
// Create guest user with old last_login
guestID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create guest user: %v", err)
}
_, err = tx.Exec(ctx, `UPDATE users SET account_role = 'guest', last_login_at = NOW() - INTERVAL '10 years' WHERE id = $1`, guestID)
if err != nil {
t.Fatalf("failed to set guest role and last_login: %v", err)
}
// Run cleanup
err = CleanupIdleAccounts(ctx)
if err != nil {
t.Fatalf("CleanupIdleAccounts failed: %v", err)
}
// Verify admin was NOT anonymized
var adminEmail string
err = tx.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, adminID).Scan(&adminEmail)
if err != nil {
t.Fatalf("failed to query admin email: %v", err)
}
if strings.Contains(adminEmail, "deleted+") {
t.Errorf("expected admin to NOT be anonymized, got email '%s'", adminEmail)
}
// Verify guest was NOT anonymized
var guestEmail string
err = tx.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, guestID).Scan(&guestEmail)
if err != nil {
t.Fatalf("failed to query guest email: %v", err)
}
if strings.Contains(guestEmail, "deleted+") {
t.Errorf("expected guest to NOT be anonymized, got email '%s'", guestEmail)
}
}
// --- Tests for CleanupOldIdempotencyKeys ---
func TestCleanupOldIdempotencyKeys_ClearsOldBookings(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
// Create an old booking (created 48h ago, status = 'completed') with idempotency_key
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
var oldBookingID string
err = tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status, idempotency_key, created_at)
VALUES ($1, NOW() - INTERVAL '1 hour', 'completed', 'old-key-001', NOW() - INTERVAL '48 hours')
RETURNING id
`, userID).Scan(&oldBookingID)
if err != nil {
t.Fatalf("failed to create old booking: %v", err)
}
// Create a recent booking (< 24h) with idempotency_key
var recentBookingID string
err = tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status, idempotency_key, created_at)
VALUES ($1, NOW(), 'completed', 'recent-key-002', NOW() - INTERVAL '2 hours')
RETURNING id
`, userID).Scan(&recentBookingID)
if err != nil {
t.Fatalf("failed to create recent booking: %v", err)
}
// Create a pending old booking (should NOT be cleared)
var pendingBookingID string
err = tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status, idempotency_key, created_at)
VALUES ($1, NOW() - INTERVAL '1 hour', 'pending', 'pending-key-003', NOW() - INTERVAL '48 hours')
RETURNING id
`, userID).Scan(&pendingBookingID)
if err != nil {
t.Fatalf("failed to create pending booking: %v", err)
}
// Run cleanup
err = CleanupOldIdempotencyKeys(ctx)
if err != nil {
t.Fatalf("CleanupOldIdempotencyKeys failed: %v", err)
}
// Verify old booking's key was cleared
var oldKey sql.NullString
err = tx.QueryRow(ctx, `SELECT idempotency_key FROM bookings WHERE id = $1`, oldBookingID).Scan(&oldKey)
if err != nil {
t.Fatalf("failed to query old booking: %v", err)
}
if oldKey.Valid {
t.Error("expected old booking's idempotency_key to be cleared")
}
// Verify recent booking's key was preserved
var recentKey sql.NullString
err = tx.QueryRow(ctx, `SELECT idempotency_key FROM bookings WHERE id = $1`, recentBookingID).Scan(&recentKey)
if err != nil {
t.Fatalf("failed to query recent booking: %v", err)
}
if !recentKey.Valid || recentKey.String != "recent-key-002" {
t.Errorf("expected recent booking's idempotency_key to be preserved, got %v", recentKey)
}
// Verify pending old booking's key was preserved
var pendingKey sql.NullString
err = tx.QueryRow(ctx, `SELECT idempotency_key FROM bookings WHERE id = $1`, pendingBookingID).Scan(&pendingKey)
if err != nil {
t.Fatalf("failed to query pending booking: %v", err)
}
if !pendingKey.Valid || pendingKey.String != "pending-key-003" {
t.Errorf("expected pending booking's idempotency_key to be preserved, got %v", pendingKey)
}
}
func TestCleanupOldIdempotencyKeys_ClearsOldPayments(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
// Create an old completed payment with idempotency_key
_, err = tx.Exec(ctx, `
INSERT INTO payments (payment_type, payment_method, status, amount, idempotency_key, created_by, created_at)
VALUES ('full', 'online_square', 'completed', 50.00, 'old-pay-key', $1, NOW() - INTERVAL '48 hours')
`, userID)
if err != nil {
t.Fatalf("failed to create old payment: %v", err)
}
err = CleanupOldIdempotencyKeys(ctx)
if err != nil {
t.Fatalf("CleanupOldIdempotencyKeys failed: %v", err)
}
var key sql.NullString
err = tx.QueryRow(ctx, `SELECT idempotency_key FROM payments WHERE idempotency_key = 'old-pay-key'`).Scan(&key)
if err == nil {
t.Error("expected old payment's idempotency_key to be cleared")
}
}
func TestCleanupOldIdempotencyKeys_ClearsOldTillSales(t *testing.T) {
ctx, tx := resetTestData(t)
// Create an admin user for till_sales.created_by
adminID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create admin: %v", err)
}
// Create an old till_sale with idempotency_key
_, err = tx.Exec(ctx, `
INSERT INTO till_sales (item_type, total_amount, unit_price, status, payment_method, idempotency_key, created_by, created_at)
VALUES ('gift_card', 25.00, 25.00, 'completed', 'cash', 'old-till-key', $1, NOW() - INTERVAL '48 hours')
`, adminID)
if err != nil {
t.Fatalf("failed to create old till_sale: %v", err)
}
err = CleanupOldIdempotencyKeys(ctx)
if err != nil {
t.Fatalf("CleanupOldIdempotencyKeys failed: %v", err)
}
var key sql.NullString
err = tx.QueryRow(ctx, `SELECT idempotency_key FROM till_sales WHERE idempotency_key = 'old-till-key'`).Scan(&key)
if err == nil {
t.Error("expected old till_sale's idempotency_key to be cleared")
}
}
// =============================================================================
// CleanupExpiredDeposits - pending_release status
// =============================================================================
func TestCleanupExpiredDeposits_SetsPendingRelease(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
t.Cleanup(func() { fixtures.DeleteUser(tx, userID) })
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
t.Cleanup(func() { fixtures.DeleteService(tx, serviceID) })
soon := clock.Now().Add(1 * time.Hour)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, soon)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
t.Cleanup(func() { fixtures.DeleteBooking(tx, bookingID) })
_, err = tx.Exec(ctx,
"UPDATE bookings SET status = 'confirmed', deposit_required = true WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to set booking: %v", err)
}
if err := CleanupExpiredDeposits(ctx); err != nil {
t.Fatalf("CleanupExpiredDeposits failed: %v", err)
}
var status string
err = tx.QueryRow(ctx,
"SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status)
if err != nil {
t.Fatalf("failed to query booking status: %v", err)
}
if status != "pending_release" {
t.Errorf("expected status 'pending_release', got %q", status)
}
}
func TestCleanupExpiredDeposits_DoesNotAffectPaidBookings(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
t.Cleanup(func() { fixtures.DeleteUser(tx, userID) })
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
t.Cleanup(func() { fixtures.DeleteService(tx, serviceID) })
soon := clock.Now().Add(1 * time.Hour)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, soon)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
t.Cleanup(func() { fixtures.DeleteBooking(tx, bookingID) })
_, err = tx.Exec(ctx,
"UPDATE bookings SET status = 'confirmed', deposit_required = true WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to set booking: %v", err)
}
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 50, "online_square", "deposit", "completed")
if err != nil {
t.Fatalf("failed to create payment: %v", err)
}
t.Cleanup(func() { fixtures.DeletePayment(tx, paymentID) })
if err := CleanupExpiredDeposits(ctx); err != nil {
t.Fatalf("CleanupExpiredDeposits failed: %v", err)
}
var status string
err = tx.QueryRow(ctx,
"SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status)
if err != nil {
t.Fatalf("failed to query booking status: %v", err)
}
if status != "confirmed" {
t.Errorf("expected status 'confirmed' (deposit was paid), got %q", status)
}
}
// =============================================================================
// CleanupExpiredLoyaltyRedemptions Tests
// =============================================================================
func TestCleanupExpiredLoyaltyRedemptions_DeletesExpiredPending(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
t.Cleanup(func() { fixtures.DeleteUser(tx, userID) })
// Insert expired pending redemption
_, err = tx.Exec(ctx, `
INSERT INTO loyalty_redemptions (user_id, status, expires_at)
VALUES ($1, 'pending', NOW() - INTERVAL '1 day')
`, userID)
if err != nil {
t.Fatalf("failed to insert expired redemption: %v", err)
}
// Insert non-expired pending redemption (should be preserved)
_, err = tx.Exec(ctx, `
INSERT INTO loyalty_redemptions (user_id, status, expires_at)
VALUES ($1, 'pending', NOW() + INTERVAL '7 days')
`, userID)
if err != nil {
t.Fatalf("failed to insert active redemption: %v", err)
}
// Insert applied redemption (different status, should be preserved)
_, err = tx.Exec(ctx, `
INSERT INTO loyalty_redemptions (user_id, status, expires_at)
VALUES ($1, 'applied', NOW() - INTERVAL '1 day')
`, userID)
if err != nil {
t.Fatalf("failed to insert applied redemption: %v", err)
}
err = CleanupExpiredLoyaltyRedemptions(ctx)
if err != nil {
t.Fatalf("CleanupExpiredLoyaltyRedemptions failed: %v", err)
}
var remaining int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM loyalty_redemptions").Scan(&remaining)
if err != nil {
t.Fatalf("failed to count redemptions: %v", err)
}
if remaining != 2 {
t.Errorf("expected 2 remaining redemptions (active + applied), got %d", remaining)
}
}
func TestCleanupExpiredLoyaltyRedemptions_NoExpired(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
t.Cleanup(func() { fixtures.DeleteUser(tx, userID) })
// Only active redemptions
_, err = tx.Exec(ctx, `
INSERT INTO loyalty_redemptions (user_id, status, expires_at)
VALUES ($1, 'pending', NOW() + INTERVAL '7 days')
`, userID)
if err != nil {
t.Fatalf("failed to insert active redemption: %v", err)
}
err = CleanupExpiredLoyaltyRedemptions(ctx)
if err != nil {
t.Fatalf("CleanupExpiredLoyaltyRedemptions failed: %v", err)
}
var count int
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM loyalty_redemptions").Scan(&count)
if err != nil {
t.Fatalf("failed to count: %v", err)
}
if count != 1 {
t.Errorf("expected 1 remaining (no expired), got %d", count)
}
}
func TestCleanupExpiredLoyaltyRedemptions_Empty(t *testing.T) {
t.Parallel()
ctx, _ := resetTestData(t)
err := CleanupExpiredLoyaltyRedemptions(ctx)
if err != nil {
t.Fatalf("CleanupExpiredLoyaltyRedemptions failed: %v", err)
}
}
// =============================================================================
// CleanupOldNameHistory Tests
// =============================================================================
func TestCleanupOldNameHistory_DeletesOldEntries(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
// Insert an old name_history entry (7 months ago)
_, err = tx.Exec(ctx, `
INSERT INTO name_history (user_id, previous_first_name, previous_last_name, changed_at)
VALUES ($1, 'Old', 'Name', NOW() - INTERVAL '7 months')
`, userID)
if err != nil {
t.Fatalf("failed to insert old name_history: %v", err)
}
// Insert a recent name_history entry (1 month ago — should be preserved)
_, err = tx.Exec(ctx, `
INSERT INTO name_history (user_id, previous_first_name, previous_last_name, changed_at)
VALUES ($1, 'Recent', 'Name', NOW() - INTERVAL '1 month')
`, userID)
if err != nil {
t.Fatalf("failed to insert recent name_history: %v", err)
}
err = CleanupOldNameHistory(ctx)
if err != nil {
t.Fatalf("CleanupOldNameHistory failed: %v", err)
}
// Verify old entry was deleted
var oldCount int
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM name_history WHERE previous_first_name = 'Old'`).Scan(&oldCount)
if err != nil {
t.Fatalf("failed to count old entries: %v", err)
}
if oldCount != 0 {
t.Errorf("expected old name_history entry to be deleted, got %d entries", oldCount)
}
// Verify recent entry was preserved
var recentCount int
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM name_history WHERE previous_first_name = 'Recent'`).Scan(&recentCount)
if err != nil {
t.Fatalf("failed to count recent entries: %v", err)
}
if recentCount != 1 {
t.Errorf("expected recent name_history entry to be preserved, got %d entries", recentCount)
}
// Verify total entries: 1 preserved (recent), old was deleted
var totalCount int
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM name_history`).Scan(&totalCount)
if err != nil {
t.Fatalf("failed to count total entries: %v", err)
}
if totalCount != 1 {
t.Errorf("expected 1 total entry (only recent preserved), got %d", totalCount)
}
}
func TestCleanupOldNameHistory_EmptyTable(t *testing.T) {
t.Parallel()
ctx, _ := resetTestData(t)
err := CleanupOldNameHistory(ctx)
if err != nil {
t.Fatalf("CleanupOldNameHistory failed: %v", err)
}
}
func TestCleanupOldNameHistory_Idempotent(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
// Insert an old entry
_, err = tx.Exec(ctx, `
INSERT INTO name_history (user_id, previous_first_name, previous_last_name, changed_at)
VALUES ($1, 'Old', 'Name', NOW() - INTERVAL '7 months')
`, userID)
if err != nil {
t.Fatalf("failed to insert name_history: %v", err)
}
// Run cleanup twice
err = CleanupOldNameHistory(ctx)
if err != nil {
t.Fatalf("first CleanupOldNameHistory failed: %v", err)
}
err = CleanupOldNameHistory(ctx)
if err != nil {
t.Fatalf("second CleanupOldNameHistory failed: %v", err)
}
// Verify no errors and still clean
var count int
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM name_history`).Scan(&count)
if err != nil {
t.Fatalf("failed to count: %v", err)
}
if count != 0 {
t.Errorf("expected 0 entries after idempotent cleanup, got %d", count)
}
}