chore: run go fix for Go 1.26 modernization
CI / Go vulnerabilities (push) Successful in 1m10s
CI / Build & Vet (push) Successful in 1m39s
CI / Frontend build (gate) (push) Successful in 1m42s
CI / Frontend QC (audit) (push) Successful in 56s
CI / Frontend QC (typecheck) (push) Successful in 1m36s
CI / Frontend QC (lint) (push) Successful in 1m51s
CI / Tests (prod) (push) Has been cancelled
CI / Tests (dev) (push) Has been cancelled
CI / Race (prod) (push) Has been cancelled
CI / Race (dev) (push) Has been cancelled

106 files: interface{}→any, strings.Split→SplitSeq, CutPrefix/Cut, strings.Builder, slices.Contains, remove redundant // +build directives, gofmt import ordering and indentation.

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-07-09 17:25:23 +01:00
co-authored by Sisyphus
parent ef26bd59e9
commit 510828c924
107 changed files with 882 additions and 745 deletions
+431
View File
@@ -0,0 +1,431 @@
# Plan: Consolidated `jobs` Package for Background Cron Tasks
**Goal:** Extract 9 side-effect cleanup functions from `GetAvailableHours` HTTP handler into a dedicated `jobs` package with cron-scheduled, parallel execution. Remove the lazy-cleanup antipattern.
**Current state:**
- 9 cleanup functions in `backend/handlers/scheduling/time-blockers.go` — all called synchronously in `default-hours.go:364-407` on every `GET /available-hours` request
- Only `CleanupOldReservations` ALSO runs on a background goroutine (`main.go:432-450`, 5min ticker)
- 4 other ad-hoc background goroutines exist (JWT cleanup, GDPR cache, login state, rate limiter)
- `robfig/cron/v3` v3.0.1 is already a `go.mod` dependency (currently used only for parsing time-blocker cron expressions)
---
## Phase 1: Create `backend/internal/jobs/` Package
**New package:** `crussell/internal/jobs`
### File: `scheduler.go`
Core scheduler abstraction built on `robfig/cron/v3`:
```go
package jobs
import (
"context"
"log"
"time"
"github.com/robfig/cron/v3"
)
// Job defines a single periodic task.
type Job struct {
Name string // Human-readable name (for logging)
Schedule string // Standard cron expression ("*/5 * * * *")
Timeout time.Duration // Per-execution timeout
Concurrency int // Max concurrent runs (0 = unlimited, 1 = serial)
Handler func(context.Context) error // The actual work
}
// Scheduler manages all registered cron jobs with parallel execution.
type Scheduler struct {
cron *cron.Cron
entries []cron.EntryID
registry []Job
baseCtx context.Context
cancel context.CancelFunc
semaphores map[string]chan struct{} // Per-job concurrency limit
}
```
**Key design decisions:**
1. Each job runs in its own goroutine (cron v3 default) — parallel by nature
2. Per-job concurrency control via channel semaphore — prevents overlapping runs of the same job
3. Panic recovery wrapper — matches existing pattern in `jwt.go`, `gdpr_export.go`, `auth/local.go`
4. Timeout via `context.WithTimeout` — matches existing `main.go` pattern
5. Graceful shutdown via `baseCtx` — the cron scheduler is stopped, then active jobs drain
```go
func New() *Scheduler {
ctx, cancel := context.WithCancel(context.Background())
return &Scheduler{
cron: cron.New(cron.WithLocation(londonLocation)),
baseCtx: ctx,
cancel: cancel,
semaphores: make(map[string]chan struct{}),
}
}
func (s *Scheduler) Register(job Job) {
// Validate cron expression at registration time
parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
if _, err := parser.Parse(job.Schedule); err != nil {
log.Fatalf("jobs: invalid cron schedule %q for job %q: %v", job.Schedule, job.Name, err)
}
s.registry = append(s.registry, job)
}
func (s *Scheduler) Start() {
for _, job := range s.registry {
j := job // capture
entryID, err := s.cron.AddFunc(j.Schedule, s.wrapJob(j))
if err != nil {
log.Fatalf("jobs: failed to register %q: %v", j.Name, err)
}
s.entries = append(s.entries, entryID)
}
s.cron.Start()
}
func (s *Scheduler) Shutdown() <-chan struct{} {
ctx := s.cron.Stop() // Stop scheduler (returns ctx that completes when all jobs finish)
s.cancel() // Cancel base context so in-flight jobs know to stop
return ctx.Done() // Caller can wait for this
}
// wrapJob adds panic recovery, timeout, concurrency control, and logging.
func (s *Scheduler) wrapJob(job Job) func() {
// Set up semaphore if concurrency limited
var sem chan struct{}
if job.Concurrency > 0 {
sem = make(chan struct{}, job.Concurrency)
sem <- struct{}{} // Initial slot filled
}
return func() {
// Concurrency guard
if sem != nil {
select {
case <-sem:
// Acquired — proceed
default:
log.Printf("jobs: %q skipped (previous run still in progress)", job.Name)
return
}
defer func() { sem <- struct{}{} }()
}
// Panic recovery
defer func() {
if r := recover(); r != nil {
log.Printf("jobs: panic recovered in %q: %v", job.Name, r)
}
}()
// Timeout
ctx, cancel := context.WithTimeout(s.baseCtx, job.Timeout)
defer cancel()
start := time.Now()
if err := job.Handler(ctx); err != nil {
log.Printf("jobs: %q failed: %v (duration: %v)", job.Name, err, time.Since(start))
} else {
log.Printf("jobs: %q completed (duration: %v)", job.Name, time.Since(start))
}
}
}
```
### File: `cleanup.go`
Registration of all 9 scheduling cleanup functions + JWT cleanup.
```go
package jobs
import (
"context"
"crussell/auth"
"crussell/handlers/scheduling"
"time"
)
// RegisterAll registers every background job.
func RegisterAll(s *Scheduler) {
// === HIGH FREQUENCY (every 5 min) ===
s.Register(Job{
Name: "cleanup-reservations",
Schedule: "*/5 * * * *",
Timeout: 30 * time.Second,
Concurrency: 1,
Handler: scheduling.CleanupOldReservations,
})
s.Register(Job{
Name: "cleanup-expired-deposits",
Schedule: "*/5 * * * *",
Timeout: 30 * time.Second,
Concurrency: 1,
Handler: scheduling.CleanupExpiredDeposits,
})
// === MID FREQUENCY (hourly) ===
s.Register(Job{
Name: "cleanup-expired-loyalty-redemptions",
Schedule: "0 * * * *",
Timeout: 30 * time.Second,
Concurrency: 1,
Handler: scheduling.CleanupExpiredLoyaltyRedemptions,
})
s.Register(Job{
Name: "cleanup-old-idempotency-keys",
Schedule: "0 * * * *",
Timeout: 30 * time.Second,
Concurrency: 1,
Handler: scheduling.CleanupOldIdempotencyKeys,
})
s.Register(Job{
Name: "cleanup-revoked-jtis",
Schedule: "0 * * * *",
Timeout: 30 * time.Second,
Concurrency: 1,
Handler: func(ctx context.Context) error {
auth.CleanupRevokedJTIs(ctx)
return nil
},
})
// === LOW FREQUENCY (daily, off-peak) ===
s.Register(Job{
Name: "anonymize-stale-guest-accounts",
Schedule: "0 3 * * *", // 3am
Timeout: 5 * time.Minute,
Concurrency: 1,
Handler: scheduling.AnonymizeStaleGuestAccounts,
})
s.Register(Job{
Name: "cleanup-expired-financial-records",
Schedule: "0 4 * * *", // 4am
Timeout: 10 * time.Minute,
Concurrency: 1,
Handler: scheduling.CleanupExpiredFinancialRecords,
})
s.Register(Job{
Name: "cleanup-expired-gift-cards",
Schedule: "0 5 * * *", // 5am
Timeout: 5 * time.Minute,
Concurrency: 1,
Handler: scheduling.CleanupExpiredGiftCards,
})
s.Register(Job{
Name: "cleanup-idle-accounts",
Schedule: "30 3 * * *", // 3:30am (offset from financial)
Timeout: 5 * time.Minute,
Concurrency: 1,
Handler: scheduling.CleanupIdleAccounts,
})
s.Register(Job{
Name: "cleanup-old-name-history",
Schedule: "30 4 * * *", // 4:30am
Timeout: 30 * time.Second,
Concurrency: 1,
Handler: scheduling.CleanupOldNameHistory,
})
}
```
**Staggering rationale:** Daily jobs at 3am/3:30am/4am/4:30am/5am spread the load so heavy operations (financial aggregation, idle account scans) don't contend with each other or with the hourly jobs. High-frequency jobs (reservations, deposits) are decoupled on their own 5-min schedules.
---
## Phase 2: Wire into `main.go`
### Before:
```go
// main.go:142
auth.StartJTICleanup()
// main.go:432-450 (background goroutine for reservations)
cleanupCtx, cleanupStop := context.WithCancel(context.Background())
go func() {
ticker := time.NewTicker(reservationCleanupInterval)
...
}()
// main.go:457-468 (shutdown)
cleanupStop()
```
### After:
```go
// At package level, after constants but before init()
var sched *jobs.Scheduler
// In main(), after initDB()/initSquare()/etc but before r := chi.NewRouter():
sched = jobs.New()
jobs.RegisterAll(sched)
sched.Start()
// In main(), shutdown block (replace the old cleanupStop() call):
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT)
go func() {
<-quit
log.Println("Shutting down server...")
// Graceful HTTP shutdown
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("Server forced to shutdown: %v", err)
}
// Graceful job scheduler shutdown
<-sched.Shutdown()
log.Println("Background jobs stopped")
}()
```
**Remove:**
- `reservationCleanupInterval` and `reservationCleanupTimeout` constants (`main.go:71-72`)
- `cleanupCtx`/`cleanupStop` variables (`main.go:433`)
- The goroutine block (`main.go:434-450`)
- Import `scheduling` from `main.go` (no longer needed there)
- `auth.StartJTICleanup()` call (`main.go:142`)
---
## Phase 3: Remove Side-Effects from `GetAvailableHours`
**File: `backend/handlers/scheduling/default-hours.go`**
Delete lines 364-407 (the entire cleanup block):
```diff
- // Clean up old reservations (older than 1 hour)
- if err := CleanupOldReservations(r.Context()); err != nil {
- log.Printf("Failed to cleanup old reservations: %v", err)
- }
-
- // Anonymize stale guest accounts (6+ months after last booking)
- if err := AnonymizeStaleGuestAccounts(r.Context()); err != nil {
- log.Printf("Failed to anonymize stale guest accounts: %v", err)
- }
-
- // Clean up expired loyalty redemptions (pending past expires_at)
- if err := CleanupExpiredLoyaltyRedemptions(r.Context()); err != nil {
- log.Printf("Failed to cleanup expired loyalty redemptions: %v", err)
- }
-
- // Clean up expired financial records (aggregate + delete granular data)
- if err := CleanupExpiredFinancialRecords(r.Context()); err != nil {
- log.Printf("Failed to cleanup expired financial records: %v", err)
- }
-
- // Clean up bookings past deposit deadline (no deposit paid)
- if err := CleanupExpiredDeposits(r.Context()); err != nil {
- log.Printf("Failed to cleanup expired deposits: %v", err)
- }
-
- // Clean up expired gift cards (unused for 24+ months)
- if err := CleanupExpiredGiftCards(r.Context()); err != nil {
- log.Printf("Failed to cleanup expired gift cards: %v", err)
- }
-
- // Clean up idle accounts (2yr no money, 5yr with money)
- if err := CleanupIdleAccounts(r.Context()); err != nil {
- log.Printf("Failed to cleanup idle accounts: %v", err)
- }
-
- // Clean up old idempotency keys (24h+ and non-pending)
- if err := CleanupOldIdempotencyKeys(r.Context()); err != nil {
- log.Printf("Failed to cleanup old idempotency keys: %v", err)
- }
-
- // Clean up old name history (6+ months)
- if err := CleanupOldNameHistory(r.Context()); err != nil {
- log.Printf("Failed to cleanup old name history: %v", err)
- }
```
Also remove `"log"` import from `default-hours.go` if it becomes unused after removal.
---
## Phase 4: Remove `auth.StartJTICleanup()` (Optional but Recommended)
**File: `backend/auth/jwt.go`**
- Keep `CleanupRevokedJTIs(ctx)` — it's used by tests and now by the jobs package
- Delete `StartJTICleanup()` entirely (no longer needed, replaced by the jobs package)
- Remove the ticker goroutine
---
## Phase 5: Update Documentation
**File: `obsidian/Crussell/Technical Manual.md`**
- Update lines ~545 and ~999: change "lazy cleanup triggered on availability fetch" to "all cleanup runs on cron schedules via the `jobs` package"
- Remove the lazy-cleanup justification
- Add a new section documenting the `jobs` package and its job registry
**File: `obsidian/Crussell/Future Work - Gap Backlog.md`**
- Mark item #2 as fully complete (strikethrough)
- Remove the note about remaining functions still running on `GET /api/availability`
**File: `README.md`** (optional)
- Add a line about the background job scheduler
---
## Effort Estimate
| Step | Files Changed | Effort |
|------|--------------|--------|
| Phase 1a: `scheduler.go` | 1 new file | 2-3h |
| Phase 1b: `cleanup.go` | 1 new file | 1h |
| Phase 2: Wire `main.go` | 1 file | 30min |
| Phase 3: Remove side-effects from handler | 1 file | 15min |
| Phase 4: Remove `StartJTICleanup` | 1 file | 15min |
| Phase 5: Update docs | 2-3 files | 30min |
| Testing & go vet | — | 1h |
| **Total** | **4-5 files (+2 new)** | **~6h** |
---
## Risks & Mitigations
| Risk | Mitigation |
|------|------------|
| **Concurrent DB load** from multiple jobs at the same cron tick | Stagger daily jobs across 3am-5am range. High-frequency jobs (5min) are lightweight. Per-job `Concurrency: 1` prevents overlapping runs. |
| **Job takes longer than interval** (e.g., financial aggregation >5min while scheduled every 5min) | `Concurrency: 1` + skip-logic: if previous run still in-flight, the new invocation is skipped and logged. |
| **Cron expression parsing differs** from existing time-blocker parser | Both use `cron.NewParser(cron.Minute \| cron.Hour \| cron.Dom \| cron.Month \| cron.Dow)` — identical. |
| **Tests expect cleanup side-effects** from `GetAvailableHours` | Search for tests that rely on the side-effect calls. Some may need an explicit cleanup call before assertions. |
| **`CleanupExpiredDeposits` timing gap** — deposit-lapsed slots not freed until next cron tick | 5-min frequency is acceptable. The previous behavior freed on the *next* availability fetch, which could be minutes or hours apart depending on user activity. 5-min max latency is actually *better* than the lazy approach during quiet periods. |
| **Panic in one job takes down the scheduler** | `wrapJob` has defer-recover. One job's panic cannot affect others (separate goroutines). |
---
## Edge Cases & Exclusions
**Not in scope (Phase 1):**
- `gdpr_export.go:init()` — GDPR cache cleanup (5min ticker). Tightly coupled to package-level state. Leave as-is.
- `auth/local.go:init()` — Login state cleanup. Tightly coupled. Leave as-is.
- `mw/ratelimit.go` goroutines — Rate limiter cleanup. Leave as-is.
- These can be migrated to the scheduler in a follow-up if desired, but require more refactoring.
**Not a concern:**
- `backend/handlers/scheduling/time-blockers.go` already uses `robfig/cron/v3` — no new dependency, no version conflict.
- All 9 cleanup functions have signature `func(context.Context) error` — perfectly uniform for the `Job.Handler` type.
- The `scheduling` package is already imported in `main.go` — no new import needed for that.
**Key invariant:** All cleanup functions are idempotent (documented in code). Running them on cron instead of on-demand has zero correctness impact — they produce the same result regardless of how often they run.
+4 -4
View File
@@ -121,7 +121,7 @@ func GenerateToken(userID string, role string) (string, string, error) {
return "", "", err return "", "", err
} }
_, tokenString, err := TokenAuth.Encode(map[string]interface{}{ _, tokenString, err := TokenAuth.Encode(map[string]any{
"user_id": userID, "user_id": userID,
"role": role, "role": role,
"jti": jti, "jti": jti,
@@ -137,7 +137,7 @@ func VerifyToken(tokenString string, ctx context.Context) (userID string, role s
return "", "", "", err return "", "", "", err
} }
var uidVal interface{} var uidVal any
if err := token.Get("user_id", &uidVal); err != nil { if err := token.Get("user_id", &uidVal); err != nil {
return "", "", "", fmt.Errorf("invalid user_id claim") return "", "", "", fmt.Errorf("invalid user_id claim")
} }
@@ -146,7 +146,7 @@ func VerifyToken(tokenString string, ctx context.Context) (userID string, role s
return "", "", "", fmt.Errorf("invalid user_id claim") return "", "", "", fmt.Errorf("invalid user_id claim")
} }
var roleVal interface{} var roleVal any
if err := token.Get("role", &roleVal); err != nil { if err := token.Get("role", &roleVal); err != nil {
return "", "", "", fmt.Errorf("invalid role claim") return "", "", "", fmt.Errorf("invalid role claim")
} }
@@ -155,7 +155,7 @@ func VerifyToken(tokenString string, ctx context.Context) (userID string, role s
return "", "", "", fmt.Errorf("invalid role claim") return "", "", "", fmt.Errorf("invalid role claim")
} }
var jtiVal interface{} var jtiVal any
if err := token.Get("jti", &jtiVal); err != nil { if err := token.Get("jti", &jtiVal); err != nil {
return "", "", "", fmt.Errorf("invalid jti claim") return "", "", "", fmt.Errorf("invalid jti claim")
} }
-1
View File
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package auth package auth
-1
View File
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package auth package auth
-1
View File
@@ -1,5 +1,4 @@
//go:build !dev //go:build !dev
// +build !dev
package db package db
-1
View File
@@ -1,5 +1,4 @@
//go:build dev //go:build dev
// +build dev
package db package db
-3
View File
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package db package db
@@ -178,5 +177,3 @@ func TestGetEnv_ReturnsEmptyWhenUnset(t *testing.T) {
// ============================================================================= // =============================================================================
// Clean-up — restore env after all tests // Clean-up — restore env after all tests
// ============================================================================= // =============================================================================
-1
View File
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package db package db
-1
View File
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package db package db
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package admin package admin
@@ -9,8 +8,8 @@ import (
"time" "time"
"crussell/clock" "crussell/clock"
"crussell/testutils"
"crussell/handlers/bookings" "crussell/handlers/bookings"
"crussell/testutils"
"crussell/testutils/fixtures" "crussell/testutils/fixtures"
) )
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package admin package admin
+1 -19
View File
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package admin package admin
@@ -32,9 +31,9 @@ import (
"crussell/clock" "crussell/clock"
"crussell/db" "crussell/db"
"crussell/testutils"
"crussell/handlers/bookings" "crussell/handlers/bookings"
"crussell/mw" "crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures" "crussell/testutils/fixtures"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -2247,7 +2246,6 @@ func TestAdminBookings_Create_WalkIn(t *testing.T) {
} }
// Seed working hours so we have a valid booking window // Seed working hours so we have a valid booking window
// Try to create booking with walk-in time (30 minutes from now - less than 1h requirement) // Try to create booking with walk-in time (30 minutes from now - less than 1h requirement)
// Regular users would be rejected, but admin should succeed // Regular users would be rejected, but admin should succeed
@@ -2291,7 +2289,6 @@ func TestAdminBookings_Create_WalkInWithDeposits(t *testing.T) {
} }
// Seed working hours // Seed working hours
// Set user to have outstanding deposits // Set user to have outstanding deposits
_, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 3 WHERE id = $1", userID)
@@ -2346,7 +2343,6 @@ func TestAdminBookings_Confirm_CompletesWithPayment_ReducesDeposits(t *testing.T
t.Fatalf("failed to create test service: %v", err) t.Fatalf("failed to create test service: %v", err)
} }
// Set user to have deposits_required = 2 // Set user to have deposits_required = 2
_, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 2 WHERE id = $1", userID) _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 2 WHERE id = $1", userID)
if err != nil { if err != nil {
@@ -2427,7 +2423,6 @@ func TestAdminBookings_Confirm_CompletesWithoutPayment_NoReduction(t *testing.T)
t.Fatalf("failed to create test service: %v", err) t.Fatalf("failed to create test service: %v", err)
} }
// Set user to have deposits_required = 2 // Set user to have deposits_required = 2
_, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 2 WHERE id = $1", userID) _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 2 WHERE id = $1", userID)
if err != nil { if err != nil {
@@ -2502,7 +2497,6 @@ func TestAdminBookings_Create_EnforceDepositsFalse_BypassesLimit(t *testing.T) {
t.Fatalf("failed to create test service: %v", err) t.Fatalf("failed to create test service: %v", err)
} }
// Set user to have deposits_required = 3 // Set user to have deposits_required = 3
_, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 3 WHERE id = $1", userID)
if err != nil { if err != nil {
@@ -2564,9 +2558,7 @@ func TestAdminBookings_Create_EnforceDepositsFalse_Within24h(t *testing.T) {
t.Fatalf("failed to create test service: %v", err) t.Fatalf("failed to create test service: %v", err)
} }
// Seed working hours // Seed working hours
// Set user to have deposits_required = 3 // Set user to have deposits_required = 3
_, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 3 WHERE id = $1", userID) _, err = tx.Exec(ctx, "UPDATE users SET deposits_required = 3 WHERE id = $1", userID)
@@ -2619,7 +2611,6 @@ func TestAdminBookings_Create_WalkInGuestUser(t *testing.T) {
} }
// Seed working hours // Seed working hours
// Create booking for tomorrow // Create booking for tomorrow
tomorrow := clock.Now().Add(24 * time.Hour).Truncate(time.Second) tomorrow := clock.Now().Add(24 * time.Hour).Truncate(time.Second)
@@ -2661,7 +2652,6 @@ func TestAdminBookings_Create_WalkInGuestUser(t *testing.T) {
func TestGetBookingsByCreatedRange(t *testing.T) { func TestGetBookingsByCreatedRange(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx) _, err := fixtures.CreateTestAdminUser(tx)
if err != nil { if err != nil {
@@ -2744,7 +2734,6 @@ func TestGetBookingsByCreatedRange(t *testing.T) {
func TestGetBookingsByCreatedRange_Empty(t *testing.T) { func TestGetBookingsByCreatedRange_Empty(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx) _, err := fixtures.CreateTestAdminUser(tx)
if err != nil { if err != nil {
@@ -2824,7 +2813,6 @@ func TestGetBookingsByCreatedRange_InvalidFormat(t *testing.T) {
func TestGetBookingsByCreatedRange_OrderedByCreatedAt(t *testing.T) { func TestGetBookingsByCreatedRange_OrderedByCreatedAt(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx) _, err := fixtures.CreateTestAdminUser(tx)
if err != nil { if err != nil {
@@ -2912,7 +2900,6 @@ func TestGetBookingsByCreatedRange_OrderedByCreatedAt(t *testing.T) {
func TestGetAdminBooking_WithDiscounts(t *testing.T) { func TestGetAdminBooking_WithDiscounts(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
@@ -3007,7 +2994,6 @@ func createCompletedBookingWithTimeForAdmin(t *testing.T, ctx context.Context, t
func TestAdminBookings_CreateWithCustomServices(t *testing.T) { func TestAdminBookings_CreateWithCustomServices(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx) _, err := fixtures.CreateTestAdminUser(tx)
if err != nil { if err != nil {
@@ -3084,7 +3070,6 @@ func TestAdminBookings_CreateWithCustomServices(t *testing.T) {
func TestAdminBookings_CreateWithCustomAndRegularServices(t *testing.T) { func TestAdminBookings_CreateWithCustomAndRegularServices(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx) _, err := fixtures.CreateTestAdminUser(tx)
if err != nil { if err != nil {
@@ -3208,7 +3193,6 @@ func TestAdminBookings_Create_CustomServiceValidation(t *testing.T) {
// Test 3: custom_service_ids is provided (should succeed — need working hours) // Test 3: custom_service_ids is provided (should succeed — need working hours)
t.Run("provides custom_service_ids only", func(t *testing.T) { t.Run("provides custom_service_ids only", func(t *testing.T) {
customServiceID, err := fixtures.CreateTestCustomService(tx) customServiceID, err := fixtures.CreateTestCustomService(tx)
if err != nil { if err != nil {
@@ -3322,7 +3306,6 @@ func TestAdminBookings_Confirm_WithCustomOverrides(t *testing.T) {
func TestAdminBookings_CreateWithCustomServicesAndOverrides(t *testing.T) { func TestAdminBookings_CreateWithCustomServicesAndOverrides(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx) _, err := fixtures.CreateTestAdminUser(tx)
if err != nil { if err != nil {
@@ -3401,7 +3384,6 @@ func TestAdminBookings_CreateWithCustomServicesAndOverrides(t *testing.T) {
func TestAdminBookings_AdminReserve_WithCustomServices(t *testing.T) { func TestAdminBookings_AdminReserve_WithCustomServices(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx) adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil { if err != nil {
+9 -7
View File
@@ -9,6 +9,7 @@ import (
"errors" "errors"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -107,7 +108,7 @@ func GetCustomServices(w http.ResponseWriter, r *http.Request) {
} }
var dataQuery string var dataQuery string
var dataArgs []interface{} var dataArgs []any
if q != "" { if q != "" {
dataQuery = ` dataQuery = `
@@ -115,7 +116,7 @@ func GetCustomServices(w http.ResponseWriter, r *http.Request) {
FROM custom_services FROM custom_services
WHERE name ILIKE $1 OR description ILIKE $1 WHERE name ILIKE $1 OR description ILIKE $1
` `
dataArgs = []interface{}{"%" + q + "%"} dataArgs = []any{"%" + q + "%"}
dataQuery += " ORDER BY created_at DESC, id DESC LIMIT $" + strconv.Itoa(len(dataArgs)+1) dataQuery += " ORDER BY created_at DESC, id DESC LIMIT $" + strconv.Itoa(len(dataArgs)+1)
dataArgs = append(dataArgs, perPage+1) dataArgs = append(dataArgs, perPage+1)
@@ -318,7 +319,7 @@ func UpdateCustomService(w http.ResponseWriter, r *http.Request) {
return return
} }
updates := make(map[string]interface{}) updates := make(map[string]any)
if req.Name != nil { if req.Name != nil {
updates["name"] = *req.Name updates["name"] = *req.Name
} }
@@ -361,7 +362,7 @@ func UpdateCustomService(w http.ResponseWriter, r *http.Request) {
} }
setClauses := make([]string, 0, len(updates)) setClauses := make([]string, 0, len(updates))
args := make([]interface{}, 0, len(updates)+1) args := make([]any, 0, len(updates)+1)
argIdx := 1 argIdx := 1
for field, val := range updates { for field, val := range updates {
setClauses = append(setClauses, field+" = $"+strconv.Itoa(argIdx)) setClauses = append(setClauses, field+" = $"+strconv.Itoa(argIdx))
@@ -530,9 +531,10 @@ func joinStrings(strs []string, sep string) string {
if len(strs) == 0 { if len(strs) == 0 {
return "" return ""
} }
result := strs[0] var result strings.Builder
result.WriteString(strs[0])
for _, s := range strs[1:] { for _, s := range strs[1:] {
result += sep + s result.WriteString(sep + s)
} }
return result return result.String()
} }
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package admin package admin
@@ -24,8 +23,8 @@ import (
"strings" "strings"
"testing" "testing"
"crussell/testutils"
"crussell/mw" "crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures" "crussell/testutils/fixtures"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
+2 -7
View File
@@ -195,7 +195,6 @@ func GetDiscountCampaigns(w http.ResponseWriter, r *http.Request) {
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
if campaigns == nil { if campaigns == nil {
@@ -384,7 +383,6 @@ func CreateDiscountCampaign(w http.ResponseWriter, r *http.Request) {
campaign.CreatedBy = &createdByDB.String campaign.CreatedBy = &createdByDB.String
} }
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(campaign); err != nil { if err := json.NewEncoder(w).Encode(campaign); err != nil {
log.Printf("Error encoding campaign: %v", err) log.Printf("Error encoding campaign: %v", err)
@@ -424,7 +422,7 @@ func UpdateDiscountCampaign(w http.ResponseWriter, r *http.Request) {
// Build dynamic update query // Build dynamic update query
query := "UPDATE discount_campaigns SET updated_at = NOW()" query := "UPDATE discount_campaigns SET updated_at = NOW()"
args := []interface{}{} args := []any{}
argNum := 1 argNum := 1
if req.Name != nil { if req.Name != nil {
@@ -603,7 +601,6 @@ func UpdateDiscountCampaign(w http.ResponseWriter, r *http.Request) {
campaign.CreatedBy = &createdBy.String campaign.CreatedBy = &createdBy.String
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(campaign); err != nil { if err := json.NewEncoder(w).Encode(campaign); err != nil {
log.Printf("Error encoding campaign: %v", err) log.Printf("Error encoding campaign: %v", err)
@@ -658,9 +655,8 @@ func DeleteDiscountCampaign(w http.ResponseWriter, r *http.Request) {
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]any{
"message": "Campaign deleted successfully", "message": "Campaign deleted successfully",
"id": campaignID, "id": campaignID,
}) })
@@ -774,7 +770,6 @@ func GetCampaignStats(w http.ResponseWriter, r *http.Request) {
BookingCount: bookingCount, BookingCount: bookingCount,
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(stats); err != nil { if err := json.NewEncoder(w).Encode(stats); err != nil {
log.Printf("Error encoding stats: %v", err) log.Printf("Error encoding stats: %v", err)
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package admin package admin
@@ -15,8 +14,8 @@ import (
"crussell/clock" "crussell/clock"
"crussell/db" "crussell/db"
"crussell/testutils"
"crussell/mw" "crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures" "crussell/testutils/fixtures"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -24,7 +23,6 @@ import (
var testAdminID string var testAdminID string
func makeCampaignRequest(handler http.Handler, method, path string, body interface{}, ctx context.Context, adminID string) *httptest.ResponseRecorder { func makeCampaignRequest(handler http.Handler, method, path string, body interface{}, ctx context.Context, adminID string) *httptest.ResponseRecorder {
var req *http.Request var req *http.Request
if body != nil { if body != nil {
+1 -1
View File
@@ -138,7 +138,7 @@ func UpdatePatchTest(w http.ResponseWriter, r *http.Request) {
} }
query := "UPDATE patch_tests SET " query := "UPDATE patch_tests SET "
args := []interface{}{} args := []any{}
i := 1 i := 1
if req.Name != nil { if req.Name != nil {
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package admin package admin
+1 -2
View File
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package admin package admin
@@ -19,9 +18,9 @@ import (
"net/http" "net/http"
"testing" "testing"
"crussell/testutils"
"crussell/handlers/services" "crussell/handlers/services"
"crussell/mw" "crussell/mw"
"crussell/testutils"
) )
// TestAdminServices_Create verifies that an admin can create a new service // TestAdminServices_Create verifies that an admin can create a new service
+7 -7
View File
@@ -8,6 +8,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
"strings"
) )
type BusinessSettings struct { type BusinessSettings struct {
@@ -67,7 +68,6 @@ func GetPublicBusinessInfo(w http.ResponseWriter, r *http.Request) {
return return
} }
json.NewEncoder(w).Encode(info) json.NewEncoder(w).Encode(info)
} }
@@ -90,7 +90,6 @@ func GetBusinessSettings(w http.ResponseWriter, r *http.Request) {
return return
} }
json.NewEncoder(w).Encode(s) json.NewEncoder(w).Encode(s)
} }
@@ -172,7 +171,7 @@ func UpdateBusinessSettings(w http.ResponseWriter, r *http.Request) {
} }
setClauses := []string{} setClauses := []string{}
args := []interface{}{} args := []any{}
argIdx := 1 argIdx := 1
if req.BusinessName != nil { if req.BusinessName != nil {
@@ -231,12 +230,13 @@ func UpdateBusinessSettings(w http.ResponseWriter, r *http.Request) {
return return
} }
query := "UPDATE business_settings SET " var query strings.Builder
query.WriteString("UPDATE business_settings SET ")
for i, clause := range setClauses { for i, clause := range setClauses {
if i > 0 { if i > 0 {
query += ", " query.WriteString(", ")
} }
query += clause query.WriteString(clause)
} }
tx, err := db.Conn.Begin(r.Context()) tx, err := db.Conn.Begin(r.Context())
@@ -247,7 +247,7 @@ func UpdateBusinessSettings(w http.ResponseWriter, r *http.Request) {
} }
defer tx.Rollback(r.Context()) defer tx.Rollback(r.Context())
_, err = tx.Exec(r.Context(), query, args...) _, err = tx.Exec(r.Context(), query.String(), args...)
if err != nil { if err != nil {
log.Printf("Failed to update business settings: %v", err) log.Printf("Failed to update business settings: %v", err)
http.Error(w, "Failed to update settings", http.StatusInternalServerError) http.Error(w, "Failed to update settings", http.StatusInternalServerError)
-1
View File
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package admin package admin
-1
View File
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package admin package admin
-1
View File
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package admin package admin
+1 -2
View File
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package admin package admin
@@ -24,10 +23,10 @@ import (
"time" "time"
"crussell/clock" "crussell/clock"
"crussell/testutils"
"crussell/handlers/notifications" "crussell/handlers/notifications"
"crussell/handlers/today" "crussell/handlers/today"
"crussell/mw" "crussell/mw"
"crussell/testutils"
) )
// TestAdminToday_CurrentNext verifies that an admin can retrieve the currently // TestAdminToday_CurrentNext verifies that an admin can retrieve the currently
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package admin package admin
@@ -22,8 +21,8 @@ import (
"crussell/clock" "crussell/clock"
"crussell/db" "crussell/db"
"crussell/testutils"
"crussell/handlers/bookings" "crussell/handlers/bookings"
"crussell/testutils"
"crussell/testutils/fixtures" "crussell/testutils/fixtures"
) )
@@ -80,7 +79,6 @@ func createSecondService(t *testing.T, tx db.Querier, ctx context.Context, name
func TestAdminBookings_UpdateServices_ReplaceServices(t *testing.T) { func TestAdminBookings_UpdateServices_ReplaceServices(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx) _, err := fixtures.CreateTestAdminUser(tx)
if err != nil { if err != nil {
@@ -129,7 +127,6 @@ func TestAdminBookings_UpdateServices_ReplaceServices(t *testing.T) {
func TestAdminBookings_UpdateServices_AddService(t *testing.T) { func TestAdminBookings_UpdateServices_AddService(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx) _, err := fixtures.CreateTestAdminUser(tx)
if err != nil { if err != nil {
@@ -175,7 +172,6 @@ func TestAdminBookings_UpdateServices_AddService(t *testing.T) {
func TestAdminBookings_UpdateServices_RemoveService(t *testing.T) { func TestAdminBookings_UpdateServices_RemoveService(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx) _, err := fixtures.CreateTestAdminUser(tx)
if err != nil { if err != nil {
@@ -233,7 +229,6 @@ func TestAdminBookings_UpdateServices_RemoveService(t *testing.T) {
func TestAdminBookings_UpdateServices_WithPriceOverride(t *testing.T) { func TestAdminBookings_UpdateServices_WithPriceOverride(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx) _, err := fixtures.CreateTestAdminUser(tx)
if err != nil { if err != nil {
@@ -286,7 +281,6 @@ func TestAdminBookings_UpdateServices_WithPriceOverride(t *testing.T) {
func TestAdminBookings_UpdateServices_WithDurationOverride(t *testing.T) { func TestAdminBookings_UpdateServices_WithDurationOverride(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx) _, err := fixtures.CreateTestAdminUser(tx)
if err != nil { if err != nil {
@@ -339,7 +333,6 @@ func TestAdminBookings_UpdateServices_WithDurationOverride(t *testing.T) {
func TestAdminBookings_UpdateServices_WithBothOverrides(t *testing.T) { func TestAdminBookings_UpdateServices_WithBothOverrides(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx) _, err := fixtures.CreateTestAdminUser(tx)
if err != nil { if err != nil {
@@ -393,7 +386,6 @@ func TestAdminBookings_UpdateServices_WithBothOverrides(t *testing.T) {
func TestAdminBookings_UpdateServices_UpdateNotes(t *testing.T) { func TestAdminBookings_UpdateServices_UpdateNotes(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx) _, err := fixtures.CreateTestAdminUser(tx)
if err != nil { if err != nil {
@@ -438,7 +430,6 @@ func TestAdminBookings_UpdateServices_UpdateNotes(t *testing.T) {
func TestAdminBookings_UpdateServices_MultipleOverrides(t *testing.T) { func TestAdminBookings_UpdateServices_MultipleOverrides(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx) _, err := fixtures.CreateTestAdminUser(tx)
if err != nil { if err != nil {
@@ -881,7 +872,6 @@ func TestAdminBookings_UpdateServices_WeCancelledBookingRejected(t *testing.T) {
func TestAdminBookings_UpdateServices_OverlapWithNextBooking(t *testing.T) { func TestAdminBookings_UpdateServices_OverlapWithNextBooking(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx) _, err := fixtures.CreateTestAdminUser(tx)
if err != nil { if err != nil {
@@ -927,7 +917,6 @@ func TestAdminBookings_UpdateServices_OverlapWithNextBooking(t *testing.T) {
func TestAdminBookings_UpdateServices_NoOverlapSucceeds(t *testing.T) { func TestAdminBookings_UpdateServices_NoOverlapSucceeds(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx) _, err := fixtures.CreateTestAdminUser(tx)
if err != nil { if err != nil {
@@ -972,7 +961,6 @@ func TestAdminBookings_UpdateServices_NoOverlapSucceeds(t *testing.T) {
func TestAdminBookings_UpdateServices_NoNextBookingSucceeds(t *testing.T) { func TestAdminBookings_UpdateServices_NoNextBookingSucceeds(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx) _, err := fixtures.CreateTestAdminUser(tx)
if err != nil { if err != nil {
@@ -1014,7 +1002,6 @@ func TestAdminBookings_UpdateServices_NoNextBookingSucceeds(t *testing.T) {
func TestAdminBookings_UpdateServices_ResponseShape(t *testing.T) { func TestAdminBookings_UpdateServices_ResponseShape(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx) _, err := fixtures.CreateTestAdminUser(tx)
if err != nil { if err != nil {
@@ -1082,7 +1069,6 @@ func TestAdminBookings_UpdateServices_ResponseShape(t *testing.T) {
func TestAdminBookings_UpdateServices_PendingBooking(t *testing.T) { func TestAdminBookings_UpdateServices_PendingBooking(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx) _, err := fixtures.CreateTestAdminUser(tx)
if err != nil { if err != nil {
@@ -1117,7 +1103,6 @@ func TestAdminBookings_UpdateServices_PendingBooking(t *testing.T) {
func TestAdminBookings_UpdateServices_InProgressBooking(t *testing.T) { func TestAdminBookings_UpdateServices_InProgressBooking(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx) _, err := fixtures.CreateTestAdminUser(tx)
if err != nil { if err != nil {
@@ -1152,7 +1137,6 @@ func TestAdminBookings_UpdateServices_InProgressBooking(t *testing.T) {
func TestAdminBookings_UpdateServices_ClearNotes(t *testing.T) { func TestAdminBookings_UpdateServices_ClearNotes(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
_, err := fixtures.CreateTestAdminUser(tx) _, err := fixtures.CreateTestAdminUser(tx)
if err != nil { if err != nil {
+1 -2
View File
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package admin package admin
@@ -21,9 +20,9 @@ import (
"testing" "testing"
"time" "time"
"crussell/testutils"
"crussell/handlers/user" "crussell/handlers/user"
"crussell/mw" "crussell/mw"
"crussell/testutils"
) )
// TestAdminUsers_List verifies that an admin can list all users in the // TestAdminUsers_List verifies that an admin can list all users in the
+3 -8
View File
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package auth package auth
@@ -357,7 +356,6 @@ func TestLogin_Success(t *testing.T) {
} }
defer fixtures.DeleteUser(tx, userID) defer fixtures.DeleteUser(tx, userID)
body := LoginRequest{ body := LoginRequest{
Email: "user@test.com", Email: "user@test.com",
Password: "testpassword123", Password: "testpassword123",
@@ -1396,7 +1394,6 @@ func TestLoginResponse_IncludesJTI(t *testing.T) {
} }
defer fixtures.DeleteUser(tx, userID) defer fixtures.DeleteUser(tx, userID)
body := LoginRequest{ body := LoginRequest{
Email: "jti-test@test.com", Email: "jti-test@test.com",
Password: "testpassword123", Password: "testpassword123",
@@ -1599,7 +1596,6 @@ func TestLogin_AccountLockout_ResetsOnSuccess(t *testing.T) {
t.Fatalf("failed to set failed_attempts: %v", err) t.Fatalf("failed to set failed_attempts: %v", err)
} }
body := LoginRequest{ body := LoginRequest{
Email: "lockout-reset@test.com", Email: "lockout-reset@test.com",
Password: "testpassword123", Password: "testpassword123",
@@ -1764,7 +1760,6 @@ func TestLogin_ResponseIncludesRefreshToken(t *testing.T) {
} }
defer fixtures.DeleteUser(tx, userID) defer fixtures.DeleteUser(tx, userID)
body := LoginRequest{ body := LoginRequest{
Email: "refresh-check@test.com", Email: "refresh-check@test.com",
Password: "testpassword123", Password: "testpassword123",
@@ -1933,9 +1928,9 @@ func TestCleanupStaleLoginEntries_Mixed(t *testing.T) {
loginStateMu.Lock() loginStateMu.Lock()
saved := loginInProgress saved := loginInProgress
loginInProgress = map[string]time.Time{ loginInProgress = map[string]time.Time{
"stale-user": clock.Now().Add(-60 * time.Second), "stale-user": clock.Now().Add(-60 * time.Second),
"recent-user": clock.Now().Add(-5 * time.Second), "recent-user": clock.Now().Add(-5 * time.Second),
"borderline": clock.Now().Add(-29 * time.Second), // Just under 30s threshold "borderline": clock.Now().Add(-29 * time.Second), // Just under 30s threshold
} }
loginStateMu.Unlock() loginStateMu.Unlock()
defer func() { defer func() {
-1
View File
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package auth package auth
@@ -1,5 +1,4 @@
//go:build test && dev //go:build test && dev
// +build test,dev
package bookings package bookings
@@ -1,5 +1,4 @@
//go:build test && dev //go:build test && dev
// +build test,dev
package bookings package bookings
@@ -20,8 +19,8 @@ import (
"time" "time"
"crussell/clock" "crussell/clock"
"crussell/testutils"
"crussell/mw" "crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures" "crussell/testutils/fixtures"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -68,7 +67,6 @@ func TestAdminReserveSlot_WalkIn_Success(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx) adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create admin: %v", err) t.Fatalf("failed to create admin: %v", err)
@@ -126,7 +124,6 @@ func TestAdminReserveSlot_CallIn_Success(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx) adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create admin: %v", err) t.Fatalf("failed to create admin: %v", err)
@@ -202,8 +199,6 @@ func TestAdminReserveSlot_WalkIn_MissingDuration(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx) adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create admin: %v", err) t.Fatalf("failed to create admin: %v", err)
@@ -241,8 +236,6 @@ func TestAdminReserveSlot_CallIn_MissingServices(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx) adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create admin: %v", err) t.Fatalf("failed to create admin: %v", err)
@@ -283,8 +276,6 @@ func TestAdminReserveSlot_InvalidReservationType(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx) adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create admin: %v", err) t.Fatalf("failed to create admin: %v", err)
@@ -321,8 +312,6 @@ func TestAdminReserveSlot_SlotOverlap(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
// Create test admin user (for the booking) // Create test admin user (for the booking)
adminID, err := fixtures.CreateTestAdminUser(tx) adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil { if err != nil {
@@ -394,8 +383,6 @@ func TestAdminReserveSlot_ReplacesExisting(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx) adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create admin: %v", err) t.Fatalf("failed to create admin: %v", err)
@@ -483,8 +470,6 @@ func TestAdminReserveSlot_WalkIn_PastStart(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx) adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create admin: %v", err) t.Fatalf("failed to create admin: %v", err)
+177 -177
View File
@@ -2,8 +2,8 @@ package bookings
import ( import (
"context" "context"
"crussell/db"
"crussell/clock" "crussell/clock"
"crussell/db"
"crussell/handlers/notifications" "crussell/handlers/notifications"
"crussell/handlers/payments" "crussell/handlers/payments"
"crussell/handlers/scheduling" "crussell/handlers/scheduling"
@@ -254,20 +254,20 @@ type AdminBookingSummary struct {
} }
type UserSummary struct { type UserSummary struct {
ID string `json:"id"` ID string `json:"id"`
FirstName string `json:"first_name"` FirstName string `json:"first_name"`
LastName string `json:"last_name"` LastName string `json:"last_name"`
FullName string `json:"full_name"` FullName string `json:"full_name"`
Email *string `json:"email,omitempty"` Email *string `json:"email,omitempty"`
Phone *string `json:"phone,omitempty"` Phone *string `json:"phone,omitempty"`
ProfilePicURL *string `json:"profile_pic_url,omitempty"` ProfilePicURL *string `json:"profile_pic_url,omitempty"`
DateOfBirth *string `json:"date_of_birth,omitempty"` DateOfBirth *string `json:"date_of_birth,omitempty"`
AccountRole string `json:"account_role"` AccountRole string `json:"account_role"`
LoyaltyStamps *int `json:"loyalty_stamps,omitempty"` LoyaltyStamps *int `json:"loyalty_stamps,omitempty"`
ReferralCode *string `json:"referral_code,omitempty"` ReferralCode *string `json:"referral_code,omitempty"`
ReferralCodeUses *int `json:"referral_code_uses,omitempty"` ReferralCodeUses *int `json:"referral_code_uses,omitempty"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"` Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
PreviousFirstName *string `json:"previous_first_name,omitempty"` PreviousFirstName *string `json:"previous_first_name,omitempty"`
PreviousLastName *string `json:"previous_last_name,omitempty"` PreviousLastName *string `json:"previous_last_name,omitempty"`
} }
@@ -387,7 +387,7 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) {
// Build WHERE conditions shared between count and data queries. // Build WHERE conditions shared between count and data queries.
whereClause := " WHERE b.user_id = $1" whereClause := " WHERE b.user_id = $1"
whereArgs := []interface{}{userID} whereArgs := []any{userID}
paramCount := 2 paramCount := 2
if req.Status != nil { if req.Status != nil {
@@ -447,7 +447,7 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) {
WHERE booking_id = b.id WHERE booking_id = b.id
) pt ON true` + whereClause ) pt ON true` + whereClause
dataArgs := make([]interface{}, len(whereArgs)) dataArgs := make([]any, len(whereArgs))
copy(dataArgs, whereArgs) copy(dataArgs, whereArgs)
dataParamCount := paramCount dataParamCount := paramCount
@@ -683,7 +683,7 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
) pre_pay ON true ) pre_pay ON true
` `
var args []interface{} var args []any
paramCount := 1 paramCount := 1
addWhereClause := func(condition string) { addWhereClause := func(condition string) {
@@ -750,7 +750,7 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
var total int var total int
{ {
countWhere := "" countWhere := ""
var countArgs []interface{} var countArgs []any
cp := 1 cp := 1
addCountWhere := func(cond string) { addCountWhere := func(cond string) {
if cp == 1 { if cp == 1 {
@@ -954,7 +954,7 @@ func GetAllBookingsByUserHandler(w http.ResponseWriter, r *http.Request) {
FROM bookings b FROM bookings b
WHERE b.user_id = $1 WHERE b.user_id = $1
` `
var args []interface{} var args []any
args = append(args, userID) args = append(args, userID)
paramCount := 2 paramCount := 2
@@ -1793,7 +1793,7 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
WHERE bcs.booking_id = b.id AND cs.name ILIKE $1 ESCAPE '\' WHERE bcs.booking_id = b.id AND cs.name ILIKE $1 ESCAPE '\'
) )
` `
var args []interface{} var args []any
args = append(args, searchPattern) args = append(args, searchPattern)
paramCount := 2 paramCount := 2
@@ -2639,9 +2639,9 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
if currentStatus == "completed" { if currentStatus == "completed" {
log.Printf("Booking %s is already completed — skipping duplicate completion", bookingID) log.Printf("Booking %s is already completed — skipping duplicate completion", bookingID)
} else { } else {
// Collect patch test IDs first so the rows are consumed before INSERT operations. // Collect patch test IDs first so the rows are consumed before INSERT operations.
var patchTestIDs []string var patchTestIDs []string
ptRows, err := tx.Query(r.Context(), ` ptRows, err := tx.Query(r.Context(), `
SELECT DISTINCT pt.id SELECT DISTINCT pt.id
FROM patch_tests pt FROM patch_tests pt
JOIN booking_services bs ON bs.booking_id = $1 JOIN booking_services bs ON bs.booking_id = $1
@@ -2649,42 +2649,42 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
SELECT pt_inner.id FROM patch_tests pt_inner WHERE bs.service_id = ANY(pt_inner.service_ids) SELECT pt_inner.id FROM patch_tests pt_inner WHERE bs.service_id = ANY(pt_inner.service_ids)
) )
`, bookingID) `, bookingID)
if err != nil { if err != nil {
log.Printf("Failed to fetch patch tests for booking %s: %v", bookingID, err) log.Printf("Failed to fetch patch tests for booking %s: %v", bookingID, err)
} else { } else {
for ptRows.Next() { for ptRows.Next() {
var ptID string var ptID string
if err := ptRows.Scan(&ptID); err == nil { if err := ptRows.Scan(&ptID); err == nil {
patchTestIDs = append(patchTestIDs, ptID) patchTestIDs = append(patchTestIDs, ptID)
}
} }
ptRows.Close()
} }
ptRows.Close() for _, ptID := range patchTestIDs {
} if _, err := tx.Exec(r.Context(), `
for _, ptID := range patchTestIDs {
if _, err := tx.Exec(r.Context(), `
INSERT INTO user_patch_tests (user_id, patch_test_id, tested_at) INSERT INTO user_patch_tests (user_id, patch_test_id, tested_at)
VALUES ($1, $2, NOW()) VALUES ($1, $2, NOW())
ON CONFLICT (user_id, patch_test_id) DO UPDATE SET tested_at = NOW() ON CONFLICT (user_id, patch_test_id) DO UPDATE SET tested_at = NOW()
`, booking.User.ID, ptID); err != nil { `, booking.User.ID, ptID); err != nil {
log.Printf("Failed to update patch test validity for user %s, patch test %s: %v", booking.User.ID, ptID, err) log.Printf("Failed to update patch test validity for user %s, patch test %s: %v", booking.User.ID, ptID, err)
}
} }
}
var bookingTotal float64 var bookingTotal float64
if err := tx.QueryRow(r.Context(), ` if err := tx.QueryRow(r.Context(), `
SELECT total_amount FROM bookings WHERE id = $1 SELECT total_amount FROM bookings WHERE id = $1
`, bookingID).Scan(&bookingTotal); err != nil { `, bookingID).Scan(&bookingTotal); err != nil {
log.Printf("Failed to calculate booking total for %s: %v", bookingID, err) log.Printf("Failed to calculate booking total for %s: %v", bookingID, err)
} }
// Don't award a stamp if this booking already used a loyalty redemption // Don't award a stamp if this booking already used a loyalty redemption
// (take or receive, never both). // (take or receive, never both).
var loyaltyAppliedOnThisBooking bool var loyaltyAppliedOnThisBooking bool
tx.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty')`, bookingID).Scan(&loyaltyAppliedOnThisBooking) tx.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty')`, bookingID).Scan(&loyaltyAppliedOnThisBooking)
var newStampCount int var newStampCount int
if bookingTotal > 0 && !loyaltyAppliedOnThisBooking { if bookingTotal > 0 && !loyaltyAppliedOnThisBooking {
if err := tx.QueryRow(r.Context(), ` if err := tx.QueryRow(r.Context(), `
UPDATE users UPDATE users
SET loyalty_stamps = loyalty_stamps + 1 SET loyalty_stamps = loyalty_stamps + 1
WHERE id = $1 WHERE id = $1
@@ -2697,108 +2697,108 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
) )
RETURNING loyalty_stamps RETURNING loyalty_stamps
`, booking.User.ID, bookingID).Scan(&newStampCount); err != nil { `, booking.User.ID, bookingID).Scan(&newStampCount); err != nil {
if !errors.Is(err, pgx.ErrNoRows) { if !errors.Is(err, pgx.ErrNoRows) {
log.Printf("Failed to add loyalty stamp for booking %s: %v", bookingID, err) log.Printf("Failed to add loyalty stamp for booking %s: %v", bookingID, err)
}
} }
} }
}
// Create pending redemption when stamps reach LoyaltyStampCost // Create pending redemption when stamps reach LoyaltyStampCost
if newStampCount == payments.LoyaltyStampCost { if newStampCount == payments.LoyaltyStampCost {
_, err = tx.Exec(r.Context(), ` _, err = tx.Exec(r.Context(), `
INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at) INSERT INTO loyalty_redemptions (user_id, stamps_redeemed, status, redeemed_at)
VALUES ($1, $2, 'pending', NOW()) VALUES ($1, $2, 'pending', NOW())
`, booking.User.ID, payments.LoyaltyStampCost) `, booking.User.ID, payments.LoyaltyStampCost)
if err != nil { if err != nil {
log.Printf("Failed to create loyalty redemption for user %s: %v", booking.User.ID, err) log.Printf("Failed to create loyalty redemption for user %s: %v", booking.User.ID, err)
}
} }
}
// Skip time-based campaign if already applied at payment time // Skip time-based campaign if already applied at payment time
var timeBasedApplied bool var timeBasedApplied bool
tx.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign' AND campaign_type = 'time_based')`, bookingID).Scan(&timeBasedApplied) tx.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign' AND campaign_type = 'time_based')`, bookingID).Scan(&timeBasedApplied)
if bookingTotal > 0 && !timeBasedApplied { if bookingTotal > 0 && !timeBasedApplied {
var campaignID string var campaignID string
var campaignPercent float64 var campaignPercent float64
if err := tx.QueryRow(r.Context(), ` if err := tx.QueryRow(r.Context(), `
SELECT id, discount_percent FROM discount_campaigns SELECT id, discount_percent FROM discount_campaigns
WHERE status = 'active' AND campaign_type = 'time_based' WHERE status = 'active' AND campaign_type = 'time_based'
AND start_date <= NOW() AND end_date >= NOW() AND start_date <= NOW() AND end_date >= NOW()
AND (max_redemptions IS NULL OR times_redeemed < max_redemptions) AND (max_redemptions IS NULL OR times_redeemed < max_redemptions)
ORDER BY discount_percent DESC LIMIT 1 ORDER BY discount_percent DESC LIMIT 1
`).Scan(&campaignID, &campaignPercent); err == nil && campaignID != "" { `).Scan(&campaignID, &campaignPercent); err == nil && campaignID != "" {
discountAmount := roundTo2(bookingTotal * campaignPercent / 100) discountAmount := roundTo2(bookingTotal * campaignPercent / 100)
if _, err := tx.Exec(r.Context(), ` if _, err := tx.Exec(r.Context(), `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'campaign', $3, 'time_based', NULL, $4, $5, $6) VALUES ($1, $2, 'campaign', $3, 'time_based', NULL, $4, $5, $6)
`, bookingID, booking.User.ID, campaignID, campaignPercent, bookingTotal, discountAmount); err != nil { `, bookingID, booking.User.ID, campaignID, campaignPercent, bookingTotal, discountAmount); err != nil {
log.Printf("ALERT: failed to insert booking discount: %v", err) log.Printf("ALERT: failed to insert booking discount: %v", err)
} }
if _, err := tx.Exec(r.Context(), ` if _, err := tx.Exec(r.Context(), `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', $2, 'completed', $3) VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
`, bookingID, discountAmount, booking.User.ID); err != nil { `, bookingID, discountAmount, booking.User.ID); err != nil {
log.Printf("ALERT: failed to insert payment record: %v", err) log.Printf("ALERT: failed to insert payment record: %v", err)
} }
if _, err := tx.Exec(r.Context(), ` if _, err := tx.Exec(r.Context(), `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1 UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
`, campaignID); err != nil { `, campaignID); err != nil {
log.Printf("ALERT: failed to update discount campaign usage: %v", err) log.Printf("ALERT: failed to update discount campaign usage: %v", err)
}
} }
} }
}
if bookingTotal > 0 { if bookingTotal > 0 {
var userBookingCount int var userBookingCount int
_ = tx.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE user_id = $1 AND status = 'completed'`, booking.User.ID).Scan(&userBookingCount) _ = tx.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE user_id = $1 AND status = 'completed'`, booking.User.ID).Scan(&userBookingCount)
var milestoneCampaignID string var milestoneCampaignID string
var milestonePercent float64 var milestonePercent float64
_ = tx.QueryRow(r.Context(), ` _ = tx.QueryRow(r.Context(), `
SELECT id, discount_percent FROM discount_campaigns SELECT id, discount_percent FROM discount_campaigns
WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'per_user_booking_count' WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'per_user_booking_count'
AND milestone_value = $1 AND milestone_value = $1
AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE user_id = $2 AND source_id = discount_campaigns.id) AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE user_id = $2 AND source_id = discount_campaigns.id)
`, userBookingCount, booking.User.ID).Scan(&milestoneCampaignID, &milestonePercent) `, userBookingCount, booking.User.ID).Scan(&milestoneCampaignID, &milestonePercent)
if milestoneCampaignID != "" { if milestoneCampaignID != "" {
discountAmount := roundTo2(bookingTotal * milestonePercent / 100) discountAmount := roundTo2(bookingTotal * milestonePercent / 100)
if _, err := tx.Exec(r.Context(), ` if _, err := tx.Exec(r.Context(), `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'campaign', $3, 'milestone', 'per_user_booking_count', $4, $5, $6) VALUES ($1, $2, 'campaign', $3, 'milestone', 'per_user_booking_count', $4, $5, $6)
`, bookingID, booking.User.ID, milestoneCampaignID, milestonePercent, bookingTotal, discountAmount); err != nil { `, bookingID, booking.User.ID, milestoneCampaignID, milestonePercent, bookingTotal, discountAmount); err != nil {
log.Printf("ALERT: failed to insert booking discount: %v", err) log.Printf("ALERT: failed to insert booking discount: %v", err)
} }
if _, err := tx.Exec(r.Context(), ` if _, err := tx.Exec(r.Context(), `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', $2, 'completed', $3) VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
`, bookingID, discountAmount, booking.User.ID); err != nil { `, bookingID, discountAmount, booking.User.ID); err != nil {
log.Printf("ALERT: failed to insert payment record: %v", err) log.Printf("ALERT: failed to insert payment record: %v", err)
} }
if _, err := tx.Exec(r.Context(), ` if _, err := tx.Exec(r.Context(), `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1 UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
`, milestoneCampaignID); err != nil { `, milestoneCampaignID); err != nil {
log.Printf("ALERT: failed to insert payment record: %v", err) log.Printf("ALERT: failed to insert payment record: %v", err)
}
} }
}
var globalMilestoneApplied bool var globalMilestoneApplied bool
tx.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign' AND campaign_type = 'milestone' AND milestone_type = 'global_booking_count')`, bookingID).Scan(&globalMilestoneApplied) tx.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'campaign' AND campaign_type = 'milestone' AND milestone_type = 'global_booking_count')`, bookingID).Scan(&globalMilestoneApplied)
if !globalMilestoneApplied { if !globalMilestoneApplied {
var globalCount int var globalCount int
_ = tx.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE status = 'completed'`).Scan(&globalCount) _ = tx.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE status = 'completed'`).Scan(&globalCount)
var hasInPersonPayment bool var hasInPersonPayment bool
tx.QueryRow(r.Context(), ` tx.QueryRow(r.Context(), `
SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1 AND payment_method = 'in_person_card')`, bookingID).Scan(&hasInPersonPayment) SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1 AND payment_method = 'in_person_card')`, bookingID).Scan(&hasInPersonPayment)
if hasInPersonPayment { if hasInPersonPayment {
var globalCampaignID string var globalCampaignID string
var globalPercent float64 var globalPercent float64
_ = tx.QueryRow(r.Context(), ` _ = tx.QueryRow(r.Context(), `
SELECT id, discount_percent FROM discount_campaigns SELECT id, discount_percent FROM discount_campaigns
WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'global_booking_count' WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'global_booking_count'
AND milestone_value <= $1 AND milestone_value <= $1
@@ -2806,126 +2806,126 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
ORDER BY milestone_value DESC LIMIT 1 ORDER BY milestone_value DESC LIMIT 1
`, globalCount).Scan(&globalCampaignID, &globalPercent) `, globalCount).Scan(&globalCampaignID, &globalPercent)
if globalCampaignID != "" { if globalCampaignID != "" {
discountAmount := roundTo2(bookingTotal * globalPercent / 100) discountAmount := roundTo2(bookingTotal * globalPercent / 100)
if _, err := tx.Exec(r.Context(), ` if _, err := tx.Exec(r.Context(), `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'campaign', $3, 'milestone', 'global_booking_count', $4, $5, $6) VALUES ($1, $2, 'campaign', $3, 'milestone', 'global_booking_count', $4, $5, $6)
`, bookingID, booking.User.ID, globalCampaignID, globalPercent, bookingTotal, discountAmount); err != nil { `, bookingID, booking.User.ID, globalCampaignID, globalPercent, bookingTotal, discountAmount); err != nil {
log.Printf("ALERT: failed to insert booking discount: %v", err) log.Printf("ALERT: failed to insert booking discount: %v", err)
} }
if _, err := tx.Exec(r.Context(), ` if _, err := tx.Exec(r.Context(), `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', $2, 'completed', $3) VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
`, bookingID, discountAmount, booking.User.ID); err != nil { `, bookingID, discountAmount, booking.User.ID); err != nil {
log.Printf("ALERT: failed to insert payment record: %v", err) log.Printf("ALERT: failed to insert payment record: %v", err)
} }
if _, err := tx.Exec(r.Context(), ` if _, err := tx.Exec(r.Context(), `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1 UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
`, globalCampaignID); err != nil { `, globalCampaignID); err != nil {
log.Printf("ALERT: failed to insert payment record: %v", err) log.Printf("ALERT: failed to insert payment record: %v", err)
}
} }
} }
} }
}
var firstVisitDate time.Time var firstVisitDate time.Time
_ = tx.QueryRow(r.Context(), `SELECT MIN(start_time) FROM bookings WHERE user_id = $1 AND status = 'completed'`, booking.User.ID).Scan(&firstVisitDate) _ = tx.QueryRow(r.Context(), `SELECT MIN(start_time) FROM bookings WHERE user_id = $1 AND status = 'completed'`, booking.User.ID).Scan(&firstVisitDate)
if !firstVisitDate.IsZero() { if !firstVisitDate.IsZero() {
annRows, err := tx.Query(r.Context(), ` annRows, err := tx.Query(r.Context(), `
SELECT id, discount_percent, milestone_value, milestone_unit FROM discount_campaigns SELECT id, discount_percent, milestone_value, milestone_unit FROM discount_campaigns
WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'anniversary' WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'anniversary'
AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE user_id = $1 AND source_id = discount_campaigns.id AND milestone_type = 'anniversary') AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE user_id = $1 AND source_id = discount_campaigns.id AND milestone_type = 'anniversary')
`, booking.User.ID) `, booking.User.ID)
if err == nil { if err == nil {
// Collect anniversary campaigns first to avoid interleaving rows with writes. // Collect anniversary campaigns first to avoid interleaving rows with writes.
type annCampaign struct { type annCampaign struct {
id string id string
pct float64 pct float64
value int value int
unit string unit string
}
var campaigns []annCampaign
for annRows.Next() {
var c annCampaign
if annRows.Scan(&c.id, &c.pct, &c.value, &c.unit) == nil {
campaigns = append(campaigns, c)
} }
} var campaigns []annCampaign
annRows.Close() for annRows.Next() {
var c annCampaign
if annRows.Scan(&c.id, &c.pct, &c.value, &c.unit) == nil {
campaigns = append(campaigns, c)
}
}
annRows.Close()
// Sort by milestone_value descending so we apply the longest anniversary only // Sort by milestone_value descending so we apply the longest anniversary only
sort.Slice(campaigns, func(i, j int) bool { sort.Slice(campaigns, func(i, j int) bool {
return campaigns[i].value > campaigns[j].value return campaigns[i].value > campaigns[j].value
}) })
for _, c := range campaigns { for _, c := range campaigns {
var matches bool var matches bool
elapsed := time.Since(firstVisitDate) elapsed := time.Since(firstVisitDate)
switch c.unit { switch c.unit {
case "months": case "months":
months := int(elapsed.Hours() / (30 * 24)) months := int(elapsed.Hours() / (30 * 24))
matches = months >= c.value matches = months >= c.value
case "years": case "years":
years := int(elapsed.Hours() / (365.25 * 24)) years := int(elapsed.Hours() / (365.25 * 24))
matches = years >= c.value matches = years >= c.value
} }
if matches { if matches {
discountAmount := roundTo2(bookingTotal * c.pct / 100) discountAmount := roundTo2(bookingTotal * c.pct / 100)
if _, err := tx.Exec(r.Context(), ` if _, err := tx.Exec(r.Context(), `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount) INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, milestone_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'campaign', $3, 'milestone', 'anniversary', $4, $5, $6) VALUES ($1, $2, 'campaign', $3, 'milestone', 'anniversary', $4, $5, $6)
`, bookingID, booking.User.ID, c.id, c.pct, bookingTotal, discountAmount); err != nil { `, bookingID, booking.User.ID, c.id, c.pct, bookingTotal, discountAmount); err != nil {
log.Printf("ALERT: failed to insert booking discount: %v", err) log.Printf("ALERT: failed to insert booking discount: %v", err)
} }
if _, err := tx.Exec(r.Context(), ` if _, err := tx.Exec(r.Context(), `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by) INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', $2, 'completed', $3) VALUES ($1, 'partial', 'discount', $2, 'completed', $3)
`, bookingID, discountAmount, booking.User.ID); err != nil { `, bookingID, discountAmount, booking.User.ID); err != nil {
log.Printf("ALERT: failed to insert payment record: %v", err) log.Printf("ALERT: failed to insert payment record: %v", err)
} }
if _, err := tx.Exec(r.Context(), ` if _, err := tx.Exec(r.Context(), `
UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1 UPDATE discount_campaigns SET times_redeemed = times_redeemed + 1 WHERE id = $1
`, c.id); err != nil { `, c.id); err != nil {
log.Printf("ALERT: failed to insert payment record: %v", err) log.Printf("ALERT: failed to insert payment record: %v", err)
}
break // apply longest matching only
} }
break // apply longest matching only
} }
} }
} }
} }
}
var paymentExists bool var paymentExists bool
if err := tx.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1)`, bookingID).Scan(&paymentExists); err == nil && paymentExists { if err := tx.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1)`, bookingID).Scan(&paymentExists); err == nil && paymentExists {
var newDepositsRequired int var newDepositsRequired int
if err := tx.QueryRow(r.Context(), ` if err := tx.QueryRow(r.Context(), `
UPDATE users SET deposits_required = GREATEST(0, deposits_required - 1) UPDATE users SET deposits_required = GREATEST(0, deposits_required - 1)
WHERE id = $1 WHERE id = $1
RETURNING deposits_required RETURNING deposits_required
`, booking.User.ID).Scan(&newDepositsRequired); err != nil { `, booking.User.ID).Scan(&newDepositsRequired); err != nil {
log.Printf("ALERT: failed to update deposits_required: %v", err) log.Printf("ALERT: failed to update deposits_required: %v", err)
} else if newDepositsRequired == 0 { } else if newDepositsRequired == 0 {
// After 3 paid bookings, forget no-shows so the counter resets. // After 3 paid bookings, forget no-shows so the counter resets.
if _, err := tx.Exec(r.Context(), ` if _, err := tx.Exec(r.Context(), `
INSERT INTO forgiven_no_shows (booking_id) INSERT INTO forgiven_no_shows (booking_id)
SELECT id FROM bookings SELECT id FROM bookings
WHERE user_id = $1 AND status = 'no_show' WHERE user_id = $1 AND status = 'no_show'
AND start_time >= NOW() - INTERVAL '6 months' AND start_time >= NOW() - INTERVAL '6 months'
AND NOT EXISTS (SELECT 1 FROM forgiven_no_shows WHERE booking_id = bookings.id) AND NOT EXISTS (SELECT 1 FROM forgiven_no_shows WHERE booking_id = bookings.id)
`, booking.User.ID); err != nil { `, booking.User.ID); err != nil {
log.Printf("ALERT: failed to auto-forgive no-shows: %v", err) log.Printf("ALERT: failed to auto-forgive no-shows: %v", err)
}
} }
} }
} // Consume unconsumed name_history entries — this booking is the "first post-name-change
// Consume unconsumed name_history entries — this booking is the "first post-name-change // booking" that completes. After this, we no longer show "formerly" on displays.
// booking" that completes. After this, we no longer show "formerly" on displays. if _, err := tx.Exec(r.Context(), `
if _, err := tx.Exec(r.Context(), `
UPDATE name_history SET booking_id = $1 UPDATE name_history SET booking_id = $1
WHERE user_id = $2 AND booking_id IS NULL WHERE user_id = $2 AND booking_id IS NULL
`, bookingID, booking.User.ID); err != nil { `, bookingID, booking.User.ID); err != nil {
log.Printf("Failed to consume name_history for user %s: %v", booking.User.ID, err) log.Printf("Failed to consume name_history for user %s: %v", booking.User.ID, err)
} }
} // close the else from alreadyCompleted check } // close the else from alreadyCompleted check
} }
if err := tx.Commit(r.Context()); err != nil { if err := tx.Commit(r.Context()); err != nil {
@@ -3288,7 +3288,7 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
} }
} }
resp := map[string]interface{}{ resp := map[string]any{
"message": "Booking cancelled successfully", "message": "Booking cancelled successfully",
"id": bookingID, "id": bookingID,
"status": req.Reason, "status": req.Reason,
@@ -3336,7 +3336,7 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]any{
"message": "Booking deleted successfully", "message": "Booking deleted successfully",
"id": bookingID, "id": bookingID,
}) })
+12 -103
View File
@@ -1,5 +1,4 @@
//go:build test && dev //go:build test && dev
// +build test,dev
package bookings package bookings
@@ -30,10 +29,10 @@ import (
"crussell/clock" "crussell/clock"
"crussell/db" "crussell/db"
"crussell/testutils"
"crussell/handlers/user" "crussell/handlers/user"
"crussell/internal/validators" "crussell/internal/validators"
"crussell/mw" "crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures" "crussell/testutils/fixtures"
"crussell/testutils/jwt" "crussell/testutils/jwt"
@@ -217,7 +216,6 @@ func TestBookings_Create(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
// Seed working hours for booking tests // Seed working hours for booking tests
// Create test user and service // Create test user and service
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
@@ -1099,10 +1097,8 @@ func TestBookings_Delete_NotFound(t *testing.T) {
// - Cancellation < 24 hours before appointment: treated as no-show (deposits = 3) // - Cancellation < 24 hours before appointment: treated as no-show (deposits = 3)
// - Cancellation >= 24 hours before appointment: treated as late_cancellation // - Cancellation >= 24 hours before appointment: treated as late_cancellation
func TestBookings_Delete_NoShow24hThreshold(t *testing.T) { func TestBookings_Delete_NoShow24hThreshold(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
@@ -1162,10 +1158,8 @@ func TestBookings_Delete_NoShow24hThreshold(t *testing.T) {
// TestBookings_Delete_NoShow_WithForgiveness tests that admin can forgive a no-show // TestBookings_Delete_NoShow_WithForgiveness tests that admin can forgive a no-show
func TestBookings_Delete_NoShow_WithForgiveness(t *testing.T) { func TestBookings_Delete_NoShow_WithForgiveness(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
@@ -1459,7 +1453,6 @@ func TestBookings_Create_MinimumAdvance(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
// Seed working hours for booking tests // Seed working hours for booking tests
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
@@ -1513,7 +1506,6 @@ func TestBookings_Create_WithNotes_StatusPending(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
// Seed working hours // Seed working hours
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
@@ -1567,7 +1559,6 @@ func TestBookings_Create_WithoutNotes_StatusConfirmed(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
// Seed working hours // Seed working hours
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
@@ -1615,7 +1606,6 @@ func TestBookings_Create_Within1Hour_ShouldFail(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
// Seed working hours // Seed working hours
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
@@ -1654,7 +1644,6 @@ func TestBookings_Create_MultipleServices(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
// Seed working hours for booking tests // Seed working hours for booking tests
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
@@ -1715,10 +1704,8 @@ func TestBookings_Create_MultipleServices(t *testing.T) {
// booking is cancelled within 24 hours (with no forgiveness), the system // booking is cancelled within 24 hours (with no forgiveness), the system
// overrides the cancellation to "no_show" and deposits_required stays 0. // overrides the cancellation to "no_show" and deposits_required stays 0.
func TestDeleteBooking_NoShowUnder24h_SetsDepositsTo3(t *testing.T) { func TestDeleteBooking_NoShowUnder24h_SetsDepositsTo3(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
@@ -1798,10 +1785,8 @@ func TestDeleteBooking_NoShowUnder24h_SetsDepositsTo3(t *testing.T) {
// cancelled with more than 24 hours notice (client_cancelled), no deposit penalty // cancelled with more than 24 hours notice (client_cancelled), no deposit penalty
// is applied and deposits_required remains 0. // is applied and deposits_required remains 0.
func TestDeleteBooking_CancelOver24h_NoDepositPenalty(t *testing.T) { func TestDeleteBooking_CancelOver24h_NoDepositPenalty(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
ctx, tx := testutils.SetupTestTx(t)
// Create test user with deposits_required = 0 // Create test user with deposits_required = 0
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
@@ -1883,10 +1868,8 @@ func TestDeleteBooking_CancelOver24h_NoDepositPenalty(t *testing.T) {
// booking is cancelled within 24 hours with forgiveness, the system overrides to // booking is cancelled within 24 hours with forgiveness, the system overrides to
// "client_cancelled" (no no-show penalty). // "client_cancelled" (no no-show penalty).
func TestDeleteBooking_NoShowWithForgiveness_NoPenalty(t *testing.T) { func TestDeleteBooking_NoShowWithForgiveness_NoPenalty(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
@@ -1968,10 +1951,8 @@ func TestDeleteBooking_NoShowWithForgiveness_NoPenalty(t *testing.T) {
// no-show, the deposits_required stays at 3 (not 6). The handler sets deposits to 3 // no-show, the deposits_required stays at 3 (not 6). The handler sets deposits to 3
// on the first no-show and doesn't increment on subsequent no-shows. // on the first no-show and doesn't increment on subsequent no-shows.
func TestDeleteBooking_SecondNoShow_StaysAt3(t *testing.T) { func TestDeleteBooking_SecondNoShow_StaysAt3(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
ctx, tx := testutils.SetupTestTx(t)
// Create test user with deposits_required = 0 // Create test user with deposits_required = 0
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
@@ -2858,8 +2839,6 @@ func TestCreateEditRequest_WithTimeChange(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)
@@ -3274,8 +3253,6 @@ func TestAdminApproveEditRequest_DeletesTimeBlocker(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)
@@ -3373,8 +3350,6 @@ func TestAdminRejectEditRequest_DeletesTimeBlocker(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)
@@ -3471,8 +3446,6 @@ func TestDeleteEditRequest_DeletesTimeBlocker(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)
@@ -3552,8 +3525,6 @@ func TestAdminApproveEditRequest_TimeBlockerOverlap(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)
@@ -3765,8 +3736,6 @@ func TestBookings_Create_PatchTestRequired_NoRecord(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
// Create user and service with patch test requirement // Create user and service with patch test requirement
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
@@ -3815,8 +3784,6 @@ func TestBookings_Create_PatchTestRequired_WithinNoticePeriod(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)
@@ -3872,8 +3839,6 @@ func TestBookings_Create_PatchTestRequired_Expired(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)
@@ -3926,8 +3891,6 @@ func TestBookings_Create_PatchTestRequired_ValidRecord(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)
@@ -3992,8 +3955,6 @@ func TestBookings_Create_DepositRequired_WithinAdvanceWindow(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)
@@ -4040,8 +4001,6 @@ func TestBookings_Create_DepositRequired_After48Hours(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)
@@ -4092,10 +4051,8 @@ func TestBookings_Create_DepositRequired_After48Hours(t *testing.T) {
// TestBookings_Create_NoDepositRequired_Within48Hours verifies that a user with deposits_required=0 // TestBookings_Create_NoDepositRequired_Within48Hours verifies that a user with deposits_required=0
// can book at any time (no 48h restriction). // can book at any time (no 48h restriction).
func TestBookings_Create_NoDepositRequired_Within48Hours(t *testing.T) { func TestBookings_Create_NoDepositRequired_Within48Hours(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
@@ -4143,8 +4100,6 @@ func TestBookings_Create_DepositSnapshot(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)
@@ -4214,8 +4169,6 @@ func TestBookings_Create_DepositRequired_OneActiveBookingLimit(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)
@@ -4276,8 +4229,6 @@ func TestBookings_Get_DepositFieldsReturned(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)
@@ -4356,7 +4307,6 @@ func TestBookings_Get_DepositFieldsReturned(t *testing.T) {
func TestBookings_Get_ServicesReturned(t *testing.T) { func TestBookings_Get_ServicesReturned(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
@@ -4427,7 +4377,6 @@ func TestBookings_Get_ServicesReturned(t *testing.T) {
func TestBookings_Get_CustomServicesReturned(t *testing.T) { func TestBookings_Get_CustomServicesReturned(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
@@ -4511,7 +4460,6 @@ func TestBookings_Get_CustomServicesReturned(t *testing.T) {
func TestBookings_Get_EmptyServices(t *testing.T) { func TestBookings_Get_EmptyServices(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
@@ -4562,8 +4510,6 @@ func TestBookings_Edit_ClosedDay_UserBlocked(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)
@@ -4651,8 +4597,6 @@ func TestBookings_Edit_OpenDay_UserAllowed(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)
@@ -4739,8 +4683,6 @@ func TestBookings_Create_OverlappingBlocker_UserBlocked(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)
@@ -4760,7 +4702,7 @@ func TestBookings_Create_OverlappingBlocker_UserBlocked(t *testing.T) {
defer fixtures.DeleteService(tx, serviceID) defer fixtures.DeleteService(tx, serviceID)
// Create a time blocker for a specific time // Create a time blocker for a specific time
blockerTime := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) blockerTime := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
_, err = tx.Exec(ctx, ` _, err = tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES ($1, 60, 'Staff meeting', NULL) VALUES ($1, 60, 'Staff meeting', NULL)
@@ -4808,8 +4750,6 @@ func TestBookings_Edit_OverlappingBlocker_UserBlocked(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)
@@ -4836,7 +4776,7 @@ func TestBookings_Edit_OverlappingBlocker_UserBlocked(t *testing.T) {
defer fixtures.DeleteBooking(tx, bookingID) defer fixtures.DeleteBooking(tx, bookingID)
// Create a time blocker for a specific time // Create a time blocker for a specific time
blockerTime := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC) blockerTime := time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)
_, err = tx.Exec(ctx, ` _, err = tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES ($1, 60, 'Staff meeting', NULL) VALUES ($1, 60, 'Staff meeting', NULL)
@@ -4985,7 +4925,6 @@ func TestGuestUser_Create_RegisteredEmailCollision(t *testing.T) {
func TestGuestBooking_Create_Success(t *testing.T) { func TestGuestBooking_Create_Success(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
// Create guest user // Create guest user
guestReq := map[string]string{ guestReq := map[string]string{
@@ -5081,7 +5020,6 @@ func TestGuestBooking_Create_NonGuestUserID(t *testing.T) {
func TestGuestBooking_SkipsDepositCheck(t *testing.T) { func TestGuestBooking_SkipsDepositCheck(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
// Create guest user // Create guest user
guestReq := map[string]string{ guestReq := map[string]string{
@@ -5123,9 +5061,8 @@ func TestGuestBooking_SkipsDepositCheck(t *testing.T) {
} }
func TestGuestBooking_BypassesAdvanceWindow(t *testing.T) { func TestGuestBooking_BypassesAdvanceWindow(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
// Create guest user // Create guest user
guestReq := map[string]string{ guestReq := map[string]string{
@@ -5158,8 +5095,6 @@ func TestGuestBooking_BypassesAdvanceWindow(t *testing.T) {
} }
nearTime := midday.Truncate(time.Second) nearTime := midday.Truncate(time.Second)
req := CreateBookingRequest{ req := CreateBookingRequest{
StartTime: nearTime, StartTime: nearTime,
ServiceIDs: []string{serviceID}, ServiceIDs: []string{serviceID},
@@ -5181,7 +5116,6 @@ func TestGuestBooking_BypassesAdvanceWindow(t *testing.T) {
func TestCreateBooking_Notifications_NewBookingAlwaysCreated(t *testing.T) { func TestCreateBooking_Notifications_NewBookingAlwaysCreated(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
@@ -5236,7 +5170,6 @@ func TestCreateBooking_Notifications_NewBookingAlwaysCreated(t *testing.T) {
func TestCreateBooking_Notifications_PendingBookingWithNotes(t *testing.T) { func TestCreateBooking_Notifications_PendingBookingWithNotes(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
@@ -5303,7 +5236,6 @@ func TestCreateBooking_Notifications_PendingBookingWithNotes(t *testing.T) {
func TestCreateBooking_Notifications_NoPendingBookingWithoutNotes(t *testing.T) { func TestCreateBooking_Notifications_NoPendingBookingWithoutNotes(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
@@ -6016,7 +5948,6 @@ func TestProgressBooking_DailyStampCap_SQLSubquery(t *testing.T) {
func TestCreateBooking_OneHourAdvanceCheck(t *testing.T) { func TestCreateBooking_OneHourAdvanceCheck(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
@@ -6071,7 +6002,6 @@ func TestCreateBooking_OneHourAdvanceCheck(t *testing.T) {
func TestCreateBooking_ActiveBookingLimit(t *testing.T) { func TestCreateBooking_ActiveBookingLimit(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
@@ -6177,7 +6107,6 @@ func TestNextWeekdayHelper(t *testing.T) {
func TestCreateBooking_DepositSnapshot(t *testing.T) { func TestCreateBooking_DepositSnapshot(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
@@ -6282,7 +6211,6 @@ func TestCreateBooking_DepositSnapshot(t *testing.T) {
func TestGetBooking_WithDiscounts(t *testing.T) { func TestGetBooking_WithDiscounts(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
@@ -6373,7 +6301,6 @@ func createCompletedBookingWithTime(t *testing.T, tx db.Querier, ctx context.Con
func TestBookings_Confirm_WithCustomServiceOverrides(t *testing.T) { func TestBookings_Confirm_WithCustomServiceOverrides(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
@@ -6495,7 +6422,6 @@ func TestBookings_Confirm_WithCustomServiceOverrides(t *testing.T) {
func TestBookings_GetBooking_WithCustomServices(t *testing.T) { func TestBookings_GetBooking_WithCustomServices(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
@@ -6576,7 +6502,6 @@ func TestBookings_GetBooking_WithCustomServices(t *testing.T) {
func TestBookings_Confirm_CustomOverrideValidation(t *testing.T) { func TestBookings_Confirm_CustomOverrideValidation(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
@@ -6665,7 +6590,6 @@ func TestBookings_Confirm_CustomOverrideValidation(t *testing.T) {
func TestBookings_Confirm_CustomServiceNotInBooking(t *testing.T) { func TestBookings_Confirm_CustomServiceNotInBooking(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
@@ -6744,7 +6668,6 @@ func TestBookings_Confirm_CustomServiceNotInBooking(t *testing.T) {
func TestBookings_Progress_WithCustomServices(t *testing.T) { func TestBookings_Progress_WithCustomServices(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
@@ -6919,10 +6842,8 @@ func TestDeleteBooking_WithPayments_ProcessesRefund(t *testing.T) {
// TestDeleteBooking_NoPayments_HardDelete verifies that when a booking has no // TestDeleteBooking_NoPayments_HardDelete verifies that when a booking has no
// payments, cancelling performs a hard delete (removes the row entirely). // payments, cancelling performs a hard delete (removes the row entirely).
func TestDeleteBooking_NoPayments_HardDelete(t *testing.T) { func TestDeleteBooking_NoPayments_HardDelete(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t)
ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
@@ -7066,8 +6987,6 @@ func TestRequestEditHandler_AutoApproves_NoPayments_FarFuture(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)
@@ -7163,8 +7082,6 @@ func TestRequestEditHandler_AutoApproves_WithTimeChange(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)
@@ -7241,8 +7158,6 @@ func TestRequestEditHandler_NoAutoApproval_WithPayments(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)
@@ -7312,8 +7227,6 @@ func TestRequestEditHandler_NoAutoApproval_Within48h(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)
@@ -7372,7 +7285,6 @@ func TestRequestEditHandler_NoAutoApproval_Within48h(t *testing.T) {
func TestCreateEditRequest_DiscountsBlockAutoApprove(t *testing.T) { func TestCreateEditRequest_DiscountsBlockAutoApprove(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
@@ -7441,7 +7353,6 @@ func TestCreateEditRequest_DiscountsBlockAutoApprove(t *testing.T) {
func TestCreateEditRequest_NoDiscountsStillAutoApproves(t *testing.T) { func TestCreateEditRequest_NoDiscountsStillAutoApproves(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
@@ -7506,7 +7417,6 @@ func TestCreateEditRequest_NoDiscountsStillAutoApproves(t *testing.T) {
func TestGetAllUserBookings_CursorNotSetOnLastPage(t *testing.T) { func TestGetAllUserBookings_CursorNotSetOnLastPage(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
@@ -7628,7 +7538,6 @@ func TestGetAllUserBookings_CursorNotSetOnLastPage(t *testing.T) {
func TestGetAllUserBookings_TotalCountMatches(t *testing.T) { func TestGetAllUserBookings_TotalCountMatches(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
@@ -1,5 +1,4 @@
//go:build test && dev //go:build test && dev
// +build test,dev
package bookings package bookings
@@ -1,5 +1,4 @@
//go:build test && dev //go:build test && dev
// +build test,dev
package bookings package bookings
@@ -65,8 +64,8 @@ func TestCheckClosingHours_InvalidFormat(t *testing.T) {
localEnd := time.Date(2026, 6, 24, 14, 0, 0, 0, london) localEnd := time.Date(2026, 6, 24, 14, 0, 0, 0, london)
tests := []struct { tests := []struct {
name string name string
closeStr string closeStr string
}{ }{
{"empty string", ""}, {"empty string", ""},
{"no colon", "1700"}, {"no colon", "1700"},
-7
View File
@@ -1,5 +1,4 @@
//go:build test && dev //go:build test && dev
// +build test,dev
package bookings package bookings
@@ -22,7 +21,6 @@ import (
func setupDedupTest(t *testing.T, tx db.Querier, ctx context.Context) (string, string, string) { func setupDedupTest(t *testing.T, tx db.Querier, ctx context.Context) (string, string, string) {
t.Helper() t.Helper()
userID := createTestUser(t, 0, tx, ctx) userID := createTestUser(t, 0, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx) serviceID := createTestService(t, 50.00, tx, ctx)
@@ -139,7 +137,6 @@ func TestProgressBooking_UserMilestoneDedup(t *testing.T) {
func TestNoShowApplyDepositsIfNeeded(t *testing.T) { func TestNoShowApplyDepositsIfNeeded(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID := createTestUser(t, 0, tx, ctx) userID := createTestUser(t, 0, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx) serviceID := createTestService(t, 50.00, tx, ctx)
@@ -170,7 +167,6 @@ func TestNoShowApplyDepositsIfNeeded(t *testing.T) {
func TestNoShowSingleNoShowDoesNotTrigger(t *testing.T) { func TestNoShowSingleNoShowDoesNotTrigger(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID := createTestUser(t, 0, tx, ctx) userID := createTestUser(t, 0, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx) serviceID := createTestService(t, 50.00, tx, ctx)
@@ -194,7 +190,6 @@ func TestNoShowSingleNoShowDoesNotTrigger(t *testing.T) {
func TestNoShowOldNoShowsExcluded(t *testing.T) { func TestNoShowOldNoShowsExcluded(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID := createTestUser(t, 0, tx, ctx) userID := createTestUser(t, 0, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx) serviceID := createTestService(t, 50.00, tx, ctx)
@@ -222,7 +217,6 @@ func TestNoShowOldNoShowsExcluded(t *testing.T) {
func TestNoShowForgivenExcluded(t *testing.T) { func TestNoShowForgivenExcluded(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID := createTestUser(t, 0, tx, ctx) userID := createTestUser(t, 0, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx) serviceID := createTestService(t, 50.00, tx, ctx)
@@ -252,7 +246,6 @@ func TestNoShowForgivenExcluded(t *testing.T) {
func TestThreePaidBookingsClearNoShows(t *testing.T) { func TestThreePaidBookingsClearNoShows(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID := createTestUser(t, 0, tx, ctx) userID := createTestUser(t, 0, tx, ctx)
serviceID := createTestService(t, 50.00, tx, ctx) serviceID := createTestService(t, 50.00, tx, ctx)
+1 -2
View File
@@ -1,5 +1,4 @@
//go:build test && dev //go:build test && dev
// +build test,dev
package bookings package bookings
@@ -14,9 +13,9 @@ import (
"crussell/clock" "crussell/clock"
"crussell/db" "crussell/db"
"crussell/testutils"
"crussell/handlers/payments" "crussell/handlers/payments"
"crussell/mw" "crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures" "crussell/testutils/fixtures"
"crussell/testutils/jwt" "crussell/testutils/jwt"
) )
+1 -2
View File
@@ -1,5 +1,4 @@
//go:build test && dev //go:build test && dev
// +build test,dev
package bookings package bookings
@@ -15,9 +14,9 @@ import (
"crussell/clock" "crussell/clock"
"crussell/db" "crussell/db"
"crussell/testutils"
"crussell/handlers/payments" "crussell/handlers/payments"
"crussell/mw" "crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures" "crussell/testutils/fixtures"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -1,5 +1,4 @@
//go:build test && dev //go:build test && dev
// +build test,dev
package bookings package bookings
@@ -34,8 +33,8 @@ import (
"crussell/clock" "crussell/clock"
"crussell/db" "crussell/db"
"crussell/testutils"
"crussell/mw" "crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures" "crussell/testutils/fixtures"
"crussell/testutils/jwt" "crussell/testutils/jwt"
@@ -1610,8 +1609,6 @@ func TestAdminApproveEditRequestHandler_WithNotesOnly(t *testing.T) {
func TestAdminApproveEditRequestHandler_OverlapWithBooking(t *testing.T) { func TestAdminApproveEditRequestHandler_OverlapWithBooking(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create user: %v", err) t.Fatalf("failed to create user: %v", err)
@@ -1883,8 +1880,6 @@ func TestUserCancelBookingHandler_CleansUpEditRequest(t *testing.T) {
func TestAdminApproveEditRequestHandler_BlockedByExceptionalClosedHours(t *testing.T) { func TestAdminApproveEditRequestHandler_BlockedByExceptionalClosedHours(t *testing.T) {
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
_, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx) _, serviceID, bookingID, token := setupEditRequestTest(t, ctx, tx)
_ = serviceID _ = serviceID
+22 -22
View File
@@ -2,9 +2,8 @@ package bookings
import ( import (
"context" "context"
"crussell/db"
"crussell/clock" "crussell/clock"
"github.com/jackc/pgx/v5" "crussell/db"
"crussell/handlers/notifications" "crussell/handlers/notifications"
"crussell/handlers/payments" "crussell/handlers/payments"
"crussell/handlers/scheduling" "crussell/handlers/scheduling"
@@ -14,6 +13,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"github.com/jackc/pgx/v5"
"log" "log"
"net/http" "net/http"
"strings" "strings"
@@ -279,7 +279,7 @@ func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
} }
if refundFailed || (refundResult != nil && refundResult.RefundableAmount > 0) { if refundFailed || (refundResult != nil && refundResult.RefundableAmount > 0) {
resp := map[string]interface{}{ resp := map[string]any{
"message": "Booking cancelled", "message": "Booking cancelled",
} }
if refundResult != nil && refundResult.RefundableAmount > 0 { if refundResult != nil && refundResult.RefundableAmount > 0 {
@@ -932,7 +932,7 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
} }
response := map[string]interface{}{ response := map[string]any{
"booking": booking, "booking": booking,
@@ -988,10 +988,10 @@ type EditSnapshot struct {
} }
type EditUserSummary struct { type EditUserSummary struct {
ID string `json:"id"` ID string `json:"id"`
FullName string `json:"full_name"` FullName string `json:"full_name"`
Email *string `json:"email,omitempty"` Email *string `json:"email,omitempty"`
Phone *string `json:"phone,omitempty"` Phone *string `json:"phone,omitempty"`
PreviousFirstName *string `json:"previous_first_name,omitempty"` PreviousFirstName *string `json:"previous_first_name,omitempty"`
PreviousLastName *string `json:"previous_last_name,omitempty"` PreviousLastName *string `json:"previous_last_name,omitempty"`
} }
@@ -1462,12 +1462,12 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
durMinutes = 60 durMinutes = 60
} }
} else { } else {
if err := tx.QueryRow(r.Context(), ` if err := tx.QueryRow(r.Context(), `
SELECT total_duration_minutes FROM bookings WHERE id = $1 SELECT total_duration_minutes FROM bookings WHERE id = $1
`, bookingID).Scan(&durMinutes); err != nil { `, bookingID).Scan(&durMinutes); err != nil {
log.Printf("Failed to get duration for existing services: %v", err) log.Printf("Failed to get duration for existing services: %v", err)
durMinutes = 60 durMinutes = 60
} }
} }
if durMinutes <= 0 { if durMinutes <= 0 {
durMinutes = 60 durMinutes = 60
@@ -1504,7 +1504,7 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
// Update booking start_time, notes, and services directly // Update booking start_time, notes, and services directly
if req.NewStartTime != nil || req.Notes != nil { if req.NewStartTime != nil || req.Notes != nil {
var setClauses []string var setClauses []string
var args []interface{} var args []any
argNum := 1 argNum := 1
if req.NewStartTime != nil { if req.NewStartTime != nil {
setClauses = append(setClauses, fmt.Sprintf("start_time = $%d", argNum)) setClauses = append(setClauses, fmt.Sprintf("start_time = $%d", argNum))
@@ -1574,7 +1574,7 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]any{
"auto_approved": true, "auto_approved": true,
"edit_request": editReq, "edit_request": editReq,
}) })
@@ -1684,7 +1684,7 @@ func AdminListEditRequestsHandler(w http.ResponseWriter, r *http.Request) {
JOIN users u ON ber.requested_by = u.id JOIN users u ON ber.requested_by = u.id
` `
var args []interface{} var args []any
baseQuery += " ORDER BY ber.updated_at DESC" baseQuery += " ORDER BY ber.updated_at DESC"
@@ -1747,7 +1747,7 @@ func AdminListEditRequestsHandler(w http.ResponseWriter, r *http.Request) {
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]any{
"requests": requests, "requests": requests,
"total": total, "total": total,
}) })
@@ -1939,7 +1939,7 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
// setClauses contains only hardcoded column name assignments ("start_time = $N", "notes = $N"). // setClauses contains only hardcoded column name assignments ("start_time = $N", "notes = $N").
// Column names are never derived from user input. User values are in args and always parameterised. // Column names are never derived from user input. User values are in args and always parameterised.
var setClauses []string var setClauses []string
var args []interface{} var args []any
argNum := 1 argNum := 1
if newStartTime != nil { if newStartTime != nil {
@@ -2153,7 +2153,7 @@ func GetMyEditRequestHandler(w http.ResponseWriter, r *http.Request) {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]any{
"edit_request": nil, "edit_request": nil,
}) })
return return
@@ -2172,7 +2172,7 @@ func GetMyEditRequestHandler(w http.ResponseWriter, r *http.Request) {
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]any{
"edit_request": enriched, "edit_request": enriched,
}) })
} }
@@ -2236,7 +2236,7 @@ func GetMyEditRequestsHandler(w http.ResponseWriter, r *http.Request) {
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]any{
"edit_requests": enrichedRequests, "edit_requests": enrichedRequests,
}) })
} }
@@ -2293,7 +2293,7 @@ func AdminListAllEditRequestsHandler(w http.ResponseWriter, r *http.Request) {
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]any{
"edit_requests": enrichedRequests, "edit_requests": enrichedRequests,
}) })
} }
@@ -2342,7 +2342,7 @@ func AdminGetBookingEditRequestHandler(w http.ResponseWriter, r *http.Request) {
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]any{
"edit_request": enriched, "edit_request": enriched,
}) })
} }
+5 -6
View File
@@ -1,5 +1,4 @@
//go:build test && dev //go:build test && dev
// +build test,dev
package bookings package bookings
@@ -1301,7 +1300,7 @@ func TestAdminCreateBooking_Weekday_BST_Boundary(t *testing.T) {
// Compute weekStart the same way the handler does: from the booking time's // Compute weekStart the same way the handler does: from the booking time's
// London weekday, find Monday's date, store as UTC midnight. // London weekday, find Monday's date, store as UTC midnight.
bkLondon := sunday2330UTC.In(londonLocation) // 00:30 BST Monday bkLondon := sunday2330UTC.In(londonLocation) // 00:30 BST Monday
daysToMonday := int(bkLondon.Weekday()) // Monday in Go = 1 daysToMonday := int(bkLondon.Weekday()) // Monday in Go = 1
if daysToMonday == 0 { if daysToMonday == 0 {
daysToMonday = 7 daysToMonday = 7
} }
@@ -2182,10 +2181,10 @@ func TestAdminReserveSlot_CleansUpAnonReservation(t *testing.T) {
w := makeIPRequest(http.HandlerFunc(AdminReserveSlotHandler), "POST", "/api/admin/bookings/reserve", w := makeIPRequest(http.HandlerFunc(AdminReserveSlotHandler), "POST", "/api/admin/bookings/reserve",
&AdminReserveSlotRequest{ &AdminReserveSlotRequest{
StartTime: future, StartTime: future,
ServiceIDs: []string{serviceID}, ServiceIDs: []string{serviceID},
DurationMinutes: 60, DurationMinutes: 60,
ReservationType: "walkin", ReservationType: "walkin",
}, token, testIP, adminID, ctx) }, token, testIP, adminID, ctx)
if w.Code != http.StatusCreated { if w.Code != http.StatusCreated {
+2 -2
View File
@@ -80,8 +80,8 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
hasAuth := hasUser && userID != "" hasAuth := hasUser && userID != ""
if !hasAuth { if !hasAuth {
authHeader := r.Header.Get("Authorization") authHeader := r.Header.Get("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") { if after, ok := strings.CutPrefix(authHeader, "Bearer "); ok {
tokenString := strings.TrimPrefix(authHeader, "Bearer ") tokenString := after
var err error var err error
userID, _, _, err = auth.VerifyToken(tokenString, r.Context()) userID, _, _, err = auth.VerifyToken(tokenString, r.Context())
if err != nil { if err != nil {
@@ -1,5 +1,4 @@
//go:build test && dev //go:build test && dev
// +build test,dev
package bookings package bookings
@@ -1,5 +1,4 @@
//go:build test && dev //go:build test && dev
// +build test,dev
package bookings package bookings
@@ -1,5 +1,4 @@
//go:build test && dev //go:build test && dev
// +build test,dev
package bookings package bookings
@@ -201,7 +201,6 @@ func GetNotifications(w http.ResponseWriter, r *http.Request) {
NextCursor: nextCursor, NextCursor: nextCursor,
} }
if err := json.NewEncoder(w).Encode(resp); err != nil { if err := json.NewEncoder(w).Encode(resp); err != nil {
log.Printf("Failed to encode response: %v", err) log.Printf("Failed to encode response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -222,7 +221,6 @@ func GetUnreadCount(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := json.NewEncoder(w).Encode(map[string]int{"count": count}); err != nil { if err := json.NewEncoder(w).Encode(map[string]int{"count": count}); err != nil {
log.Printf("Failed to encode response: %v", err) log.Printf("Failed to encode response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -269,13 +267,12 @@ func AcknowledgeNotification(w http.ResponseWriter, r *http.Request) {
return return
} }
json.NewEncoder(w).Encode(map[string]string{ json.NewEncoder(w).Encode(map[string]string{
"status": "ok", "status": "ok",
}) })
} }
func AcknowledgePendingBookingNotification(tx interface{}, ctx context.Context, bookingID string) error { func AcknowledgePendingBookingNotification(tx any, ctx context.Context, bookingID string) error {
query := ` query := `
UPDATE admin_notifications UPDATE admin_notifications
SET acknowledged_at = NOW() SET acknowledged_at = NOW()
@@ -284,7 +281,7 @@ func AcknowledgePendingBookingNotification(tx interface{}, ctx context.Context,
// Use type assertion to get the Exec method - pgx.Tx satisfies this interface // Use type assertion to get the Exec method - pgx.Tx satisfies this interface
execer, ok := tx.(interface { execer, ok := tx.(interface {
Exec(ctx context.Context, sql string, arguments ...interface{}) (pgconn.CommandTag, error) Exec(ctx context.Context, sql string, arguments ...any) (pgconn.CommandTag, error)
}) })
if !ok { if !ok {
log.Printf("Warning: cannot acknowledge notification - tx does not satisfy Execer interface for booking %s", bookingID) log.Printf("Warning: cannot acknowledge notification - tx does not satisfy Execer interface for booking %s", bookingID)
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package notifications package notifications
@@ -14,8 +13,8 @@ import (
"time" "time"
"crussell/db" "crussell/db"
"crussell/testutils"
"crussell/mw" "crussell/mw"
"crussell/testutils"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
@@ -571,5 +570,3 @@ func createNotificationWithBooking(t *testing.T, ctx context.Context, q db.Queri
} }
return notificationID return notificationID
} }
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package notifications package notifications
@@ -25,8 +24,8 @@ import (
"time" "time"
"crussell/clock" "crussell/clock"
"crussell/testutils"
"crussell/mw" "crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures" "crussell/testutils/fixtures"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package notifications package notifications
@@ -1,5 +1,4 @@
//go:build test && dev //go:build test && dev
// +build test,dev
package payments package payments
@@ -13,8 +12,8 @@ import (
"crussell/clock" "crussell/clock"
"crussell/db" "crussell/db"
"crussell/testutils"
"crussell/mw" "crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures" "crussell/testutils/fixtures"
"crussell/testutils/jwt" "crussell/testutils/jwt"
+10 -10
View File
@@ -11,8 +11,8 @@ import (
"strings" "strings"
"time" "time"
"crussell/db"
"crussell/clock" "crussell/clock"
"crussell/db"
"crussell/internal/square" "crussell/internal/square"
"crussell/internal/validators" "crussell/internal/validators"
"crussell/mw" "crussell/mw"
@@ -160,7 +160,7 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) {
} }
var gcTotal int var gcTotal int
var gcListArgs []interface{} var gcListArgs []any
gcListQuery := fmt.Sprintf(` gcListQuery := fmt.Sprintf(`
SELECT id, total_funds_added, amount_remaining, created_at, is_inventory SELECT id, total_funds_added, amount_remaining, created_at, is_inventory
@@ -170,7 +170,7 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) {
if searchTerm != "" { if searchTerm != "" {
searchPattern := "%" + searchTerm + "%" searchPattern := "%" + searchTerm + "%"
gcListArgs = []interface{}{searchPattern} gcListArgs = []any{searchPattern}
if cursorStr != "" { if cursorStr != "" {
cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr) cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr)
@@ -202,7 +202,7 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) {
// transaction (single connection). // transaction (single connection).
gcTotal = 0 gcTotal = 0
if whereSQL != "" { if whereSQL != "" {
countArgs := []interface{}{} countArgs := []any{}
if searchTerm != "" { if searchTerm != "" {
countArgs = append(countArgs, "%"+searchTerm+"%") countArgs = append(countArgs, "%"+searchTerm+"%")
} }
@@ -243,7 +243,7 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) {
var ubTotal int var ubTotal int
var ubListQuery string var ubListQuery string
var ubListArgs []interface{} var ubListArgs []any
if searchTerm != "" { if searchTerm != "" {
searchPattern := "%" + searchTerm + "%" searchPattern := "%" + searchTerm + "%"
@@ -258,7 +258,7 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) {
OR u.email ILIKE $1 OR u.email ILIKE $1
ORDER BY b.updated_at DESC ORDER BY b.updated_at DESC
` `
ubListArgs = []interface{}{searchPattern} ubListArgs = []any{searchPattern}
} else { } else {
ubListQuery = ` ubListQuery = `
SELECT SELECT
@@ -267,7 +267,7 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) {
JOIN users u ON b.user_id = u.id JOIN users u ON b.user_id = u.id
ORDER BY b.updated_at DESC ORDER BY b.updated_at DESC
` `
ubListArgs = []interface{}{} ubListArgs = []any{}
} }
ubRows, err := db.Conn.Query(ctx, ubListQuery, ubListArgs...) ubRows, err := db.Conn.Query(ctx, ubListQuery, ubListArgs...)
@@ -736,7 +736,7 @@ func RedeemGiftCard(w http.ResponseWriter, r *http.Request) {
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]any{
"status": "success", "status": "success",
"amount_redeemed": amountRemaining, "amount_redeemed": amountRemaining,
}) })
@@ -1065,7 +1065,7 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
} }
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]any{
"status": "success", "status": "success",
"code": cardID, "code": cardID,
"amount": amountPounds, "amount": amountPounds,
@@ -1137,7 +1137,7 @@ func GetExpiredBalances(w http.ResponseWriter, r *http.Request) {
balances = []ExpiredBalance{} balances = []ExpiredBalance{}
} }
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]any{
"expired_balances": balances, "expired_balances": balances,
"total": len(balances), "total": len(balances),
}) })
@@ -1,5 +1,4 @@
//go:build test && dev //go:build test && dev
// +build test,dev
package payments package payments
@@ -23,7 +22,6 @@ import (
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
) )
func TestAdminCreateGiftCard(t *testing.T) { func TestAdminCreateGiftCard(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
+2 -2
View File
@@ -2,8 +2,8 @@ package payments
import ( import (
"context" "context"
"crussell/db"
"crussell/clock" "crussell/clock"
"crussell/db"
"crussell/internal/square" "crussell/internal/square"
"crussell/internal/validators" "crussell/internal/validators"
"crussell/mw" "crussell/mw"
@@ -1943,7 +1943,7 @@ func AcquirePaymentLock(w http.ResponseWriter, r *http.Request) {
return return
} }
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]any{
"status": "locked", "status": "locked",
"ttl_min": PaymentLockDuration, "ttl_min": PaymentLockDuration,
"bookingID": bookingID, "bookingID": bookingID,
+1 -2
View File
@@ -150,8 +150,7 @@ func ApplyLoyaltyRedemption(w http.ResponseWriter, r *http.Request) {
return return
} }
json.NewEncoder(w).Encode(map[string]any{
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true, "success": true,
"discount_amount": discountAmount, "discount_amount": discountAmount,
}) })
+1 -2
View File
@@ -1,5 +1,4 @@
//go:build test && dev //go:build test && dev
// +build test,dev
package payments package payments
@@ -13,8 +12,8 @@ import (
"crussell/clock" "crussell/clock"
"crussell/db" "crussell/db"
"crussell/testutils"
"crussell/mw" "crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures" "crussell/testutils/fixtures"
"crussell/testutils/jwt" "crussell/testutils/jwt"
@@ -1,5 +1,4 @@
//go:build test && dev //go:build test && dev
// +build test,dev
package payments package payments
@@ -11,8 +10,8 @@ import (
"time" "time"
"crussell/db" "crussell/db"
"crussell/testutils"
"crussell/mw" "crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures" "crussell/testutils/fixtures"
"crussell/testutils/jwt" "crussell/testutils/jwt"
+1 -2
View File
@@ -1,5 +1,4 @@
//go:build test && dev //go:build test && dev
// +build test,dev
package payments package payments
@@ -16,8 +15,8 @@ import (
"crussell/clock" "crussell/clock"
"crussell/db" "crussell/db"
"crussell/testutils"
"crussell/mw" "crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures" "crussell/testutils/fixtures"
"crussell/testutils/jwt" "crussell/testutils/jwt"
@@ -1,5 +1,4 @@
//go:build test && dev //go:build test && dev
// +build test,dev
package payments package payments
+12 -13
View File
@@ -1,5 +1,4 @@
//go:build test && dev //go:build test && dev
// +build test,dev
package payments package payments
@@ -154,7 +153,7 @@ func TestProcessCancellationRefund_CreatesRefundRecords(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create user: %v", err) t.Fatalf("failed to create user: %v", err)
} }
@@ -210,7 +209,7 @@ func TestProcessCancellationRefund_NoRefundWhenNotNeeded(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create user: %v", err) t.Fatalf("failed to create user: %v", err)
} }
@@ -246,7 +245,7 @@ func TestProcessCancellationRefund_NoPaymentsNoop(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create user: %v", err) t.Fatalf("failed to create user: %v", err)
} }
@@ -289,7 +288,7 @@ func TestProcessCancellationRefund_GiftCardCreditsUserBalance(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create user: %v", err) t.Fatalf("failed to create user: %v", err)
} }
@@ -377,7 +376,7 @@ func TestProcessCancellationRefund_CashCreditsUserBalance(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create user: %v", err) t.Fatalf("failed to create user: %v", err)
} }
@@ -432,7 +431,7 @@ func TestProcessCancellationRefund_CardSquareRefundWithoutBalanceCredit(t *testi
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create user: %v", err) t.Fatalf("failed to create user: %v", err)
} }
@@ -507,7 +506,7 @@ func TestProcessCancellationRefund_DiscountPaymentSkipped(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create user: %v", err) t.Fatalf("failed to create user: %v", err)
} }
@@ -562,7 +561,7 @@ func TestProcessCancellationRefund_OnTheHousePaymentSkipped(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create user: %v", err) t.Fatalf("failed to create user: %v", err)
} }
@@ -621,7 +620,7 @@ func TestProcessCancellationRefund_MissingUserID_LogsWarning(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create user: %v", err) t.Fatalf("failed to create user: %v", err)
} }
@@ -687,7 +686,7 @@ func TestProcessCancellationRefund_GuestGiftcardDoesNotCreditBalance(t *testing.
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
// Create a user and promote them to guest role. // Create a user and promote them to guest role.
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create user: %v", err) t.Fatalf("failed to create user: %v", err)
} }
@@ -758,7 +757,7 @@ func TestProcessCancellationRefund_GuestCashDoesNotCreditBalance(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create user: %v", err) t.Fatalf("failed to create user: %v", err)
} }
@@ -824,7 +823,7 @@ func TestProcessCancellationRefund_SplitPayment_DeduplicatesSquareRefund(t *test
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create user: %v", err) t.Fatalf("failed to create user: %v", err)
} }
@@ -1,5 +1,4 @@
//go:build test && dev //go:build test && dev
// +build test,dev
package payments package payments
-5
View File
@@ -1,5 +1,4 @@
//go:build test && dev //go:build test && dev
// +build test,dev
package payments package payments
@@ -25,7 +24,6 @@ func TestCreateTillSale_OnTheHouse(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx) adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create admin user: %v", err) t.Fatalf("failed to create admin user: %v", err)
@@ -104,7 +102,6 @@ func TestCreateTillSale_Idempotency(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx) adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create admin user: %v", err) t.Fatalf("failed to create admin user: %v", err)
@@ -192,7 +189,6 @@ func TestCreateTillSale_CreatesGiftCardTransaction(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx) adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create admin user: %v", err) t.Fatalf("failed to create admin user: %v", err)
@@ -294,7 +290,6 @@ func TestCreateTillSale_TopupOnRedeemedCard(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := testutils.SetupTestTx(t) ctx, tx := testutils.SetupTestTx(t)
adminID, err := fixtures.CreateTestAdminUser(tx) adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create admin user: %v", err) t.Fatalf("failed to create admin user: %v", err)
-1
View File
@@ -1,5 +1,4 @@
//go:build test && dev //go:build test && dev
// +build test,dev
package payments package payments
+22 -22
View File
@@ -3,8 +3,8 @@ package portfolio
import ( import (
"bytes" "bytes"
"context" "context"
"crussell/db"
"crussell/clock" "crussell/clock"
"crussell/db"
"crussell/internal/images" "crussell/internal/images"
"crussell/internal/s3" "crussell/internal/s3"
"crussell/internal/validators" "crussell/internal/validators"
@@ -165,8 +165,8 @@ func ListImages(w http.ResponseWriter, r *http.Request) {
return return
} }
filterClauses := "" var filterClauses strings.Builder
filterArgs := []interface{}{} filterArgs := []any{}
for key, values := range r.URL.Query() { for key, values := range r.URL.Query() {
if len(values) == 0 || values[0] == "" { if len(values) == 0 || values[0] == "" {
@@ -190,14 +190,14 @@ func ListImages(w http.ResponseWriter, r *http.Request) {
return return
} }
filterClauses += fmt.Sprintf(" AND $%d::text = ANY(tag_names)", len(filterArgs)+1) filterClauses.WriteString(fmt.Sprintf(" AND $%d::text = ANY(tag_names)", len(filterArgs)+1))
filterArgs = append(filterArgs, category+":"+value) filterArgs = append(filterArgs, category+":"+value)
} }
} }
} }
var query string var query string
var args []interface{} var args []any
const formatCols = `, full_avif_url, full_webp_url, full_jpg_url, full_jxl_url, thumb_avif_url, thumb_webp_url, thumb_jpg_url` const formatCols = `, full_avif_url, full_webp_url, full_jpg_url, full_jxl_url, thumb_avif_url, thumb_webp_url, thumb_jpg_url`
@@ -226,16 +226,16 @@ func ListImages(w http.ResponseWriter, r *http.Request) {
FROM images, unnest(tag_names) as t FROM images, unnest(tag_names) as t
WHERE %s%s WHERE %s%s
GROUP BY id GROUP BY id
`, formatCols, similaritySum, whereClause, filterClauses) `, formatCols, similaritySum, whereClause, filterClauses.String())
var cursorArgs []interface{} var cursorArgs []any
if cursorStr != "" { if cursorStr != "" {
cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr) cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr)
if err != nil { if err != nil {
http.Error(w, "Invalid cursor: "+err.Error(), http.StatusBadRequest) http.Error(w, "Invalid cursor: "+err.Error(), http.StatusBadRequest)
return return
} }
cursorArgs = []interface{}{cursorCreatedAt, cursorID} cursorArgs = []any{cursorCreatedAt, cursorID}
havingIdx := len(cleanTags) + len(filterArgs) + 1 havingIdx := len(cleanTags) + len(filterArgs) + 1
query += fmt.Sprintf(" HAVING (created_at, id) < ($%d, $%d)", havingIdx, havingIdx+1) query += fmt.Sprintf(" HAVING (created_at, id) < ($%d, $%d)", havingIdx, havingIdx+1)
} }
@@ -244,7 +244,7 @@ func ListImages(w http.ResponseWriter, r *http.Request) {
argOffset = len(filterArgs) + len(cleanTags) + len(cursorArgs) argOffset = len(filterArgs) + len(cleanTags) + len(cursorArgs)
query += fmt.Sprintf(" LIMIT $%d", argOffset+1) query += fmt.Sprintf(" LIMIT $%d", argOffset+1)
queryArgs := make([]interface{}, len(filterArgs)+len(cleanTags)+len(cursorArgs)+1) queryArgs := make([]any, len(filterArgs)+len(cleanTags)+len(cursorArgs)+1)
copy(queryArgs, filterArgs) copy(queryArgs, filterArgs)
for i, t := range cleanTags { for i, t := range cleanTags {
queryArgs[len(filterArgs)+i] = t queryArgs[len(filterArgs)+i] = t
@@ -264,9 +264,9 @@ func ListImages(w http.ResponseWriter, r *http.Request) {
similarity(t, $%d) as relevance similarity(t, $%d) as relevance
FROM images, unnest(tag_names) as t FROM images, unnest(tag_names) as t
WHERE 1=1%s AND t ILIKE '%%' || $%d || '%%' WHERE 1=1%s AND t ILIKE '%%' || $%d || '%%'
`, formatCols, searchIdx, searchIdx, filterClauses, searchIdx) `, formatCols, searchIdx, searchIdx, filterClauses.String(), searchIdx)
cursorArgs := []interface{}{} cursorArgs := []any{}
if cursorStr != "" { if cursorStr != "" {
cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr) cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr)
if err != nil { if err != nil {
@@ -280,7 +280,7 @@ func ListImages(w http.ResponseWriter, r *http.Request) {
query += " ORDER BY match_priority DESC, relevance DESC, created_at DESC, id DESC" query += " ORDER BY match_priority DESC, relevance DESC, created_at DESC, id DESC"
query += fmt.Sprintf(" LIMIT $%d", searchIdx+1+len(cursorArgs)) query += fmt.Sprintf(" LIMIT $%d", searchIdx+1+len(cursorArgs))
queryArgs := make([]interface{}, searchIdx+1+len(cursorArgs)) queryArgs := make([]any, searchIdx+1+len(cursorArgs))
copy(queryArgs[:argOffset], filterArgs) copy(queryArgs[:argOffset], filterArgs)
queryArgs[argOffset] = searchPattern queryArgs[argOffset] = searchPattern
for i, ca := range cursorArgs { for i, ca := range cursorArgs {
@@ -294,7 +294,7 @@ func ListImages(w http.ResponseWriter, r *http.Request) {
SELECT id, url, thumbnail_url, tag_names, created_at%s, 0 as match_count, 0.0 as relevance SELECT id, url, thumbnail_url, tag_names, created_at%s, 0 as match_count, 0.0 as relevance
FROM images FROM images
WHERE 1=1%s WHERE 1=1%s
`, formatCols, filterClauses) `, formatCols, filterClauses.String())
if cursorStr != "" { if cursorStr != "" {
cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr) cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr)
@@ -310,7 +310,7 @@ func ListImages(w http.ResponseWriter, r *http.Request) {
query += " ORDER BY created_at DESC, id DESC" query += " ORDER BY created_at DESC, id DESC"
query += fmt.Sprintf(" LIMIT $%d", argOffset+1) query += fmt.Sprintf(" LIMIT $%d", argOffset+1)
queryArgs := make([]interface{}, len(filterArgs)+1) queryArgs := make([]any, len(filterArgs)+1)
copy(queryArgs, filterArgs) copy(queryArgs, filterArgs)
queryArgs[len(filterArgs)] = limit queryArgs[len(filterArgs)] = limit
args = queryArgs args = queryArgs
@@ -394,7 +394,7 @@ func ListTags(w http.ResponseWriter, r *http.Request) {
} }
var query string var query string
var args []interface{} var args []any
// Query tags from images.tag_names column (stored as array) // Query tags from images.tag_names column (stored as array)
if q != "" { if q != "" {
@@ -409,7 +409,7 @@ func ListTags(w http.ResponseWriter, r *http.Request) {
ORDER BY tag ORDER BY tag
LIMIT 20 LIMIT 20
` `
args = []interface{}{q} args = []any{q}
} else { } else {
query = ` query = `
SELECT DISTINCT tag SELECT DISTINCT tag
@@ -484,7 +484,7 @@ func ListFilters(w http.ResponseWriter, r *http.Request) {
// Build base query // Build base query
baseQuery := "SELECT DISTINCT id FROM images WHERE 1=1" baseQuery := "SELECT DISTINCT id FROM images WHERE 1=1"
args := []interface{}{} args := []any{}
argNum := 1 argNum := 1
if tagFilter != "" { if tagFilter != "" {
@@ -507,7 +507,7 @@ func ListFilters(w http.ResponseWriter, r *http.Request) {
// Build category filters for OTHER categories // Build category filters for OTHER categories
otherFilters := make([]string, 0) otherFilters := make([]string, 0)
otherArgs := make([]interface{}, len(args)) otherArgs := make([]any, len(args))
copy(otherArgs, args) copy(otherArgs, args)
otherArgNum := argNum otherArgNum := argNum
for cat, val := range selectedCategories { for cat, val := range selectedCategories {
@@ -709,7 +709,7 @@ func UploadImage(w http.ResponseWriter, r *http.Request) {
tagsStr := r.FormValue("tags") tagsStr := r.FormValue("tags")
tags := []string{} tags := []string{}
if tagsStr != "" { if tagsStr != "" {
for _, t := range strings.Split(tagsStr, ",") { for t := range strings.SplitSeq(tagsStr, ",") {
if trimmed := strings.TrimSpace(t); trimmed != "" { if trimmed := strings.TrimSpace(t); trimmed != "" {
tags = append(tags, trimmed) tags = append(tags, trimmed)
} }
@@ -1034,11 +1034,11 @@ func extractKey(url string) string {
// URL format: https://endpoint/bucket/portfolio/1234567890.jpg // URL format: https://endpoint/bucket/portfolio/1234567890.jpg
// Need to return: portfolio/1234567890.jpg // Need to return: portfolio/1234567890.jpg
// Find the bucket segment: skip past scheme://endpoint/ // Find the bucket segment: skip past scheme://endpoint/
idx := strings.Index(url, "://") _, after, ok := strings.Cut(url, "://")
if idx == -1 { if !ok {
return url return url
} }
rest := url[idx+3:] // skip "://" rest := after // skip "://"
// Now rest = "endpoint/bucket/portfolio/1234567890.jpg" // Now rest = "endpoint/bucket/portfolio/1234567890.jpg"
// Skip first path segment (endpoint) // Skip first path segment (endpoint)
slashIdx := strings.Index(rest, "/") slashIdx := strings.Index(rest, "/")
+1 -2
View File
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
// Package portfolio contains tests for portfolio image management endpoints. // Package portfolio contains tests for portfolio image management endpoints.
// //
@@ -27,8 +26,8 @@ import (
"strings" "strings"
"testing" "testing"
"crussell/testutils"
"crussell/mw" "crussell/mw"
"crussell/testutils"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/kovidgoyal/imaging" "github.com/kovidgoyal/imaging"
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package portfolio package portfolio
+7 -7
View File
@@ -8,8 +8,8 @@ import (
"strings" "strings"
"time" "time"
"crussell/db"
"crussell/clock" "crussell/clock"
"crussell/db"
"crussell/internal/validators" "crussell/internal/validators"
"crussell/mw" "crussell/mw"
"log" "log"
@@ -308,17 +308,17 @@ func isValidTime15Min(t string) bool {
} }
// --- helper: sqlIn generates IN queries dynamically for Postgres --- // --- helper: sqlIn generates IN queries dynamically for Postgres ---
func sqlIn(query string, args []int) (string, []interface{}, error) { func sqlIn(query string, args []int) (string, []any, error) {
inArgs := []interface{}{} inArgs := []any{}
placeholders := "" var placeholders strings.Builder
for i, arg := range args { for i, arg := range args {
if i > 0 { if i > 0 {
placeholders += "," placeholders.WriteString(",")
} }
placeholders += fmt.Sprintf("$%d", i+1) placeholders.WriteString(fmt.Sprintf("$%d", i+1))
inArgs = append(inArgs, arg) inArgs = append(inArgs, arg)
} }
query = fmt.Sprintf(query, placeholders) query = fmt.Sprintf(query, placeholders.String())
return query, inArgs, nil return query, inArgs, nil
} }
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package scheduling package scheduling
+23 -24
View File
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package scheduling package scheduling
@@ -1023,7 +1022,7 @@ func TestScheduling_GetAvailableHours_WithBlocker_NonAdmin(t *testing.T) {
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
// Create a time blocker for 2026-03-16 10:00-11:00 (Monday - an open day) // Create a time blocker for 2026-03-16 10:00-11:00 (Monday - an open day)
blockerTime := time.Date(2026, 3, 16, 10, 0, 0, 0, time.UTC) blockerTime := time.Date(2026, 3, 16, 10, 0, 0, 0, time.UTC)
_, err := tx.Exec(ctx, ` _, err := tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES ($1, 60, 'Staff Meeting', NULL) VALUES ($1, 60, 'Staff Meeting', NULL)
@@ -1091,7 +1090,7 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin(t *testing.T) {
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
// Create a time blocker for 2026-03-16 10:00-11:00 (Monday - open day) // Create a time blocker for 2026-03-16 10:00-11:00 (Monday - open day)
blockerTime := time.Date(2026, 3, 16, 10, 0, 0, 0, time.UTC) blockerTime := time.Date(2026, 3, 16, 10, 0, 0, 0, time.UTC)
_, err := tx.Exec(ctx, ` _, err := tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES ($1, 60, 'Staff Meeting', NULL) VALUES ($1, 60, 'Staff Meeting', NULL)
@@ -1309,7 +1308,7 @@ func getWorkingHoursForDate(t *testing.T, ctx context.Context, date string) (sta
func TestScheduling_GetAvailableHours_WithBlocker_Admin_MultipleBlockers(t *testing.T) { func TestScheduling_GetAvailableHours_WithBlocker_Admin_MultipleBlockers(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
// Create two blockers on Tuesday 2026-03-17 (open 09:00-17:00): // 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) // 10:00-11:00 (Staff Meeting) and 14:00-15:00 (Training)
b1 := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC) b1 := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
@@ -1353,7 +1352,7 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_MultipleBlockers(t *test
func TestScheduling_GetAvailableHours_WithBlocker_Admin_BlockerAndBooking(t *testing.T) { func TestScheduling_GetAvailableHours_WithBlocker_Admin_BlockerAndBooking(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
// Tuesday 2026-03-17 (open 09:00-17:00) // 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 // 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, time.UTC) bookingStart := time.Date(2026, 3, 17, 11, 0, 0, 0, time.UTC)
@@ -1413,7 +1412,7 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_BlockerAndBooking(t *tes
func TestScheduling_GetAvailableHours_WithBlocker_Admin_AllDayBlocker(t *testing.T) { func TestScheduling_GetAvailableHours_WithBlocker_Admin_AllDayBlocker(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
// Tuesday 2026-03-17 (open 09:00-17:00) — block entire open period // Tuesday 2026-03-17 (open 09:00-17:00) — block entire open period
blockerTime := time.Date(2026, 3, 17, 9, 0, 0, 0, time.UTC) 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) tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 480, 'All day closure', NULL)`, blockerTime)
@@ -1440,7 +1439,7 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_AllDayBlocker(t *testing
func TestScheduling_GetAvailableHours_WithBlocker_Admin_NonOverlappingBlocker(t *testing.T) { func TestScheduling_GetAvailableHours_WithBlocker_Admin_NonOverlappingBlocker(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
// Tuesday 2026-03-17 (open 09:00-17:00) — blocker at 17:00-18:00 (after close) // 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, time.UTC) 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) tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'After hours cleaning', NULL)`, blockerTime)
@@ -1476,7 +1475,7 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_NonOverlappingBlocker(t
func TestScheduling_GetAvailableHours_WithBlocker_Admin_MultiDay(t *testing.T) { func TestScheduling_GetAvailableHours_WithBlocker_Admin_MultiDay(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
// Blockers on Tue 2026-03-17 10:00-11:00 and Wed 2026-03-18 14:00-15:00 // 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, time.UTC) b1 := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
b2 := time.Date(2026, 3, 18, 14, 0, 0, 0, time.UTC) b2 := time.Date(2026, 3, 18, 14, 0, 0, 0, time.UTC)
@@ -1525,7 +1524,7 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_MultiDay(t *testing.T) {
func TestScheduling_GetAvailableHours_WithBlocker_Admin_BoundaryStart(t *testing.T) { func TestScheduling_GetAvailableHours_WithBlocker_Admin_BoundaryStart(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
// Tuesday 2026-03-17 (open 09:00-17:00) — blocker at 09:00-10:00 (start of day) // 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, time.UTC) 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) tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Morning setup', NULL)`, blockerTime)
@@ -1561,7 +1560,7 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_BoundaryStart(t *testing
func TestScheduling_GetAvailableHours_WithBlocker_Admin_BoundaryEnd(t *testing.T) { func TestScheduling_GetAvailableHours_WithBlocker_Admin_BoundaryEnd(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
// Tuesday 2026-03-17 (open 09:00-17:00) — blocker at 16:00-17:00 (end of day) // 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, time.UTC) 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) tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'End of day cleanup', NULL)`, blockerTime)
@@ -1597,7 +1596,7 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_BoundaryEnd(t *testing.T
func TestScheduling_GetAvailableHours_WithBlocker_Admin_OutOfHours(t *testing.T) { func TestScheduling_GetAvailableHours_WithBlocker_Admin_OutOfHours(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
// Tuesday 2026-03-17 — blocker at 10:00-11:00 // Tuesday 2026-03-17 — blocker at 10:00-11:00
blockerTime := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC) 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) tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Morning Meeting', NULL)`, blockerTime)
@@ -1639,7 +1638,7 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_OutOfHours(t *testing.T)
func TestScheduling_GetAvailableHours_WithBlocker_NonAdmin_Regression(t *testing.T) { func TestScheduling_GetAvailableHours_WithBlocker_NonAdmin_Regression(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
// Tuesday 2026-03-17 — blocker at 10:00-11:00 // Tuesday 2026-03-17 — blocker at 10:00-11:00
blockerTime := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC) 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) tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Staff Meeting', NULL)`, blockerTime)
@@ -1686,7 +1685,7 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_Recurring(t *testing.T)
// No t.Parallel() — GetAvailableHours cleanup operations can deadlock with // No t.Parallel() — GetAvailableHours cleanup operations can deadlock with
// concurrent test transactions on the shared test database. // concurrent test transactions on the shared test database.
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
// Daily recurring blocker 12:00-13:00 starting Mon 2026-03-16 // Daily recurring blocker 12:00-13:00 starting Mon 2026-03-16
startTime := time.Date(2026, 3, 16, 12, 0, 0, 0, time.UTC) startTime := time.Date(2026, 3, 16, 12, 0, 0, 0, time.UTC)
cronExpr := "0 12 * * *" // Every day at 12:00 cronExpr := "0 12 * * *" // Every day at 12:00
@@ -1730,7 +1729,7 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_Recurring(t *testing.T)
// RESERVATION:admin time_blocker entries are also subtracted from admin slots. // RESERVATION:admin time_blocker entries are also subtracted from admin slots.
func TestScheduling_GetAvailableHours_WithBlocker_Admin_Reservation(t *testing.T) { func TestScheduling_GetAvailableHours_WithBlocker_Admin_Reservation(t *testing.T) {
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
// Create a real admin user to satisfy FK constraint, then simulate a reservation // Create a real admin user to satisfy FK constraint, then simulate a reservation
adminID, err := fixtures.CreateTestAdminUser(tx) adminID, err := fixtures.CreateTestAdminUser(tx)
if err != nil { if err != nil {
@@ -1774,7 +1773,7 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_Reservation(t *testing.T
func TestScheduling_GetAvailableHours_WithBlocker_Admin_Reservation_NonAdmin(t *testing.T) { func TestScheduling_GetAvailableHours_WithBlocker_Admin_Reservation_NonAdmin(t *testing.T) {
// Not parallel (see above) // Not parallel (see above)
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
reservationTime := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC) reservationTime := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
tx.Exec(ctx, ` tx.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
@@ -1812,7 +1811,7 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_Reservation_NonAdmin(t *
func TestScheduling_GetAvailableHours_WithBlocker_Admin_OverlappingBlockers(t *testing.T) { func TestScheduling_GetAvailableHours_WithBlocker_Admin_OverlappingBlockers(t *testing.T) {
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
// Two overlapping blockers on Tue 2026-03-17: 10:00-12:00 and 11:00-13:00 // 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, time.UTC) b1 := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
b2 := time.Date(2026, 3, 17, 11, 0, 0, 0, time.UTC) b2 := time.Date(2026, 3, 17, 11, 0, 0, 0, time.UTC)
@@ -1854,7 +1853,7 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_OverlappingBlockers(t *t
func TestScheduling_GetAvailableHours_WithBlocker_Admin_AdjacentBoundaries(t *testing.T) { func TestScheduling_GetAvailableHours_WithBlocker_Admin_AdjacentBoundaries(t *testing.T) {
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
// Tue 2026-03-17: booking 10:00-11:00, blocker 11:00-12:00 (adjacent) // 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, time.UTC) bookingStart := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
bookingEnd := bookingStart.Add(60 * time.Minute) bookingEnd := bookingStart.Add(60 * time.Minute)
@@ -1899,7 +1898,7 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_AdjacentBoundaries(t *te
func TestScheduling_GetAvailableHours_WithBlocker_Admin_MidnightBlocker(t *testing.T) { func TestScheduling_GetAvailableHours_WithBlocker_Admin_MidnightBlocker(t *testing.T) {
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
// Use the first open day found and the following day // Use the first open day found and the following day
tueStart, tueEnd, tueOpen := getWorkingHoursForDate(t, ctx, "2026-03-17") tueStart, tueEnd, tueOpen := getWorkingHoursForDate(t, ctx, "2026-03-17")
wedStart, _, wedOpen := getWorkingHoursForDate(t, ctx, "2026-03-18") wedStart, _, wedOpen := getWorkingHoursForDate(t, ctx, "2026-03-18")
@@ -1987,7 +1986,7 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_NoBlockers(t *testing.T)
func TestScheduling_GetAvailableHours_WithBlocker_Admin_ClosedDayBlocker(t *testing.T) { func TestScheduling_GetAvailableHours_WithBlocker_Admin_ClosedDayBlocker(t *testing.T) {
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
// Sunday 2026-03-22 is closed. Blocker at 10:00-11:00. // Sunday 2026-03-22 is closed. Blocker at 10:00-11:00.
blockerTime := time.Date(2026, 3, 22, 10, 0, 0, 0, time.UTC) 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) tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Sunday Maintenance', NULL)`, blockerTime)
@@ -2017,7 +2016,7 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_ClosedDayBlocker(t *test
func TestScheduling_GetAvailableHours_WithBlocker_Admin_MultiDayRangePartial(t *testing.T) { func TestScheduling_GetAvailableHours_WithBlocker_Admin_MultiDayRangePartial(t *testing.T) {
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
// Blocker only on Tuesday (2026-03-17) at 10:00-11:00 // Blocker only on Tuesday (2026-03-17) at 10:00-11:00
blockerTime := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC) 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) tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Tue Only Blocker', NULL)`, blockerTime)
@@ -2065,7 +2064,7 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_MultiDayRangePartial(t *
func TestScheduling_GetAvailableHours_WithBlocker_Admin_ExceptionalHours(t *testing.T) { func TestScheduling_GetAvailableHours_WithBlocker_Admin_ExceptionalHours(t *testing.T) {
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
// Monday 2026-03-16 is normally CLOSED. Add exceptional hours: 10:00-16:00. // Monday 2026-03-16 is normally CLOSED. Add exceptional hours: 10:00-16:00.
// Also add a blocker at 12:00-13:00. // Also add a blocker at 12:00-13:00.
// First create the exceptional group // First create the exceptional group
@@ -2124,7 +2123,7 @@ func TestScheduling_GetAvailableHours_WithBlocker_Admin_ExceptionalHours(t *test
func TestScheduling_GetAvailableHours_WithBlocker_NonAdmin_ClosedDay(t *testing.T) { func TestScheduling_GetAvailableHours_WithBlocker_NonAdmin_ClosedDay(t *testing.T) {
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
// Sunday 2026-03-22 closed, blocker at 10:00-11:00 // Sunday 2026-03-22 closed, blocker at 10:00-11:00
blockerTime := time.Date(2026, 3, 22, 10, 0, 0, 0, time.UTC) 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) tx.Exec(ctx, `INSERT INTO time_blockers (start_time, duration_minutes, description, created_by) VALUES ($1, 60, 'Weekend Maintenance', NULL)`, blockerTime)
@@ -2163,7 +2162,7 @@ func TestScheduling_GetAvailableHours_WithBlocker_NonAdmin_ClosedDay(t *testing.
func TestScheduling_GetAvailableHours_WithBlocker_NonAdmin_BookingAdjacent(t *testing.T) { func TestScheduling_GetAvailableHours_WithBlocker_NonAdmin_BookingAdjacent(t *testing.T) {
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
bookingStart := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC) bookingStart := time.Date(2026, 3, 17, 10, 0, 0, 0, time.UTC)
bookingEnd := bookingStart.Add(60 * time.Minute) bookingEnd := bookingStart.Add(60 * time.Minute)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
@@ -2417,7 +2416,7 @@ func TestNormalizeTime_Regression_RealWorldFormats(t *testing.T) {
// slots from each affected day. // slots from each affected day.
func TestScheduling_GetAvailableHours_CrossDayBlocker(t *testing.T) { func TestScheduling_GetAvailableHours_CrossDayBlocker(t *testing.T) {
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
// Use Tue 2026-03-17 and Wed 2026-03-18 — both open days // Use Tue 2026-03-17 and Wed 2026-03-18 — both open days
_, tueEnd, tueOpen := getWorkingHoursForDate(t, ctx, "2026-03-17") _, tueEnd, tueOpen := getWorkingHoursForDate(t, ctx, "2026-03-17")
_, _, wedOpen := getWorkingHoursForDate(t, ctx, "2026-03-18") _, _, wedOpen := getWorkingHoursForDate(t, ctx, "2026-03-18")
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package scheduling package scheduling
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package scheduling package scheduling
@@ -430,7 +429,6 @@ func TestGetTimeBlockersInRange(t *testing.T) {
t.Fatalf("failed to create blockers: %v", err) t.Fatalf("failed to create blockers: %v", err)
} }
// Query range that includes blocker1 and blocker2 but not blocker3 // Query range that includes blocker1 and blocker2 but not blocker3
start := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) start := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2026, 3, 16, 23, 59, 59, 0, time.UTC) end := time.Date(2026, 3, 16, 23, 59, 59, 0, time.UTC)
@@ -468,7 +466,6 @@ func TestGetTimeBlockersInRange_Empty(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
// Create a blocker // Create a blocker
blockerTime := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) blockerTime := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC)
_, err := tx.Exec(ctx, ` _, err := tx.Exec(ctx, `
@@ -479,7 +476,6 @@ func TestGetTimeBlockersInRange_Empty(t *testing.T) {
t.Fatalf("failed to create blocker: %v", err) t.Fatalf("failed to create blocker: %v", err)
} }
// Query range with no blockers // Query range with no blockers
start := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC) start := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2026, 4, 30, 23, 59, 59, 0, time.UTC) end := time.Date(2026, 4, 30, 23, 59, 59, 0, time.UTC)
@@ -514,7 +510,6 @@ func TestGetTimeBlockersInRange_IncludesRecurring(t *testing.T) {
t.Fatalf("failed to create blockers: %v", err) t.Fatalf("failed to create blockers: %v", err)
} }
// Query range: March 1-31, 2026 // Query range: March 1-31, 2026
start := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) start := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2026, 3, 31, 23, 59, 59, 0, time.UTC) end := time.Date(2026, 3, 31, 23, 59, 59, 0, time.UTC)
@@ -559,7 +554,6 @@ func TestCleanupOldReservations(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
// Create fixture users for the test // Create fixture users for the test
oldUserID, err := fixtures.CreateTestUser(tx) oldUserID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
@@ -666,7 +660,6 @@ func TestCleanupOldReservations_AdminWalkIn(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
// Create old walk-in reservation (>15 min old) // Create old walk-in reservation (>15 min old)
oldTime := clock.Now().Add(-16 * time.Minute) oldTime := clock.Now().Add(-16 * time.Minute)
_, err := tx.Exec(ctx, ` _, err := tx.Exec(ctx, `
@@ -722,7 +715,6 @@ func TestCleanupOldReservations_AdminCallIn(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
// Create old call-in reservation (>15 min old) // Create old call-in reservation (>15 min old)
oldTime := clock.Now().Add(-16 * time.Minute) oldTime := clock.Now().Add(-16 * time.Minute)
_, err := tx.Exec(ctx, ` _, err := tx.Exec(ctx, `
@@ -778,7 +770,6 @@ func TestCleanupOldReservations_MixedTypes(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
// Create old user reservation (>1 hour old) // Create old user reservation (>1 hour old)
oldUserTime := clock.Now().Add(-2 * time.Hour) oldUserTime := clock.Now().Add(-2 * time.Hour)
_, err := tx.Exec(ctx, ` _, err := tx.Exec(ctx, `
@@ -925,7 +916,6 @@ func TestGetTimeBlockersInRange_IncludesReservations(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
// Create a regular blocker for tomorrow at 10:00 // Create a regular blocker for tomorrow at 10:00
tomorrow := clock.Now().Add(24 * time.Hour) tomorrow := clock.Now().Add(24 * time.Hour)
blockerTime := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 10, 0, 0, 0, time.UTC) blockerTime := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 10, 0, 0, 0, time.UTC)
@@ -1130,7 +1120,6 @@ func TestAnonymizeStaleGuestAccounts_Exactly6Months(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
// Create guest user // Create guest user
guestID, err := fixtures.CreateTestUser(tx) guestID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
@@ -1183,7 +1172,6 @@ func TestAnonymizeStaleGuestAccounts_ActiveBooking_Skipped(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
// Create guest user // Create guest user
guestID, err := fixtures.CreateTestUser(tx) guestID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
@@ -1240,7 +1228,6 @@ func TestAnonymizeStaleGuestAccounts_NoBookings(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
// Create guest user with no bookings // Create guest user with no bookings
guestID, err := fixtures.CreateTestUser(tx) guestID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
@@ -1280,7 +1267,6 @@ func TestCleanupExpiredFinancialRecords_PaymentOlderThan7Years(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create user: %v", err) t.Fatalf("failed to create user: %v", err)
@@ -1363,7 +1349,6 @@ func TestCleanupExpiredFinancialRecords_AnonUserWithin1YearBuffer(t *testing.T)
t.Parallel() t.Parallel()
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
guestID, err := fixtures.CreateTestUser(tx) guestID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create user: %v", err) t.Fatalf("failed to create user: %v", err)
@@ -1434,7 +1419,6 @@ func TestCleanupExpiredFinancialRecords_PaymentOlderThan9Years(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create user: %v", err) t.Fatalf("failed to create user: %v", err)
@@ -1502,7 +1486,6 @@ func TestCleanupExpiredFinancialRecords_AggregationCorrectTotals(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create user: %v", err) t.Fatalf("failed to create user: %v", err)
@@ -1601,7 +1584,6 @@ func TestCleanupExpiredFinancialRecords_Idempotent(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create user: %v", err) t.Fatalf("failed to create user: %v", err)
@@ -1701,7 +1683,6 @@ func TestCleanupExpiredFinancialRecords_ActiveUserWithin7Years(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create user: %v", err) t.Fatalf("failed to create user: %v", err)
@@ -1763,7 +1744,6 @@ func TestCleanupExpiredFinancialRecords_AnonUserBothThresholdsElapsed(t *testing
t.Parallel() t.Parallel()
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
guestID, err := fixtures.CreateTestUser(tx) guestID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create user: %v", err) t.Fatalf("failed to create user: %v", err)
@@ -1839,7 +1819,6 @@ func TestCleanupExpiredFinancialRecords_RefundAggregatedAndDeleted(t *testing.T)
t.Parallel() t.Parallel()
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create user: %v", err) t.Fatalf("failed to create user: %v", err)
@@ -1934,7 +1913,6 @@ func TestAnonymizeStaleGuestAccounts(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
// Guest 1: last booking 7 months ago — should be anonymized // Guest 1: last booking 7 months ago — should be anonymized
guest1ID, _ := fixtures.CreateTestUser(tx) guest1ID, _ := fixtures.CreateTestUser(tx)
tx.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guest1ID) tx.Exec(ctx, `UPDATE users SET account_role = 'guest' WHERE id = $1`, guest1ID)
@@ -2002,7 +1980,6 @@ func TestCleanupOldReservations_EditRequest(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
// Create old edit_request reservation (>24 hours old) // Create old edit_request reservation (>24 hours old)
oldTime := clock.Now().Add(-25 * time.Hour) oldTime := clock.Now().Add(-25 * time.Hour)
_, err := tx.Exec(ctx, ` _, err := tx.Exec(ctx, `
@@ -2056,7 +2033,6 @@ func TestCleanupExpiredDeposits_ExpiredConfirmed(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create user: %v", err) t.Fatalf("failed to create user: %v", err)
@@ -2118,7 +2094,6 @@ func TestCleanupExpiredDeposits_ExpiredPending(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create user: %v", err) t.Fatalf("failed to create user: %v", err)
@@ -2182,7 +2157,6 @@ func TestCleanupExpiredDeposits_PaidDepositPreserved(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create user: %v", err) t.Fatalf("failed to create user: %v", err)
@@ -2251,7 +2225,6 @@ func TestCleanupExpiredDeposits_FutureDeadlinePreserved(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
t.Fatalf("failed to create user: %v", err) t.Fatalf("failed to create user: %v", err)
@@ -2313,7 +2286,6 @@ func TestCleanupExpiredGiftCards(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
// Make account_id nullable for this test — CleanupExpiredGiftCards inserts NULL // Make account_id nullable for this test — CleanupExpiredGiftCards inserts NULL
// for unredeemed gift cards (no user account to reference). // for unredeemed gift cards (no user account to reference).
_, err := tx.Exec(ctx, `ALTER TABLE gift_card_expired_balances ALTER COLUMN account_id DROP NOT NULL`) _, err := tx.Exec(ctx, `ALTER TABLE gift_card_expired_balances ALTER COLUMN account_id DROP NOT NULL`)
@@ -2385,7 +2357,6 @@ func TestCleanupExpiredGiftCards_SkipRecentlyUsed(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
// Make account_id nullable for this test — CleanupExpiredGiftCards inserts NULL // Make account_id nullable for this test — CleanupExpiredGiftCards inserts NULL
// for unredeemed gift cards (no user account to reference). // for unredeemed gift cards (no user account to reference).
_, err := tx.Exec(ctx, `ALTER TABLE gift_card_expired_balances ALTER COLUMN account_id DROP NOT NULL`) _, err := tx.Exec(ctx, `ALTER TABLE gift_card_expired_balances ALTER COLUMN account_id DROP NOT NULL`)
@@ -2437,7 +2408,6 @@ func TestCleanupExpiredGiftCards_SkipRedeemed(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
// Make account_id nullable for this test — CleanupExpiredGiftCards inserts NULL // Make account_id nullable for this test — CleanupExpiredGiftCards inserts NULL
// for unredeemed gift cards (no user account to reference). Even though this // for unredeemed gift cards (no user account to reference). Even though this
// card is redeemed, the function may also match other cards; ensure schema allows it. // card is redeemed, the function may also match other cards; ensure schema allows it.
@@ -2498,7 +2468,6 @@ func TestCleanupIdleAccounts_WithBalance(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
// Create a user // Create a user
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
@@ -2567,7 +2536,6 @@ func TestCleanupIdleAccounts_NoBalance(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
// Create a user // Create a user
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
@@ -2603,7 +2571,6 @@ func TestCleanupIdleAccounts_SkipActive(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
// Create a user with recent last_login // Create a user with recent last_login
userID, err := fixtures.CreateTestUser(tx) userID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
@@ -2639,7 +2606,6 @@ func TestCleanupIdleAccounts_SkipAdminGuest(t *testing.T) {
t.Parallel() t.Parallel()
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
// Create admin user with old last_login // Create admin user with old last_login
adminID, err := fixtures.CreateTestUser(tx) adminID, err := fixtures.CreateTestUser(tx)
if err != nil { if err != nil {
@@ -2799,7 +2765,7 @@ func TestCleanupOldIdempotencyKeys_ClearsOldPayments(t *testing.T) {
} }
func TestCleanupOldIdempotencyKeys_ClearsOldTillSales(t *testing.T) { func TestCleanupOldIdempotencyKeys_ClearsOldTillSales(t *testing.T) {
ctx, tx := resetTestData(t) ctx, tx := resetTestData(t)
// Create an admin user for till_sales.created_by // Create an admin user for till_sales.created_by
+3 -6
View File
@@ -4,12 +4,12 @@ import (
"context" "context"
"crussell/clock" "crussell/clock"
"crussell/db" "crussell/db"
"github.com/jackc/pgx/v5"
"crussell/internal/validators" "crussell/internal/validators"
"crussell/mw" "crussell/mw"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
"github.com/jackc/pgx/v5"
"net/http" "net/http"
"time" "time"
@@ -84,7 +84,7 @@ func ToggleService(w http.ResponseWriter, r *http.Request) {
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]any{
"message": "Service toggled successfully", "message": "Service toggled successfully",
"id": serviceID, "id": serviceID,
}) })
@@ -247,7 +247,7 @@ func DeleteServiceHandler(w http.ResponseWriter, r *http.Request) {
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]any{
"message": "Service deleted successfully", "message": "Service deleted successfully",
"id": serviceID, "id": serviceID,
}) })
@@ -305,7 +305,6 @@ func ServicesHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
if services == nil { if services == nil {
@@ -402,7 +401,6 @@ func ServicesHandler(w http.ResponseWriter, r *http.Request) {
// Combine: eligible + ineligible // Combine: eligible + ineligible
services = append(services, ineligibleServices...) services = append(services, ineligibleServices...)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
if services == nil { if services == nil {
@@ -509,7 +507,6 @@ func ServicesEligibleForUserHandler(w http.ResponseWriter, r *http.Request) {
// Sort and combine: valid first, then grayed out // Sort and combine: valid first, then grayed out
services = append(services, grayedOutServices...) services = append(services, grayedOutServices...)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
if services == nil { if services == nil {
+1 -2
View File
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
// Package services contains tests for service listing and eligibility endpoints. // Package services contains tests for service listing and eligibility endpoints.
// //
@@ -22,8 +21,8 @@ import (
"testing" "testing"
"crussell/db" "crussell/db"
"crussell/testutils"
"crussell/handlers/user" "crussell/handlers/user"
"crussell/testutils"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package services package services
-1
View File
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package today package today
+25 -25
View File
@@ -10,8 +10,8 @@ import (
"strings" "strings"
"time" "time"
"crussell/db"
"crussell/clock" "crussell/clock"
"crussell/db"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
) )
@@ -31,11 +31,11 @@ type ServiceInfo struct {
} }
type UserInfo struct { type UserInfo struct {
ID string `json:"id"` ID string `json:"id"`
FullName string `json:"full_name"` FullName string `json:"full_name"`
Phone *string `json:"phone,omitempty"` Phone *string `json:"phone,omitempty"`
Email *string `json:"email,omitempty"` Email *string `json:"email,omitempty"`
ProfilePicURL *string `json:"profile_pic_url,omitempty"` ProfilePicURL *string `json:"profile_pic_url,omitempty"`
PreviousFirstName *string `json:"previous_first_name,omitempty"` PreviousFirstName *string `json:"previous_first_name,omitempty"`
PreviousLastName *string `json:"previous_last_name,omitempty"` PreviousLastName *string `json:"previous_last_name,omitempty"`
} }
@@ -552,7 +552,7 @@ func getClosingTime(r *http.Request, date time.Time) string {
} }
// Helper function to fetch a single appointment with all details // Helper function to fetch a single appointment with all details
func fetchAppointment(r *http.Request, query string, args ...interface{}) (*AppointmentInfo, error) { func fetchAppointment(r *http.Request, query string, args ...any) (*AppointmentInfo, error) {
var bookingID string var bookingID string
var startTime time.Time var startTime time.Time
var status string var status string
@@ -681,15 +681,15 @@ func fetchAppointment(r *http.Request, query string, args ...interface{}) (*Appo
} }
type TodayAppointment struct { type TodayAppointment struct {
ID string `json:"id"` ID string `json:"id"`
StartTime string `json:"start_time"` StartTime string `json:"start_time"`
Status string `json:"status"` Status string `json:"status"`
UserName string `json:"user_name"` UserName string `json:"user_name"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
Services []string `json:"services"` Services []string `json:"services"`
DurationMinutes int `json:"duration_minutes"` DurationMinutes int `json:"duration_minutes"`
PreviousFirstName *string `json:"previous_first_name,omitempty"` PreviousFirstName *string `json:"previous_first_name,omitempty"`
PreviousLastName *string `json:"previous_last_name,omitempty"` PreviousLastName *string `json:"previous_last_name,omitempty"`
} }
type TodayAppointmentsResponse struct { type TodayAppointmentsResponse struct {
@@ -903,15 +903,15 @@ func GetTodayAppointmentsHandler(w http.ResponseWriter, r *http.Request) {
} }
type PendingApproval struct { type PendingApproval struct {
ID string `json:"id"` ID string `json:"id"`
StartTime string `json:"start_time"` StartTime string `json:"start_time"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
UserName string `json:"user_name"` UserName string `json:"user_name"`
PreviousFirstName *string `json:"previous_first_name,omitempty"` PreviousFirstName *string `json:"previous_first_name,omitempty"`
PreviousLastName *string `json:"previous_last_name,omitempty"` PreviousLastName *string `json:"previous_last_name,omitempty"`
Services []string `json:"services"` Services []string `json:"services"`
DurationMinutes int `json:"duration_minutes"` DurationMinutes int `json:"duration_minutes"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
} }
type PendingApprovalsResponse struct { type PendingApprovalsResponse struct {
-1
View File
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package today package today
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package user package user
+3 -4
View File
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package user package user
@@ -12,8 +11,8 @@ import (
"time" "time"
"crussell/clock" "crussell/clock"
"crussell/testutils"
"crussell/mw" "crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures" "crussell/testutils/fixtures"
"crussell/testutils/jwt" "crussell/testutils/jwt"
) )
@@ -1284,8 +1283,8 @@ func TestCleanupGDPRExportCache_Mixed(t *testing.T) {
gdprExportCacheMu.Lock() gdprExportCacheMu.Lock()
gdprExportCache = map[string]*gdprCacheEntry{ gdprExportCache = map[string]*gdprCacheEntry{
"user-expired": {expiresAt: clock.Now().Add(-2 * time.Hour)}, "user-expired": {expiresAt: clock.Now().Add(-2 * time.Hour)},
"user-valid": {expiresAt: clock.Now().Add(2 * time.Hour)}, "user-valid": {expiresAt: clock.Now().Add(2 * time.Hour)},
"user-expired2": {expiresAt: clock.Now().Add(-30 * time.Minute)}, "user-expired2": {expiresAt: clock.Now().Add(-30 * time.Minute)},
} }
gdprExportCacheMu.Unlock() gdprExportCacheMu.Unlock()
+1 -3
View File
@@ -136,7 +136,6 @@ func CreateGuestUserHandler(w http.ResponseWriter, r *http.Request) {
// Note: We intentionally don't sync to CardDAV - guests don't need calendar contacts // Note: We intentionally don't sync to CardDAV - guests don't need calendar contacts
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(CreateGuestUserResponse{ID: userID, Role: "guest"}) json.NewEncoder(w).Encode(CreateGuestUserResponse{ID: userID, Role: "guest"})
} }
@@ -192,8 +191,7 @@ func CheckEmailHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
json.NewEncoder(w).Encode(map[string]any{
json.NewEncoder(w).Encode(map[string]interface{}{
"suggestion": suggestion, "suggestion": suggestion,
}) })
} }
-1
View File
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package user package user
+1 -4
View File
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package user package user
@@ -10,8 +9,8 @@ import (
"strings" "strings"
"testing" "testing"
"crussell/testutils"
"crussell/mw" "crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures" "crussell/testutils/fixtures"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -174,5 +173,3 @@ func TestDeletePatchTest_InvalidTestID(t *testing.T) {
t.Errorf("expected 404 for invalid test ID, got %d", w.Code) t.Errorf("expected 404 for invalid test ID, got %d", w.Code)
} }
} }
+26 -26
View File
@@ -21,14 +21,14 @@ import (
"golang.org/x/text/cases" "golang.org/x/text/cases"
"golang.org/x/text/language" "golang.org/x/text/language"
"crussell/db"
"crussell/clock" "crussell/clock"
"github.com/jackc/pgx/v5" "crussell/db"
"crussell/handlers/auth" "crussell/handlers/auth"
"crussell/internal/images" "crussell/internal/images"
"crussell/internal/s3" "crussell/internal/s3"
"crussell/internal/validators" "crussell/internal/validators"
"crussell/mw" "crussell/mw"
"github.com/jackc/pgx/v5"
) )
func getEnv(key, fallback string) string { func getEnv(key, fallback string) string {
@@ -41,19 +41,19 @@ func getEnv(key, fallback string) string {
var titleCaser = cases.Title(language.English) var titleCaser = cases.Title(language.English)
type UserProfile struct { type UserProfile struct {
ID string `json:"id"` ID string `json:"id"`
Email string `json:"email"` Email string `json:"email"`
FirstName string `json:"firstName"` FirstName string `json:"firstName"`
LastName string `json:"lastName"` LastName string `json:"lastName"`
Phone *string `json:"phone,omitempty"` Phone *string `json:"phone,omitempty"`
DateOfBirth *string `json:"dateOfBirth,omitempty"` DateOfBirth *string `json:"dateOfBirth,omitempty"`
Role string `json:"role"` Role string `json:"role"`
LoyaltyStamps int `json:"loyaltyStamps"` LoyaltyStamps int `json:"loyaltyStamps"`
ReferralCode string `json:"referralCode"` ReferralCode string `json:"referralCode"`
ReferralCodeUses int `json:"referralCodeUses"` ReferralCodeUses int `json:"referralCodeUses"`
ReferralSavings float64 `json:"referralSavings"` ReferralSavings float64 `json:"referralSavings"`
ProfilePicURL *string `json:"profilePicUrl,omitempty"` ProfilePicURL *string `json:"profilePicUrl,omitempty"`
DepositsRequired int `json:"deposits_required"` DepositsRequired int `json:"deposits_required"`
PreviousFirstName *string `json:"previousFirstName,omitempty"` PreviousFirstName *string `json:"previousFirstName,omitempty"`
PreviousLastName *string `json:"previousLastName,omitempty"` PreviousLastName *string `json:"previousLastName,omitempty"`
} }
@@ -103,15 +103,15 @@ type SocialLogin struct {
} }
type UserListItem struct { type UserListItem struct {
ID string `json:"id"` ID string `json:"id"`
FullName string `json:"fullName"` FullName string `json:"fullName"`
Email *string `json:"email,omitempty"` Email *string `json:"email,omitempty"`
Phone *string `json:"phone,omitempty"` Phone *string `json:"phone,omitempty"`
AccountRole string `json:"account_role"` AccountRole string `json:"account_role"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
PreviousFirstName *string `json:"previousFirstName,omitempty"` PreviousFirstName *string `json:"previousFirstName,omitempty"`
PreviousLastName *string `json:"previousLastName,omitempty"` PreviousLastName *string `json:"previousLastName,omitempty"`
CompletedCount int `json:"completed_count"` CompletedCount int `json:"completed_count"`
} }
type UserListResponse struct { type UserListResponse struct {
@@ -511,7 +511,7 @@ func ListAdminUsersHandler(w http.ResponseWriter, r *http.Request) {
` `
var listQuery string var listQuery string
var listArgs []interface{} var listArgs []any
if searchTerm != "" { if searchTerm != "" {
searchPattern := "%" + searchTerm + "%" searchPattern := "%" + searchTerm + "%"
@@ -520,7 +520,7 @@ func ListAdminUsersHandler(w http.ResponseWriter, r *http.Request) {
WHERE (u.fn ILIKE $1 OR u.email ILIKE $1 OR u.phone ILIKE $1) WHERE (u.fn ILIKE $1 OR u.email ILIKE $1 OR u.phone ILIKE $1)
) sub` ) sub`
listArgs = []interface{}{searchPattern} listArgs = []any{searchPattern}
if cursorStr != "" { if cursorStr != "" {
cursorCount, cursorCreatedAt, cursorID, err := validators.ParseCursor3(cursorStr) cursorCount, cursorCreatedAt, cursorID, err := validators.ParseCursor3(cursorStr)
+1 -2
View File
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package user package user
@@ -25,8 +24,8 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"crussell/testutils"
"crussell/mw" "crussell/mw"
"crussell/testutils"
"crussell/testutils/fixtures" "crussell/testutils/fixtures"
"crussell/testutils/jwt" "crussell/testutils/jwt"
) )
-1
View File
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package user package user
+4 -5
View File
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package webhooks package webhooks
@@ -194,7 +193,7 @@ func TestHandleSquareWebhook_BodyTooLarge(t *testing.T) {
} }
func TestHandleSquareWebhook_ValidSignatureWithEnvKey(t *testing.T) { func TestHandleSquareWebhook_ValidSignatureWithEnvKey(t *testing.T) {
body := []byte(`{"type":"payment.updated","event_id":"evt_1"}`) body := []byte(`{"type":"payment.updated","event_id":"evt_1"}`)
key := "env-signing-key" key := "env-signing-key"
notificationURL := "http://localhost:8080/webhooks/square" notificationURL := "http://localhost:8080/webhooks/square"
@@ -213,7 +212,7 @@ func TestHandleSquareWebhook_ValidSignatureWithEnvKey(t *testing.T) {
} }
func TestHandleSquareWebhook_InvalidSignatureWithEnvKey(t *testing.T) { func TestHandleSquareWebhook_InvalidSignatureWithEnvKey(t *testing.T) {
body := []byte(`{"type":"payment.updated","event_id":"evt_1"}`) body := []byte(`{"type":"payment.updated","event_id":"evt_1"}`)
t.Setenv("SQUARE_WEBHOOK_SIGNATURE_KEY", "env-signing-key") t.Setenv("SQUARE_WEBHOOK_SIGNATURE_KEY", "env-signing-key")
@@ -225,7 +224,7 @@ func TestHandleSquareWebhook_InvalidSignatureWithEnvKey(t *testing.T) {
} }
func TestHandleSquareWebhook_NoSignatureWhenKeySet(t *testing.T) { func TestHandleSquareWebhook_NoSignatureWhenKeySet(t *testing.T) {
body := []byte(`{"type":"payment.updated","event_id":"evt_1"}`) body := []byte(`{"type":"payment.updated","event_id":"evt_1"}`)
t.Setenv("SQUARE_WEBHOOK_SIGNATURE_KEY", "env-signing-key") t.Setenv("SQUARE_WEBHOOK_SIGNATURE_KEY", "env-signing-key")
@@ -238,7 +237,7 @@ func TestHandleSquareWebhook_NoSignatureWhenKeySet(t *testing.T) {
} }
func TestHandleSquareWebhook_SignatureSkippedWhenKeyEmpty(t *testing.T) { func TestHandleSquareWebhook_SignatureSkippedWhenKeyEmpty(t *testing.T) {
t.Setenv("SQUARE_WEBHOOK_SIGNATURE_KEY", "") t.Setenv("SQUARE_WEBHOOK_SIGNATURE_KEY", "")
body := []byte(`{"type":"payment.updated","event_id":"evt_1"}`) body := []byte(`{"type":"payment.updated","event_id":"evt_1"}`)
-1
View File
@@ -1,5 +1,4 @@
//go:build dev //go:build dev
// +build dev
package dav package dav
-1
View File
@@ -1,5 +1,4 @@
//go:build !dev //go:build !dev
// +build !dev
package dav package dav
+3 -3
View File
@@ -1,8 +1,8 @@
package dav package dav
import ( import (
"crussell/clock"
"context" "context"
"crussell/clock"
"fmt" "fmt"
"strings" "strings"
"time" "time"
@@ -131,7 +131,7 @@ func (s *BaseService) ListRecentContacts(days int) ([]Contact, error) {
// Helper Methods // Helper Methods
// ============================================================================ // ============================================================================
func (s *BaseService) queryEventsWithContacts(query string, args ...interface{}) ([]CalendarEvent, error) { func (s *BaseService) queryEventsWithContacts(query string, args ...any) ([]CalendarEvent, error) {
rows, err := s.db.Query(context.Background(), query, args...) rows, err := s.db.Query(context.Background(), query, args...)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -155,7 +155,7 @@ func (s *BaseService) queryEventsWithContacts(query string, args ...interface{})
func extractContactURIsFromICalendar(icalData string) []string { func extractContactURIsFromICalendar(icalData string) []string {
var uris []string var uris []string
for _, line := range strings.Split(icalData, "\n") { for line := range strings.SplitSeq(icalData, "\n") {
line = strings.TrimSpace(line) line = strings.TrimSpace(line)
if strings.HasPrefix(line, "ATTENDEE") { if strings.HasPrefix(line, "ATTENDEE") {
parts := strings.Split(line, ":") parts := strings.Split(line, ":")
+8 -7
View File
@@ -3,6 +3,7 @@ package dav
import ( import (
"crussell/clock" "crussell/clock"
"fmt" "fmt"
"strings"
"time" "time"
) )
@@ -102,9 +103,9 @@ func GenerateICalEvent(input EventInput) string {
} }
// Build attendees section // Build attendees section
attendees := "" var attendees strings.Builder
for _, contactURI := range input.ContactURIs { for _, contactURI := range input.ContactURIs {
attendees += fmt.Sprintf("ATTENDEE;CN=%s:%s\n", contactURI, contactURI) attendees.WriteString(fmt.Sprintf("ATTENDEE;CN=%s:%s\n", contactURI, contactURI))
} }
ical := fmt.Sprintf(`BEGIN:VCALENDAR ical := fmt.Sprintf(`BEGIN:VCALENDAR
@@ -127,7 +128,7 @@ END:VCALENDAR`, uid, dtstamp, dtstart, dtend,
escapeICalText(input.Summary), escapeICalText(input.Summary),
escapeICalText(input.Description), escapeICalText(input.Description),
escapeICalText(input.Location), escapeICalText(input.Location),
attendees) attendees.String())
return ical return ical
} }
@@ -164,13 +165,13 @@ func escapeICalText(text string) string {
} }
func replaceAll(s, old, new string) string { func replaceAll(s, old, new string) string {
result := "" var result strings.Builder
for _, char := range s { for _, char := range s {
if string(char) == old { if string(char) == old {
result += new result.WriteString(new)
} else { } else {
result += string(char) result.WriteString(string(char))
} }
} }
return result return result.String()
} }
-1
View File
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package images package images
-1
View File
@@ -1,5 +1,4 @@
//go:build !dev //go:build !dev
// +build !dev
package s3 package s3
-1
View File
@@ -1,5 +1,4 @@
//go:build dev //go:build dev
// +build dev
package s3 package s3
-1
View File
@@ -1,5 +1,4 @@
//go:build !dev //go:build !dev
// +build !dev
package square package square
+1 -2
View File
@@ -1,11 +1,10 @@
//go:build dev //go:build dev
// +build dev
package square package square
import ( import (
"crussell/clock"
"context" "context"
"crussell/clock"
"fmt" "fmt"
"log" "log"
"os" "os"
@@ -1,5 +1,4 @@
//go:build test && dev //go:build test && dev
// +build test,dev
package square package square
+3 -3
View File
@@ -30,11 +30,11 @@ func ValidateEmail(email string) error {
// NormalizeGiftCardCode strips non-alphanumeric characters and upper-cases the code. // NormalizeGiftCardCode strips non-alphanumeric characters and upper-cases the code.
func NormalizeGiftCardCode(code string) string { func NormalizeGiftCardCode(code string) string {
clean := "" var clean strings.Builder
for _, char := range code { for _, char := range code {
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') { if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') {
clean += string(char) clean.WriteString(string(char))
} }
} }
return clean return clean.String()
} }
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package validators package validators
+24 -24
View File
@@ -25,8 +25,8 @@ import (
"crussell/db" "crussell/db"
"crussell/mw" "crussell/mw"
authHandlers "crussell/handlers/auth"
"crussell/handlers/admin" "crussell/handlers/admin"
authHandlers "crussell/handlers/auth"
"crussell/handlers/bookings" "crussell/handlers/bookings"
"crussell/handlers/notifications" "crussell/handlers/notifications"
"crussell/handlers/payments" "crussell/handlers/payments"
@@ -68,9 +68,9 @@ func limitBody(limit int64) func(http.Handler) http.Handler {
} }
const ( const (
defaultBodyLimit int64 = 1 * 1024 * 1024 // 1MB defaultBodyLimit int64 = 1 * 1024 * 1024 // 1MB
uploadBodyLimit int64 = 15 * 1024 * 1024 // 15MB uploadBodyLimit int64 = 15 * 1024 * 1024 // 15MB
portfolioBodyLimit int64 = 40 * 1024 * 1024 // 40MB (7 variants from 20MB source) portfolioBodyLimit int64 = 40 * 1024 * 1024 // 40MB (7 variants from 20MB source)
) )
// nColor / bColor — Chi-style ANSI colors for request logging. // nColor / bColor — Chi-style ANSI colors for request logging.
@@ -78,11 +78,11 @@ type nColor string
type bColor string type bColor string
var ( var (
reset = nColor(logutil.Reset) reset = nColor(logutil.Reset)
nGreen = nColor(logutil.Green) nGreen = nColor(logutil.Green)
nYellow = nColor(logutil.Yellow) nYellow = nColor(logutil.Yellow)
nCyan = nColor(logutil.Cyan) nCyan = nColor(logutil.Cyan)
nRed = nColor(logutil.Red) nRed = nColor(logutil.Red)
bGreen = bColor(logutil.BoldGreen) bGreen = bColor(logutil.BoldGreen)
bYellow = bColor(logutil.BoldYellow) bYellow = bColor(logutil.BoldYellow)
@@ -153,7 +153,7 @@ func healthCheckHandler(w http.ResponseWriter, r *http.Request) {
} else { } else {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
} }
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]any{
"status": status, "status": status,
"services": services, "services": services,
}) })
@@ -440,15 +440,15 @@ func main() {
r.Post("/{id}/edit-requests/{request_id}/deny", bookings.AdminRejectEditRequestHandler) r.Post("/{id}/edit-requests/{request_id}/deny", bookings.AdminRejectEditRequestHandler)
}) })
r.Route("/admin/users", func(r chi.Router) { r.Route("/admin/users", func(r chi.Router) {
r.Get("/", user.ListAdminUsersHandler) r.Get("/", user.ListAdminUsersHandler)
r.Get("/{id}", user.GetAdminUserHandler) r.Get("/{id}", user.GetAdminUserHandler)
r.Get("/{id}/relationship", user.GetCustomerRelationshipHandler) r.Get("/{id}/relationship", user.GetCustomerRelationshipHandler)
r.Get("/{id}/patch-tests/eligible", user.GetEligiblePatchTestServicesHandler) r.Get("/{id}/patch-tests/eligible", user.GetEligiblePatchTestServicesHandler)
r.Post("/{id}/patch-tests", user.AddPatchTestHandler) r.Post("/{id}/patch-tests", user.AddPatchTestHandler)
r.Get("/{id}/giftcard-balance", payments.GetUserGiftCardBalanceAdmin) r.Get("/{id}/giftcard-balance", payments.GetUserGiftCardBalanceAdmin)
r.Get("/{id}/payment-methods", payments.AdminGetUserPaymentMethods) r.Get("/{id}/payment-methods", payments.AdminGetUserPaymentMethods)
}) })
r.Route("/admin/today", func(r chi.Router) { r.Route("/admin/today", func(r chi.Router) {
r.Get("/current-next", today.GetCurrentAndNextHandler) r.Get("/current-next", today.GetCurrentAndNextHandler)
@@ -456,11 +456,11 @@ r.Route("/admin/users", func(r chi.Router) {
r.Get("/pending-approvals", today.GetPendingApprovalsHandler) r.Get("/pending-approvals", today.GetPendingApprovalsHandler)
}) })
r.Route("/admin/notifications", func(r chi.Router) { r.Route("/admin/notifications", func(r chi.Router) {
r.Get("/", notifications.GetNotifications) r.Get("/", notifications.GetNotifications)
r.Get("/unread-count", notifications.GetUnreadCount) r.Get("/unread-count", notifications.GetUnreadCount)
r.Post("/{id}/acknowledge", notifications.AcknowledgeNotification) r.Post("/{id}/acknowledge", notifications.AcknowledgeNotification)
}) })
r.Route("/admin/time-blockers", func(r chi.Router) { r.Route("/admin/time-blockers", func(r chi.Router) {
r.Get("/", scheduling.ListTimeBlockers) r.Get("/", scheduling.ListTimeBlockers)
-1
View File
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package main package main
+2 -7
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"log" "log"
"net/http" "net/http"
"slices"
"strings" "strings"
"crussell/auth" "crussell/auth"
@@ -77,13 +78,7 @@ func RequireRole(allowedRoles ...string) func(http.Handler) http.Handler {
} }
// Check if user has one of the allowed roles // Check if user has one of the allowed roles
hasRole := false hasRole := slices.Contains(allowedRoles, role)
for _, allowedRole := range allowedRoles {
if role == allowedRole {
hasRole = true
break
}
}
if !hasRole { if !hasRole {
http.Error(w, "forbidden", http.StatusForbidden) http.Error(w, "forbidden", http.StatusForbidden)
-1
View File
@@ -1,5 +1,4 @@
//go:build test //go:build test
// +build test
package mw package mw

Some files were not shown because too many files have changed in this diff Show More