Gift-card rolling expiry (setting-driven, was dead config): - GetGiftCardExpiryMonths(): single source of truth (business_settings gift_card_expiry_months, fallback 24) shared by payment handlers and the CleanupExpiredGiftCards job (was hardcoded 24). - expiry_date now maintained on ALL 9 gift-card write sites (buy, topup, transfer, redeem, terminal payment, refund credit, till) so the refund-time guard at refunds.go actually fires. Schema default 12->24 + migration note; test-DB seed aligned. Stale "expiry_date IS NULL" test rewritten; new expired-card-rejected regression test. Frontend SvelteDate purge (docs' stated convention, wide): - All 180+ raw `new SvelteDate(...)` uses across routes/components replaced with parseWallClockDate (backend UTC ISO) or new Date (wall-clock constructors). SvelteDate imports removed. timeSlots.ts getDayWithOrdinal fixed. Zero SvelteDate references remain; svelte-check clean. Strict timezone/DST testing + QA fixes: - 8 new hermetic boundary tests: clock.DST transitions (both 2026 folds), closing-hours GMT vs BST, booking date-window midnight, refund-tier elapsed-time independence, deposit-window UTC-instant, scheduling LondonDateString midnight, today AT TIME ZONE window + UTC round-trip. - today.go summary date labels fixed to London wall-clock (were showing the previous UTC day during BST) + regression test. - pgx ScanLocation fixed to UTC via AfterConnect (was host-local -> JSON offsets depended on deployment TZ, contradicting the documented UTC invariant) + regression test. Registered as a new *Type to avoid a data race on the shared type map (caught by -race). Admin Business Settings (setting now functional => legal floor): - gift_card_expiry_months validation floor raised 1 -> 12 months (CMA/ Consumer Rights Act 2015 unfair-contract-term guidance) in endpoint + UI, with rolling-expiry semantics shown in both display and edit form. - 3 new expiry validation tests; 2 pre-existing message assertions updated. Full suite 25/25 + race clean via run-tests.sh lockfile; svelte-check 0 errors/warnings; production build succeeds.
217 lines
5.4 KiB
Go
217 lines
5.4 KiB
Go
//go:build test
|
|
|
|
package db
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func resetEnv() {
|
|
os.Setenv("POSTGRES_USER", "myuser")
|
|
os.Setenv("POSTGRES_PASSWORD", "mypassword")
|
|
os.Setenv("POSTGRES_HOST", savedPOSTGRESHost)
|
|
os.Setenv("POSTGRES_DB", "crussell_test_db")
|
|
}
|
|
|
|
func closePool() {
|
|
if Conn != nil {
|
|
Conn.Pool().Close()
|
|
Conn = nil
|
|
}
|
|
}
|
|
|
|
var testDBName = "crussell_test_db"
|
|
|
|
// =============================================================================
|
|
// Happy path — connect + ping
|
|
// =============================================================================
|
|
|
|
func TestConnect_Success(t *testing.T) {
|
|
closePool()
|
|
resetEnv()
|
|
|
|
err := Connect()
|
|
if err != nil {
|
|
t.Fatalf("Connect() failed: %v", err)
|
|
}
|
|
defer closePool()
|
|
|
|
if Conn == nil {
|
|
t.Fatal("Conn is nil after successful Connect")
|
|
}
|
|
}
|
|
|
|
func TestConnect_PingViaTestDB(t *testing.T) {
|
|
closePool()
|
|
resetEnv()
|
|
|
|
err := Connect()
|
|
if err != nil {
|
|
t.Fatalf("Connect() failed: %v", err)
|
|
}
|
|
defer closePool()
|
|
|
|
poolConn, err := Conn.Acquire(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Acquire failed: %v", err)
|
|
}
|
|
defer poolConn.Release()
|
|
|
|
var result int
|
|
err = poolConn.QueryRow(context.Background(), "SELECT 1").Scan(&result)
|
|
if err != nil {
|
|
t.Fatalf("Ping query failed: %v", err)
|
|
}
|
|
if result != 1 {
|
|
t.Errorf("expected 1, got %d", result)
|
|
}
|
|
}
|
|
|
|
// TestScanLocation_UTC proves the AfterConnect hook: TIMESTAMPTZ values are
|
|
// scanned into time.Time in UTC, never the host's local timezone. Without the
|
|
// hook, pgx scans into time.Local, so a London-host dev server would emit
|
|
// +01:00 JSON offsets while a UTC Docker host emits Z — same instant, but the
|
|
// documented "backend emits UTC" invariant would silently depend on the
|
|
// deployment host's TZ.
|
|
func TestScanLocation_UTC(t *testing.T) {
|
|
closePool()
|
|
resetEnv()
|
|
|
|
err := Connect()
|
|
if err != nil {
|
|
t.Fatalf("Connect() failed: %v", err)
|
|
}
|
|
defer closePool()
|
|
|
|
poolConn, err := Conn.Acquire(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Acquire failed: %v", err)
|
|
}
|
|
defer poolConn.Release()
|
|
|
|
// A known instant: 2026-06-15 00:30 BST = 2026-06-14 23:30 UTC.
|
|
instant := time.Date(2026, 6, 14, 23, 30, 0, 0, time.UTC)
|
|
var scanned time.Time
|
|
if err := poolConn.QueryRow(context.Background(), "SELECT $1::timestamptz", instant).Scan(&scanned); err != nil {
|
|
t.Fatalf("timestamptz scan failed: %v", err)
|
|
}
|
|
if !scanned.Equal(instant) {
|
|
t.Errorf("scanned instant = %s, expected %s", scanned.Format(time.RFC3339Nano), instant.Format(time.RFC3339Nano))
|
|
}
|
|
if scanned.Location() != time.UTC {
|
|
t.Errorf("scanned time.Time location = %v, expected time.UTC (host TZ is %v)", scanned.Location(), time.Local)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Connection failure scenarios
|
|
// =============================================================================
|
|
|
|
func TestConnect_InvalidCredentials(t *testing.T) {
|
|
closePool()
|
|
|
|
os.Setenv("POSTGRES_USER", "wronguser")
|
|
os.Setenv("POSTGRES_PASSWORD", "wrongpassword")
|
|
os.Setenv("POSTGRES_DB", "crussell_test")
|
|
os.Setenv("POSTGRES_HOST", "localhost")
|
|
|
|
err := Connect()
|
|
if err == nil {
|
|
t.Error("expected error for invalid credentials, got nil")
|
|
closePool()
|
|
}
|
|
|
|
resetEnv()
|
|
}
|
|
|
|
func TestConnect_RefusedConnection(t *testing.T) {
|
|
closePool()
|
|
|
|
os.Setenv("POSTGRES_USER", "myuser")
|
|
os.Setenv("POSTGRES_PASSWORD", "mypassword")
|
|
os.Setenv("POSTGRES_DB", "crussell_test")
|
|
os.Setenv("POSTGRES_HOST", "localhost")
|
|
|
|
t.Skip("pgxpool.New is lazy — connection errors surface on Acquire, not Connect")
|
|
|
|
resetEnv()
|
|
}
|
|
|
|
// =============================================================================
|
|
// Concurrent access — pool should handle parallel queries
|
|
// =============================================================================
|
|
|
|
func TestConcurrentQueries(t *testing.T) {
|
|
closePool()
|
|
resetEnv()
|
|
|
|
err := Connect()
|
|
if err != nil {
|
|
t.Fatalf("Connect() failed: %v", err)
|
|
}
|
|
defer closePool()
|
|
|
|
var wg sync.WaitGroup
|
|
errs := make(chan error, 20)
|
|
|
|
for i := 0; i < 20; i++ {
|
|
wg.Add(1)
|
|
go func(id int) {
|
|
defer wg.Done()
|
|
poolConn, err := Conn.Acquire(context.Background())
|
|
if err != nil {
|
|
errs <- fmt.Errorf("goroutine %d: acquire: %w", id, err)
|
|
return
|
|
}
|
|
defer poolConn.Release()
|
|
|
|
var result int
|
|
err = poolConn.QueryRow(context.Background(), "SELECT $1::int", id).Scan(&result)
|
|
if err != nil {
|
|
errs <- fmt.Errorf("goroutine %d: query: %w", id, err)
|
|
return
|
|
}
|
|
if result != id {
|
|
errs <- fmt.Errorf("goroutine %d: expected %d, got %d", id, id, result)
|
|
}
|
|
}(i)
|
|
}
|
|
|
|
wg.Wait()
|
|
close(errs)
|
|
|
|
for e := range errs {
|
|
t.Error(e)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// getEnv unit tests
|
|
// =============================================================================
|
|
|
|
func TestGetEnv_ReturnsValue(t *testing.T) {
|
|
os.Setenv("TEST_DB_VAR", "expected_value")
|
|
defer os.Unsetenv("TEST_DB_VAR")
|
|
|
|
if v := getEnv("TEST_DB_VAR"); v != "expected_value" {
|
|
t.Errorf("expected 'expected_value', got %q", v)
|
|
}
|
|
}
|
|
|
|
func TestGetEnv_ReturnsEmptyWhenUnset(t *testing.T) {
|
|
os.Unsetenv("TEST_DB_MISSING_VAR")
|
|
|
|
if v := getEnv("TEST_DB_MISSING_VAR"); v != "" {
|
|
t.Errorf("expected empty string, got %q", v)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Clean-up — restore env after all tests
|
|
// =============================================================================
|