Gift-card rolling expiry, SvelteDate→Date purge, strict DST tests, UTC scan-location + settings legal floor
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.
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
//go:build test
|
||||
|
||||
package today
|
||||
|
||||
// Strict timezone tests for today.go's `AT TIME ZONE 'Europe/London'` date
|
||||
// math. All assertions use fixed time.Date instants.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/clock"
|
||||
"crussell/db"
|
||||
"crussell/testutils"
|
||||
"crussell/testutils/fixtures"
|
||||
)
|
||||
|
||||
// TestToday_LondonDateWindow proves the SQL used by findNewBookingServices
|
||||
// (today.go) computes the London calendar date from a UTC instant. At
|
||||
// 2026-06-14 23:30 UTC (= 2026-06-15 00:30 BST) the London date is 2026-06-15
|
||||
// even though the UTC date is 2026-06-14 — the 00:00-01:00 BST window where a
|
||||
// naive UTC date would start the 14-day "last working day" scan a day early.
|
||||
func TestToday_LondonDateWindow(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, _ := testutils.SetupTestTx(t)
|
||||
|
||||
londonDate := func(utc time.Time) string {
|
||||
t.Helper()
|
||||
var date string
|
||||
err := db.Conn.QueryRow(ctx, `
|
||||
SELECT (($1::timestamptz AT TIME ZONE 'Europe/London')::date)::text
|
||||
`, utc).Scan(&date)
|
||||
if err != nil {
|
||||
t.Fatalf("London-date query failed for %s: %v", utc.Format(time.RFC3339), err)
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
utc time.Time
|
||||
want string
|
||||
}{
|
||||
{"00:30 BST window", time.Date(2026, 6, 14, 23, 30, 0, 0, time.UTC), "2026-06-15"},
|
||||
{"01:30 BST same London day", time.Date(2026, 6, 15, 0, 30, 0, 0, time.UTC), "2026-06-15"},
|
||||
{"GMT winter day", time.Date(2026, 1, 15, 16, 0, 0, 0, time.UTC), "2026-01-15"},
|
||||
{"spring-forward day", time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC), "2026-03-29"},
|
||||
{"autumn-transition day", time.Date(2026, 10, 25, 12, 0, 0, 0, time.UTC), "2026-10-25"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := londonDate(tc.utc); got != tc.want {
|
||||
t.Errorf("London date of %s UTC = %s, expected %s", tc.utc.Format(time.RFC3339), got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestToday_UTCUnchanged_Boundary pins that the same boundary instant is
|
||||
// stored as a TIMESTAMPTZ unchanged (UTC), so the Go-side clock.Now() and the
|
||||
// SQL-side NOW()/TIMESTAMPTZ never disagree about the instant itself.
|
||||
func TestToday_UTCUnchanged_Boundary(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, _ := testutils.SetupTestTx(t)
|
||||
|
||||
boundary := time.Date(2026, 6, 14, 23, 30, 0, 0, time.UTC)
|
||||
var roundTrip time.Time
|
||||
err := db.Conn.QueryRow(ctx, `SELECT $1::timestamptz`, boundary).Scan(&roundTrip)
|
||||
if err != nil {
|
||||
t.Fatalf("timestamptz round-trip failed: %v", err)
|
||||
}
|
||||
if !roundTrip.Equal(boundary) {
|
||||
t.Errorf("timestamptz round-trip altered the instant: got %s, want %s", roundTrip.Format(time.RFC3339Nano), boundary.Format(time.RFC3339Nano))
|
||||
}
|
||||
// The London date derived from the round-tripped value must still be the
|
||||
// BST-day date — pgx may decode with a nil Go location, so only the instant
|
||||
// and its London wall-clock date matter.
|
||||
zone, off := roundTrip.In(londonLocation).Zone()
|
||||
if zone != "BST" || off != 3600 {
|
||||
t.Errorf("round-tripped instant in London = zone %q offset %ds, expected BST +3600", zone, off)
|
||||
}
|
||||
if got := roundTrip.In(londonLocation).Format("2006-01-02"); got != "2026-06-15" {
|
||||
t.Errorf("round-tripped instant maps to London date %s, expected 2026-06-15", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestToday_SummaryDateLabels_LondonBusinessDay is a regression test for the
|
||||
// QA-flagged DST bug: the done-for-the-day summary header showed the PREVIOUS
|
||||
// day during BST. todayStart is London midnight converted to UTC (e.g.
|
||||
// 2026-08-04 00:00 BST = 2026-08-03 23:00 UTC), and formatting that UTC
|
||||
// instant directly with "2006-01-02" yielded "2026-08-03" for the Aug 4
|
||||
// business day. The summary labels must use the London-located instant so the
|
||||
// header shows the business day (and the same date as the UTC-derived London
|
||||
// calendar day the rest of the pipeline uses).
|
||||
func TestToday_SummaryDateLabels_LondonBusinessDay(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, tx := testutils.SetupTestTx(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(tx)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
svcID := createTodayService(t, ctx, tx)
|
||||
|
||||
// Seed a completed booking TODAY (London) so the handler is in the
|
||||
// "done for the day" branch with a summary.
|
||||
now := clock.Now()
|
||||
var bookingID string
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO bookings (user_id, start_time, status)
|
||||
VALUES ($1, $2, 'completed')
|
||||
RETURNING id
|
||||
`, userID, now).Scan(&bookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create booking: %v", err)
|
||||
}
|
||||
addBookingService(t, ctx, tx, bookingID, svcID)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/today/current-next", nil)
|
||||
req = req.WithContext(ctx)
|
||||
rr := httptest.NewRecorder()
|
||||
GetCurrentAndNextHandler(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp CurrentNextResponse
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
if resp.Summary == nil {
|
||||
t.Fatal("expected a summary (done-for-the-day branch)")
|
||||
}
|
||||
|
||||
londonToday := now.In(londonLocation).Format("2006-01-02")
|
||||
if resp.Summary.SummaryStartDate != londonToday {
|
||||
t.Errorf("summary_start_date = %s, expected %s (the London business day — the pre-fix code emitted the previous UTC day during BST)",
|
||||
resp.Summary.SummaryStartDate, londonToday)
|
||||
}
|
||||
// End date is the London day AFTER todayStart (todayEnd = todayStart + 24h,
|
||||
// which in London wall-clock is the NEXT day).
|
||||
londonTomorrow := now.In(londonLocation).AddDate(0, 0, 1).Format("2006-01-02")
|
||||
if resp.Summary.SummaryEndDate != londonTomorrow {
|
||||
t.Errorf("summary_end_date = %s, expected %s (the London day after the business day)",
|
||||
resp.Summary.SummaryEndDate, londonTomorrow)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user