fix: payments hardening — SCA wire contract (saved-card ref + tokenize-result), terminal/till token routing, tip-cap overflow carve, completion campaign atomicity, orphan B1-evidence gate, gift-card gates/locks, admin backstops
- ValidateCardInfo accepts saved-card ref + new_card_token coexistence (matches resolveChargeSource); new_card_token added to terminal/till request structs so SCA tokens are never dropped - maxOnlineTipPence (£250) enforced on the overflow-tip carve AND buildSplitRecords (both carve paths) — closes the £10k bypass - completion-path campaign increments made atomic reserve-first (conditional UPDATE ... RETURNING) + schema backstops (chk_times_redeemed, partial unique index on milestone redemptions) - webhook orphan detection gated on B1 evidence (b1_attempts / sweep-duplicate refund row) so a delayed legit completion is never marked failed - gift-card: per-user £500/day cap lock held across read-modify-write, expired-card top-up gate, NaN/Inf float bounds, refund_failed ack filter, on_the_house excluded from balance, postChargeRecheck notification - admin apply-redemption route + admin-or-owner, in-handler isAdminRequest on 4 gift-card handlers, tip lock key aligned - 2FA fallback machinery removed (insertTwoFAFallbackAudit/reissue/consent), dead fields stripped from charge structs - tests: prod-tag suite, mock SCA parity, tip-cap overflow, completion races, cards pagination, ValidateCardInfo tables
This commit is contained in:
@@ -3,11 +3,14 @@
|
||||
package scheduling
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/clock"
|
||||
"crussell/db"
|
||||
"crussell/internal/adminnotify"
|
||||
"crussell/testutils/fixtures"
|
||||
)
|
||||
@@ -677,6 +680,146 @@ func TestCleanupExpiredRefreshTokens_PreservesValid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ApplyScheduledDefaultHours Tests
|
||||
// ============================================================
|
||||
|
||||
// seedStagedDefaultHoursChange inserts one pending default-hours change due on
|
||||
// the given London date and returns its id. Weekday 1 (Monday) is staged to
|
||||
// 10:00-18:00 open (the seeded default is 09:00-17:00 open), so an applied
|
||||
// change is observable on the working_hours row.
|
||||
func seedStagedDefaultHoursChange(t *testing.T, ctx context.Context, tx db.Querier, effectiveDate string) int {
|
||||
t.Helper()
|
||||
var id int
|
||||
err := tx.QueryRow(ctx, `
|
||||
INSERT INTO default_hours_scheduled_changes (effective_date, hours)
|
||||
VALUES ($1::date, $2::jsonb)
|
||||
RETURNING id
|
||||
`, effectiveDate, `[{"weekday":1,"startTime":"10:00","endTime":"18:00","isOpen":true}]`).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to seed default hours change: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func getWorkingHoursForWeekday(t *testing.T, ctx context.Context, q db.Querier, weekday int) (start, end string, isOpen bool) {
|
||||
t.Helper()
|
||||
if err := q.QueryRow(ctx, `SELECT start_time, end_time, is_open FROM working_hours WHERE weekday = $1`, weekday).Scan(&start, &end, &isOpen); err != nil {
|
||||
t.Fatalf("failed to read working_hours for weekday %d: %v", weekday, err)
|
||||
}
|
||||
return start, end, isOpen
|
||||
}
|
||||
|
||||
// TestApplyScheduledDefaultHours_AppliesStagedChange seeds a default-hours
|
||||
// change with effective_date YESTERDAY (London), runs ApplyScheduledDefaultHours
|
||||
// and asserts the working_hours row was updated, the change marked applied_at,
|
||||
// and the flood-capped 'default_hours_changed' admin notification inserted.
|
||||
func TestApplyScheduledDefaultHours_AppliesStagedChange(t *testing.T) {
|
||||
ctx, tx := resetTestData(t)
|
||||
|
||||
yesterday := LondonDateString(clock.Now().Add(-24 * time.Hour))
|
||||
changeID := seedStagedDefaultHoursChange(t, ctx, tx, yesterday)
|
||||
|
||||
n, err := ApplyScheduledDefaultHours(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyScheduledDefaultHours failed: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("expected 1 applied change, got %d", n)
|
||||
}
|
||||
|
||||
start, end, isOpen := getWorkingHoursForWeekday(t, ctx, tx, 1)
|
||||
if start != "10:00:00" || end != "18:00:00" || !isOpen {
|
||||
t.Errorf("expected working_hours weekday 1 updated to 10:00-18:00 open, got %q-%q open=%v", start, end, isOpen)
|
||||
}
|
||||
|
||||
var appliedAt sql.NullTime
|
||||
if err := tx.QueryRow(ctx, `SELECT applied_at FROM default_hours_scheduled_changes WHERE id = $1`, changeID).Scan(&appliedAt); err != nil {
|
||||
t.Fatalf("failed to read change applied_at: %v", err)
|
||||
}
|
||||
if !appliedAt.Valid {
|
||||
t.Error("expected the scheduled change to be marked applied_at")
|
||||
}
|
||||
|
||||
var notifCount int
|
||||
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'default_hours_changed'`).Scan(¬ifCount); err != nil {
|
||||
t.Fatalf("failed to count notifications: %v", err)
|
||||
}
|
||||
if notifCount != 1 {
|
||||
t.Errorf("expected 1 default_hours_changed admin notification, got %d", notifCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyScheduledDefaultHours_NoDueChange_Noop verifies the no-op path: with
|
||||
// no change due (effective_date in the future) the job returns 0 and touches
|
||||
// nothing.
|
||||
func TestApplyScheduledDefaultHours_NoDueChange_Noop(t *testing.T) {
|
||||
ctx, tx := resetTestData(t)
|
||||
|
||||
tomorrow := LondonDateString(clock.Now().Add(24 * time.Hour))
|
||||
seedStagedDefaultHoursChange(t, ctx, tx, tomorrow)
|
||||
|
||||
n, err := ApplyScheduledDefaultHours(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyScheduledDefaultHours failed: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("expected 0 applied changes for a future effective_date, got %d", n)
|
||||
}
|
||||
|
||||
start, end, isOpen := getWorkingHoursForWeekday(t, ctx, tx, 1)
|
||||
if start != "09:00:00" || end != "17:00:00" || !isOpen {
|
||||
t.Errorf("expected working_hours untouched, got %q-%q open=%v", start, end, isOpen)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyScheduledDefaultHours_NotificationFloodCap pins C5 for the
|
||||
// 'default_hours_changed' insert site: the unacknowledged queue is flood-capped
|
||||
// at adminnotify.MaxUnacknowledgedCriticalLogs, so the change still applies
|
||||
// (money/schedule-first) but no notification row is added past the cap.
|
||||
func TestApplyScheduledDefaultHours_NotificationFloodCap(t *testing.T) {
|
||||
ctx, tx := resetTestData(t)
|
||||
|
||||
yesterday := LondonDateString(clock.Now().Add(-24 * time.Hour))
|
||||
changeID := seedStagedDefaultHoursChange(t, ctx, tx, yesterday)
|
||||
|
||||
for i := 0; i < adminnotify.MaxUnacknowledgedCriticalLogs; i++ {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO admin_notifications (reason, created_at)
|
||||
VALUES ('default_hours_changed', NOW())
|
||||
`); err != nil {
|
||||
t.Fatalf("failed to seed default_hours_changed notification %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if !adminnotify.CriticalLogsCapExceeded(ctx, tx, "default_hours_changed") {
|
||||
t.Fatal("expected the unacknowledged default_hours_changed queue to be at the cap")
|
||||
}
|
||||
|
||||
n, err := ApplyScheduledDefaultHours(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyScheduledDefaultHours failed: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("expected the change to still apply at the notification cap, got %d", n)
|
||||
}
|
||||
|
||||
var appliedAt sql.NullTime
|
||||
if err := tx.QueryRow(ctx, `SELECT applied_at FROM default_hours_scheduled_changes WHERE id = $1`, changeID).Scan(&appliedAt); err != nil {
|
||||
t.Fatalf("failed to read change applied_at: %v", err)
|
||||
}
|
||||
if !appliedAt.Valid {
|
||||
t.Error("expected the scheduled change to be marked applied_at despite the notification cap")
|
||||
}
|
||||
|
||||
var dbCount int
|
||||
if err := tx.QueryRow(ctx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'default_hours_changed'`).Scan(&dbCount); err != nil {
|
||||
t.Fatalf("failed to count notifications: %v", err)
|
||||
}
|
||||
if dbCount != adminnotify.MaxUnacknowledgedCriticalLogs {
|
||||
t.Errorf("expected the queue to stay capped at %d, got %d", adminnotify.MaxUnacknowledgedCriticalLogs, dbCount)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Multi-row Count Tests
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user