refactor(handlers): migrate remaining backend handlers to clock.Now() and transaction patterns

Apply clock.Now() migration, transaction wrapping, and minor refactors across admin, scheduling, today, user, auth handler, notifications, webhooks, services, portfolio, ratelimit, testutils, and main.go.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-24 23:43:50 +01:00
co-authored by Sisyphus
parent 7b24f8e484
commit e4b9003439
36 changed files with 1923 additions and 590 deletions
+49 -26
View File
@@ -9,11 +9,20 @@ import (
"time"
"crussell/db"
"crussell/clock"
"crussell/internal/validators"
"crussell/mw"
"log"
)
var londonLocation = func() *time.Location {
loc, err := time.LoadLocation("Europe/London")
if err != nil {
panic("failed to load Europe/London timezone: " + err.Error())
}
return loc
}()
// --- Types ---
type DefaultHours struct {
Weekday int `json:"weekday" validate:"gte=0,lte=6"`
@@ -156,9 +165,11 @@ func GetWorkingHours(w http.ResponseWriter, r *http.Request) {
}
useOutOfHours := outOfHours && isAdmin
// Set to local start/end of day
start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, time.Local)
end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 999999999, time.Local)
// Set to local start/end of day in Europe/London so that bookings
// at BST midnight (23:00 UTC the previous day) are included in the
// correct date range.
start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, londonLocation)
end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 999999999, londonLocation)
// Load default hours
defaultMap := map[int]DefaultHours{}
@@ -197,7 +208,7 @@ func GetWorkingHours(w http.ResponseWriter, r *http.Request) {
for appRows.Next() {
var a appEntry
if err := appRows.Scan(&a.GroupID, &a.WeekStart); err == nil {
a.WeekStart = time.Date(a.WeekStart.Year(), a.WeekStart.Month(), a.WeekStart.Day(), 0, 0, 0, 0, time.Local)
a.WeekStart = time.Date(a.WeekStart.Year(), a.WeekStart.Month(), a.WeekStart.Day(), 0, 0, 0, 0, time.UTC)
apps = append(apps, a)
groupIDs = append(groupIDs, a.GroupID)
}
@@ -236,7 +247,7 @@ func GetWorkingHours(w http.ResponseWriter, r *http.Request) {
daysSinceMonday = 6 // Sunday
}
weekStart := d.AddDate(0, 0, -daysSinceMonday)
weekStart = time.Date(weekStart.Year(), weekStart.Month(), weekStart.Day(), 0, 0, 0, 0, time.Local)
weekStart = time.Date(weekStart.Year(), weekStart.Month(), weekStart.Day(), 0, 0, 0, 0, time.UTC)
var applied *ExceptionalHours
weekStartStr := weekStart.Format("2006-01-02")
@@ -346,9 +357,9 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) {
// Parse out_of_hours toggle (admin-only extended hours)
outOfHours := r.URL.Query().Get("out_of_hours") == "true"
// set start/end of day
start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, time.Local)
end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 999999999, time.Local)
// set start/end of day in Europe/London (see comment above)
start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, londonLocation)
end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 999999999, londonLocation)
// Clean up old reservations (older than 1 hour)
if err := CleanupOldReservations(r.Context()); err != nil {
@@ -429,7 +440,7 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) {
for appRows.Next() {
var a appEntry
if err := appRows.Scan(&a.GroupID, &a.WeekStart); err == nil {
a.WeekStart = time.Date(a.WeekStart.Year(), a.WeekStart.Month(), a.WeekStart.Day(), 0, 0, 0, 0, time.Local)
a.WeekStart = time.Date(a.WeekStart.Year(), a.WeekStart.Month(), a.WeekStart.Day(), 0, 0, 0, 0, time.UTC)
apps = append(apps, a)
groupIDs = append(groupIDs, a.GroupID)
}
@@ -472,11 +483,12 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) {
var t time.Time
var dur int
if err := bookingRows.Scan(&t, &dur); err == nil {
dateStr := t.Format("2006-01-02")
tLondon := t.In(londonLocation)
dateStr := tLondon.Format("2006-01-02")
endTime := t.Add(time.Duration(dur) * time.Minute)
bookings[dateStr] = append(bookings[dateStr], TimeSlot{
StartTime: t.Format("15:04"),
EndTime: endTime.Format("15:04"),
StartTime: tLondon.Format("15:04"),
EndTime: endTime.In(londonLocation).Format("15:04"),
})
}
}
@@ -501,19 +513,23 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) {
// "00:00" as an end time would incorrectly appear before all slot times.
cur := blockStart
for cur.Before(blockEnd) {
dayEnd := time.Date(cur.Year(), cur.Month(), cur.Day(), 0, 0, 0, 0, cur.Location()).AddDate(0, 0, 1)
dayEnd := time.Date(cur.Year(), cur.Month(), cur.Day(), 0, 0, 0, 0, londonLocation).AddDate(0, 0, 1)
segEnd := blockEnd
if segEnd.After(dayEnd) {
segEnd = dayEnd
}
dateStr := cur.Format("2006-01-02")
endStr := segEnd.Format("15:04")
// Format times in Europe/London so that blocker time strings use
// wall-clock hours matching working_hours and booking slots.
londonStart := cur.In(londonLocation)
londonEnd := segEnd.In(londonLocation)
endStr := londonEnd.Format("15:04")
if segEnd.Equal(dayEnd) {
endStr = "24:00"
}
blockerMap[dateStr] = append(blockerMap[dateStr], TimeSlot{
StartTime: cur.Format("15:04"),
StartTime: londonStart.Format("15:04"),
EndTime: endStr,
})
@@ -543,7 +559,7 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) {
daysSinceMonday = 6 // Sunday
}
weekStart := d.AddDate(0, 0, -daysSinceMonday)
weekStart = time.Date(weekStart.Year(), weekStart.Month(), weekStart.Day(), 0, 0, 0, 0, time.Local)
weekStart = time.Date(weekStart.Year(), weekStart.Month(), weekStart.Day(), 0, 0, 0, 0, time.UTC)
var applied *ExceptionalHours
weekStartStr := weekStart.Format("2006-01-02")
@@ -610,8 +626,9 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) {
// Late night lock: after 22:00, block next morning 00:00-11:00 for non-admin users
if !isAdmin {
now := time.Now()
if now.Hour() >= 22 {
now := clock.Now()
londonNow := now.In(londonLocation)
if londonNow.Hour() >= 22 {
// Check if this is tomorrow's date
tomorrow := now.AddDate(0, 0, 1)
tomorrowStr := tomorrow.Format("2006-01-02")
@@ -639,15 +656,21 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) {
// normalizeTime strips seconds from HH:MM:SS to HH:MM for consistent string
// comparison with blocker and booking time formats in subtractTimeSlots.
func normalizeTime(t string) string {
// Strip trailing :SS (seconds) from HH:MM:SS format while leaving
// bare HH:MM untouched and handling single-digit hours (e.g. 9:00:00).
if len(t) > 5 && t[len(t)-3] == ':' {
prefix := t[:len(t)-3]
if strings.Contains(prefix, ":") {
return prefix
}
parts := strings.Split(t, ":")
if len(parts) < 2 {
return t
}
return t
hour := parts[0]
minute := parts[1]
// Only pad numeric single-digit segments. Non-numeric single-char
// values (e.g. from garbage input) pass through without padding.
if len(hour) == 1 && hour[0] >= '0' && hour[0] <= '9' {
hour = "0" + hour
}
if len(minute) == 1 && minute[0] >= '0' && minute[0] <= '9' {
minute = "0" + minute
}
return hour + ":" + minute
}
// subtractTimeSlots removes gaps from available slots
@@ -38,6 +38,7 @@ func ListExceptionalGroups(w http.ResponseWriter, r *http.Request) {
http.Error(w, "failed to fetch groups", http.StatusInternalServerError)
return
}
defer rows.Close()
// Collect all groups first, then close rows to avoid "conn busy" when
// the context carries a test transaction (single connection).
@@ -51,7 +52,7 @@ func ListExceptionalGroups(w http.ResponseWriter, r *http.Request) {
}
groups = append(groups, g)
}
rows.Close()
rows.Close() // explicit close before subsequent queries (hours/apps below); defer covers error path
if err := rows.Err(); err != nil {
http.Error(w, "error iterating groups", http.StatusInternalServerError)
@@ -116,7 +117,7 @@ func ListExceptionalGroups(w http.ResponseWriter, r *http.Request) {
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(groups)
}
@@ -164,9 +165,8 @@ func CreateExceptionalGroup(w http.ResponseWriter, r *http.Request) {
}
var parsedWeeks []time.Time
ukLocation, _ := time.LoadLocation("Europe/London")
for _, ws := range g.WeekStarts {
weekStart, err := time.ParseInLocation("2006-01-02", ws, ukLocation)
weekStart, err := time.Parse("2006-01-02", ws)
if err != nil {
http.Error(w, "invalid week_start format, expected YYYY-MM-DD", http.StatusBadRequest)
return
@@ -175,8 +175,6 @@ func CreateExceptionalGroup(w http.ResponseWriter, r *http.Request) {
http.Error(w, "week_start must be a Monday", http.StatusBadRequest)
return
}
// Normalize to UK midnight
weekStart = time.Date(weekStart.Year(), weekStart.Month(), weekStart.Day(), 0, 0, 0, 0, ukLocation)
parsedWeeks = append(parsedWeeks, weekStart)
}
@@ -231,7 +229,7 @@ func CreateExceptionalGroup(w http.ResponseWriter, r *http.Request) {
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(g)
}
@@ -253,7 +251,14 @@ func DeleteExceptionalGroup(w http.ResponseWriter, r *http.Request) {
return
}
result, err := db.Conn.Exec(r.Context(), `
tx, err := db.Conn.Begin(r.Context())
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
result, err := tx.Exec(r.Context(), `
DELETE FROM exceptional_working_hours_groups WHERE id=$1
`, id)
if err != nil {
@@ -267,6 +272,11 @@ func DeleteExceptionalGroup(w http.ResponseWriter, r *http.Request) {
return
}
if err := tx.Commit(r.Context()); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
@@ -292,9 +302,8 @@ func UpdateExceptionalApplications(w http.ResponseWriter, r *http.Request) {
// Validate and parse weeks
var parsedWeeks []time.Time
ukLocation, _ := time.LoadLocation("Europe/London")
for _, ws := range req.WeekStarts {
weekStart, err := time.ParseInLocation("2006-01-02", ws, ukLocation)
weekStart, err := time.Parse("2006-01-02", ws)
if err != nil {
http.Error(w, "invalid week_start format, expected YYYY-MM-DD", http.StatusBadRequest)
return
@@ -303,8 +312,6 @@ func UpdateExceptionalApplications(w http.ResponseWriter, r *http.Request) {
http.Error(w, "week_start must be a Monday", http.StatusBadRequest)
return
}
// Normalize to UK midnight
weekStart = time.Date(weekStart.Year(), weekStart.Month(), weekStart.Day(), 0, 0, 0, 0, ukLocation)
parsedWeeks = append(parsedWeeks, weekStart)
}
+475 -85
View File
@@ -28,6 +28,7 @@ import (
"testing"
"time"
"crussell/clock"
"crussell/db"
"crussell/mw"
"crussell/testutils"
@@ -755,7 +756,7 @@ func TestScheduling_GetAvailableHours_OutOfHours_RespectsBookings(t *testing.T)
t.Fatalf("failed to create service: %v", err)
}
// Create a booking on Tuesday 2026-02-17 at 09:00, 60min (blocks 09:00-10:00)
bookingTime := time.Date(2026, 2, 17, 9, 0, 0, 0, time.Local)
bookingTime := time.Date(2026, 2, 17, 9, 0, 0, 0, time.UTC)
_, err = tx.Exec(ctx, `
INSERT INTO bookings (user_id, start_time, status, created_at)
VALUES ($1, $2, 'confirmed', NOW())
@@ -834,7 +835,7 @@ func TestScheduling_GetAvailableHours_OutOfHours_ExceptionalOpen(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
today := time.Now()
today := clock.Now()
weekday := int(today.Weekday())
if weekday == 0 {
weekday = 6
@@ -1022,8 +1023,7 @@ func TestScheduling_GetAvailableHours_WithBlocker_NonAdmin(t *testing.T) {
ctx, tx := resetTestData(t)
// Create a time blocker for 2026-03-16 10:00-11:00 (Monday - an open day)
ukLocation, _ := time.LoadLocation("Europe/London")
blockerTime := time.Date(2026, 3, 16, 10, 0, 0, 0, ukLocation)
blockerTime := time.Date(2026, 3, 16, 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)
@@ -1091,8 +1091,7 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin(t *testing.T) {
ctx, tx := resetTestData(t)
// Create a time blocker for 2026-03-16 10:00-11:00 (Monday - open day)
ukLocation, _ := time.LoadLocation("Europe/London")
blockerTime := time.Date(2026, 3, 16, 10, 0, 0, 0, ukLocation)
blockerTime := time.Date(2026, 3, 16, 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)
@@ -1310,12 +1309,11 @@ func getWorkingHoursForDate(t *testing.T, ctx context.Context, date string) (sta
func TestScheduling_GetAvailableHours_WithBlocker_Admin_MultipleBlockers(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
ukLocation, _ := time.LoadLocation("Europe/London")
// Create two blockers on Tuesday 2026-03-17 (open 09:00-17:00):
// 10:00-11:00 (Staff Meeting) and 14:00-15:00 (Training)
b1 := time.Date(2026, 3, 17, 10, 0, 0, 0, ukLocation)
b2 := time.Date(2026, 3, 17, 14, 0, 0, 0, ukLocation)
b1 := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
b2 := time.Date(2026, 3, 17, 14, 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Staff Meeting', NULL)`, b1)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Training', NULL)`, b2)
@@ -1355,11 +1353,10 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_MultipleBlockers(t *test
func TestScheduling_GetAvailableHours_WithBlocker_Admin_BlockerAndBooking(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
ukLocation, _ := time.LoadLocation("Europe/London")
// Tuesday 2026-03-17 (open 09:00-17:00)
// Create a booking at 11:00-12:00 and a blocker at 14:00-15:00
bookingStart := time.Date(2026, 3, 17, 11, 0, 0, 0, ukLocation)
bookingStart := time.Date(2026, 3, 17, 11, 0, 0, 0, time.UTC)
bookingEnd := bookingStart.Add(60 * time.Minute)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
@@ -1374,7 +1371,7 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_BlockerAndBooking(t *tes
t.Fatalf("failed to create booking: %v", err)
}
blockerTime := time.Date(2026, 3, 17, 14, 0, 0, 0, ukLocation)
blockerTime := time.Date(2026, 3, 17, 14, 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Equipment Maintenance', NULL)`, blockerTime)
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17")
@@ -1416,10 +1413,9 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_BlockerAndBooking(t *tes
func TestScheduling_GetAvailableHours_WithBlocker_Admin_AllDayBlocker(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
ukLocation, _ := time.LoadLocation("Europe/London")
// Tuesday 2026-03-17 (open 09:00-17:00) — block entire open period
blockerTime := time.Date(2026, 3, 17, 9, 0, 0, 0, ukLocation)
blockerTime := time.Date(2026, 3, 17, 9, 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 480, 'All day closure', NULL)`, blockerTime)
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17")
@@ -1444,10 +1440,9 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_AllDayBlocker(t *testing
func TestScheduling_GetAvailableHours_WithBlocker_Admin_NonOverlappingBlocker(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
ukLocation, _ := time.LoadLocation("Europe/London")
// Tuesday 2026-03-17 (open 09:00-17:00) — blocker at 17:00-18:00 (after close)
blockerTime := time.Date(2026, 3, 17, 17, 0, 0, 0, ukLocation)
blockerTime := time.Date(2026, 3, 17, 17, 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'After hours cleaning', NULL)`, blockerTime)
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17")
@@ -1481,11 +1476,10 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_NonOverlappingBlocker(t
func TestScheduling_GetAvailableHours_WithBlocker_Admin_MultiDay(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
ukLocation, _ := time.LoadLocation("Europe/London")
// Blockers on Tue 2026-03-17 10:00-11:00 and Wed 2026-03-18 14:00-15:00
b1 := time.Date(2026, 3, 17, 10, 0, 0, 0, ukLocation)
b2 := time.Date(2026, 3, 18, 14, 0, 0, 0, ukLocation)
b1 := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
b2 := time.Date(2026, 3, 18, 14, 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Tue Meeting', NULL)`, b1)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Wed Training', NULL)`, b2)
@@ -1531,10 +1525,9 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_MultiDay(t *testing.T) {
func TestScheduling_GetAvailableHours_WithBlocker_Admin_BoundaryStart(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
ukLocation, _ := time.LoadLocation("Europe/London")
// Tuesday 2026-03-17 (open 09:00-17:00) — blocker at 09:00-10:00 (start of day)
blockerTime := time.Date(2026, 3, 17, 9, 0, 0, 0, ukLocation)
blockerTime := time.Date(2026, 3, 17, 9, 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Morning setup', NULL)`, blockerTime)
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17")
@@ -1568,10 +1561,9 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_BoundaryStart(t *testing
func TestScheduling_GetAvailableHours_WithBlocker_Admin_BoundaryEnd(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
ukLocation, _ := time.LoadLocation("Europe/London")
// Tuesday 2026-03-17 (open 09:00-17:00) — blocker at 16:00-17:00 (end of day)
blockerTime := time.Date(2026, 3, 17, 16, 0, 0, 0, ukLocation)
blockerTime := time.Date(2026, 3, 17, 16, 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'End of day cleanup', NULL)`, blockerTime)
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17")
@@ -1605,10 +1597,9 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_BoundaryEnd(t *testing.T
func TestScheduling_GetAvailableHours_WithBlocker_Admin_OutOfHours(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
ukLocation, _ := time.LoadLocation("Europe/London")
// Tuesday 2026-03-17 — blocker at 10:00-11:00
blockerTime := time.Date(2026, 3, 17, 10, 0, 0, 0, ukLocation)
blockerTime := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Morning Meeting', NULL)`, blockerTime)
// Request with out_of_hours=true (extends to 06:00-22:00 for admin)
@@ -1648,10 +1639,9 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_OutOfHours(t *testing.T)
func TestScheduling_GetAvailableHours_WithBlocker_NonAdmin_Regression(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
ukLocation, _ := time.LoadLocation("Europe/London")
// Tuesday 2026-03-17 — blocker at 10:00-11:00
blockerTime := time.Date(2026, 3, 17, 10, 0, 0, 0, ukLocation)
blockerTime := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Staff Meeting', NULL)`, blockerTime)
// Request as non-admin
@@ -1696,10 +1686,9 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_Recurring(t *testing.T)
// No t.Parallel() — GetAvailableHours cleanup operations can deadlock with
// concurrent test transactions on the shared test database.
ctx, tx := resetTestData(t)
ukLocation, _ := time.LoadLocation("Europe/London")
// Daily recurring blocker 12:00-13:00 starting Mon 2026-03-16
startTime := time.Date(2026, 3, 16, 12, 0, 0, 0, ukLocation)
startTime := time.Date(2026, 3, 16, 12, 0, 0, 0, time.UTC)
cronExpr := "0 12 * * *" // Every day at 12:00
tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, cron_expression, created_by)
@@ -1741,8 +1730,7 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_Recurring(t *testing.T)
// RESERVATION:admin time_blocker entries are also subtracted from admin slots.
func TestScheduling_GetAvailableHours_WithBlocker_Admin_Reservation(t *testing.T) {
ctx, tx := resetTestData(t)
ukLocation, _ := time.LoadLocation("Europe/London")
// Create a real admin user to satisfy FK constraint, then simulate a reservation
adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil {
@@ -1750,7 +1738,7 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_Reservation(t *testing.T
}
defer fixtures.DeleteUser(tx, adminID)
reservationTime := time.Date(2026, 3, 17, 10, 0, 0, 0, ukLocation)
reservationTime := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
if _, err := tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES ($1, 30, 'RESERVATION:admin:callin:guest:1712345678', $2)
@@ -1786,9 +1774,8 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_Reservation(t *testing.T
func TestScheduling_GetAvailableHours_WithBlocker_Admin_Reservation_NonAdmin(t *testing.T) {
// Not parallel (see above)
ctx, tx := resetTestData(t)
ukLocation, _ := time.LoadLocation("Europe/London")
reservationTime := time.Date(2026, 3, 17, 10, 0, 0, 0, ukLocation)
reservationTime := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES ($1, 30, 'RESERVATION:admin:callin:guest:1712345678', 'admin001')
@@ -1825,11 +1812,10 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_Reservation_NonAdmin(t *
func TestScheduling_GetAvailableHours_WithBlocker_Admin_OverlappingBlockers(t *testing.T) {
ctx, tx := resetTestData(t)
ukLocation, _ := time.LoadLocation("Europe/London")
// Two overlapping blockers on Tue 2026-03-17: 10:00-12:00 and 11:00-13:00
b1 := time.Date(2026, 3, 17, 10, 0, 0, 0, ukLocation)
b2 := time.Date(2026, 3, 17, 11, 0, 0, 0, ukLocation)
b1 := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
b2 := time.Date(2026, 3, 17, 11, 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 120, 'Long Morning Meeting', NULL)`, b1)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 120, 'Extended Training', NULL)`, b2)
@@ -1868,10 +1854,9 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_OverlappingBlockers(t *t
func TestScheduling_GetAvailableHours_WithBlocker_Admin_AdjacentBoundaries(t *testing.T) {
ctx, tx := resetTestData(t)
ukLocation, _ := time.LoadLocation("Europe/London")
// Tue 2026-03-17: booking 10:00-11:00, blocker 11:00-12:00 (adjacent)
bookingStart := time.Date(2026, 3, 17, 10, 0, 0, 0, ukLocation)
bookingStart := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
bookingEnd := bookingStart.Add(60 * time.Minute)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
@@ -1883,7 +1868,7 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_AdjacentBoundaries(t *te
VALUES ($1, $2, 'confirmed', 60, $3)
`, userID, bookingStart, bookingEnd)
blockerTime := time.Date(2026, 3, 17, 11, 0, 0, 0, ukLocation)
blockerTime := time.Date(2026, 3, 17, 11, 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Adjacent Blocker', NULL)`, blockerTime)
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-17")
@@ -1914,8 +1899,7 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_AdjacentBoundaries(t *te
func TestScheduling_GetAvailableHours_WithBlocker_Admin_MidnightBlocker(t *testing.T) {
ctx, tx := resetTestData(t)
ukLocation, _ := time.LoadLocation("Europe/London")
// Use the first open day found and the following day
tueStart, tueEnd, tueOpen := getWorkingHoursForDate(t, ctx, "2026-03-17")
wedStart, _, wedOpen := getWorkingHoursForDate(t, ctx, "2026-03-18")
@@ -1926,14 +1910,14 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_MidnightBlocker(t *testi
// Blocker at 1 hour before close on Tuesday
tueBlockHour := mustParseHour(tueEnd) - 1
tueBlockStart := fmt.Sprintf("%02d:00", tueBlockHour)
tueBlockTime := time.Date(2026, 3, 17, tueBlockHour, 0, 0, 0, ukLocation)
tueBlockTime := time.Date(2026, 3, 17, tueBlockHour, 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'End-of-day blocker', NULL)`, tueBlockTime)
// Blocker at opening on Wednesday (first 2 hours)
wedBlockStart := wedStart
wedBlockEnd := fmt.Sprintf("%02d:00", mustParseHour(wedStart)+2)
wedBlockDur := 120
wedBlockTime := time.Date(2026, 3, 18, mustParseHour(wedStart), 0, 0, 0, ukLocation)
wedBlockTime := time.Date(2026, 3, 18, mustParseHour(wedStart), 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, $2, 'Opening blocker', NULL)`, wedBlockTime, wedBlockDur)
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-18")
@@ -2003,10 +1987,9 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_NoBlockers(t *testing.T)
func TestScheduling_GetAvailableHours_WithBlocker_Admin_ClosedDayBlocker(t *testing.T) {
ctx, tx := resetTestData(t)
ukLocation, _ := time.LoadLocation("Europe/London")
// Sunday 2026-03-22 is closed. Blocker at 10:00-11:00.
blockerTime := time.Date(2026, 3, 22, 10, 0, 0, 0, ukLocation)
blockerTime := time.Date(2026, 3, 22, 10, 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Sunday Maintenance', NULL)`, blockerTime)
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-22", "2026-03-22")
@@ -2034,10 +2017,9 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_ClosedDayBlocker(t *test
func TestScheduling_GetAvailableHours_WithBlocker_Admin_MultiDayRangePartial(t *testing.T) {
ctx, tx := resetTestData(t)
ukLocation, _ := time.LoadLocation("Europe/London")
// Blocker only on Tuesday (2026-03-17) at 10:00-11:00
blockerTime := time.Date(2026, 3, 17, 10, 0, 0, 0, ukLocation)
blockerTime := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Tue Only Blocker', NULL)`, blockerTime)
// Query Tue-Thu (17th, 18th, 19th)
@@ -2083,8 +2065,7 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_MultiDayRangePartial(t *
func TestScheduling_GetAvailableHours_WithBlocker_Admin_ExceptionalHours(t *testing.T) {
ctx, tx := resetTestData(t)
ukLocation, _ := time.LoadLocation("Europe/London")
// Monday 2026-03-16 is normally CLOSED. Add exceptional hours: 10:00-16:00.
// Also add a blocker at 12:00-13:00.
// First create the exceptional group
@@ -2109,7 +2090,7 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_ExceptionalHours(t *test
`, groupID)
// Blocker on Monday 12:00-13:00
blockerTime := time.Date(2026, 3, 16, 12, 0, 0, 0, ukLocation)
blockerTime := time.Date(2026, 3, 16, 12, 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Lunch Break', NULL)`, blockerTime)
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-16", "2026-03-16")
@@ -2143,10 +2124,9 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_ExceptionalHours(t *test
func TestScheduling_GetAvailableHours_WithBlocker_NonAdmin_ClosedDay(t *testing.T) {
ctx, tx := resetTestData(t)
ukLocation, _ := time.LoadLocation("Europe/London")
// Sunday 2026-03-22 closed, blocker at 10:00-11:00
blockerTime := time.Date(2026, 3, 22, 10, 0, 0, 0, ukLocation)
blockerTime := time.Date(2026, 3, 22, 10, 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Weekend Maintenance', NULL)`, blockerTime)
handler := http.HandlerFunc(GetAvailableHours)
@@ -2183,9 +2163,8 @@ func TestScheduling_GetAvailableHours_WithBlocker_NonAdmin_ClosedDay(t *testing.
func TestScheduling_GetAvailableHours_WithBlocker_NonAdmin_BookingAdjacent(t *testing.T) {
ctx, tx := resetTestData(t)
ukLocation, _ := time.LoadLocation("Europe/London")
bookingStart := time.Date(2026, 3, 17, 10, 0, 0, 0, ukLocation)
bookingStart := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
bookingEnd := bookingStart.Add(60 * time.Minute)
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
@@ -2197,7 +2176,7 @@ func TestScheduling_GetAvailableHours_WithBlocker_NonAdmin_BookingAdjacent(t *te
VALUES ($1, $2, 'confirmed', 60, $3)
`, userID, bookingStart, bookingEnd)
blockerTime := time.Date(2026, 3, 17, 11, 0, 0, 0, ukLocation)
blockerTime := time.Date(2026, 3, 17, 11, 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Maintenance Window', NULL)`, blockerTime)
handler := http.HandlerFunc(GetAvailableHours)
@@ -2245,15 +2224,15 @@ func TestNormalizeTime_StripsSeconds(t *testing.T) {
{"HH:MM:SS", "09:00:00", "09:00"},
{"already HH:MM", "09:00", "09:00"},
{"empty string", "", ""},
{"single digit hour stripped", "9:00:00", "9:00"},
{"single digit hour stripped", "9:00:00", "09:00"},
{"midnight", "00:00:00", "00:00"},
{"23:59:59", "23:59:59", "23:59"},
{"12:30:45", "12:30:45", "12:30"},
{"malformed no colon", "0900", "0900"},
{"single colon", "09:00", "09:00"},
{"extra suffix", "09:00:00:extra", "09:00:00:extra"},
{"short string", "9:00", "9:00"},
{"minimal HH:MM:SS", "1:2:3", "1:2:3"},
{"extra suffix", "09:00:00:extra", "09:00"},
{"short string", "9:00", "09:00"},
{"minimal HH:MM:SS", "1:2:3", "01:02"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -2371,11 +2350,11 @@ func TestNormalizeTime_SingleDigitHour(t *testing.T) {
input string
expected string
}{
{"single digit hour with seconds", "9:00:00", "9:00"},
{"single digit hour no seconds", "9:00", "9:00"},
{"single digit hour with seconds", "9:00:00", "09:00"},
{"single digit hour no seconds", "9:00", "09:00"},
{"double digit hour with seconds", "09:00:00", "09:00"},
{"double digit hour no seconds", "09:00", "09:00"},
{"single digit min with seconds", "09:5:00", "09:5"},
{"single digit min with seconds", "09:5:00", "09:05"},
{"hour only no colon", "0900", "0900"},
{"empty string", "", ""},
{"midnight with seconds", "00:00:00", "00:00"},
@@ -2400,8 +2379,8 @@ func TestNormalizeTime_NoChangeForEdgeCases(t *testing.T) {
{"no colons", "hello", "hello"},
{"single colon only", ":", ":"},
{"trailing colon", "09:", "09:"},
{"only two chars after colon", "9:0", "9:0"},
{"three colons no trailing pair", "a:b:c:d", "a:b:c:d"},
{"only two chars after colon", "9:0", "09:00"},
{"three colons no trailing pair", "a:b:c:d", "a:b"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -2438,8 +2417,7 @@ func TestNormalizeTime_Regression_RealWorldFormats(t *testing.T) {
// slots from each affected day.
func TestScheduling_GetAvailableHours_CrossDayBlocker(t *testing.T) {
ctx, tx := resetTestData(t)
ukLocation, _ := time.LoadLocation("Europe/London")
// Use Tue 2026-03-17 and Wed 2026-03-18 — both open days
_, tueEnd, tueOpen := getWorkingHoursForDate(t, ctx, "2026-03-17")
_, _, wedOpen := getWorkingHoursForDate(t, ctx, "2026-03-18")
@@ -2449,7 +2427,7 @@ func TestScheduling_GetAvailableHours_CrossDayBlocker(t *testing.T) {
// Blocker starts at 15:00 on Tuesday and lasts 20 hours (covers all of
// Wednesday's working hours up to 11:00).
blockerStart := time.Date(2026, 3, 17, 15, 0, 0, 0, ukLocation)
blockerStart := time.Date(2026, 3, 17, 15, 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 1200, 'Multi-day blocker', NULL)`, blockerStart)
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-17", "2026-03-18")
@@ -2492,3 +2470,415 @@ func TestScheduling_GetAvailableHours_CrossDayBlocker(t *testing.T) {
t.Errorf("expected 12:00 available on Wednesday")
}
}
// =============================================================================
// DST Transition Tests
// =============================================================================
//
// These tests verify that timezone handling is correct during BST (summer)
// when the wall-clock time differs from UTC by +1 hour.
// TestScheduling_DST_BlockerTimeFormatting verifies that blocker times are
// formatted in Europe/London during BST, so the blocker correctly subtracts
// wall-clock slots. A blocker at 15:00 BST (= 14:00 UTC) should block the
// 15:00-16:00 BST slot, not 14:00-15:00 BST.
func TestScheduling_DST_BlockerTimeFormatting(t *testing.T) {
ctx, tx := resetTestData(t)
// Monday 2026-06-15 is in BST (UTC+1). Working hours: 09:00-17:00.
// Insert a blocker at 15:00 BST = 14:00 UTC.
blockerTime := time.Date(2026, 6, 15, 14, 0, 0, 0, time.UTC)
_, err := tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES ($1, 60, 'DST Blocker', NULL)
`, blockerTime)
if err != nil {
t.Fatalf("failed to create DST blocker: %v", err)
}
response := makeAdminAvailableHoursRequest(t, ctx, "2026-06-15", "2026-06-15")
targetDay := findDayByDate(response, "2026-06-15")
if targetDay == nil {
t.Fatal("expected day 2026-06-15 in response")
}
// 15:00 BST slot should be blocked (wall-clock time)
if slotExists(targetDay.Slots, "15:00") {
t.Error("expected 15:00 BST slot to be blocked by DST blocker")
}
// 14:00 BST slot should still be available (blocker starts at 15:00)
if !slotExists(targetDay.Slots, "14:00") {
t.Error("expected 14:00 BST slot to remain available")
}
// 16:00 BST slot should be available (blocker ends at 16:00)
if !slotExists(targetDay.Slots, "16:00") {
t.Error("expected 16:00 BST slot to be available after blocker")
}
// Verify blocker appears in blockers field with correct wall-clock times
found := false
for _, b := range targetDay.Blockers {
if b.StartTime == "15:00" && b.EndTime == "16:00" {
found = true
break
}
}
if !found {
t.Error("expected blocker 15:00-16:00 in blockers field (BST wall-clock time)")
}
}
// TestScheduling_DST_MultipleBlockers verifies multiple blockers during BST
// are all correctly applied to wall-clock time slots.
func TestScheduling_DST_MultipleBlockers(t *testing.T) {
ctx, tx := resetTestData(t)
// Monday 2026-06-15 BST: two blockers at 10:00 BST (= 09:00 UTC)
// and 14:00 BST (= 13:00 UTC).
b1 := time.Date(2026, 6, 15, 9, 0, 0, 0, time.UTC)
b2 := time.Date(2026, 6, 15, 13, 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Morning blocker', NULL)`, b1)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Afternoon blocker', NULL)`, b2)
response := makeAdminAvailableHoursRequest(t, ctx, "2026-06-15", "2026-06-15")
targetDay := findDayByDate(response, "2026-06-15")
if targetDay == nil {
t.Fatal("expected day 2026-06-15 in response")
}
// Morning blocker at 10:00 BST
if slotExists(targetDay.Slots, "10:00") {
t.Error("expected 10:00 BST slot blocked (morning blocker)")
}
// Afternoon blocker at 14:00 BST
if slotExists(targetDay.Slots, "14:00") {
t.Error("expected 14:00 BST slot blocked (afternoon blocker)")
}
// 09:00 should be available (before any blocker)
if !slotExists(targetDay.Slots, "09:00") {
t.Error("expected 09:00 BST to remain available")
}
// 11:00 should be available (between blockers)
if !slotExists(targetDay.Slots, "11:00") {
t.Error("expected 11:00 BST to remain available")
}
// 15:00 should be available (after both blockers)
if !slotExists(targetDay.Slots, "15:00") {
t.Error("expected 15:00 BST to remain available")
}
// Both blockers visible in wall-clock time
found1, found2 := false, false
for _, b := range targetDay.Blockers {
if b.StartTime == "10:00" && b.EndTime == "11:00" {
found1 = true
}
if b.StartTime == "14:00" && b.EndTime == "15:00" {
found2 = true
}
}
if !found1 {
t.Error("expected morning blocker 10:00-11:00 in blockers field")
}
if !found2 {
t.Error("expected afternoon blocker 14:00-15:00 in blockers field")
}
}
// TestScheduling_DST_BlockerOnSpringForward verifies time blocker handling
// during the March BST transition (clocks spring forward 01:00→02:00).
// 2026-03-29 is the spring-forward date. The blocker is placed within
// working hours (10:00-11:00 BST) to verify it correctly blocks wall-clock
// time on the transition day.
func TestScheduling_DST_BlockerOnSpringForward(t *testing.T) {
ctx, tx := resetTestData(t)
// 2026-03-29 is the BST start date (clocks spring forward). Sunday is closed
// by default. Use an exceptional hours override to open 09:00-17:00.
var groupID int
err := tx.QueryRow(ctx, `
INSERT INTO exceptional_working_hours_groups (name, description)
VALUES ('Spring Forward Test', 'Test BST transition on 2026-03-29')
RETURNING id
`).Scan(&groupID)
if err != nil {
t.Fatalf("failed to create exceptional group: %v", err)
}
tx.Exec(ctx, `
INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
VALUES ($1, 6, '09:00:00', '17:00:00', true)
`, groupID)
tx.Exec(ctx, `
INSERT INTO exceptional_group_applications (group_id, week_start)
VALUES ($1, '2026-03-23')
`, groupID)
// Blocker at 10:00 BST on 2026-03-29 = 09:00 UTC (spring-forward day,
// clocks jump 01:00→02:00, so 10:00 BST = 09:00 UTC as usual).
blockerTime := time.Date(2026, 3, 29, 9, 0, 0, 0, time.UTC)
tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Spring Forward Blocker', NULL)`, blockerTime)
response := makeAdminAvailableHoursRequest(t, ctx, "2026-03-29", "2026-03-29")
targetDay := findDayByDate(response, "2026-03-29")
if targetDay == nil {
t.Fatal("expected day 2026-03-29 in response")
}
if !targetDay.IsOpen {
t.Fatal("expected 2026-03-29 to be open (exceptional hours)")
}
// 10:00 BST slot should be blocked (wall-clock time within working hours)
if slotExists(targetDay.Slots, "10:00") {
t.Error("expected 10:00 BST slot to be blocked (spring-forward blocker)")
}
// 09:00 BST should be available (before blocker)
if !slotExists(targetDay.Slots, "09:00") {
t.Error("expected 09:00 BST to be available before blocker")
}
// 11:00 BST should be available (after blocker ends)
if !slotExists(targetDay.Slots, "11:00") {
t.Error("expected 11:00 BST to be available after blocker")
}
// Verify blocker in wall-clock time
found := false
for _, b := range targetDay.Blockers {
if b.StartTime == "10:00" && b.EndTime == "11:00" {
found = true
break
}
}
if !found {
t.Error("expected blocker 10:00-11:00 in blockers field (spring-forward wall-clock)")
}
}
// TestNormalizeTime_NonNumericInput verifies that normalizeTime does not
// silently pad non-numeric single-character segments (defensive guard).
func TestNormalizeTime_NonNumericInput(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"non-numeric hour single char", "a:00", "a:00"},
{"non-numeric minute single char", "09:b", "09:b"},
{"both non-numeric single char", "a:b", "a:b"},
{"numeric still works", "9:00", "09:00"},
{"single digit minute", "09:5", "09:05"},
{"both single digit", "9:5", "09:05"},
{"non-numeric multi-char hour passes through", "ab:00", "ab:00"},
{"non-numeric with seconds stripped", "a:b:c", "a:b"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := normalizeTime(tt.input)
if got != tt.expected {
t.Errorf("normalizeTime(%q) = %q, want %q", tt.input, got, tt.expected)
}
})
}
}
// TestScheduling_DST_DateBoundary verifies that GetAvailableHours includes
// BST early-morning bookings (00:00-00:59 BST = 23:00-23:59 UTC previous day)
// in the correct date range. Uses londonLocation for date boundaries.
func TestScheduling_DST_DateBoundary(t *testing.T) {
ctx, tx := resetTestData(t)
// Monday 2026-06-15 is BST. Create a booking at 00:30 BST (= 23:30 UTC June 14).
// This booking should appear in the June 15 results (BST date).
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
defer fixtures.DeleteService(tx, serviceID)
// 00:30 BST on June 15 = 23:30 UTC on June 14
bkStart := time.Date(2026, 6, 15, 0, 30, 0, 0, londonLocation)
bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, bkStart)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
defer fixtures.DeleteBooking(tx, bookingID)
// Query available hours for June 15. The query uses londonLocation for
// date boundaries, so it should see the 00:30 BST booking.
response := makeAdminAvailableHoursRequest(t, ctx, "2026-06-15", "2026-06-15")
targetDay := findDayByDate(response, "2026-06-15")
if targetDay == nil {
t.Fatal("expected day 2026-06-15 in response")
}
if !targetDay.IsOpen {
t.Fatal("expected June 15 to be open")
}
// The booking at 00:30 BST should consume a slot before it.
// If the date boundary was UTC-based, the booking would be invisible
// (23:30 UTC June 14 < 00:00 UTC June 15 query start).
// With londonLocation boundary, 00:30 BST is within the range.
// Verify the 00:00 slot is NOT available (blocked by the 00:30 booking
// because available-hours represents open slots, not individual bookings).
// Actually, available-hours shows slots that ARE available, so a booking
// at 00:30 means the 00:00 slot's 30-min window is partially taken.
// For a 30-min service, 00:00 would be blocked by the 00:30 booking.
if slotExists(targetDay.Slots, "00:00") {
t.Error("expected 00:00 BST slot to be unavailable (booked at 00:30 BST)")
}
}
// TestScheduling_DST_AutumnBack_BookingAt0130BST verifies that a booking at
// 01:30 BST (= 00:30 UTC) on Oct 25, 2026 (BST→GMT transition) is correctly
// handled. Oct 25 is the autumn DST date where clocks go back at 02:00 BST →
// 01:00 GMT, creating a duplicated 01:00-02:00 hour.
func TestScheduling_DST_AutumnBack_BookingAt0130BST(t *testing.T) {
ctx, tx := resetTestData(t)
// 2026-10-25 is the autumn DST date (BST→GMT). Sunday is closed
// by default. Use exceptional hours override to open 09:00-17:00.
var groupID int
err := tx.QueryRow(ctx, `
INSERT INTO exceptional_working_hours_groups (name, description)
VALUES ('Autumn DST BST Test', 'Test BST→GMT transition on 2026-10-25')
RETURNING id
`).Scan(&groupID)
if err != nil {
t.Fatalf("failed to create exceptional group: %v", err)
}
tx.Exec(ctx, `
INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
VALUES ($1, 6, '09:00:00', '17:00:00', true)
`, groupID)
tx.Exec(ctx, `
INSERT INTO exceptional_group_applications (group_id, week_start)
VALUES ($1, '2026-10-19')
`, groupID)
// Create a user and service for the booking
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
defer fixtures.DeleteService(tx, serviceID)
// Booking at 01:30 BST = 00:30 UTC on 2026-10-25 (spans DST transition)
bookingTime := time.Date(2026, 10, 25, 0, 30, 0, 0, time.UTC)
var bookingID string
err = tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status, notes)
VALUES ($1, $2, 'pending', 'Autumn DST BST booking at 01:30 BST')
RETURNING id
`, userID, bookingTime).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id)
VALUES ($1, $2)
`, bookingID, serviceID)
if err != nil {
t.Fatalf("failed to link service to booking: %v", err)
}
response := makeAdminAvailableHoursRequest(t, ctx, "2026-10-25", "2026-10-25")
targetDay := findDayByDate(response, "2026-10-25")
if targetDay == nil {
t.Fatal("expected day 2026-10-25 in response")
}
if !targetDay.IsOpen {
t.Fatal("expected 2026-10-25 to be open (exceptional hours)")
}
// 01:30 BST slot should be blocked (booking at 01:30 BST = 00:30 UTC)
if slotExists(targetDay.Slots, "01:30") {
t.Error("expected 01:30 BST slot to be blocked (booking at 01:30 BST = 00:30 UTC)")
}
}
// TestScheduling_DST_AutumnBack_BookingAt0130GMT verifies that a booking at
// 01:30 GMT (= 01:30 UTC) on Oct 25, 2026 (BST→GMT transition) is also
// correctly handled. This is the second occurrence of 01:30 during the
// duplicated hour on the autumn DST day.
func TestScheduling_DST_AutumnBack_BookingAt0130GMT(t *testing.T) {
ctx, tx := resetTestData(t)
// Same exceptional hours setup as the BST variant
var groupID int
err := tx.QueryRow(ctx, `
INSERT INTO exceptional_working_hours_groups (name, description)
VALUES ('Autumn DST GMT Test', 'Test BST→GMT transition on 2026-10-25')
RETURNING id
`).Scan(&groupID)
if err != nil {
t.Fatalf("failed to create exceptional group: %v", err)
}
tx.Exec(ctx, `
INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
VALUES ($1, 6, '09:00:00', '17:00:00', true)
`, groupID)
tx.Exec(ctx, `
INSERT INTO exceptional_group_applications (group_id, week_start)
VALUES ($1, '2026-10-19')
`, groupID)
// Create a user and service for the booking
userID, err := fixtures.CreateTestUser(tx)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
defer fixtures.DeleteUser(tx, userID)
serviceID, err := fixtures.CreateTestService(tx)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
defer fixtures.DeleteService(tx, serviceID)
// Booking at 01:30 GMT = 01:30 UTC on 2026-10-25
bookingTime := time.Date(2026, 10, 25, 1, 30, 0, 0, time.UTC)
var bookingID string
err = tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status, notes)
VALUES ($1, $2, 'pending', 'Autumn DST GMT booking at 01:30 GMT')
RETURNING id
`, userID, bookingTime).Scan(&bookingID)
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id)
VALUES ($1, $2)
`, bookingID, serviceID)
if err != nil {
t.Fatalf("failed to link service to booking: %v", err)
}
response := makeAdminAvailableHoursRequest(t, ctx, "2026-10-25", "2026-10-25")
targetDay := findDayByDate(response, "2026-10-25")
if targetDay == nil {
t.Fatal("expected day 2026-10-25 in response")
}
if !targetDay.IsOpen {
t.Fatal("expected 2026-10-25 to be open (exceptional hours)")
}
// 01:30 GMT slot should be blocked (booking at 01:30 GMT = 01:30 UTC)
if slotExists(targetDay.Slots, "01:30") {
t.Error("expected 01:30 GMT slot to be blocked (booking at 01:30 GMT = 01:30 UTC)")
}
}
+108 -37
View File
@@ -9,6 +9,7 @@ import (
"time"
"crussell/db"
"crussell/clock"
"crussell/internal/validators"
"crussell/mw"
@@ -51,15 +52,14 @@ func ListTimeBlockers(w http.ResponseWriter, r *http.Request) {
if startStr != "" && endStr != "" {
// Filter by date range
ukLocation, _ := time.LoadLocation("Europe/London")
start, err1 := time.ParseInLocation("2006-01-02", startStr, ukLocation)
end, err2 := time.ParseInLocation("2006-01-02", endStr, ukLocation)
start, err1 := time.Parse("2006-01-02", startStr)
end, err2 := time.Parse("2006-01-02", endStr)
if err1 != nil || err2 != nil {
http.Error(w, "invalid date format, expected YYYY-MM-DD", http.StatusBadRequest)
return
}
start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, ukLocation)
end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 999999999, ukLocation)
start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, londonLocation)
end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 999999999, londonLocation)
// Get one-off blockers in range + ALL recurring blockers
rows, err = db.Conn.Query(r.Context(), `
@@ -73,7 +73,7 @@ func ListTimeBlockers(w http.ResponseWriter, r *http.Request) {
`, start, end)
} else {
// Get future one-off blockers + ALL recurring blockers
now := time.Now()
now := clock.Now()
rows, err = db.Conn.Query(r.Context(), `
SELECT id, start_time, duration_minutes, description, cron_expression, created_at, created_by
FROM time_blockers
@@ -145,9 +145,16 @@ func CreateTimeBlocker(w http.ResponseWriter, r *http.Request) {
createdBy = &userID
}
tx, err := db.Conn.Begin(r.Context())
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
// Insert the time blocker
var blocker TimeBlocker
err := db.Conn.QueryRow(r.Context(), `
err = tx.QueryRow(r.Context(), `
INSERT INTO time_blockers (start_time, duration_minutes, description, cron_expression, created_by)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, start_time, duration_minutes, description, cron_expression, created_at, created_by
@@ -160,6 +167,11 @@ func CreateTimeBlocker(w http.ResponseWriter, r *http.Request) {
return
}
if err := tx.Commit(r.Context()); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(blocker)
@@ -174,7 +186,14 @@ func DeleteTimeBlocker(w http.ResponseWriter, r *http.Request) {
return
}
result, err := db.Conn.Exec(r.Context(), `
tx, err := db.Conn.Begin(r.Context())
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
result, err := tx.Exec(r.Context(), `
DELETE FROM time_blockers WHERE id = $1
`, id)
if err != nil {
@@ -188,6 +207,11 @@ func DeleteTimeBlocker(w http.ResponseWriter, r *http.Request) {
return
}
if err := tx.Commit(r.Context()); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
@@ -243,17 +267,17 @@ func expandCronOccurrences(blocker TimeBlocker, rangeStart, rangeEnd time.Time)
return nil
}
// Get the time-of-day from the blocker's start_time
blockerHour := blocker.StartTime.Hour()
blockerMinute := blocker.StartTime.Minute()
// Use UK timezone for expansion
ukLocation, _ := time.LoadLocation("Europe/London")
// Get the time-of-day from the blocker's start_time in London time,
// so the recurrence fires at the same wall-clock time regardless of DST.
blockerLondon := blocker.StartTime.In(londonLocation)
blockerHour := blockerLondon.Hour()
blockerMinute := blockerLondon.Minute()
var occurrences []TimeBlocker
// Start from the beginning of the range
current := time.Date(rangeStart.Year(), rangeStart.Month(), rangeStart.Day(), blockerHour, blockerMinute, 0, 0, ukLocation)
// Start from the beginning of the range using London timezone,
// ensuring the same wall-clock time applies year-round.
current := time.Date(rangeStart.Year(), rangeStart.Month(), rangeStart.Day(), blockerHour, blockerMinute, 0, 0, londonLocation)
// Find the first occurrence on or after rangeStart
firstNext := schedule.Next(current.Add(-time.Second))
@@ -329,12 +353,18 @@ func CheckTimeBlockerOverlap(ctx context.Context, startTime, endTime time.Time)
// - Admin walk-in (RESERVATION:admin:walkin:%): older than 15 minutes
// - Admin call-in (RESERVATION:admin:callin:%): older than 15 minutes
func CleanupOldReservations(ctx context.Context) error {
oneHourAgo := time.Now().Add(-1 * time.Hour)
tenMinutesAgo := time.Now().Add(-10 * time.Minute)
fifteenMinutesAgo := time.Now().Add(-15 * time.Minute)
twentyFourHoursAgo := time.Now().Add(-24 * time.Hour)
oneHourAgo := clock.Now().Add(-1 * time.Hour)
tenMinutesAgo := clock.Now().Add(-10 * time.Minute)
fifteenMinutesAgo := clock.Now().Add(-15 * time.Minute)
twentyFourHoursAgo := clock.Now().Add(-24 * time.Hour)
_, err := db.Conn.Exec(ctx, `
tx, err := db.Conn.Begin(ctx)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `
DELETE FROM time_blockers
WHERE (description LIKE 'RESERVATION:user:%' AND created_at < $1)
OR (description LIKE 'RESERVATION:anon:%' AND created_at < $2)
@@ -343,7 +373,11 @@ func CleanupOldReservations(ctx context.Context) error {
OR (description LIKE 'RESERVATION:edit_request:%' AND created_at < $4)
OR (description LIKE 'PAYMENT_IN_FLIGHT:%' AND start_time + (duration_minutes * INTERVAL '1 minute') < NOW())
`, oneHourAgo, tenMinutesAgo, fifteenMinutesAgo, twentyFourHoursAgo)
return err
if err != nil {
return err
}
return tx.Commit(ctx)
}
// AnonymizeStaleGuestAccounts anonymizes personal data for guest accounts
@@ -351,7 +385,13 @@ func CleanupOldReservations(ctx context.Context) error {
// Financial records (bookings, payments) remain intact — only PII is scrubbed.
// Active/pending bookings are excluded so the salon can still contact the guest.
func AnonymizeStaleGuestAccounts(ctx context.Context) error {
_, err := db.Conn.Exec(ctx, `
tx, err := db.Conn.Begin(ctx)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `
UPDATE users SET
n_first_name = 'Guest',
n_last_name = 'Anonymized',
@@ -372,7 +412,7 @@ func AnonymizeStaleGuestAccounts(ctx context.Context) error {
}
// Anonymize patch test records for stale guests (medical-adjacent PII)
_, err = db.Conn.Exec(ctx, `
_, err = tx.Exec(ctx, `
UPDATE user_patch_tests SET user_id = NULL
WHERE user_id IN (
SELECT id FROM users
@@ -386,7 +426,7 @@ func AnonymizeStaleGuestAccounts(ctx context.Context) error {
}
// Anonymize referral relationships for stale guests
_, err = db.Conn.Exec(ctx, `
_, err = tx.Exec(ctx, `
UPDATE user_referrals SET referrer_id = NULL
WHERE referrer_id IN (
SELECT id FROM users
@@ -399,7 +439,7 @@ func AnonymizeStaleGuestAccounts(ctx context.Context) error {
return err
}
_, err = db.Conn.Exec(ctx, `
_, err = tx.Exec(ctx, `
UPDATE user_referrals SET referred_id = NULL
WHERE referred_id IN (
SELECT id FROM users
@@ -413,7 +453,7 @@ func AnonymizeStaleGuestAccounts(ctx context.Context) error {
}
// Anonymize admin notification references for stale guests
_, err = db.Conn.Exec(ctx, `
_, err = tx.Exec(ctx, `
UPDATE admin_notifications SET user_id = NULL
WHERE user_id IN (
SELECT id FROM users
@@ -422,16 +462,30 @@ func AnonymizeStaleGuestAccounts(ctx context.Context) error {
AND n_last_name = 'Anonymized'
)
`)
return err
if err != nil {
return err
}
return tx.Commit(ctx)
}
func CleanupExpiredLoyaltyRedemptions(ctx context.Context) error {
_, err := db.Conn.Exec(ctx, `
tx, err := db.Conn.Begin(ctx)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `
DELETE FROM loyalty_redemptions
WHERE status = 'pending'
AND expires_at < NOW()
`)
return err
if err != nil {
return err
}
return tx.Commit(ctx)
}
// CleanupExpiredFinancialRecords deletes granular payment/refund records whose
@@ -459,7 +513,7 @@ func CleanupExpiredFinancialRecords(ctx context.Context) error {
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `
INSERT INTO financial_aggregates (month, total_payments, total_square_fees, total_cash, total_online, total_in_person, total_discounts, total_giftcard, total_tips, total_deposits, total_balances, total_partials, booking_count)
INSERT INTO financial_aggregates (month, total_payments, total_square_fees, total_cash, total_online, total_in_person, total_discounts, total_giftcard, total_tips, total_deposits, total_balances, total_partials, total_vat_amount, total_net_amount, booking_count)
SELECT
DATE_TRUNC('month', p.created_at)::date AS month,
COALESCE(SUM(p.amount), 0) AS total_payments,
@@ -473,6 +527,8 @@ func CleanupExpiredFinancialRecords(ctx context.Context) error {
COALESCE(SUM(p.amount) FILTER (WHERE p.payment_type = 'deposit'), 0) AS total_deposits,
COALESCE(SUM(p.amount) FILTER (WHERE p.payment_type = 'balance'), 0) AS total_balances,
COALESCE(SUM(p.amount) FILTER (WHERE p.payment_type = 'partial'), 0) AS total_partials,
COALESCE(SUM(p.vat_amount), 0) AS total_vat_amount,
COALESCE(SUM(p.net_amount), 0) AS total_net_amount,
COALESCE(COUNT(DISTINCT p.booking_id), 0) AS booking_count
FROM payments p
LEFT JOIN bookings b ON p.booking_id = b.id
@@ -492,6 +548,8 @@ func CleanupExpiredFinancialRecords(ctx context.Context) error {
total_deposits = financial_aggregates.total_deposits + EXCLUDED.total_deposits,
total_balances = financial_aggregates.total_balances + EXCLUDED.total_balances,
total_partials = financial_aggregates.total_partials + EXCLUDED.total_partials,
total_vat_amount = financial_aggregates.total_vat_amount + EXCLUDED.total_vat_amount,
total_net_amount = financial_aggregates.total_net_amount + EXCLUDED.total_net_amount,
booking_count = financial_aggregates.booking_count + EXCLUDED.booking_count
`)
if err != nil {
@@ -884,7 +942,13 @@ func CleanupIdleAccounts(ctx context.Context) error {
// till_sales that are older than 24 hours and no longer pending. This prevents
// unbounded table growth while preserving keys for recent in-flight requests.
func CleanupOldIdempotencyKeys(ctx context.Context) error {
_, err := db.Conn.Exec(ctx, `
tx, err := db.Conn.Begin(ctx)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `
UPDATE bookings
SET idempotency_key = NULL
WHERE idempotency_key IS NOT NULL
@@ -895,7 +959,7 @@ func CleanupOldIdempotencyKeys(ctx context.Context) error {
return fmt.Errorf("failed to cleanup booking idempotency keys: %w", err)
}
_, err = db.Conn.Exec(ctx, `
_, err = tx.Exec(ctx, `
UPDATE payments
SET idempotency_key = NULL
WHERE idempotency_key IS NOT NULL
@@ -906,7 +970,7 @@ func CleanupOldIdempotencyKeys(ctx context.Context) error {
return fmt.Errorf("failed to cleanup payment idempotency keys: %w", err)
}
_, err = db.Conn.Exec(ctx, `
_, err = tx.Exec(ctx, `
UPDATE till_sales
SET idempotency_key = NULL
WHERE idempotency_key IS NOT NULL
@@ -916,7 +980,7 @@ func CleanupOldIdempotencyKeys(ctx context.Context) error {
return fmt.Errorf("failed to cleanup till sale idempotency keys: %w", err)
}
return nil
return tx.Commit(ctx)
}
// CleanupOldNameHistory removes name_history entries older than 6 months.
@@ -924,12 +988,19 @@ func CleanupOldIdempotencyKeys(ctx context.Context) error {
// to be retained indefinitely. 6 months provides a reasonable window for
// displaying former names on booking receipts and admin views.
func CleanupOldNameHistory(ctx context.Context) error {
_, err := db.Conn.Exec(ctx, `
tx, err := db.Conn.Begin(ctx)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `
DELETE FROM name_history
WHERE changed_at < NOW() - INTERVAL '6 months'
`)
if err != nil {
return fmt.Errorf("failed to cleanup old name history: %w", err)
}
return nil
return tx.Commit(ctx)
}
+95 -109
View File
@@ -26,6 +26,7 @@ import (
"testing"
"time"
"crussell/clock"
"crussell/mw"
"crussell/testutils/fixtures"
@@ -74,9 +75,8 @@ func TestTimeBlockers_List(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
ukLocation, _ := time.LoadLocation("Europe/London")
blockerTime1 := time.Now().In(ukLocation).Add(7 * 24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
blockerTime2 := time.Now().In(ukLocation).Add(8 * 24 * time.Hour).Truncate(24 * time.Hour).Add(14 * time.Hour)
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)
@@ -110,11 +110,10 @@ func TestTimeBlockers_ListWithDateFilter(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
ukLocation, _ := time.LoadLocation("Europe/London")
// Create blockers on different dates
blockerTime1 := time.Date(2026, 3, 10, 10, 0, 0, 0, ukLocation) // In range
blockerTime2 := time.Date(2026, 3, 15, 14, 0, 0, 0, ukLocation) // Out of range
blockerTime3 := time.Date(2026, 3, 12, 9, 0, 0, 0, ukLocation) // In range
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)
@@ -150,8 +149,7 @@ func TestTimeBlockers_Create(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
ukLocation, _ := time.LoadLocation("Europe/London")
blockerTime := time.Date(2026, 3, 20, 10, 0, 0, 0, ukLocation)
blockerTime := time.Date(2026, 3, 20, 10, 0, 0, 0, time.UTC)
reqBody := CreateTimeBlockerRequest{
StartTime: blockerTime,
@@ -209,8 +207,7 @@ func TestTimeBlockers_Create_ValidationErrors(t *testing.T) {
}
// Test missing duration_minutes
ukLocation, _ := time.LoadLocation("Europe/London")
blockerTime := time.Date(2026, 3, 20, 10, 0, 0, 0, ukLocation)
blockerTime := time.Date(2026, 3, 20, 10, 0, 0, 0, time.UTC)
reqBody2 := map[string]interface{}{
"start_time": blockerTime,
"description": "Test",
@@ -250,8 +247,7 @@ func TestTimeBlockers_Delete(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
ukLocation, _ := time.LoadLocation("Europe/London")
blockerTime := time.Date(2026, 3, 25, 10, 0, 0, 0, ukLocation)
blockerTime := time.Date(2026, 3, 25, 10, 0, 0, 0, time.UTC)
// Create a blocker to delete
var blockerID string
@@ -330,9 +326,8 @@ func TestCheckTimeBlockerOverlap(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
ukLocation, _ := time.LoadLocation("Europe/London")
// Create blocker for 10:00-11:00 (60 minutes)
blockerTime := time.Date(2026, 3, 15, 10, 0, 0, 0, ukLocation)
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)
@@ -344,8 +339,8 @@ func TestCheckTimeBlockerOverlap(t *testing.T) {
// Test case 1: Exact overlap (10:00-11:00)
hasOverlap, desc, err := CheckTimeBlockerOverlap(ctx,
time.Date(2026, 3, 15, 10, 0, 0, 0, ukLocation),
time.Date(2026, 3, 15, 11, 0, 0, 0, ukLocation))
time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC),
time.Date(2026, 3, 15, 11, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("CheckTimeBlockerOverlap failed: %v", err)
}
@@ -358,8 +353,8 @@ func TestCheckTimeBlockerOverlap(t *testing.T) {
// Test case 2: No overlap (09:00-10:00 - ends exactly when blocker starts)
hasOverlap, _, err = CheckTimeBlockerOverlap(ctx,
time.Date(2026, 3, 15, 9, 0, 0, 0, ukLocation),
time.Date(2026, 3, 15, 10, 0, 0, 0, ukLocation))
time.Date(2026, 3, 15, 9, 0, 0, 0, time.UTC),
time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("CheckTimeBlockerOverlap failed: %v", err)
}
@@ -369,8 +364,8 @@ func TestCheckTimeBlockerOverlap(t *testing.T) {
// Test case 3: Partial overlap (10:30-11:30 - starts during blocker)
hasOverlap, _, err = CheckTimeBlockerOverlap(ctx,
time.Date(2026, 3, 15, 10, 30, 0, 0, ukLocation),
time.Date(2026, 3, 15, 11, 30, 0, 0, ukLocation))
time.Date(2026, 3, 15, 10, 30, 0, 0, time.UTC),
time.Date(2026, 3, 15, 11, 30, 0, 0, time.UTC))
if err != nil {
t.Fatalf("CheckTimeBlockerOverlap failed: %v", err)
}
@@ -380,8 +375,8 @@ func TestCheckTimeBlockerOverlap(t *testing.T) {
// Test case 4: Partial overlap (09:30-10:30 - ends during blocker)
hasOverlap, _, err = CheckTimeBlockerOverlap(ctx,
time.Date(2026, 3, 15, 9, 30, 0, 0, ukLocation),
time.Date(2026, 3, 15, 10, 30, 0, 0, ukLocation))
time.Date(2026, 3, 15, 9, 30, 0, 0, time.UTC),
time.Date(2026, 3, 15, 10, 30, 0, 0, time.UTC))
if err != nil {
t.Fatalf("CheckTimeBlockerOverlap failed: %v", err)
}
@@ -391,8 +386,8 @@ func TestCheckTimeBlockerOverlap(t *testing.T) {
// Test case 5: No overlap (completely before blocker)
hasOverlap, _, err = CheckTimeBlockerOverlap(ctx,
time.Date(2026, 3, 15, 8, 0, 0, 0, ukLocation),
time.Date(2026, 3, 15, 9, 0, 0, 0, ukLocation))
time.Date(2026, 3, 15, 8, 0, 0, 0, time.UTC),
time.Date(2026, 3, 15, 9, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("CheckTimeBlockerOverlap failed: %v", err)
}
@@ -402,8 +397,8 @@ func TestCheckTimeBlockerOverlap(t *testing.T) {
// Test case 6: No overlap (completely after blocker)
hasOverlap, _, err = CheckTimeBlockerOverlap(ctx,
time.Date(2026, 3, 15, 14, 0, 0, 0, ukLocation),
time.Date(2026, 3, 15, 15, 0, 0, 0, ukLocation))
time.Date(2026, 3, 15, 14, 0, 0, 0, time.UTC),
time.Date(2026, 3, 15, 15, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("CheckTimeBlockerOverlap failed: %v", err)
}
@@ -420,11 +415,10 @@ func TestGetTimeBlockersInRange(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
ukLocation, _ := time.LoadLocation("Europe/London")
// Create blockers on different dates
blocker1 := time.Date(2026, 3, 10, 10, 0, 0, 0, ukLocation)
blocker2 := time.Date(2026, 3, 15, 14, 0, 0, 0, ukLocation)
blocker3 := time.Date(2026, 3, 20, 9, 0, 0, 0, ukLocation)
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)
@@ -438,8 +432,8 @@ func TestGetTimeBlockersInRange(t *testing.T) {
// Query range that includes blocker1 and blocker2 but not blocker3
start := time.Date(2026, 3, 1, 0, 0, 0, 0, ukLocation)
end := time.Date(2026, 3, 16, 23, 59, 59, 0, ukLocation)
start := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2026, 3, 16, 23, 59, 59, 0, time.UTC)
blockers, err := GetTimeBlockersInRange(ctx, start, end)
if err != nil {
@@ -474,10 +468,9 @@ func TestGetTimeBlockersInRange_Empty(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
ukLocation, _ := time.LoadLocation("Europe/London")
// Create a blocker
blockerTime := time.Date(2026, 3, 15, 10, 0, 0, 0, ukLocation)
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)
@@ -488,8 +481,8 @@ func TestGetTimeBlockersInRange_Empty(t *testing.T) {
// Query range with no blockers
start := time.Date(2026, 4, 1, 0, 0, 0, 0, ukLocation)
end := time.Date(2026, 4, 30, 23, 59, 59, 0, ukLocation)
start := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2026, 4, 30, 23, 59, 59, 0, time.UTC)
blockers, err := GetTimeBlockersInRange(ctx, start, end)
if err != nil {
@@ -507,9 +500,8 @@ func TestGetTimeBlockersInRange_IncludesRecurring(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
ukLocation, _ := time.LoadLocation("Europe/London")
// Create one-off blocker for March 15
oneOffTime := time.Date(2026, 3, 15, 10, 0, 0, 0, ukLocation)
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"
@@ -524,8 +516,8 @@ func TestGetTimeBlockersInRange_IncludesRecurring(t *testing.T) {
// Query range: March 1-31, 2026
start := time.Date(2026, 3, 1, 0, 0, 0, 0, ukLocation)
end := time.Date(2026, 3, 31, 23, 59, 59, 0, ukLocation)
start := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2026, 3, 31, 23, 59, 59, 0, time.UTC)
blockers, err := GetTimeBlockersInRange(ctx, start, end)
if err != nil {
@@ -567,8 +559,7 @@ func TestCleanupOldReservations(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
ukLocation, _ := time.LoadLocation("Europe/London")
// Create fixture users for the test
oldUserID, err := fixtures.CreateTestUser(tx)
if err != nil {
@@ -581,37 +572,37 @@ func TestCleanupOldReservations(t *testing.T) {
}
// Create old reservation (> 1 hour old)
oldTime := time.Now().Add(-2 * time.Hour).In(ukLocation)
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, time.Now().UnixNano()), oldUserID)
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`, time.Now().Add(-2*time.Hour), oldTime)
_, 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 := time.Now().Add(-30 * time.Minute).In(ukLocation)
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, time.Now().UnixNano()), recentUserID)
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`, time.Now().Add(-30*time.Minute), recentTime)
_, 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 := time.Now().Add(-2 * time.Hour).In(ukLocation)
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")
@@ -675,24 +666,23 @@ func TestCleanupOldReservations_AdminWalkIn(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
ukLocation, _ := time.LoadLocation("Europe/London")
// Create old walk-in reservation (>15 min old)
oldTime := time.Now().Add(-16 * time.Minute).In(ukLocation)
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, time.Now().Add(-16*time.Minute))
`, 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 := time.Now().Add(-14 * time.Minute).In(ukLocation)
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, time.Now().Add(-14*time.Minute))
`, recentTime, clock.Now().Add(-14*time.Minute))
if err != nil {
t.Fatalf("failed to create recent walk-in reservation: %v", err)
}
@@ -732,24 +722,23 @@ func TestCleanupOldReservations_AdminCallIn(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
ukLocation, _ := time.LoadLocation("Europe/London")
// Create old call-in reservation (>15 min old)
oldTime := time.Now().Add(-16 * time.Minute).In(ukLocation)
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, time.Now().Add(-16*time.Minute))
`, 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 := time.Now().Add(-14 * time.Minute).In(ukLocation)
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, time.Now().Add(-14*time.Minute))
`, recentTime, clock.Now().Add(-14*time.Minute))
if err != nil {
t.Fatalf("failed to create recent call-in reservation: %v", err)
}
@@ -789,84 +778,83 @@ func TestCleanupOldReservations_MixedTypes(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
ukLocation, _ := time.LoadLocation("Europe/London")
// Create old user reservation (>1 hour old)
oldUserTime := time.Now().Add(-2 * time.Hour).In(ukLocation)
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, time.Now().Add(-2*time.Hour))
`, 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 := time.Now().Add(-30 * time.Minute).In(ukLocation)
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, time.Now().Add(-30*time.Minute))
`, 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 := time.Now().Add(-15 * time.Minute).In(ukLocation)
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, time.Now().Add(-15*time.Minute))
`, 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 := time.Now().Add(-5 * time.Minute).In(ukLocation)
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, time.Now().Add(-5*time.Minute))
`, 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 := time.Now().Add(-20 * time.Minute).In(ukLocation)
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, time.Now().Add(-20*time.Minute))
`, 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 := time.Now().Add(-10 * time.Minute).In(ukLocation)
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, time.Now().Add(-10*time.Minute))
`, 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 := time.Now().Add(-20 * time.Minute).In(ukLocation)
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, time.Now().Add(-20*time.Minute))
`, 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 := time.Now().Add(-10 * time.Minute).In(ukLocation)
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, time.Now().Add(-10*time.Minute))
`, recentCallinTime, clock.Now().Add(-10*time.Minute))
if err != nil {
t.Fatalf("failed to create recent call-in reservation: %v", err)
}
@@ -937,11 +925,10 @@ func TestGetTimeBlockersInRange_IncludesReservations(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
ukLocation, _ := time.LoadLocation("Europe/London")
// Create a regular blocker for tomorrow at 10:00
tomorrow := time.Now().Add(24 * time.Hour).In(ukLocation)
blockerTime := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 10, 0, 0, 0, ukLocation)
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)
@@ -951,7 +938,7 @@ func TestGetTimeBlockersInRange_IncludesReservations(t *testing.T) {
}
// Create a reservation for tomorrow at 11:00
reservationTime := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 11, 0, 0, 0, ukLocation)
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)
@@ -961,8 +948,8 @@ func TestGetTimeBlockersInRange_IncludesReservations(t *testing.T) {
}
// Query range covering both times
start := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 0, 0, 0, 0, ukLocation)
end := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 23, 59, 59, 0, ukLocation)
start := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 0, 0, 0, 0, time.UTC)
end := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 23, 59, 59, 0, time.UTC)
blockers, err := GetTimeBlockersInRange(ctx, start, end)
if err != nil {
@@ -1059,7 +1046,7 @@ func TestAnonymizeStaleGuestAccounts_ActiveBooking_Skipped(t *testing.T) {
}
// Create active booking (tomorrow)
tomorrow := time.Now().Add(24 * time.Hour)
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)
@@ -1149,7 +1136,7 @@ func TestCleanupExpiredFinancialRecords_PaymentOlderThan7Years(t *testing.T) {
t.Fatalf("failed to create booking: %v", err)
}
eightYearsAgo := time.Now().AddDate(-8, 0, 0)
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)
@@ -1234,7 +1221,7 @@ func TestCleanupExpiredFinancialRecords_AnonUserWithin1YearBuffer(t *testing.T)
t.Fatalf("failed to create booking: %v", err)
}
fourYearsAgo := time.Now().AddDate(-4, 0, 0)
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)
@@ -1295,7 +1282,7 @@ func TestCleanupExpiredFinancialRecords_PaymentOlderThan9Years(t *testing.T) {
}
// Payment created 9 years ago
nineYearsAgo := time.Now().AddDate(-9, 0, 0)
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)
@@ -1354,7 +1341,7 @@ func TestCleanupExpiredFinancialRecords_AggregationCorrectTotals(t *testing.T) {
}
// 3 payments 8 years ago, all in the same month
sameMonth := time.Now().AddDate(-8, 0, 0)
sameMonth := clock.Now().AddDate(-8, 0, 0)
_ = sameMonth // used for all payments
// Payment 1: £50 cash
@@ -1444,7 +1431,7 @@ func TestCleanupExpiredFinancialRecords_Idempotent(t *testing.T) {
}
// Payment 8 years ago
eightYearsAgo := time.Now().AddDate(-8, 0, 0)
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)
@@ -1535,7 +1522,7 @@ func TestCleanupExpiredFinancialRecords_ActiveUserWithin7Years(t *testing.T) {
}
// Payment created 3 years ago (< 7 years)
threeYearsAgo := time.Now().AddDate(-3, 0, 0)
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)
@@ -1611,7 +1598,7 @@ func TestCleanupExpiredFinancialRecords_AnonUserBothThresholdsElapsed(t *testing
// Payment created 8 years ago (past 7yr rule)
// User anonymized 2 years ago (past 1yr buffer)
// Both conditions met → should be deleted
eightYearsAgo := time.Now().AddDate(-8, 0, 0)
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)
@@ -1673,7 +1660,7 @@ func TestCleanupExpiredFinancialRecords_RefundAggregatedAndDeleted(t *testing.T)
}
// Payment 8 years ago
eightYearsAgo := time.Now().AddDate(-8, 0, 0)
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)
@@ -1810,24 +1797,23 @@ func TestCleanupOldReservations_EditRequest(t *testing.T) {
t.Parallel()
ctx, tx := resetTestData(t)
ukLocation, _ := time.LoadLocation("Europe/London")
// Create old edit_request reservation (>24 hours old)
oldTime := time.Now().Add(-25 * time.Hour).In(ukLocation)
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, time.Now().Add(-25*time.Hour))
`, 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 := time.Now().Add(-12 * time.Hour).In(ukLocation)
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, time.Now().Add(-12*time.Hour))
`, recentTime, clock.Now().Add(-12*time.Hour))
if err != nil {
t.Fatalf("failed to create recent edit_request reservation: %v", err)
}
@@ -1871,7 +1857,7 @@ func TestCleanupExpiredDeposits_ExpiredConfirmed(t *testing.T) {
t.Fatalf("failed to create user: %v", err)
}
startTime := time.Now().Add(12 * time.Hour)
startTime := clock.Now().Add(12 * time.Hour)
var bookingID string
err = tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status, deposit_required)
@@ -1933,7 +1919,7 @@ func TestCleanupExpiredDeposits_ExpiredPending(t *testing.T) {
t.Fatalf("failed to create user: %v", err)
}
startTime := time.Now().Add(12 * time.Hour)
startTime := clock.Now().Add(12 * time.Hour)
var bookingID string
err = tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status, deposit_required)
@@ -1997,7 +1983,7 @@ func TestCleanupExpiredDeposits_PaidDepositPreserved(t *testing.T) {
t.Fatalf("failed to create user: %v", err)
}
startTime := time.Now().Add(12 * time.Hour)
startTime := clock.Now().Add(12 * time.Hour)
var bookingID string
err = tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status, deposit_required)
@@ -2066,7 +2052,7 @@ func TestCleanupExpiredDeposits_FutureDeadlinePreserved(t *testing.T) {
t.Fatalf("failed to create user: %v", err)
}
startTime := time.Now().Add(48 * time.Hour)
startTime := clock.Now().Add(48 * time.Hour)
var bookingID string
err = tx.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status, deposit_required)
@@ -2658,7 +2644,7 @@ func TestCleanupExpiredDeposits_SetsPendingRelease(t *testing.T) {
}
t.Cleanup(func() { fixtures.DeleteService(tx, serviceID) })
soon := time.Now().Add(1 * time.Hour)
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)
@@ -2702,7 +2688,7 @@ func TestCleanupExpiredDeposits_DoesNotAffectPaidBookings(t *testing.T) {
}
t.Cleanup(func() { fixtures.DeleteService(tx, serviceID) })
soon := time.Now().Add(1 * time.Hour)
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)