- ChangePassword/DeleteAccount: failed-attempt lockout matching login escalation, atomic check-increment (no burst), uniform 401 with distinct bodies, passwordless accounts require 2FA unconditionally to delete, NULL-password change-password clear error - erasure: CardDAV dav_cards rows deleted inside the erasure transaction (was fire-and-forget goroutine); S3 profile-pic deletion via pending_s3_deletions outbox + retry job; stale-guest/idle-account batch paths write the outbox in-tx and skip the guessed-bucket fallback - S3_PROFILE_PICS_BUCKET unset -> fail-closed warning (once per process) - scheduler test: 27 jobs (retry-s3-deletions) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
4416 lines
151 KiB
Go
4416 lines
151 KiB
Go
//go: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"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"crussell/clock"
|
|
"crussell/db"
|
|
"crussell/handlers/payments"
|
|
"crussell/internal/adminnotify"
|
|
"crussell/internal/s3"
|
|
"crussell/internal/square"
|
|
"crussell/mw"
|
|
"crussell/testutils"
|
|
"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), nil)
|
|
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), nil)
|
|
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), nil)
|
|
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), nil)
|
|
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), nil)
|
|
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), nil)
|
|
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, nil)
|
|
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, nil)
|
|
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, nil)
|
|
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, nil)
|
|
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 GetTimeBlockersInRange (Exclude User Reservations) ---
|
|
|
|
// TestGetTimeBlockersInRange_ExcludeOwnReservation verifies that when
|
|
// excludeUserID is set, RESERVATION entries owned by that user are excluded
|
|
// from results, while other blockers (including other users' reservations) remain.
|
|
func TestGetTimeBlockersInRange_ExcludeOwnReservation(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
userAID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user A: %v", err)
|
|
}
|
|
userBID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user B: %v", err)
|
|
}
|
|
|
|
tomorrow := clock.Now().Add(24 * time.Hour)
|
|
dayStart := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 0, 0, 0, 0, time.UTC)
|
|
dayEnd := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 23, 59, 59, 0, time.UTC)
|
|
|
|
// Create a regular blocker (always returned)
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
|
|
VALUES ($1, 60, 'Staff meeting', NULL)
|
|
`, dayStart.Add(9*time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("failed to create regular blocker: %v", err)
|
|
}
|
|
|
|
// Create a RESERVATION for userA
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
|
|
VALUES ($1, 60, 'RESERVATION:user:' || $2 || ':123', $2)
|
|
`, dayStart.Add(10*time.Hour), userAID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create userA reservation: %v", err)
|
|
}
|
|
|
|
// Create a RESERVATION for userB
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
|
|
VALUES ($1, 60, 'RESERVATION:user:' || $2 || ':456', $2)
|
|
`, dayStart.Add(11*time.Hour), userBID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create userB reservation: %v", err)
|
|
}
|
|
|
|
// Test 1: excludeUserID = userA — should see regular + userB, but NOT userA's reservation
|
|
blockers, err := GetTimeBlockersInRange(ctx, dayStart, dayEnd, &userAID)
|
|
if err != nil {
|
|
t.Fatalf("GetTimeBlockersInRange failed: %v", err)
|
|
}
|
|
if len(blockers) != 2 {
|
|
t.Errorf("expected 2 blockers (regular + userB) when excluding userA, got %d", len(blockers))
|
|
for _, b := range blockers {
|
|
t.Logf(" blocker: %s created_by=%v", b.Description, b.CreatedBy)
|
|
}
|
|
}
|
|
|
|
// Test 2: excludeUserID = nil — should see all 3 (regular + userA + userB)
|
|
blockers, err = GetTimeBlockersInRange(ctx, dayStart, dayEnd, nil)
|
|
if err != nil {
|
|
t.Fatalf("GetTimeBlockersInRange failed: %v", err)
|
|
}
|
|
if len(blockers) != 3 {
|
|
t.Errorf("expected 3 blockers (all) when excludeUserID=nil, got %d", len(blockers))
|
|
}
|
|
|
|
// Test 3: excludeUserID = userB — should see regular + userA, but NOT userB's reservation
|
|
blockers, err = GetTimeBlockersInRange(ctx, dayStart, dayEnd, &userBID)
|
|
if err != nil {
|
|
t.Fatalf("GetTimeBlockersInRange failed: %v", err)
|
|
}
|
|
if len(blockers) != 2 {
|
|
t.Errorf("expected 2 blockers (regular + userA) when excluding userB, got %d", len(blockers))
|
|
}
|
|
}
|
|
|
|
// --- Tests for CheckTimeBlockerOverlap (Exclude User Reservations) ---
|
|
|
|
// TestCheckTimeBlockerOverlap_ExcludeOwnReservation verifies that RESERVATION
|
|
// entries are not considered overlapping when excludeUserID matches the owner.
|
|
func TestCheckTimeBlockerOverlap_ExcludeOwnReservation(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)
|
|
}
|
|
|
|
tomorrow := clock.Now().Add(24 * time.Hour)
|
|
slotStart := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 10, 0, 0, 0, time.UTC)
|
|
slotEnd := slotStart.Add(1 * time.Hour)
|
|
|
|
// Create a regular blocker (always detected)
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
|
|
VALUES ($1, 60, 'Existing blocker', NULL)
|
|
`, slotStart)
|
|
if err != nil {
|
|
t.Fatalf("failed to create regular blocker: %v", err)
|
|
}
|
|
|
|
// Create a RESERVATION for this user at a different time
|
|
reservationStart := slotStart.Add(2 * time.Hour)
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
|
|
VALUES ($1, 60, 'RESERVATION:user:' || $2 || ':123', $2)
|
|
`, reservationStart, userID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user reservation: %v", err)
|
|
}
|
|
|
|
// Test 1: excludeUserID = userID — the regular blocker at slotStart should
|
|
// still be detected, but the user's own RESERVATION at reservationStart
|
|
// should be ignored.
|
|
hasOverlap, desc, err := CheckTimeBlockerOverlap(ctx, slotStart, slotEnd, &userID)
|
|
if err != nil {
|
|
t.Fatalf("CheckTimeBlockerOverlap failed: %v", err)
|
|
}
|
|
if !hasOverlap {
|
|
t.Error("expected overlap with regular blocker even when excluding own reservation")
|
|
}
|
|
if desc != "Existing blocker" {
|
|
t.Errorf("expected description 'Existing blocker', got %s", desc)
|
|
}
|
|
|
|
// Test 2: excludeUserID = nil — the regular blocker should still be detected
|
|
hasOverlap, _, err = CheckTimeBlockerOverlap(ctx, slotStart, slotEnd, nil)
|
|
if err != nil {
|
|
t.Fatalf("CheckTimeBlockerOverlap failed: %v", err)
|
|
}
|
|
if !hasOverlap {
|
|
t.Error("expected overlap with regular blocker when excludeUserID=nil")
|
|
}
|
|
|
|
// Test 3: Overlap with own RESERVATION at reservationStart — with excludeUserID
|
|
// set, this should NOT be detected as overlap
|
|
resEnd := reservationStart.Add(1 * time.Hour)
|
|
hasOverlap, _, err = CheckTimeBlockerOverlap(ctx, reservationStart, resEnd, &userID)
|
|
if err != nil {
|
|
t.Fatalf("CheckTimeBlockerOverlap failed: %v", err)
|
|
}
|
|
if hasOverlap {
|
|
t.Error("expected NO overlap with own RESERVATION when excludeUserID matches")
|
|
}
|
|
|
|
// Test 4: Without excludeUserID, the own RESERVATION SHOULD be detected
|
|
hasOverlap, _, err = CheckTimeBlockerOverlap(ctx, reservationStart, resEnd, nil)
|
|
if err != nil {
|
|
t.Fatalf("CheckTimeBlockerOverlap failed: %v", err)
|
|
}
|
|
if !hasOverlap {
|
|
t.Error("expected overlap with own RESERVATION when excludeUserID=nil")
|
|
}
|
|
}
|
|
|
|
// --- 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")
|
|
}
|
|
}
|
|
|
|
// TestAnonymizeStaleGuestAccounts_ScrubsSavedCards verifies the saved-card
|
|
// scrub for stale guests: square_card_id / square_customer_id are NULLed
|
|
// (external-system references removed for GDPR storage limitation), the card
|
|
// is soft-deleted, last_4 is masked, and the fingerprint is NULLed.
|
|
func TestAnonymizeStaleGuestAccounts_ScrubsSavedCards(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
guestID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create guest user: %v", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guestID); err != nil {
|
|
t.Fatalf("failed to set guest role: %v", err)
|
|
}
|
|
|
|
// Stale booking: completed > 6 months ago.
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO bookings (user_id, start_time, status, deposit_required)
|
|
VALUES ($1, NOW() - INTERVAL '7 months', 'completed', false)
|
|
`, guestID); err != nil {
|
|
t.Fatalf("failed to create stale booking: %v", err)
|
|
}
|
|
|
|
var cardID string
|
|
if err := tx.QueryRow(ctx, `
|
|
INSERT INTO user_saved_cards (user_id, square_card_id, square_customer_id, brand, last_4, exp_month, exp_year, fingerprint, is_default)
|
|
VALUES ($1, 'sq_card_stale', 'sq_customer_stale', 'Visa', '4242', 12, 2030, 'fp_stale123', true)
|
|
RETURNING id
|
|
`, guestID).Scan(&cardID); err != nil {
|
|
t.Fatalf("failed to insert saved card: %v", err)
|
|
}
|
|
|
|
if _, err := AnonymizeStaleGuestAccounts(ctx); err != nil {
|
|
t.Fatalf("AnonymizeStaleGuestAccounts failed: %v", err)
|
|
}
|
|
|
|
var deletedAt, fingerprint, squareCardID, squareCustomerID interface{}
|
|
var last4 string
|
|
if err := tx.QueryRow(ctx, `
|
|
SELECT deleted_at, last_4, fingerprint, square_card_id, square_customer_id
|
|
FROM user_saved_cards WHERE id = $1
|
|
`, cardID).Scan(&deletedAt, &last4, &fingerprint, &squareCardID, &squareCustomerID); err != nil {
|
|
t.Fatalf("failed to query saved card after anonymization: %v", err)
|
|
}
|
|
|
|
if deletedAt == nil {
|
|
t.Error("expected deleted_at to be set after anonymization")
|
|
}
|
|
if last4 != "XXXX" {
|
|
t.Errorf("expected last_4 to be 'XXXX', got %q", last4)
|
|
}
|
|
if fingerprint != nil {
|
|
t.Errorf("expected fingerprint to be NULL, got %v", fingerprint)
|
|
}
|
|
if squareCardID != nil {
|
|
t.Errorf("expected square_card_id to be NULL (external-system reference), got %v", squareCardID)
|
|
}
|
|
if squareCustomerID != nil {
|
|
t.Errorf("expected square_customer_id to be NULL, got %v", squareCustomerID)
|
|
}
|
|
}
|
|
|
|
// recordingDisableClient records every DeleteCardOnFile and DeleteCustomer call
|
|
// so tests can assert which cards/customers the anonymization cleaned up at
|
|
// Square.
|
|
type recordingDisableClient struct {
|
|
square.SquareClient
|
|
mu sync.Mutex
|
|
disabled []string
|
|
deletedCustomers []string
|
|
}
|
|
|
|
func (c *recordingDisableClient) DeleteCardOnFile(ctx context.Context, cardID string) error {
|
|
c.mu.Lock()
|
|
c.disabled = append(c.disabled, cardID)
|
|
c.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (c *recordingDisableClient) DeleteCustomer(ctx context.Context, customerID string) error {
|
|
c.mu.Lock()
|
|
c.deletedCustomers = append(c.deletedCustomers, customerID)
|
|
c.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (c *recordingDisableClient) disabledIDs() []string {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return append([]string(nil), c.disabled...)
|
|
}
|
|
|
|
func (c *recordingDisableClient) deletedCustomerIDs() []string {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return append([]string(nil), c.deletedCustomers...)
|
|
}
|
|
|
|
// TestAnonymizeStaleGuestAccounts_DisablesCardsAtSquare verifies the
|
|
// best-effort Square disable: stale-guests' saved cards are disabled at Square
|
|
// BEFORE square_card_id is NULLed locally (GDPR erasure completeness).
|
|
// Deliberately NOT t.Parallel: it swaps the package-level payments.SquareClient
|
|
// and must not overlap the parallel Anonymize tests that read it.
|
|
func TestAnonymizeStaleGuestAccounts_DisablesCardsAtSquare(t *testing.T) {
|
|
ctx, tx := resetTestData(t)
|
|
|
|
guestID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create guest user: %v", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guestID); err != nil {
|
|
t.Fatalf("failed to set guest role: %v", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO bookings (user_id, start_time, status, deposit_required)
|
|
VALUES ($1, NOW() - INTERVAL '7 months', 'completed', false)
|
|
`, guestID); err != nil {
|
|
t.Fatalf("failed to create stale booking: %v", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO user_saved_cards (user_id, square_card_id, square_customer_id, brand, last_4, exp_month, exp_year, fingerprint, is_default)
|
|
VALUES ($1, 'ccof:stale_card_123', 'cus_stale_123', 'Visa', '4242', 12, 2030, 'fp1', true)
|
|
`, guestID); err != nil {
|
|
t.Fatalf("failed to insert saved card: %v", err)
|
|
}
|
|
|
|
origSquare := payments.SquareClient
|
|
rec := &recordingDisableClient{}
|
|
payments.SquareClient = rec
|
|
defer func() { payments.SquareClient = origSquare }()
|
|
|
|
if _, err := AnonymizeStaleGuestAccounts(ctx); err != nil {
|
|
t.Fatalf("AnonymizeStaleGuestAccounts failed: %v", err)
|
|
}
|
|
|
|
got := rec.disabledIDs()
|
|
if len(got) != 1 || got[0] != "ccof:stale_card_123" {
|
|
t.Fatalf("expected the stale guest's card to be disabled at Square, got %v", got)
|
|
}
|
|
}
|
|
|
|
// TestAnonymizeStaleGuestAccounts_DeletesCustomersAtSquare verifies the
|
|
// best-effort Square customer deletion: stale-guests' Square customer profiles
|
|
// (real name + email PII) are deleted at Square once per DISTINCT customer ID
|
|
// BEFORE square_customer_id is NULLed locally (GDPR erasure completeness).
|
|
// Deliberately NOT t.Parallel: it swaps the package-level payments.SquareClient
|
|
// and must not overlap the parallel Anonymize tests that read it.
|
|
func TestAnonymizeStaleGuestAccounts_DeletesCustomersAtSquare(t *testing.T) {
|
|
ctx, tx := resetTestData(t)
|
|
|
|
// Guest A: two saved cards sharing one provisioned Square customer.
|
|
guestAID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create guest A: %v", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guestAID); err != nil {
|
|
t.Fatalf("failed to set guest A role: %v", err)
|
|
}
|
|
|
|
// Guest B: one saved card on a different Square customer.
|
|
guestBID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create guest B: %v", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guestBID); err != nil {
|
|
t.Fatalf("failed to set guest B role: %v", err)
|
|
}
|
|
|
|
// Stale bookings (> 6 months) for both guests.
|
|
for _, uid := range []string{guestAID, guestBID} {
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO bookings (user_id, start_time, status, deposit_required)
|
|
VALUES ($1, NOW() - INTERVAL '7 months', 'completed', false)
|
|
`, uid); err != nil {
|
|
t.Fatalf("failed to create stale booking: %v", err)
|
|
}
|
|
}
|
|
|
|
// Guest A's two cards share 'cus_stale_a'; guest B's card is 'cus_stale_b'.
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO user_saved_cards (user_id, square_card_id, square_customer_id, brand, last_4, exp_month, exp_year, fingerprint, is_default)
|
|
VALUES ($1, 'ccof:stale_card_a1', 'cus_stale_a', 'Visa', '4242', 12, 2030, 'fp_a1', true),
|
|
($1, 'ccof:stale_card_a2', 'cus_stale_a', 'Mastercard', '1111', 12, 2030, 'fp_a2', false)
|
|
`, guestAID); err != nil {
|
|
t.Fatalf("failed to insert guest A saved cards: %v", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO user_saved_cards (user_id, square_card_id, square_customer_id, brand, last_4, exp_month, exp_year, fingerprint, is_default)
|
|
VALUES ($1, 'ccof:stale_card_b1', 'cus_stale_b', 'Visa', '9999', 12, 2030, 'fp_b1', true)
|
|
`, guestBID); err != nil {
|
|
t.Fatalf("failed to insert guest B saved card: %v", err)
|
|
}
|
|
|
|
origSquare := payments.SquareClient
|
|
rec := &recordingDisableClient{}
|
|
payments.SquareClient = rec
|
|
defer func() { payments.SquareClient = origSquare }()
|
|
|
|
if _, err := AnonymizeStaleGuestAccounts(ctx); err != nil {
|
|
t.Fatalf("AnonymizeStaleGuestAccounts failed: %v", err)
|
|
}
|
|
|
|
got := rec.deletedCustomerIDs()
|
|
if len(got) != 2 {
|
|
t.Fatalf("expected DeleteCustomer to run once per DISTINCT customer id (2), got %v", got)
|
|
}
|
|
gotSet := map[string]bool{}
|
|
for _, id := range got {
|
|
if gotSet[id] {
|
|
t.Errorf("DeleteCustomer called more than once for customer %q: %v", id, got)
|
|
}
|
|
gotSet[id] = true
|
|
}
|
|
if !gotSet["cus_stale_a"] || !gotSet["cus_stale_b"] {
|
|
t.Errorf("expected both distinct Square customer ids to be deleted, got %v", got)
|
|
}
|
|
|
|
// The customer delete runs after the card-disable loop, so all cards were
|
|
// disabled too.
|
|
disabled := rec.disabledIDs()
|
|
if len(disabled) != 3 {
|
|
t.Errorf("expected all 3 stale cards to be disabled, got %v", disabled)
|
|
}
|
|
}
|
|
|
|
// --- 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)
|
|
}
|
|
|
|
// Anonymize user so they qualify for deletion (>1 year ago, guest role, anon email)
|
|
_, err = tx.Exec(ctx, `
|
|
UPDATE users SET account_role = 'guest', email = 'anon-' || id || '@anon.invalid', updated_at = NOW() - INTERVAL '2 years'
|
|
WHERE id = $1
|
|
`, userID)
|
|
if err != nil {
|
|
t.Fatalf("failed to anonymize user: %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)
|
|
}
|
|
|
|
// Anonymize user so record qualifies for deletion
|
|
_, err = tx.Exec(ctx, `
|
|
UPDATE users SET account_role = 'guest', email = 'anon-' || id || '@anon.invalid', updated_at = NOW() - INTERVAL '2 years'
|
|
WHERE id = $1
|
|
`, userID)
|
|
if err != nil {
|
|
t.Fatalf("failed to anonymize user: %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)
|
|
}
|
|
|
|
// Anonymize user so records qualify for deletion
|
|
_, err = tx.Exec(ctx, `
|
|
UPDATE users SET account_role = 'guest', email = 'anon-' || id || '@anon.invalid', updated_at = NOW() - INTERVAL '2 years'
|
|
WHERE id = $1
|
|
`, userID)
|
|
if err != nil {
|
|
t.Fatalf("failed to anonymize user: %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)
|
|
}
|
|
|
|
// Anonymize user so record qualifies for deletion
|
|
_, err = tx.Exec(ctx, `
|
|
UPDATE users SET account_role = 'guest', email = 'anon-' || id || '@anon.invalid', updated_at = NOW() - INTERVAL '2 years'
|
|
WHERE id = $1
|
|
`, userID)
|
|
if err != nil {
|
|
t.Fatalf("failed to anonymize user: %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)
|
|
}
|
|
|
|
// Anonymize user so records qualify for deletion
|
|
_, err = tx.Exec(ctx, `
|
|
UPDATE users SET account_role = 'guest', email = 'anon-' || id || '@anon.invalid', updated_at = NOW() - INTERVAL '2 years'
|
|
WHERE id = $1
|
|
`, userID)
|
|
if err != nil {
|
|
t.Fatalf("failed to anonymize user: %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 CleanupOldReservations (Placeholder) ---
|
|
|
|
// TestCleanupOldReservations_Placeholder verifies that placeholder reservations
|
|
// older than 24 hours are deleted, while recent ones are preserved.
|
|
func TestCleanupOldReservations_Placeholder(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
// Create old placeholder 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:placeholder:bk123', $2)
|
|
`, oldTime, clock.Now().Add(-25*time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("failed to create old placeholder reservation: %v", err)
|
|
}
|
|
|
|
// Create recent placeholder 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:placeholder:bk456', $2)
|
|
`, recentTime, clock.Now().Add(-12*time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("failed to create recent placeholder reservation: %v", err)
|
|
}
|
|
|
|
// Run cleanup
|
|
_, err = CleanupOldReservations(ctx)
|
|
if err != nil {
|
|
t.Fatalf("CleanupOldReservations failed: %v", err)
|
|
}
|
|
|
|
// Verify old placeholder was deleted
|
|
var oldCount int
|
|
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:placeholder:bk123'").Scan(&oldCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to check old placeholder: %v", err)
|
|
}
|
|
if oldCount != 0 {
|
|
t.Error("expected old placeholder reservation (25h) to be deleted")
|
|
}
|
|
|
|
// Verify recent placeholder still exists
|
|
var recentCount int
|
|
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:placeholder:bk456'").Scan(&recentCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to check recent placeholder: %v", err)
|
|
}
|
|
if recentCount != 1 {
|
|
t.Error("expected recent placeholder reservation (12h) to be preserved")
|
|
}
|
|
}
|
|
|
|
// TestCleanupOldReservations_HolidayPlaceholder verifies that holiday placeholder
|
|
// reservations older than 24 hours are deleted, while recent ones are preserved.
|
|
func TestCleanupOldReservations_HolidayPlaceholder(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
// Create old holiday_placeholder 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:holiday_placeholder:hld123', $2)
|
|
`, oldTime, clock.Now().Add(-25*time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("failed to create old holiday_placeholder reservation: %v", err)
|
|
}
|
|
|
|
// Create recent holiday_placeholder 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:holiday_placeholder:hld456', $2)
|
|
`, recentTime, clock.Now().Add(-12*time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("failed to create recent holiday_placeholder reservation: %v", err)
|
|
}
|
|
|
|
// Run cleanup
|
|
_, err = CleanupOldReservations(ctx)
|
|
if err != nil {
|
|
t.Fatalf("CleanupOldReservations failed: %v", err)
|
|
}
|
|
|
|
// Verify old placeholder was deleted
|
|
var oldCount int
|
|
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:holiday_placeholder:hld123'").Scan(&oldCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to check old holiday_placeholder: %v", err)
|
|
}
|
|
if oldCount != 0 {
|
|
t.Error("expected old holiday_placeholder reservation (25h) to be deleted")
|
|
}
|
|
|
|
// Verify recent placeholder still exists
|
|
var recentCount int
|
|
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:holiday_placeholder:hld456'").Scan(&recentCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to check recent holiday_placeholder: %v", err)
|
|
}
|
|
if recentCount != 1 {
|
|
t.Error("expected recent holiday_placeholder reservation (12h) to be preserved")
|
|
}
|
|
}
|
|
|
|
// TestCleanupOldReservations_MixedPlaceholders verifies that both placeholder
|
|
// and holiday_placeholder patterns are cleaned up together alongside other types.
|
|
func TestCleanupOldReservations_MixedPlaceholders(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
// Old placeholder (>24 hours)
|
|
_, err := tx.Exec(ctx, `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
|
VALUES ($1, 60, 'RESERVATION:placeholder:bkOld', $2)
|
|
`, clock.Now().Add(-25*time.Hour), clock.Now().Add(-25*time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("failed to create old placeholder: %v", err)
|
|
}
|
|
|
|
// Old holiday_placeholder (>24 hours)
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
|
|
VALUES ($1, 60, 'RESERVATION:holiday_placeholder:hldOld', $2)
|
|
`, clock.Now().Add(-25*time.Hour), clock.Now().Add(-25*time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("failed to create old holiday_placeholder: %v", err)
|
|
}
|
|
|
|
// Old user reservation (>1 hour) — should also be deleted
|
|
oldUserTime := clock.Now().Add(-2 * time.Hour)
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by, created_at)
|
|
VALUES ($1, 60, 'RESERVATION:user:test', $2, $3)
|
|
`, oldUserTime, userID, clock.Now().Add(-2*time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("failed to create old user reservation: %v", err)
|
|
}
|
|
|
|
// Count before cleanup
|
|
var countBefore int
|
|
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers").Scan(&countBefore)
|
|
if err != nil {
|
|
t.Fatalf("failed to count before: %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)
|
|
}
|
|
|
|
// All 3 should be deleted
|
|
var countAfter int
|
|
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers").Scan(&countAfter)
|
|
if err != nil {
|
|
t.Fatalf("failed to count after: %v", err)
|
|
}
|
|
if countAfter != 0 {
|
|
t.Errorf("expected 0 blockers after cleanup (all 3 expired), got %d", countAfter)
|
|
}
|
|
}
|
|
|
|
// --- 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(¬ifCount)
|
|
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(¬ifCount)
|
|
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)
|
|
|
|
// account_id is nullable in the schema — CleanupExpiredGiftCards inserts NULL
|
|
// for unredeemed gift cards (no user account to reference).
|
|
|
|
// 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)
|
|
|
|
// 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)
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// TestCleanupExpiredGiftCards_ConcurrentTopUp_NotClobbered pins the M4
|
|
// expiry-sweep TOCTOU fix: a top-up landing between the sweep reading a card as
|
|
// expired and its zeroing step must NEVER have its value destroyed. The test
|
|
// reproduces the exact race deterministically — a top-up transaction locks the
|
|
// card row, applies its refresh (adds balance, sets last_used_at = NOW()), and
|
|
// stays open while the sweep runs, so the sweep's zeroing UPDATE blocks behind
|
|
// the top-up's row lock. When the top-up commits, the sweep re-evaluates its
|
|
// rolling-expiry predicate against the refreshed row and skips the card. The
|
|
// OLD sweep (plain SELECT then unconditional `UPDATE ... WHERE id = ANY($1)`)
|
|
// fails this test: its SELECT already read the card as expired and its UPDATE
|
|
// then zeroed the freshly topped-up balance.
|
|
//
|
|
// The card row is created on the REAL pool (not inside this test's transaction)
|
|
// because the sweep runs on its own connection and must see the committed row.
|
|
// The test deliberately does NOT call t.Parallel(): its real-pool writes are
|
|
// committed to the shared test DB, and the parallel sibling sweep tests assert
|
|
// global counts on gift_card_expired_balances — a concurrent committed row from
|
|
// this test would flake them. Running sequentially isolates the real-pool rows
|
|
// (cleaned up before the parallel tests resume).
|
|
func TestCleanupExpiredGiftCards_ConcurrentTopUp_NotClobbered(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
const cardID = "m4race000001"
|
|
if _, err := db.Conn.Exec(ctx, `
|
|
INSERT INTO gift_cards (id, total_funds_added, amount_remaining, last_used_at)
|
|
VALUES ($1, 50.00, 50.00, NOW() - INTERVAL '25 months')
|
|
`, cardID); err != nil {
|
|
t.Fatalf("failed to create expired-looking gift card: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
cctx := context.Background()
|
|
_, _ = db.Conn.Exec(cctx, `DELETE FROM gift_card_transactions WHERE gift_card_id = $1`, cardID)
|
|
_, _ = db.Conn.Exec(cctx, `DELETE FROM gift_card_expired_balances WHERE account_id IS NULL AND original_balance = $1`, 50.00)
|
|
_, _ = db.Conn.Exec(cctx, `DELETE FROM gift_cards WHERE id = $1`, cardID)
|
|
})
|
|
|
|
// The concurrent top-up: lock the card row FOR UPDATE, apply the same
|
|
// refresh TopUpGiftCard performs (add balance, last_used_at = NOW(),
|
|
// expiry extended), and hold the transaction OPEN so the sweep's zeroing
|
|
// UPDATE blocks on this row lock until the top-up commits.
|
|
topUpTx, err := db.Conn.Begin(ctx)
|
|
if err != nil {
|
|
t.Fatalf("failed to begin top-up transaction: %v", err)
|
|
}
|
|
_, err = topUpTx.Exec(ctx, `
|
|
UPDATE gift_cards
|
|
SET amount_remaining = amount_remaining + 25.00,
|
|
total_funds_added = total_funds_added + 25.00,
|
|
last_used_at = NOW(),
|
|
expiry_date = NOW() + INTERVAL '1 month'
|
|
WHERE id = $1
|
|
`, cardID)
|
|
if err != nil {
|
|
_ = topUpTx.Rollback(ctx)
|
|
t.Fatalf("failed to apply concurrent top-up: %v", err)
|
|
}
|
|
|
|
// Run the sweep while the top-up transaction is still open: its UPDATE
|
|
// blocks on the row lock the top-up holds.
|
|
sweepDone := make(chan error, 1)
|
|
go func() {
|
|
_, err := CleanupExpiredGiftCards(context.Background())
|
|
sweepDone <- err
|
|
}()
|
|
|
|
// Commit the top-up — its refreshed last_used_at is now visible, so the
|
|
// sweep's re-checked predicate must skip the card.
|
|
if err := topUpTx.Commit(ctx); err != nil {
|
|
t.Fatalf("failed to commit concurrent top-up: %v", err)
|
|
}
|
|
|
|
select {
|
|
case err := <-sweepDone:
|
|
if err != nil {
|
|
t.Fatalf("CleanupExpiredGiftCards failed: %v", err)
|
|
}
|
|
case <-time.After(30 * time.Second):
|
|
t.Fatal("CleanupExpiredGiftCards did not finish after the top-up committed — deadlock?")
|
|
}
|
|
|
|
// The topped-up value must be intact: £50 + £25 = £75, NOT zeroed.
|
|
var remaining float64
|
|
if err := db.Conn.QueryRow(ctx, `SELECT amount_remaining FROM gift_cards WHERE id = $1`, cardID).Scan(&remaining); err != nil {
|
|
t.Fatalf("failed to query gift card: %v", err)
|
|
}
|
|
if remaining != 75.00 {
|
|
t.Errorf("expected amount_remaining 75.00 after a concurrent top-up, got %.2f (top-up value was clobbered)", remaining)
|
|
}
|
|
|
|
// The sweep must not have recorded an expired-balance claim or an 'expire'
|
|
// transaction for the card — the card was never expired by this run.
|
|
var expiredBalanceCount int
|
|
if err := db.Conn.QueryRow(ctx, `
|
|
SELECT COUNT(*) FROM gift_card_expired_balances
|
|
WHERE account_id IS NULL AND original_balance = $1`, 50.00).Scan(&expiredBalanceCount); err != nil {
|
|
t.Fatalf("failed to count expired balances: %v", err)
|
|
}
|
|
if expiredBalanceCount != 0 {
|
|
t.Errorf("expected no expired-balance record for the topped-up card, got %d", expiredBalanceCount)
|
|
}
|
|
|
|
var expireTxCount int
|
|
if err := db.Conn.QueryRow(ctx, `
|
|
SELECT COUNT(*) FROM gift_card_transactions
|
|
WHERE gift_card_id = $1 AND transaction_type = 'expire'`, cardID).Scan(&expireTxCount); err != nil {
|
|
t.Fatalf("failed to count expire transactions: %v", err)
|
|
}
|
|
if expireTxCount != 0 {
|
|
t.Errorf("expected no 'expire' transaction for the topped-up card, got %d", expireTxCount)
|
|
}
|
|
}
|
|
|
|
// --- 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_Scrubs2FA_RetainsNotes verifies the GDPR erasure gap
|
|
// closure for the BATCH path: accounts anonymized via anonymize_user(unnest(...))
|
|
// must have their 2FA columns scrubbed, exactly like the user-initiated
|
|
// DeleteAccountHandler path, while the notes field — a single free-text
|
|
// medical/safety record — is RETAINED de-identified for reasonable adjustments
|
|
// (Equality Act 2010) and legal-claims defence.
|
|
func TestCleanupIdleAccounts_Scrubs2FA_RetainsNotes(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
// Create a user idle past the 2yr threshold (no balance) with 2FA + a note
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, `
|
|
UPDATE users SET
|
|
last_login_at = NOW() - INTERVAL '3 years',
|
|
notes = 'Staff note with PII for idle-account cleanup',
|
|
two_factor_enabled = TRUE,
|
|
two_factor_method = 'email',
|
|
two_factor_pending_code_hash = 'batch-abc123',
|
|
two_factor_pending_code_expires = NOW() + INTERVAL '10 minutes'
|
|
WHERE id = $1
|
|
`, userID)
|
|
if err != nil {
|
|
t.Fatalf("failed to set last_login_at + 2FA + notes: %v", err)
|
|
}
|
|
|
|
// Run cleanup (batch path: SELECT anonymize_user(unnest($1::text[])))
|
|
_, err = CleanupIdleAccounts(ctx)
|
|
if err != nil {
|
|
t.Fatalf("CleanupIdleAccounts failed: %v", err)
|
|
}
|
|
|
|
// Verify the 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)
|
|
}
|
|
|
|
// Verify 2FA columns were scrubbed by the batch anonymization while the
|
|
// notes are retained as a de-identified medical/safety record.
|
|
var notes string
|
|
var enabled bool
|
|
var method, pendingHash, pendingExpires interface{}
|
|
err = tx.QueryRow(ctx, `
|
|
SELECT notes, two_factor_enabled, two_factor_method,
|
|
two_factor_pending_code_hash, two_factor_pending_code_expires
|
|
FROM users WHERE id = $1
|
|
`, userID).Scan(¬es, &enabled, &method, &pendingHash, &pendingExpires)
|
|
if err != nil {
|
|
t.Fatalf("failed to query user after cleanup: %v", err)
|
|
}
|
|
if notes != "Staff note with PII for idle-account cleanup" {
|
|
t.Errorf("expected users.notes to be retained after idle-account cleanup, got %q", notes)
|
|
}
|
|
if enabled {
|
|
t.Error("expected two_factor_enabled to be FALSE after idle-account cleanup")
|
|
}
|
|
if method != nil {
|
|
t.Errorf("expected two_factor_method to be NULL after idle-account cleanup, got %v", method)
|
|
}
|
|
if pendingHash != nil {
|
|
t.Errorf("expected two_factor_pending_code_hash to be NULL after idle-account cleanup, got %v", pendingHash)
|
|
}
|
|
if pendingExpires != nil {
|
|
t.Errorf("expected two_factor_pending_code_expires to be NULL after idle-account cleanup, got %v", pendingExpires)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Gap-Filling Tests for Uncovered Branches
|
|
// =============================================================================
|
|
|
|
// TestTimeBlockers_Create_InvalidJSON verifies that CreateTimeBlocker returns
|
|
// 400 for invalid JSON payload.
|
|
func TestTimeBlockers_Create_InvalidJSON(t *testing.T) {
|
|
ctx, _ := resetTestData(t)
|
|
handler := http.HandlerFunc(CreateTimeBlocker)
|
|
req := httptest.NewRequest("POST", "/api/admin/time-blockers", strings.NewReader("not json"))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req = req.WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestTimeBlockers_Create_WithCron verifies that an admin can create a recurring
|
|
// time blocker with a cron expression and the created_by field is populated.
|
|
func TestTimeBlockers_Create_WithCron(t *testing.T) {
|
|
ctx, tx := resetTestData(t)
|
|
|
|
// Create a real admin user so the created_by FK constraint is satisfied
|
|
adminID, err := fixtures.CreateTestAdminUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create admin user: %v", err)
|
|
}
|
|
defer fixtures.DeleteUser(tx, adminID)
|
|
|
|
blockerTime := time.Date(2026, 3, 20, 10, 0, 0, 0, time.UTC)
|
|
cronExpr := "0 10 * * 1" // Every Monday at 10:00
|
|
|
|
reqBody := CreateTimeBlockerRequest{
|
|
StartTime: blockerTime,
|
|
DurationMinutes: 60,
|
|
Description: "Recurring Monday Blocker",
|
|
CronExpression: &cronExpr,
|
|
}
|
|
|
|
// Use context with both role and real user ID to exercise createdBy branch
|
|
handler := http.HandlerFunc(CreateTimeBlocker)
|
|
reqBodyBytes, _ := json.Marshal(reqBody)
|
|
req := httptest.NewRequest("POST", "/api/admin/time-blockers", bytes.NewReader(reqBodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
reqCtx := context.WithValue(ctx, mw.UserRoleKey, "admin")
|
|
reqCtx = context.WithValue(reqCtx, mw.UserIDKey, adminID)
|
|
req = req.WithContext(reqCtx)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
|
|
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 != "Recurring Monday Blocker" {
|
|
t.Errorf("expected description 'Recurring Monday Blocker', got %s", response.Description)
|
|
}
|
|
if response.CronExpression == nil || *response.CronExpression != cronExpr {
|
|
t.Errorf("expected cron expression %s, got %v", cronExpr, response.CronExpression)
|
|
}
|
|
if response.CreatedBy == nil || *response.CreatedBy != adminID {
|
|
t.Errorf("expected created_by %s, got %v", adminID, response.CreatedBy)
|
|
}
|
|
|
|
// Verify it exists in DB
|
|
var count int
|
|
err2 := tx.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE id = $1`, response.ID).Scan(&count)
|
|
if err2 != nil {
|
|
t.Fatalf("failed to verify blocker in DB: %v", err2)
|
|
}
|
|
if count != 1 {
|
|
t.Error("expected blocker to exist in DB")
|
|
}
|
|
}
|
|
|
|
// TestTimeBlockers_List_InvalidDateFilter verifies that ListTimeBlockers
|
|
// returns 400 for invalid date filter parameters.
|
|
func TestTimeBlockers_List_InvalidDateFilter(t *testing.T) {
|
|
ctx, _ := resetTestData(t)
|
|
handler := http.HandlerFunc(ListTimeBlockers)
|
|
w := makeTimeBlockerRequest(handler, "GET", "/api/admin/time-blockers?start=invalid&end=2026-03-13", nil, ctx)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestTimeBlockers_List_InvalidEndDate verifies that ListTimeBlockers
|
|
// returns 400 when end date is invalid but start is valid.
|
|
func TestTimeBlockers_List_InvalidEndDate(t *testing.T) {
|
|
ctx, _ := resetTestData(t)
|
|
handler := http.HandlerFunc(ListTimeBlockers)
|
|
w := makeTimeBlockerRequest(handler, "GET", "/api/admin/time-blockers?start=2026-03-10&end=invalid", nil, ctx)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d. body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestTimeBlockers_List_Empty verifies that ListTimeBlockers returns an empty
|
|
// array when no time blockers exist.
|
|
func TestTimeBlockers_List_Empty(t *testing.T) {
|
|
ctx, _ := resetTestData(t)
|
|
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) != 0 {
|
|
t.Errorf("expected empty array, got %d blockers", len(response))
|
|
}
|
|
}
|
|
|
|
// TestTimeBlockers_List_WithRecurring verifies that ListTimeBlockers includes
|
|
// both one-off and recurring blockers in the default (no date filter) query.
|
|
func TestTimeBlockers_List_WithRecurring(t *testing.T) {
|
|
ctx, tx := resetTestData(t)
|
|
|
|
// Create a one-off blocker in the future
|
|
futureTime := clock.Now().Add(30 * 24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
|
|
cronExpr := "0 14 * * 3" // Every Wednesday at 14:00
|
|
|
|
_, err := tx.Exec(ctx, `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, cron_expression, created_by)
|
|
VALUES ($1, 60, 'One-off Blocker', NULL, NULL),
|
|
($2, 30, 'Recurring Blocker', $3, NULL)
|
|
`, futureTime, futureTime, cronExpr)
|
|
if err != nil {
|
|
t.Fatalf("failed to create 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))
|
|
}
|
|
|
|
foundOneOff := false
|
|
foundRecurring := false
|
|
for _, b := range response {
|
|
if b.Description == "One-off Blocker" {
|
|
foundOneOff = true
|
|
}
|
|
if b.Description == "Recurring Blocker" {
|
|
foundRecurring = true
|
|
}
|
|
}
|
|
if !foundOneOff {
|
|
t.Error("expected to find 'One-off Blocker'")
|
|
}
|
|
if !foundRecurring {
|
|
t.Error("expected to find 'Recurring Blocker'")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Gap-Filling Tests for CheckTimeBlockerOverlap
|
|
// =============================================================================
|
|
|
|
// TestCheckTimeBlockerOverlap_EmptyDescription verifies that a blocker with
|
|
// an empty description returns "Time blocked" as the fallback description
|
|
// when overlapping.
|
|
func TestCheckTimeBlockerOverlap_EmptyDescription(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
// Create blocker with empty description
|
|
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, '', NULL)
|
|
`, blockerTime)
|
|
if err != nil {
|
|
t.Fatalf("failed to create blocker with empty description: %v", err)
|
|
}
|
|
|
|
// Overlap with the empty-description blocker at 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), nil)
|
|
if err != nil {
|
|
t.Fatalf("CheckTimeBlockerOverlap failed: %v", err)
|
|
}
|
|
if !hasOverlap {
|
|
t.Error("expected overlap for empty-description blocker")
|
|
}
|
|
if desc != "Time blocked" {
|
|
t.Errorf("expected description 'Time blocked', got %q", desc)
|
|
}
|
|
}
|
|
|
|
// TestCheckTimeBlockerOverlap_Recurring verifies that overlap detection works
|
|
// correctly for recurring (cron-based) blockers and includes the cron expression
|
|
// in the returned description.
|
|
func TestCheckTimeBlockerOverlap_Recurring(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
// Create recurring blocker every Monday at 10:00 starting 2026-03-02
|
|
startTime := time.Date(2026, 3, 2, 10, 0, 0, 0, time.UTC)
|
|
cronExpr := "0 10 * * 1"
|
|
_, err := tx.Exec(ctx, `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, cron_expression, created_by)
|
|
VALUES ($1, 60, 'Recurring Monday Meeting', $2, NULL)
|
|
`, startTime, cronExpr)
|
|
if err != nil {
|
|
t.Fatalf("failed to create recurring blocker: %v", err)
|
|
}
|
|
|
|
// Check overlap on Monday 2026-03-09 at 10:00-11:00 (recurring occurrence)
|
|
hasOverlap, desc, err := CheckTimeBlockerOverlap(ctx,
|
|
time.Date(2026, 3, 9, 10, 0, 0, 0, time.UTC),
|
|
time.Date(2026, 3, 9, 11, 0, 0, 0, time.UTC), nil)
|
|
if err != nil {
|
|
t.Fatalf("CheckTimeBlockerOverlap failed: %v", err)
|
|
}
|
|
if !hasOverlap {
|
|
t.Error("expected overlap with recurring blocker")
|
|
}
|
|
expectedDesc := "Recurring Monday Meeting (recurring: 0 10 * * 1)"
|
|
if desc != expectedDesc {
|
|
t.Errorf("expected description %q, got %q", expectedDesc, desc)
|
|
}
|
|
}
|
|
|
|
// TestTimeBlockers_List_WithDateFilter_RecurringOnly verifies that when a date
|
|
// range filter is applied, recurring blockers are always included even if their
|
|
// start time falls outside the range.
|
|
func TestTimeBlockers_List_WithDateFilter_RecurringOnly(t *testing.T) {
|
|
ctx, tx := resetTestData(t)
|
|
|
|
// Create only a recurring blocker with start time outside the query range
|
|
futureTime := clock.Now().Add(60 * 24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
|
|
cronExpr := "0 10 * * 1" // Every Monday at 10:00
|
|
|
|
_, err := tx.Exec(ctx, `
|
|
INSERT INTO time_blockers (start_time, duration_minutes, description, cron_expression, created_by)
|
|
VALUES ($1, 60, 'Only Recurring', $2, NULL)
|
|
`, futureTime, cronExpr)
|
|
if err != nil {
|
|
t.Fatalf("failed to create recurring blocker: %v", err)
|
|
}
|
|
|
|
// Query a past date range — the recurring blocker should still appear
|
|
handler := http.HandlerFunc(ListTimeBlockers)
|
|
w := makeTimeBlockerRequest(handler, "GET", "/api/admin/time-blockers?start=2022-01-01&end=2022-01-31", 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) != 1 {
|
|
t.Errorf("expected 1 recurring blocker in filtered results, got %d", len(response))
|
|
}
|
|
if len(response) > 0 && response[0].Description != "Only Recurring" {
|
|
t.Errorf("expected 'Only Recurring', got %s", response[0].Description)
|
|
}
|
|
}
|
|
|
|
// TestAnonymizeStaleGuestAccounts_InvalidatesSquareCustomerCache verifies the
|
|
// stale-guest anonymization drops each erased guest's process-local Square
|
|
// customer cache entry (GDPR erasure completeness): after the guest is
|
|
// anonymized (email → anon-{id}@anon.invalid, square_customer_id NULLed), a
|
|
// later save-card flow must re-mint a fresh Square customer instead of reusing
|
|
// the deleted one's stale cached id. Deliberately NOT t.Parallel: it swaps the
|
|
// package-level payments.SquareClient.
|
|
func TestAnonymizeStaleGuestAccounts_InvalidatesSquareCustomerCache(t *testing.T) {
|
|
ctx, tx := resetTestData(t)
|
|
|
|
guestID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create guest user: %v", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guestID); err != nil {
|
|
t.Fatalf("failed to set guest role: %v", err)
|
|
}
|
|
// Stale booking (> 6 months) so the stale-guest predicate matches.
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO bookings (user_id, start_time, status, deposit_required)
|
|
VALUES ($1, NOW() - INTERVAL '7 months', 'completed', false)
|
|
`, guestID); err != nil {
|
|
t.Fatalf("failed to create stale booking: %v", err)
|
|
}
|
|
// A saved card so the provisioning path has a persistence point.
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default)
|
|
VALUES ($1, 'ccof:cache_stale_card', 'Visa', '4242', 12, 2030, 'fp1', true)
|
|
`, guestID); err != nil {
|
|
t.Fatalf("failed to insert saved card: %v", err)
|
|
}
|
|
|
|
origSquare := payments.SquareClient
|
|
payments.SquareClient = testutils.NewTestSquareClient()
|
|
defer func() { payments.SquareClient = origSquare }()
|
|
t.Cleanup(func() { payments.InvalidateSquareCustomerCache(guestID) })
|
|
|
|
svc := payments.NewPaymentService()
|
|
originalID, err := svc.EnsureSquareCustomer(ctx, guestID)
|
|
if err != nil {
|
|
t.Fatalf("failed to provision Square customer: %v", err)
|
|
}
|
|
if originalID == "" {
|
|
t.Fatal("expected a provisioned Square customer id")
|
|
}
|
|
|
|
if _, err := AnonymizeStaleGuestAccounts(ctx); err != nil {
|
|
t.Fatalf("AnonymizeStaleGuestAccounts failed: %v", err)
|
|
}
|
|
|
|
// The guest is anonymized and the cache must have been invalidated:
|
|
// re-provisioning re-queries the NULLed DB and mints a fresh customer from
|
|
// the anonymized email — a different id. Without the invalidation it would
|
|
// return the stale originalID for the erased guest.
|
|
reprovisioned, err := svc.EnsureSquareCustomer(ctx, guestID)
|
|
if err != nil {
|
|
t.Fatalf("failed to re-provision Square customer: %v", err)
|
|
}
|
|
if reprovisioned == originalID {
|
|
t.Errorf("anonymized stale guest must not reuse the deleted Square customer id %q from the cache", originalID)
|
|
}
|
|
}
|
|
|
|
// TestInsertSquareCleanupCriticalNotification_FloodCap pins Round 2 Loop B
|
|
// finding 1 for the time-blockers cleanup insert site: the 'critical_payment_log'
|
|
// notification is flood-capped at adminnotify.MaxUnacknowledgedCriticalLogs
|
|
// (pre-checked by CriticalLogsCapExceeded and enforced atomically inside the
|
|
// INSERT), so an unbounded flood of failed Square erasures cannot bury the
|
|
// operator. The per-user deterministic id is preserved: re-delivery of the
|
|
// same failure stays a no-op, and once the unacknowledged queue is at the cap a
|
|
// NEW user's failure is suppressed without growing the queue.
|
|
func TestInsertSquareCleanupCriticalNotification_FloodCap(t *testing.T) {
|
|
ctx, tx := resetTestData(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
// Below the cap: the first delivery inserts exactly one notification with
|
|
// the deterministic per-user id.
|
|
insertSquareCleanupCriticalNotification(ctx, "user-cleanup-1")
|
|
var n int
|
|
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log'`).Scan(&n); err != nil {
|
|
t.Fatalf("failed to count critical notifications: %v", err)
|
|
}
|
|
if n != 1 {
|
|
t.Fatalf("expected 1 critical notification below the cap, got %d", n)
|
|
}
|
|
|
|
// Re-delivery of the same failure is a no-op (deterministic id dedup).
|
|
insertSquareCleanupCriticalNotification(ctx, "user-cleanup-1")
|
|
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log'`).Scan(&n); err != nil {
|
|
t.Fatalf("failed to count critical notifications: %v", err)
|
|
}
|
|
if n != 1 {
|
|
t.Errorf("expected the deterministic per-user id to dedup re-delivery, got %d notifications", n)
|
|
}
|
|
|
|
// Fill the unacknowledged queue to the cap, then a NEW user's failure must
|
|
// be suppressed (the queue must not exceed the cap).
|
|
if _, err := tx.Exec(ctx, `DELETE FROM admin_notifications WHERE reason = 'critical_payment_log'`); err != nil {
|
|
t.Fatalf("failed to clear critical notifications: %v", err)
|
|
}
|
|
for i := 0; i < adminnotify.MaxUnacknowledgedCriticalLogs; i++ {
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO admin_notifications (reason, user_id, created_at)
|
|
VALUES ('critical_payment_log', $1, NOW())
|
|
`, userID); err != nil {
|
|
t.Fatalf("failed to seed critical notification %d: %v", i, err)
|
|
}
|
|
}
|
|
if !adminnotify.CriticalLogsCapExceeded(ctx, db.Conn, "critical_payment_log") {
|
|
t.Fatal("expected the unacknowledged critical_payment_log queue to be at the cap")
|
|
}
|
|
|
|
insertSquareCleanupCriticalNotification(ctx, "flood-user")
|
|
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log'`).Scan(&n); err != nil {
|
|
t.Fatalf("failed to count critical notifications: %v", err)
|
|
}
|
|
if n != adminnotify.MaxUnacknowledgedCriticalLogs {
|
|
t.Errorf("expected the queue to stay capped at %d, got %d", adminnotify.MaxUnacknowledgedCriticalLogs, n)
|
|
}
|
|
}
|
|
|
|
// TestCleanupExpiredDeposits_NotificationFloodCap pins C5 for the
|
|
// 'deposit_not_paid_by_deadline' insert site: the unacknowledged queue is
|
|
// flood-capped at adminnotify.MaxUnacknowledgedCriticalLogs, so an eviction
|
|
// flood cannot bury the operator. At the cap the notification insert is
|
|
// suppressed, but the eviction + time_blocker cleanup still run.
|
|
func TestCleanupExpiredDeposits_NotificationFloodCap(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)
|
|
}
|
|
|
|
// Fill the unacknowledged queue to the cap before the cleanup runs, so the
|
|
// insert site must suppress instead of growing it.
|
|
for i := 0; i < adminnotify.MaxUnacknowledgedCriticalLogs; i++ {
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO admin_notifications (reason, user_id, created_at)
|
|
VALUES ('deposit_not_paid_by_deadline', $1, NOW())
|
|
`, userID); err != nil {
|
|
t.Fatalf("failed to seed deposit_not_paid_by_deadline notification %d: %v", i, err)
|
|
}
|
|
}
|
|
if !adminnotify.CriticalLogsCapExceeded(ctx, db.Conn, "deposit_not_paid_by_deadline") {
|
|
t.Fatal("expected the unacknowledged deposit_not_paid_by_deadline queue to be at the cap")
|
|
}
|
|
|
|
_, err = CleanupExpiredDeposits(ctx)
|
|
if err != nil {
|
|
t.Fatalf("CleanupExpiredDeposits failed: %v", err)
|
|
}
|
|
|
|
// The eviction + time_blocker cleanup still run; only the notification
|
|
// insert is suppressed by the cap.
|
|
var status string
|
|
if err := tx.QueryRow(ctx, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&status); err != nil {
|
|
t.Fatalf("failed to query booking: %v", err)
|
|
}
|
|
if status != "pending_release" {
|
|
t.Errorf("expected status 'pending_release', got '%s'", status)
|
|
}
|
|
|
|
var tbCount int
|
|
if err := tx.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = $1", "RESERVATION:user:"+userID+":bk123").Scan(&tbCount); err != nil {
|
|
t.Fatalf("failed to query time blockers: %v", err)
|
|
}
|
|
if tbCount != 0 {
|
|
t.Error("expected reservation time blocker to be deleted")
|
|
}
|
|
|
|
var n int
|
|
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'deposit_not_paid_by_deadline'`).Scan(&n); err != nil {
|
|
t.Fatalf("failed to count deposit_not_paid_by_deadline notifications: %v", err)
|
|
}
|
|
if n != adminnotify.MaxUnacknowledgedCriticalLogs {
|
|
t.Errorf("expected the queue to stay capped at %d, got %d", adminnotify.MaxUnacknowledgedCriticalLogs, n)
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// FIX 1b — batch erasure external artifacts (CardDAV + S3 outbox, in-tx)
|
|
// ============================================================================
|
|
|
|
// recordingS3Uploader records S3 Delete calls so the batch S3-outbox tests can
|
|
// assert what was (and wasn't) enqueued. Every Uploader method is implemented
|
|
// explicitly (no embedded nil) so tests that swap the global s3.Client never
|
|
// panic.
|
|
type recordingS3Uploader struct {
|
|
mu sync.Mutex
|
|
deleted []string // "bucket/key"
|
|
}
|
|
|
|
func (r *recordingS3Uploader) Upload(context.Context, string, string, io.Reader, string) error {
|
|
return nil
|
|
}
|
|
|
|
func (r *recordingS3Uploader) Download(context.Context, string, string, io.Writer) error {
|
|
return nil
|
|
}
|
|
|
|
func (r *recordingS3Uploader) GetURL(context.Context, string, string) (string, error) {
|
|
return "https://cdn.example.com/x/y", nil
|
|
}
|
|
|
|
func (r *recordingS3Uploader) HealthCheck(context.Context) error {
|
|
return nil
|
|
}
|
|
|
|
func (r *recordingS3Uploader) Delete(_ context.Context, bucket, key string) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.deleted = append(r.deleted, bucket+"/"+key)
|
|
return nil
|
|
}
|
|
|
|
func (r *recordingS3Uploader) Deleted() []string {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return append([]string(nil), r.deleted...)
|
|
}
|
|
|
|
// seedStaleGuest creates a guest user with a booking older than 6 months and a
|
|
// CardDAV vCard row, so it is eligible for AnonymizeStaleGuestAccounts erasure.
|
|
func seedStaleGuest(t *testing.T, ctx context.Context, q db.Querier) string {
|
|
t.Helper()
|
|
guestID, err := fixtures.CreateTestUser(q)
|
|
if err != nil {
|
|
t.Fatalf("failed to create guest: %v", err)
|
|
}
|
|
if _, err := q.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guestID); err != nil {
|
|
t.Fatalf("failed to set guest role: %v", err)
|
|
}
|
|
if _, err := q.Exec(ctx, `
|
|
INSERT INTO bookings (user_id, start_time, status, deposit_required)
|
|
VALUES ($1, NOW() - INTERVAL '7 months', 'completed', false)
|
|
`, guestID); err != nil {
|
|
t.Fatalf("failed to create stale booking: %v", err)
|
|
}
|
|
return guestID
|
|
}
|
|
|
|
// TestAnonymizeStaleGuestAccounts_DeletesDavCardsInTx verifies FIX 1b: the
|
|
// stale guest's dav_cards vCard row (name/email/phone/DOB PII) is deleted
|
|
// INSIDE the erasure transaction — not a post-commit best-effort call.
|
|
func TestAnonymizeStaleGuestAccounts_DeletesDavCardsInTx(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
guestID := seedStaleGuest(t, ctx, tx)
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO dav_cards (addressbookid, uri, carddata, lastmodified, etag, size)
|
|
VALUES (1, $1, 'BEGIN:VCARD', 0, '0', 0)
|
|
`, guestID+".vcf"); err != nil {
|
|
t.Fatalf("failed to seed dav_cards row: %v", err)
|
|
}
|
|
|
|
if _, err := AnonymizeStaleGuestAccounts(ctx); err != nil {
|
|
t.Fatalf("AnonymizeStaleGuestAccounts failed: %v", err)
|
|
}
|
|
|
|
var count int
|
|
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM dav_cards WHERE uri = $1`, guestID+".vcf").Scan(&count); err != nil {
|
|
t.Fatalf("failed to count dav_cards: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Errorf("expected the guest's dav_cards row to be deleted inside the erasure tx, found %d", count)
|
|
}
|
|
}
|
|
|
|
// TestCleanupIdleAccounts_DeletesDavCardsInTx verifies FIX 1b for the idle
|
|
// account batch path: the erased account's dav_cards vCard row is deleted
|
|
// inside the erasure transaction.
|
|
func TestCleanupIdleAccounts_DeletesDavCardsInTx(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)
|
|
}
|
|
if _, err := tx.Exec(ctx, `UPDATE users SET last_login_at = NOW() - INTERVAL '3 years' WHERE id = $1`, userID); err != nil {
|
|
t.Fatalf("failed to set last_login_at: %v", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO dav_cards (addressbookid, uri, carddata, lastmodified, etag, size)
|
|
VALUES (1, $1, 'BEGIN:VCARD', 0, '0', 0)
|
|
`, userID+".vcf"); err != nil {
|
|
t.Fatalf("failed to seed dav_cards row: %v", err)
|
|
}
|
|
|
|
if _, err := CleanupIdleAccounts(ctx); err != nil {
|
|
t.Fatalf("CleanupIdleAccounts failed: %v", err)
|
|
}
|
|
|
|
var count int
|
|
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM dav_cards WHERE uri = $1`, userID+".vcf").Scan(&count); err != nil {
|
|
t.Fatalf("failed to count dav_cards: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Errorf("expected the account's dav_cards row to be deleted inside the erasure tx, found %d", count)
|
|
}
|
|
}
|
|
|
|
// TestAnonymizeStaleGuestAccounts_S3OutboxPersisted verifies FIX 1b (a): with
|
|
// S3_PROFILE_PICS_BUCKET configured and an object store client, the stale-guest
|
|
// erasure writes a pending_s3_deletions outbox row (same shape as account.go)
|
|
// so the retry-s3-deletions job covers the photo deletion. Deliberately NOT
|
|
// t.Parallel: swaps the package-level s3.Client and the env.
|
|
func TestAnonymizeStaleGuestAccounts_S3OutboxPersisted(t *testing.T) {
|
|
ctx, tx := resetTestData(t)
|
|
|
|
saved := s3.Client
|
|
rec := &recordingS3Uploader{}
|
|
s3.Client = rec
|
|
defer func() { s3.Client = saved }()
|
|
t.Setenv("S3_PROFILE_PICS_BUCKET", "test-profile-pics-bucket")
|
|
|
|
guestID := seedStaleGuest(t, ctx, tx)
|
|
if _, err := tx.Exec(ctx, `UPDATE users SET profile_pic_url = 'https://cdn.example.com/pics/old.jpg' WHERE id = $1`, guestID); err != nil {
|
|
t.Fatalf("failed to set profile pic: %v", err)
|
|
}
|
|
|
|
if _, err := AnonymizeStaleGuestAccounts(ctx); err != nil {
|
|
t.Fatalf("AnonymizeStaleGuestAccounts failed: %v", err)
|
|
}
|
|
|
|
var bucket, objectKey string
|
|
if err := tx.QueryRow(ctx, `SELECT bucket, object_key FROM pending_s3_deletions WHERE user_id = $1`, guestID).Scan(&bucket, &objectKey); err != nil {
|
|
t.Fatalf("expected a pending_s3_deletions outbox row: %v", err)
|
|
}
|
|
if bucket != "test-profile-pics-bucket" {
|
|
t.Errorf("expected bucket %q, got %q", "test-profile-pics-bucket", bucket)
|
|
}
|
|
if objectKey != "profiles/"+guestID+".jpg" {
|
|
t.Errorf("expected object_key %q, got %q", "profiles/"+guestID+".jpg", objectKey)
|
|
}
|
|
}
|
|
|
|
// TestAnonymizeStaleGuestAccounts_S3Outbox_FailClosedOnEmptyBucket verifies
|
|
// FIX 1b (b): with the bucket unset the batch erasure writes NO outbox row and
|
|
// never attempts a deletion against a guessed bucket — fail-closed, matching
|
|
// DeleteAccountHandler. Deliberately NOT t.Parallel: swaps the package-level
|
|
// s3.Client and the env.
|
|
func TestAnonymizeStaleGuestAccounts_S3Outbox_FailClosedOnEmptyBucket(t *testing.T) {
|
|
ctx, tx := resetTestData(t)
|
|
|
|
saved := s3.Client
|
|
rec := &recordingS3Uploader{}
|
|
s3.Client = rec
|
|
defer func() { s3.Client = saved }()
|
|
t.Setenv("S3_PROFILE_PICS_BUCKET", "")
|
|
|
|
guestID := seedStaleGuest(t, ctx, tx)
|
|
if _, err := tx.Exec(ctx, `UPDATE users SET profile_pic_url = 'https://cdn.example.com/pics/old.jpg' WHERE id = $1`, guestID); err != nil {
|
|
t.Fatalf("failed to set profile pic: %v", err)
|
|
}
|
|
|
|
if _, err := AnonymizeStaleGuestAccounts(ctx); err != nil {
|
|
t.Fatalf("AnonymizeStaleGuestAccounts failed: %v", err)
|
|
}
|
|
|
|
var count int
|
|
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM pending_s3_deletions WHERE user_id = $1`, guestID).Scan(&count); err != nil {
|
|
t.Fatalf("failed to count pending_s3_deletions: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Errorf("no outbox row may be written when the bucket is unset, found %d", count)
|
|
}
|
|
if got := rec.Deleted(); len(got) != 0 {
|
|
t.Errorf("no S3 deletion may be attempted against a guessed bucket, got %v", got)
|
|
}
|
|
}
|
|
|
|
// TestCleanupIdleAccounts_S3OutboxPersisted verifies FIX 1b (a) for the idle
|
|
// account batch path: with a configured bucket the erasure writes the
|
|
// pending_s3_deletions outbox row for a with-balance account that had a profile
|
|
// picture. Deliberately NOT t.Parallel: swaps the package-level s3.Client and
|
|
// the env.
|
|
func TestCleanupIdleAccounts_S3OutboxPersisted(t *testing.T) {
|
|
ctx, tx := resetTestData(t)
|
|
|
|
saved := s3.Client
|
|
rec := &recordingS3Uploader{}
|
|
s3.Client = rec
|
|
defer func() { s3.Client = saved }()
|
|
t.Setenv("S3_PROFILE_PICS_BUCKET", "test-profile-pics-bucket")
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `UPDATE users SET last_login_at = NOW() - INTERVAL '6 years', profile_pic_url = 'https://cdn.example.com/pics/old.jpg' WHERE id = $1`, userID); err != nil {
|
|
t.Fatalf("failed to set last_login_at + profile pic: %v", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO user_giftcard_balances (user_id, balance)
|
|
VALUES ($1, 150.00)
|
|
`, userID); err != nil {
|
|
t.Fatalf("failed to create user giftcard balance: %v", err)
|
|
}
|
|
|
|
if _, err := CleanupIdleAccounts(ctx); err != nil {
|
|
t.Fatalf("CleanupIdleAccounts failed: %v", err)
|
|
}
|
|
|
|
var bucket, objectKey string
|
|
if err := tx.QueryRow(ctx, `SELECT bucket, object_key FROM pending_s3_deletions WHERE user_id = $1`, userID).Scan(&bucket, &objectKey); err != nil {
|
|
t.Fatalf("expected a pending_s3_deletions outbox row: %v", err)
|
|
}
|
|
if bucket != "test-profile-pics-bucket" {
|
|
t.Errorf("expected bucket %q, got %q", "test-profile-pics-bucket", bucket)
|
|
}
|
|
if objectKey != "profiles/"+userID+".jpg" {
|
|
t.Errorf("expected object_key %q, got %q", "profiles/"+userID+".jpg", objectKey)
|
|
}
|
|
}
|