Files
Crussell/backend/handlers/scheduling/scheduled_cleanup_test.go
T
popertots 3029fd5179
CI / Nginx config check (push) Successful in 13s
CI / Env docs check (push) Successful in 15s
CI / Docker compose check (push) Successful in 15s
CI / Frontend major deps (push) Failing after 24s
CI / Frontend deps check (push) Successful in 30s
CI / Secrets scan (push) Successful in 38s
CI / Go build (push) Successful in 39s
CI / Frontend build (push) Successful in 1m3s
CI / Knip (push) Successful in 45s
CI / Go vet (prod) (push) Failing after 1m42s
CI / Frontend a11y check (push) Successful in 2m34s
CI / Go vet (dev) (push) Successful in 2m29s
CI / Staticcheck (prod) (push) Failing after 2m38s
CI / go mod tidy (push) Successful in 1m3s
CI / Staticcheck (dev) (push) Successful in 2m55s
CI / Frontend QC (audit) (push) Successful in 51s
CI / golangci-lint (push) Successful in 3m22s
CI / Go vulnerabilities (push) Successful in 1m26s
CI / Frontend QC (typecheck) (push) Successful in 2m18s
CI / Security scan (prod) (push) Successful in 4m18s
CI / Security scan (dev) (push) Successful in 4m40s
CI / Tests (prod) (push) Has been skipped
CI / Tests (dev) (push) Has been skipped
CI / Race (prod) (push) Has been skipped
CI / Race (dev) (push) Has been skipped
CI / Frontend QC (lint) (push) Successful in 2m18s
CI / Svelte strict check (push) Successful in 43s
test: add coverage tests across backend + fix mock for PENDING checkout support
New test files cover previously untested paths across DAV, validators,
S3, Square, mw, bookings, user, and payments packages.

Includes mock fix: HoldCheckouts flag on MockClient allows tests to
pause auto-complete goroutine for testing PENDING checkout states.

Coverage: 50.4% → 65.0% (+14.6pp)
2026-07-10 18:13:44 +01:00

995 lines
28 KiB
Go

//go:build test
package scheduling
import (
"fmt"
"testing"
"time"
"crussell/clock"
"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")
}
}
// ============================================================
// 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)
}
}