Files
Crussell/backend/testutils/fixtures/fixtures.go
T
popertots 78e6d00dc5 fix: payments review rounds — money-safety, GDPR, security, gift-card cancel, modal stacking
Money-safety:
- Deterministic till idempotency fallback (Square-charging only); cash/on_the_house keep unique keys; £250 till gift-card cap; 45-char key validation
- Gift-card admin caps £250/tx + £5,000/day; user buy £500/day; BuyGiftCard allowlist unchanged
- CancelGiftCard: CCR 2013 14-day right with partial-spend refund of the unspent balance (spend verified via payments.gift_card_id); atomic vs redeem/transfer; refunds stay pending until reversal commits; admin cancel surface (AdminCancelGiftCard)
- Sweep: cancelled-booking charges failed+notified instead of silently completed; source-override replay uses live square_source_id; legacy square-less refund sweep; snapshot refresh on pending reuse
- Refund lock consolidation; recordTerminalPaymentTx shared recorder; structured Square error codes; terminal checkout CustomerID

GDPR / security:
- Notes retained as de-identified medical/safety record at erasure (single field treated as health data; rest of record wiped, no re-identification map) + comments updated per UK GDPR/Art 9/Equality Act 2010
- square_request_snapshot PII scrubbed on all erasure paths; delete_guest_user FK unlinks; verification codes + dispute reasons handled; idle/stale-guest erasure deletes Square cards/customers + CardDAV/R2
- Durable square-erasure outbox job (retry-square-erasures); 2FA dev/prod build split, pepper fail-closed, no prod code-in-log; prod 2FA delivery fail-loud without a channel
- Webhook unknown-type family split (non-money acked, money retried); untracked dispute notifications; rate-limit CF/X-Real-IP trust gating; nginx CSP nonce + api_limit

Frontend:
- Dynamic z-index stack (ui/dialog/zindex.ts) claimed in open order via data-state observer; re-claims on every reopen; removes stale !z-* overrides — nested modals (booking→user→booking) always paint newest-on-top (browser-verified 3-level + reopen)
- Mobile: iOS zoom fixes, bottom-sheet dialogs, 44px touch targets, inputmode decimal, dvh
- Gift-card buy/cancel UI, admin £250 + daily limits, cancellation/privacy/terms policy accuracy

S3:
- Connect() creates buckets before probing; in-memory fallback only on genuine unreachability; health reports degraded; stale S3_PUBLIC_URL documented (host-specific)

Tests/docs:
- 2263 test functions; all 22 backend packages green; round8/9/10 regression suites; NextEditWindowTime removes wall-clock flake; docs reconciled (notes retention, gift-card partial-use, modal T15 future work)
2026-08-22 00:34:50 +01:00

382 lines
13 KiB
Go

//go:build test
// +build test
package fixtures
import (
"context"
"fmt"
"sync/atomic"
"time"
"crussell/clock"
"crussell/db"
"golang.org/x/crypto/bcrypt"
)
// Global counter for unique emails in tests
var testEmailCounter atomic.Int64
func CreateTestAdminUser(q db.Querier) (string, error) {
return createTestUser(q, "Admin", "User", "", "admin")
}
func CreateTestUser(q db.Querier) (string, error) {
return createTestUser(q, "Test", "User", "", "verified_email")
}
func CreateTestUserWithEmail(q db.Querier, email, role string) (string, error) {
return createTestUser(q, "Test", "User", email, role)
}
func createTestUser(q db.Querier, firstName, lastName, email, role string) (string, error) {
passwordHash, err := bcrypt.GenerateFromPassword([]byte("testpassword123"), bcrypt.DefaultCost)
if err != nil {
return "", fmt.Errorf("failed to hash password: %w", err)
}
// Generate unique email if not provided
if email == "" {
n := testEmailCounter.Add(1)
email = fmt.Sprintf("%s.%s.%d@test.com", firstName, lastName, n)
}
ctx := context.Background()
var userID string
err = q.QueryRow(ctx, `
INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'email')
RETURNING id
`, firstName, lastName, email, "+447123456789", "1990-01-01", string(passwordHash), role).Scan(&userID)
if err != nil {
return "", fmt.Errorf("failed to create user: %w", err)
}
return userID, nil
}
func CreateTestService(q db.Querier) (string, error) {
ctx := context.Background()
var serviceID string
err := q.QueryRow(ctx, `
INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id
`, "Test Service", "A test service for unit tests", 50.00, 60, true, 16).Scan(&serviceID)
if err != nil {
return "", fmt.Errorf("failed to create service: %w", err)
}
return serviceID, nil
}
func CreateTestServiceWithDuration(q db.Querier, durationMinutes int) (string, error) {
ctx := context.Background()
var serviceID string
err := q.QueryRow(ctx, `
INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id
`, fmt.Sprintf("Test Service %dmin", durationMinutes), "A test service for unit tests", 50.00, durationMinutes, true, 16).Scan(&serviceID)
if err != nil {
return "", fmt.Errorf("failed to create service: %w", err)
}
return serviceID, nil
}
// CreateTestServiceWithPatchTest creates a service and a patch test that links to it
// Returns serviceID, patchTestID
func CreateTestServiceWithPatchTest(q db.Querier) (string, string, error) {
ctx := context.Background()
// First create the service
var serviceID string
err := q.QueryRow(ctx, `
INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id
`, "Test Patch Test Service", "A test service requiring patch test", 75.00, 90, true, 18).Scan(&serviceID)
if err != nil {
return "", "", fmt.Errorf("failed to create service: %w", err)
}
// Now create a patch test that links to this service
var patchTestID string
err = q.QueryRow(ctx, `
INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
`, "Test Patch Test", "A patch test for testing", 24, 6, []string{serviceID}).Scan(&patchTestID)
if err != nil {
return "", "", fmt.Errorf("failed to create patch test: %w", err)
}
return serviceID, patchTestID, nil
}
// CreateTestPatchTest creates a patch test definition
func CreateTestPatchTest(q db.Querier, serviceIDs []string) (string, error) {
ctx := context.Background()
var patchTestID string
err := q.QueryRow(ctx, `
INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
`, "Test Patch Test", "A patch test for testing", 24, 6, serviceIDs).Scan(&patchTestID)
if err != nil {
return "", fmt.Errorf("failed to create patch test: %w", err)
}
return patchTestID, nil
}
// CreateUserPatchTest creates a user patch test record
func CreateUserPatchTest(q db.Querier, userID, patchTestID string, testedAt string) error {
ctx := context.Background()
_, err := q.Exec(ctx, `
INSERT INTO user_patch_tests (user_id, patch_test_id, tested_at)
VALUES ($1, $2, $3)
`, userID, patchTestID, testedAt)
if err != nil {
return fmt.Errorf("failed to create user patch test: %w", err)
}
return nil
}
func CreateTestBooking(q db.Querier, userID, serviceID string) (string, error) {
return CreateTestBookingAtTime(q, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
}
func CreateTestBookingAtTime(q db.Querier, userID, serviceID string, startTime time.Time) (string, error) {
ctx := context.Background()
var bookingID string
err := q.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status, notes)
VALUES ($1, $2, $3, $4)
RETURNING id
`, userID, startTime, "pending", "Test booking").Scan(&bookingID)
if err != nil {
return "", fmt.Errorf("failed to create booking: %w", err)
}
_, err = q.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id)
VALUES ($1, $2)
`, bookingID, serviceID)
if err != nil {
return "", fmt.Errorf("failed to link service to booking: %w", err)
}
return bookingID, nil
}
// NextWorkingDayAt returns the time at `hour` UTC on a day `daysAhead` days from
// now. Hours in [8, 18] are guaranteed inside the fixture's Mon-Sun 08:00-20:00
// London working hours at any wall-clock time (London is at most UTC+1), unlike
// clock.Now().Add(N * time.Hour), which can land after closing and flake tests.
func NextWorkingDayAt(daysAhead, hour int) time.Time {
day := clock.Now().AddDate(0, 0, daysAhead)
return time.Date(day.Year(), day.Month(), day.Day(), hour, 0, 0, 0, time.UTC)
}
// NextEditWindowTime returns a 10:00 UTC slot T (inside the fixture's Mon-Sun
// 08:00-20:00 London working hours) such that T+offset sits in the [24h, 48h]
// window from now — the band where RequestEditHandler neither 403s ("too close
// to reschedule": hoursUntilCurrent < 24) nor auto-approves the request at
// creation time (hoursUntilCurrent > 48). Callers whose edited booking starts
// at base+lead pass `lead` as offset so the *booking* start lands in the
// window. Because 10:00 UTC slots repeat every 24h and the window is exactly
// 24h wide (closed bounds, matching the handler's strict </> comparisons), a
// slot is always found on the first scan — at any wall-clock run time, unlike
// NextWorkingDayAt(1, 10) which lands <24h away when the suite runs after 10:00
// UTC and the sibling day-2 fallback can overshoot past 48h.
func NextEditWindowTime(offset time.Duration) time.Time {
now := clock.Now()
for days := 1; days <= 4; days++ {
t := NextWorkingDayAt(days, 10)
d := t.Add(offset).Sub(now)
if d >= 24*time.Hour && d <= 48*time.Hour {
return t
}
}
// Unreachable in practice (the [24h,48h] window is one slot-period wide);
// fall back to day+2 so tests never silently hang on a pathological clock.
return NextWorkingDayAt(2, 10)
}
func CreateTestVerifiedUser(q db.Querier) (string, error) {
return createTestUser(q, "Verified", "User", "verified@test.com", "verified_email")
}
func CreateTestUnverifiedUser(q db.Querier) (string, error) {
return createTestUser(q, "Unverified", "User", "unverified@test.com", "unverified_email")
}
func CreateTestGuestUser(q db.Querier) (string, error) {
return createTestUser(q, "Guest", "User", "guest@test.com", "guest")
}
func DeleteUser(q db.Querier, userID string) error {
ctx := context.Background()
_, err := q.Exec(ctx, "DELETE FROM users WHERE id = $1", userID)
return err
}
func DeleteService(q db.Querier, serviceID string) error {
ctx := context.Background()
_, err := q.Exec(ctx, "DELETE FROM services WHERE id = $1", serviceID)
return err
}
func DeleteBooking(q db.Querier, bookingID string) error {
ctx := context.Background()
_, err := q.Exec(ctx, "DELETE FROM bookings WHERE id = $1", bookingID)
return err
}
func CreateTestCustomService(q db.Querier) (string, error) {
ctx := context.Background()
var serviceID string
err := q.QueryRow(ctx, `
INSERT INTO custom_services (name, description, price, duration_minutes, minimum_age_required)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
`, "Test Custom Service", "A test custom service", 75.00, 45, 16).Scan(&serviceID)
if err != nil {
return "", fmt.Errorf("failed to create custom service: %w", err)
}
return serviceID, nil
}
func DeleteCustomService(q db.Querier, serviceID string) error {
ctx := context.Background()
_, err := q.Exec(ctx, "DELETE FROM custom_services WHERE id = $1", serviceID)
return err
}
// SafeDeleteUser wraps DeleteUser and returns error (for tests that care about cleanup failure)
func SafeDeleteUser(q db.Querier, userID string) error {
return DeleteUser(q, userID)
}
// SafeDeleteService wraps DeleteService and returns error (for tests that care about cleanup failure)
func SafeDeleteService(q db.Querier, serviceID string) error {
return DeleteService(q, serviceID)
}
// SafeDeleteBooking wraps DeleteBooking and returns error (for tests that care about cleanup failure)
func SafeDeleteBooking(q db.Querier, bookingID string) error {
return DeleteBooking(q, bookingID)
}
// CreateTestTimeBlocker creates a time blocker for testing
// Returns the blocker ID
func CreateTestTimeBlocker(q db.Querier, startTime time.Time, durationMinutes int, description string) (string, error) {
ctx := context.Background()
var blockerID string
err := q.QueryRow(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description)
VALUES ($1, $2, $3)
RETURNING id
`, startTime, durationMinutes, description).Scan(&blockerID)
if err != nil {
return "", fmt.Errorf("failed to create time blocker: %w", err)
}
return blockerID, nil
}
// DeleteTimeBlocker removes a time blocker from the database
func DeleteTimeBlocker(q db.Querier, blockerID string) error {
ctx := context.Background()
_, err := q.Exec(ctx, "DELETE FROM time_blockers WHERE id = $1", blockerID)
return err
}
// CreateTestPayment creates a payment record for testing
// Returns payment ID
func CreateTestPayment(q db.Querier, bookingID string, amount float64, method string, ptype string, status string) (string, error) {
ctx := context.Background()
var paymentID string
err := q.QueryRow(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
RETURNING id
`, bookingID, ptype, method, status, amount).Scan(&paymentID)
if err != nil {
return "", fmt.Errorf("failed to create payment: %w", err)
}
return paymentID, nil
}
// CreateTestRefund creates a refund record for testing
// Returns refund ID
func CreateTestRefund(q db.Querier, paymentID string, bookingID string, amount float64) (string, error) {
ctx := context.Background()
var refundID string
err := q.QueryRow(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, created_at)
VALUES ($1, $2, $3, 'completed', 'test refund', NOW())
RETURNING id
`, paymentID, bookingID, amount).Scan(&refundID)
if err != nil {
return "", fmt.Errorf("failed to create refund: %w", err)
}
return refundID, nil
}
// CreateTestPaymentMethod creates a saved card for a user
// Returns card ID
func CreateTestPaymentMethod(q db.Querier, userID string, squareCardID string, brand string, last4 string) (string, error) {
ctx := context.Background()
var cardID string
err := q.QueryRow(ctx, `
INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default, created_at)
VALUES ($1, $2, $3, $4, 12, 2030, 'test_fp', false, NOW())
RETURNING id
`, userID, squareCardID, brand, last4).Scan(&cardID)
if err != nil {
return "", fmt.Errorf("failed to create payment method: %w", err)
}
return cardID, nil
}
// DeletePayment deletes a payment from the database
func DeletePayment(q db.Querier, paymentID string) error {
ctx := context.Background()
_, err := q.Exec(ctx, "DELETE FROM payments WHERE id = $1", paymentID)
return err
}
// DeleteRefund deletes a refund from the database
func DeleteRefund(q db.Querier, refundID string) error {
ctx := context.Background()
_, err := q.Exec(ctx, "DELETE FROM refunds WHERE id = $1", refundID)
return err
}
// DeletePaymentMethod deletes a saved card from the database
func DeletePaymentMethod(q db.Querier, cardID string) error {
ctx := context.Background()
_, err := q.Exec(ctx, "DELETE FROM user_saved_cards WHERE id = $1", cardID)
return err
}