- 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
1195 lines
35 KiB
Go
1195 lines
35 KiB
Go
//go:build test
|
|
|
|
package scheduling
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
"crussell/clock"
|
|
"crussell/db"
|
|
"crussell/internal/adminnotify"
|
|
"crussell/testutils/fixtures"
|
|
)
|
|
|
|
// ============================================================
|
|
// NotifyUnpaidOneWeek Tests
|
|
// ============================================================
|
|
|
|
func TestNotifyUnpaidOneWeek_CreatesNotification(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
// Create a booking that ended 14 days ago with no payment
|
|
var bookingID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO bookings (user_id, start_time, status)
|
|
VALUES ($1, $2, 'completed')
|
|
RETURNING id
|
|
`, userID, clock.Now().Add(-14*24*time.Hour)).Scan(&bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
n, err := NotifyUnpaidOneWeek(ctx)
|
|
if err != nil {
|
|
t.Fatalf("NotifyUnpaidOneWeek failed: %v", err)
|
|
}
|
|
if n != 1 {
|
|
t.Errorf("expected count 1, got %d", n)
|
|
}
|
|
|
|
var dbCount int
|
|
err = tx.QueryRow(ctx,
|
|
`SELECT COUNT(*) FROM admin_notifications WHERE reason = '1_week_no_pay' AND booking_id = $1`,
|
|
bookingID).Scan(&dbCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query notification count: %v", err)
|
|
}
|
|
if dbCount != 1 {
|
|
t.Errorf("expected 1 notification, got %d", dbCount)
|
|
}
|
|
|
|
var reason string
|
|
err = tx.QueryRow(ctx,
|
|
`SELECT reason FROM admin_notifications WHERE booking_id = $1`, bookingID).Scan(&reason)
|
|
if err != nil {
|
|
t.Fatalf("failed to query notification reason: %v", err)
|
|
}
|
|
if reason != "1_week_no_pay" {
|
|
t.Errorf("expected reason '1_week_no_pay', got %q", reason)
|
|
}
|
|
}
|
|
|
|
func TestNotifyUnpaidOneWeek_SkipsPaidBookings(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
var bookingID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO bookings (user_id, start_time, status)
|
|
VALUES ($1, $2, 'completed')
|
|
RETURNING id
|
|
`, userID, clock.Now().Add(-14*24*time.Hour)).Scan(&bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
_, err = fixtures.CreateTestPayment(tx, bookingID, 1000, "in_person_card", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
|
|
n, err := NotifyUnpaidOneWeek(ctx)
|
|
if err != nil {
|
|
t.Fatalf("NotifyUnpaidOneWeek failed: %v", err)
|
|
}
|
|
if n != 0 {
|
|
t.Errorf("expected count 0 for paid booking, got %d", n)
|
|
}
|
|
|
|
var dbCount int
|
|
err = tx.QueryRow(ctx,
|
|
`SELECT COUNT(*) FROM admin_notifications WHERE reason = '1_week_no_pay' AND booking_id = $1`,
|
|
bookingID).Scan(&dbCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query notification count: %v", err)
|
|
}
|
|
if dbCount != 0 {
|
|
t.Errorf("expected 0 notifications for paid booking, got %d", dbCount)
|
|
}
|
|
}
|
|
|
|
func TestNotifyUnpaidOneWeek_SkipsRecentBookings(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
var bookingID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO bookings (user_id, start_time, status)
|
|
VALUES ($1, $2, 'completed')
|
|
RETURNING id
|
|
`, userID, clock.Now().Add(-2*24*time.Hour)).Scan(&bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
n, err := NotifyUnpaidOneWeek(ctx)
|
|
if err != nil {
|
|
t.Fatalf("NotifyUnpaidOneWeek failed: %v", err)
|
|
}
|
|
if n != 0 {
|
|
t.Errorf("expected count 0 for recent booking, got %d", n)
|
|
}
|
|
|
|
var dbCount int
|
|
err = tx.QueryRow(ctx,
|
|
`SELECT COUNT(*) FROM admin_notifications WHERE reason = '1_week_no_pay' AND booking_id = $1`,
|
|
bookingID).Scan(&dbCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query notification count: %v", err)
|
|
}
|
|
if dbCount != 0 {
|
|
t.Errorf("expected 0 notifications for recent booking, got %d", dbCount)
|
|
}
|
|
}
|
|
|
|
func TestNotifyUnpaidOneWeek_Idempotent(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
var bookingID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO bookings (user_id, start_time, status)
|
|
VALUES ($1, $2, 'completed')
|
|
RETURNING id
|
|
`, userID, clock.Now().Add(-14*24*time.Hour)).Scan(&bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
n1, err := NotifyUnpaidOneWeek(ctx)
|
|
if err != nil {
|
|
t.Fatalf("first call failed: %v", err)
|
|
}
|
|
if n1 != 1 {
|
|
t.Errorf("expected count 1 on first call, got %d", n1)
|
|
}
|
|
|
|
n2, err := NotifyUnpaidOneWeek(ctx)
|
|
if err != nil {
|
|
t.Fatalf("second call failed: %v", err)
|
|
}
|
|
if n2 != 0 {
|
|
t.Errorf("expected count 0 on second call (idempotent), got %d", n2)
|
|
}
|
|
|
|
var dbCount int
|
|
err = tx.QueryRow(ctx,
|
|
`SELECT COUNT(*) FROM admin_notifications WHERE reason = '1_week_no_pay' AND booking_id = $1`,
|
|
bookingID).Scan(&dbCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query notification count: %v", err)
|
|
}
|
|
if dbCount != 1 {
|
|
t.Errorf("expected exactly 1 notification after 2 runs, got %d", dbCount)
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// NotifyUnpaidOneMonth Tests
|
|
// ============================================================
|
|
|
|
func TestNotifyUnpaidOneMonth_CreatesNotification(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
var bookingID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO bookings (user_id, start_time, status)
|
|
VALUES ($1, $2, 'completed')
|
|
RETURNING id
|
|
`, userID, clock.Now().Add(-45*24*time.Hour)).Scan(&bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
n, err := NotifyUnpaidOneMonth(ctx)
|
|
if err != nil {
|
|
t.Fatalf("NotifyUnpaidOneMonth failed: %v", err)
|
|
}
|
|
if n != 1 {
|
|
t.Errorf("expected count 1, got %d", n)
|
|
}
|
|
|
|
var dbCount int
|
|
err = tx.QueryRow(ctx,
|
|
`SELECT COUNT(*) FROM admin_notifications WHERE reason = '1_month_no_pay' AND booking_id = $1`,
|
|
bookingID).Scan(&dbCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query notification count: %v", err)
|
|
}
|
|
if dbCount != 1 {
|
|
t.Errorf("expected 1 notification, got %d", dbCount)
|
|
}
|
|
}
|
|
|
|
func TestNotifyUnpaidOneMonth_SkipsPaidBookings(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
var bookingID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO bookings (user_id, start_time, status)
|
|
VALUES ($1, $2, 'completed')
|
|
RETURNING id
|
|
`, userID, clock.Now().Add(-45*24*time.Hour)).Scan(&bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
_, err = fixtures.CreateTestPayment(tx, bookingID, 1000, "in_person_card", "full", "completed")
|
|
if err != nil {
|
|
t.Fatalf("failed to create payment: %v", err)
|
|
}
|
|
|
|
n, err := NotifyUnpaidOneMonth(ctx)
|
|
if err != nil {
|
|
t.Fatalf("NotifyUnpaidOneMonth failed: %v", err)
|
|
}
|
|
if n != 0 {
|
|
t.Errorf("expected count 0 for paid booking, got %d", n)
|
|
}
|
|
|
|
var dbCount int
|
|
err = tx.QueryRow(ctx,
|
|
`SELECT COUNT(*) FROM admin_notifications WHERE reason = '1_month_no_pay' AND booking_id = $1`,
|
|
bookingID).Scan(&dbCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query notification count: %v", err)
|
|
}
|
|
if dbCount != 0 {
|
|
t.Errorf("expected 0 notifications for paid booking, got %d", dbCount)
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// TransitionDiscountCampaigns Tests
|
|
// ============================================================
|
|
|
|
func TestTransitionDiscountCampaigns_ActivatesDraft(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
var campaignID string
|
|
err := tx.QueryRow(ctx, `
|
|
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date)
|
|
VALUES ($1, 'time_based', 10.0, 'draft', $2, $3)
|
|
RETURNING id
|
|
`, "Test Campaign", clock.Now().Add(-1*time.Hour), clock.Now().Add(24*time.Hour)).Scan(&campaignID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create campaign: %v", err)
|
|
}
|
|
|
|
n, err := TransitionDiscountCampaigns(ctx)
|
|
if err != nil {
|
|
t.Fatalf("TransitionDiscountCampaigns failed: %v", err)
|
|
}
|
|
if n != 1 {
|
|
t.Errorf("expected count 1 (activated), got %d", n)
|
|
}
|
|
|
|
var status string
|
|
err = tx.QueryRow(ctx, `SELECT status FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&status)
|
|
if err != nil {
|
|
t.Fatalf("failed to query campaign status: %v", err)
|
|
}
|
|
if status != "active" {
|
|
t.Errorf("expected status 'active', got %q", status)
|
|
}
|
|
}
|
|
|
|
func TestTransitionDiscountCampaigns_CompletesExpired(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
var campaignID string
|
|
err := tx.QueryRow(ctx, `
|
|
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date)
|
|
VALUES ($1, 'time_based', 10.0, 'active', $2, $3)
|
|
RETURNING id
|
|
`, "Expired Campaign", clock.Now().Add(-48*time.Hour), clock.Now().Add(-1*time.Hour)).Scan(&campaignID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create campaign: %v", err)
|
|
}
|
|
|
|
n, err := TransitionDiscountCampaigns(ctx)
|
|
if err != nil {
|
|
t.Fatalf("TransitionDiscountCampaigns failed: %v", err)
|
|
}
|
|
if n != 1 {
|
|
t.Errorf("expected count 1 (completed), got %d", n)
|
|
}
|
|
|
|
var status string
|
|
err = tx.QueryRow(ctx, `SELECT status FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&status)
|
|
if err != nil {
|
|
t.Fatalf("failed to query campaign status: %v", err)
|
|
}
|
|
if status != "completed" {
|
|
t.Errorf("expected status 'completed', got %q", status)
|
|
}
|
|
}
|
|
|
|
func TestTransitionDiscountCampaigns_CompletesMaxRedemptions(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
var campaignID string
|
|
err := tx.QueryRow(ctx, `
|
|
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, max_redemptions, times_redeemed)
|
|
VALUES ($1, 'time_based', 10.0, 'active', $2, $3, 10, 10)
|
|
RETURNING id
|
|
`, "Full Campaign", clock.Now().Add(-48*time.Hour), clock.Now().Add(24*time.Hour)).Scan(&campaignID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create campaign: %v", err)
|
|
}
|
|
|
|
n, err := TransitionDiscountCampaigns(ctx)
|
|
if err != nil {
|
|
t.Fatalf("TransitionDiscountCampaigns failed: %v", err)
|
|
}
|
|
if n != 1 {
|
|
t.Errorf("expected count 1 (redemption limit), got %d", n)
|
|
}
|
|
|
|
var status string
|
|
err = tx.QueryRow(ctx, `SELECT status FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&status)
|
|
if err != nil {
|
|
t.Fatalf("failed to query campaign status: %v", err)
|
|
}
|
|
if status != "completed" {
|
|
t.Errorf("expected status 'completed', got %q", status)
|
|
}
|
|
}
|
|
|
|
func TestTransitionDiscountCampaigns_KeepsActiveCampaigns(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
var campaignID string
|
|
err := tx.QueryRow(ctx, `
|
|
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date)
|
|
VALUES ($1, 'time_based', 10.0, 'active', $2, $3)
|
|
RETURNING id
|
|
`, "Active Campaign", clock.Now().Add(-24*time.Hour), clock.Now().Add(24*time.Hour)).Scan(&campaignID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create campaign: %v", err)
|
|
}
|
|
|
|
n, err := TransitionDiscountCampaigns(ctx)
|
|
if err != nil {
|
|
t.Fatalf("TransitionDiscountCampaigns failed: %v", err)
|
|
}
|
|
if n != 0 {
|
|
t.Errorf("expected count 0 (no transitions needed), got %d", n)
|
|
}
|
|
|
|
var status string
|
|
err = tx.QueryRow(ctx, `SELECT status FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&status)
|
|
if err != nil {
|
|
t.Fatalf("failed to query campaign status: %v", err)
|
|
}
|
|
if status != "active" {
|
|
t.Errorf("expected status 'active', got %q", status)
|
|
}
|
|
}
|
|
|
|
func TestTransitionDiscountCampaigns_PreservesCancelled(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
var campaignID string
|
|
err := tx.QueryRow(ctx, `
|
|
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date)
|
|
VALUES ($1, 'time_based', 10.0, 'cancelled', $2, $3)
|
|
RETURNING id
|
|
`, "Cancelled Campaign", clock.Now().Add(-48*time.Hour), clock.Now().Add(-1*time.Hour)).Scan(&campaignID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create campaign: %v", err)
|
|
}
|
|
|
|
n, err := TransitionDiscountCampaigns(ctx)
|
|
if err != nil {
|
|
t.Fatalf("TransitionDiscountCampaigns failed: %v", err)
|
|
}
|
|
if n != 0 {
|
|
t.Errorf("expected count 0 (cancelled skipped), got %d", n)
|
|
}
|
|
|
|
var status string
|
|
err = tx.QueryRow(ctx, `SELECT status FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&status)
|
|
if err != nil {
|
|
t.Fatalf("failed to query campaign status: %v", err)
|
|
}
|
|
if status != "cancelled" {
|
|
t.Errorf("expected status 'cancelled' to be preserved, got %q", status)
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// CleanupExpiredVerificationCodes Tests
|
|
// ============================================================
|
|
|
|
func TestCleanupExpiredVerificationCodes_DeletesExpired(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
var codeID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO verification_codes (user_id, purpose, code, expires_at)
|
|
VALUES ($1, 'email_verify', 'EXPIRED01', $2)
|
|
RETURNING id
|
|
`, userID, clock.Now().Add(-1*time.Hour)).Scan(&codeID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create expired code: %v", err)
|
|
}
|
|
|
|
n, err := CleanupExpiredVerificationCodes(ctx)
|
|
if err != nil {
|
|
t.Fatalf("CleanupExpiredVerificationCodes failed: %v", err)
|
|
}
|
|
if n != 1 {
|
|
t.Errorf("expected count 1 (expired), got %d", n)
|
|
}
|
|
|
|
var dbCount int
|
|
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM verification_codes WHERE id = $1`, codeID).Scan(&dbCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query code count: %v", err)
|
|
}
|
|
if dbCount != 0 {
|
|
t.Error("expected expired code to be deleted")
|
|
}
|
|
}
|
|
|
|
func TestCleanupExpiredVerificationCodes_DeletesOldUsed(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
var codeID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO verification_codes (user_id, purpose, code, expires_at, used_at)
|
|
VALUES ($1, 'email_verify', 'USEDOLD01', $2, $3)
|
|
RETURNING id
|
|
`, userID, clock.Now().Add(1*time.Hour), clock.Now().Add(-45*24*time.Hour)).Scan(&codeID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create old used code: %v", err)
|
|
}
|
|
|
|
n, err := CleanupExpiredVerificationCodes(ctx)
|
|
if err != nil {
|
|
t.Fatalf("CleanupExpiredVerificationCodes failed: %v", err)
|
|
}
|
|
if n != 1 {
|
|
t.Errorf("expected count 1 (old used), got %d", n)
|
|
}
|
|
|
|
var dbCount int
|
|
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM verification_codes WHERE id = $1`, codeID).Scan(&dbCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query code count: %v", err)
|
|
}
|
|
if dbCount != 0 {
|
|
t.Error("expected old used code to be deleted")
|
|
}
|
|
}
|
|
|
|
func TestCleanupExpiredVerificationCodes_PreservesValid(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
var codeID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO verification_codes (user_id, purpose, code, expires_at)
|
|
VALUES ($1, 'email_verify', 'VALID001', $2)
|
|
RETURNING id
|
|
`, userID, clock.Now().Add(24*time.Hour)).Scan(&codeID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create valid code: %v", err)
|
|
}
|
|
|
|
n, err := CleanupExpiredVerificationCodes(ctx)
|
|
if err != nil {
|
|
t.Fatalf("CleanupExpiredVerificationCodes failed: %v", err)
|
|
}
|
|
if n != 0 {
|
|
t.Errorf("expected count 0 (valid preserved), got %d", n)
|
|
}
|
|
|
|
var dbCount int
|
|
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM verification_codes WHERE id = $1`, codeID).Scan(&dbCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query code count: %v", err)
|
|
}
|
|
if dbCount != 1 {
|
|
t.Error("expected valid code to be preserved")
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// CleanupExpiredRefreshTokens Tests
|
|
// ============================================================
|
|
|
|
func TestCleanupExpiredRefreshTokens_DeletesExpired(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
var tokenID int
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO refresh_tokens (user_id, token_hash, role, expires_at)
|
|
VALUES ($1, 'expired_hash', 'test', $2)
|
|
RETURNING id
|
|
`, userID, clock.Now().Add(-1*time.Hour)).Scan(&tokenID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create expired token: %v", err)
|
|
}
|
|
|
|
n, err := CleanupExpiredRefreshTokens(ctx)
|
|
if err != nil {
|
|
t.Fatalf("CleanupExpiredRefreshTokens failed: %v", err)
|
|
}
|
|
if n != 1 {
|
|
t.Errorf("expected count 1 (expired), got %d", n)
|
|
}
|
|
|
|
var dbCount int
|
|
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM refresh_tokens WHERE id = $1`, tokenID).Scan(&dbCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query token count: %v", err)
|
|
}
|
|
if dbCount != 0 {
|
|
t.Error("expected expired token to be deleted")
|
|
}
|
|
}
|
|
|
|
func TestCleanupExpiredRefreshTokens_DeletesRevoked(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
var tokenID int
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO refresh_tokens (user_id, token_hash, role, expires_at, revoked, created_at)
|
|
VALUES ($1, 'revoked_hash', 'test', $2, TRUE, $3)
|
|
RETURNING id
|
|
`, userID, clock.Now().Add(24*time.Hour), clock.Now().Add(-100*24*time.Hour)).Scan(&tokenID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create revoked token: %v", err)
|
|
}
|
|
|
|
n, err := CleanupExpiredRefreshTokens(ctx)
|
|
if err != nil {
|
|
t.Fatalf("CleanupExpiredRefreshTokens failed: %v", err)
|
|
}
|
|
if n != 1 {
|
|
t.Errorf("expected count 1 (revoked), got %d", n)
|
|
}
|
|
|
|
var dbCount int
|
|
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM refresh_tokens WHERE id = $1`, tokenID).Scan(&dbCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query token count: %v", err)
|
|
}
|
|
if dbCount != 0 {
|
|
t.Error("expected revoked token to be deleted")
|
|
}
|
|
}
|
|
|
|
func TestCleanupExpiredRefreshTokens_PreservesValid(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
var tokenID int
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO refresh_tokens (user_id, token_hash, role, expires_at)
|
|
VALUES ($1, 'valid_hash', 'test', $2)
|
|
RETURNING id
|
|
`, userID, clock.Now().Add(90*24*time.Hour)).Scan(&tokenID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create valid token: %v", err)
|
|
}
|
|
|
|
n, err := CleanupExpiredRefreshTokens(ctx)
|
|
if err != nil {
|
|
t.Fatalf("CleanupExpiredRefreshTokens failed: %v", err)
|
|
}
|
|
if n != 0 {
|
|
t.Errorf("expected count 0 (valid preserved), got %d", n)
|
|
}
|
|
|
|
var dbCount int
|
|
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM refresh_tokens WHERE id = $1`, tokenID).Scan(&dbCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query token count: %v", err)
|
|
}
|
|
if dbCount != 1 {
|
|
t.Error("expected valid token to be preserved")
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// 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
|
|
// ============================================================
|
|
|
|
func TestNotifyUnpaidOneWeek_MultipleBookings(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
user1, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user1: %v", err)
|
|
}
|
|
user2, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user2: %v", err)
|
|
}
|
|
|
|
for _, uid := range []string{user1, user2} {
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO bookings (user_id, start_time, status)
|
|
VALUES ($1, $2, 'completed')
|
|
`, uid, clock.Now().Add(-14*24*time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking for %s: %v", uid, err)
|
|
}
|
|
}
|
|
|
|
n, err := NotifyUnpaidOneWeek(ctx)
|
|
if err != nil {
|
|
t.Fatalf("NotifyUnpaidOneWeek failed: %v", err)
|
|
}
|
|
if n != 2 {
|
|
t.Errorf("expected count 2 for 2 unpaid bookings, got %d", n)
|
|
}
|
|
}
|
|
|
|
func TestTransitionDiscountCampaigns_MultipleTransitions(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
// Create one draft (should activate) + one active+expired (should complete)
|
|
_, err := tx.Exec(ctx, `
|
|
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date)
|
|
VALUES ($1, 'time_based', 10.0, 'draft', $2, $3)
|
|
`, "Draft Campaign", clock.Now().Add(-1*time.Hour), clock.Now().Add(24*time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("failed to create draft campaign: %v", err)
|
|
}
|
|
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date)
|
|
VALUES ($1, 'time_based', 10.0, 'active', $2, $3)
|
|
`, "Expired Active Campaign", clock.Now().Add(-48*time.Hour), clock.Now().Add(-1*time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("failed to create expired active campaign: %v", err)
|
|
}
|
|
|
|
n, err := TransitionDiscountCampaigns(ctx)
|
|
if err != nil {
|
|
t.Fatalf("TransitionDiscountCampaigns failed: %v", err)
|
|
}
|
|
if n != 2 {
|
|
t.Errorf("expected count 2 (1 activated + 1 completed), got %d", n)
|
|
}
|
|
}
|
|
|
|
func TestCleanupExpiredVerificationCodes_MultipleCodes(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
// 2 expired codes + 1 used old code = 3 deletable + 1 valid = 4 total
|
|
for i := 0; i < 2; i++ {
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO verification_codes (user_id, purpose, code, expires_at)
|
|
VALUES ($1, 'email_verify', $2, $3)
|
|
`, userID, fmt.Sprintf("EXP%04d", i), clock.Now().Add(-1*time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("failed to create expired code: %v", err)
|
|
}
|
|
}
|
|
|
|
// One old used code
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO verification_codes (user_id, purpose, code, expires_at, used_at)
|
|
VALUES ($1, 'email_verify', 'USEDEX', $2, $3)
|
|
`, userID, clock.Now().Add(1*time.Hour), clock.Now().Add(-45*24*time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("failed to create used code: %v", err)
|
|
}
|
|
|
|
n, err := CleanupExpiredVerificationCodes(ctx)
|
|
if err != nil {
|
|
t.Fatalf("CleanupExpiredVerificationCodes failed: %v", err)
|
|
}
|
|
if n != 3 {
|
|
t.Errorf("expected count 3 (2 expired + 1 old used), got %d", n)
|
|
}
|
|
}
|
|
|
|
func TestCleanupExpiredRefreshTokens_MultipleTokens(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
// 1 expired
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO refresh_tokens (user_id, token_hash, role, expires_at)
|
|
VALUES ($1, 'exp1', 'test', $2)
|
|
`, userID, clock.Now().Add(-1*time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("failed to create expired token: %v", err)
|
|
}
|
|
|
|
// 1 revoked + old
|
|
_, err = tx.Exec(ctx, `
|
|
INSERT INTO refresh_tokens (user_id, token_hash, role, expires_at, revoked, created_at)
|
|
VALUES ($1, 'rev1', 'test', $2, TRUE, $3)
|
|
`, userID, clock.Now().Add(24*time.Hour), clock.Now().Add(-100*24*time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("failed to create revoked token: %v", err)
|
|
}
|
|
|
|
n, err := CleanupExpiredRefreshTokens(ctx)
|
|
if err != nil {
|
|
t.Fatalf("CleanupExpiredRefreshTokens failed: %v", err)
|
|
}
|
|
if n != 2 {
|
|
t.Errorf("expected count 2 (1 expired + 1 revoked), got %d", n)
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// Gap-Filling Tests for Uncovered Branches
|
|
// ============================================================
|
|
|
|
func TestNotifyUnpaidOneWeek_SkipsCancelled(t *testing.T) {
|
|
ctx, tx := resetTestData(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
// Create a cancelled booking that ended 14 days ago (within the 7-30d window)
|
|
var bookingID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO bookings (user_id, start_time, status)
|
|
VALUES ($1, $2, 'client_cancelled')
|
|
RETURNING id
|
|
`, userID, clock.Now().Add(-14*24*time.Hour)).Scan(&bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create cancelled booking: %v", err)
|
|
}
|
|
|
|
n, err := NotifyUnpaidOneWeek(ctx)
|
|
if err != nil {
|
|
t.Fatalf("NotifyUnpaidOneWeek failed: %v", err)
|
|
}
|
|
if n != 0 {
|
|
t.Errorf("expected count 0 for cancelled booking, got %d", n)
|
|
}
|
|
}
|
|
|
|
func TestNotifyUnpaidOneWeek_SkipsOlderThan30Days(t *testing.T) {
|
|
ctx, tx := resetTestData(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
// Create a booking that ended 45 days ago (outside 7-30 day window)
|
|
var bookingID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO bookings (user_id, start_time, status)
|
|
VALUES ($1, $2, 'completed')
|
|
RETURNING id
|
|
`, userID, clock.Now().Add(-45*24*time.Hour)).Scan(&bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create old booking: %v", err)
|
|
}
|
|
|
|
n, err := NotifyUnpaidOneWeek(ctx)
|
|
if err != nil {
|
|
t.Fatalf("NotifyUnpaidOneWeek failed: %v", err)
|
|
}
|
|
if n != 0 {
|
|
t.Errorf("expected count 0 for booking >30 days old, got %d", n)
|
|
}
|
|
}
|
|
|
|
func TestNotifyUnpaidOneWeek_SkipsLessThan7Days(t *testing.T) {
|
|
ctx, tx := resetTestData(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
// Create a booking that ended 3 days ago (less than 7 days)
|
|
var bookingID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO bookings (user_id, start_time, status)
|
|
VALUES ($1, $2, 'completed')
|
|
RETURNING id
|
|
`, userID, clock.Now().Add(-3*24*time.Hour)).Scan(&bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create recent booking: %v", err)
|
|
}
|
|
|
|
n, err := NotifyUnpaidOneWeek(ctx)
|
|
if err != nil {
|
|
t.Fatalf("NotifyUnpaidOneWeek failed: %v", err)
|
|
}
|
|
if n != 0 {
|
|
t.Errorf("expected count 0 for booking <7 days old, got %d", n)
|
|
}
|
|
}
|
|
|
|
func TestNotifyUnpaidOneMonth_SkipsCancelled(t *testing.T) {
|
|
ctx, tx := resetTestData(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
var bookingID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO bookings (user_id, start_time, status)
|
|
VALUES ($1, $2, 'we_cancelled')
|
|
RETURNING id
|
|
`, userID, clock.Now().Add(-45*24*time.Hour)).Scan(&bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create cancelled booking: %v", err)
|
|
}
|
|
|
|
n, err := NotifyUnpaidOneMonth(ctx)
|
|
if err != nil {
|
|
t.Fatalf("NotifyUnpaidOneMonth failed: %v", err)
|
|
}
|
|
if n != 0 {
|
|
t.Errorf("expected count 0 for cancelled booking, got %d", n)
|
|
}
|
|
}
|
|
|
|
func TestTransitionDiscountCampaigns_OnlyActivated(t *testing.T) {
|
|
ctx, tx := resetTestData(t)
|
|
|
|
var campaignID string
|
|
err := tx.QueryRow(ctx, `
|
|
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date)
|
|
VALUES ($1, 'time_based', 15.0, 'draft', $2, $3)
|
|
RETURNING id
|
|
`, "Draft Only", clock.Now().Add(-1*time.Hour), clock.Now().Add(24*time.Hour)).Scan(&campaignID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create draft campaign: %v", err)
|
|
}
|
|
|
|
n, err := TransitionDiscountCampaigns(ctx)
|
|
if err != nil {
|
|
t.Fatalf("TransitionDiscountCampaigns failed: %v", err)
|
|
}
|
|
if n != 1 {
|
|
t.Errorf("expected count 1 (only activated), got %d", n)
|
|
}
|
|
|
|
var status string
|
|
err = tx.QueryRow(ctx, `SELECT status FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&status)
|
|
if err != nil {
|
|
t.Fatalf("failed to query campaign status: %v", err)
|
|
}
|
|
if status != "active" {
|
|
t.Errorf("expected status 'active', got %q", status)
|
|
}
|
|
}
|
|
|
|
func TestTransitionDiscountCampaigns_OnlyCompleted(t *testing.T) {
|
|
ctx, tx := resetTestData(t)
|
|
|
|
var campaignID string
|
|
err := tx.QueryRow(ctx, `
|
|
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date)
|
|
VALUES ($1, 'time_based', 20.0, 'active', $2, $3)
|
|
RETURNING id
|
|
`, "Expired Active", clock.Now().Add(-48*time.Hour), clock.Now().Add(-1*time.Hour)).Scan(&campaignID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create expired active campaign: %v", err)
|
|
}
|
|
|
|
n, err := TransitionDiscountCampaigns(ctx)
|
|
if err != nil {
|
|
t.Fatalf("TransitionDiscountCampaigns failed: %v", err)
|
|
}
|
|
if n != 1 {
|
|
t.Errorf("expected count 1 (only completed), got %d", n)
|
|
}
|
|
|
|
var status string
|
|
err = tx.QueryRow(ctx, `SELECT status FROM discount_campaigns WHERE id = $1`, campaignID).Scan(&status)
|
|
if err != nil {
|
|
t.Fatalf("failed to query campaign status: %v", err)
|
|
}
|
|
if status != "completed" {
|
|
t.Errorf("expected status 'completed', got %q", status)
|
|
}
|
|
}
|
|
|
|
// TestNotifyUnpaidOneWeek_NotificationFloodCap pins C5 for the
|
|
// '1_week_no_pay' insert site: the unacknowledged queue is flood-capped at
|
|
// adminnotify.MaxUnacknowledgedCriticalLogs, so a runaway unpaid-booking
|
|
// cleanup cannot bury the operator's notification centre. At the cap further
|
|
// inserts are suppressed and the queue stays bounded.
|
|
func TestNotifyUnpaidOneWeek_NotificationFloodCap(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, tx := resetTestData(t)
|
|
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
if err != nil {
|
|
t.Fatalf("failed to create user: %v", err)
|
|
}
|
|
|
|
var bookingID string
|
|
err = tx.QueryRow(ctx, `
|
|
INSERT INTO bookings (user_id, start_time, status)
|
|
VALUES ($1, $2, 'completed')
|
|
RETURNING id
|
|
`, userID, clock.Now().Add(-14*24*time.Hour)).Scan(&bookingID)
|
|
if err != nil {
|
|
t.Fatalf("failed to create booking: %v", err)
|
|
}
|
|
|
|
// Fill the unacknowledged '1_week_no_pay' queue to the cap before the
|
|
// cleanup runs, so the insert site must suppress instead of growing it.
|
|
for i := 0; i < adminnotify.MaxUnacknowledgedCriticalLogs; i++ {
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO admin_notifications (reason, user_id, created_at)
|
|
VALUES ('1_week_no_pay', $1, NOW())
|
|
`, userID); err != nil {
|
|
t.Fatalf("failed to seed 1_week_no_pay notification %d: %v", i, err)
|
|
}
|
|
}
|
|
if !adminnotify.CriticalLogsCapExceeded(ctx, tx, "1_week_no_pay") {
|
|
t.Fatal("expected the unacknowledged 1_week_no_pay queue to be at the cap")
|
|
}
|
|
|
|
n, err := NotifyUnpaidOneWeek(ctx)
|
|
if err != nil {
|
|
t.Fatalf("NotifyUnpaidOneWeek failed: %v", err)
|
|
}
|
|
if n != 1 {
|
|
t.Errorf("expected candidate count 1, got %d", n)
|
|
}
|
|
|
|
var dbCount int
|
|
err = tx.QueryRow(ctx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = '1_week_no_pay'`).Scan(&dbCount)
|
|
if err != nil {
|
|
t.Fatalf("failed to query notification count: %v", err)
|
|
}
|
|
if dbCount != adminnotify.MaxUnacknowledgedCriticalLogs {
|
|
t.Errorf("expected the queue to stay capped at %d, got %d", adminnotify.MaxUnacknowledgedCriticalLogs, dbCount)
|
|
}
|
|
}
|