feat(backend): add booking dedup, deposit tests and test main
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
//go:build test && dev
|
||||
// +build test,dev
|
||||
|
||||
package bookings
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/db"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// ProgressBookingHandler — Dedup guards for early-payment discounts
|
||||
// =============================================================================
|
||||
|
||||
func setupDedupTest(t *testing.T) (string, string, string) {
|
||||
t.Helper()
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
userID := createTestUser(t, 0)
|
||||
serviceID := createTestService(t, 50.00)
|
||||
|
||||
now := time.Now()
|
||||
startTime := now.Add(72 * time.Hour)
|
||||
bookingID := createPendingBooking(t, userID, serviceID, startTime)
|
||||
|
||||
return userID, serviceID, bookingID
|
||||
}
|
||||
|
||||
func insertPaymentForBooking(t *testing.T, bookingID, userID string, amount int) {
|
||||
t.Helper()
|
||||
_, err := db.DB.Exec(context.Background(), `
|
||||
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_at, updated_at)
|
||||
VALUES ($1, 'full', 'cash', $2, 'completed', NOW(), NOW())
|
||||
`, bookingID, amount)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestProgressBooking_LoyaltyDedup(t *testing.T) {
|
||||
resetTestData(t)
|
||||
userID, _, bookingID := setupDedupTest(t)
|
||||
|
||||
// Pre-apply loyalty discount (simulating early-payment)
|
||||
_, err := db.DB.Exec(context.Background(), `
|
||||
INSERT INTO booking_discounts (booking_id, user_id, discount_source, discount_percent, original_total, discount_amount)
|
||||
VALUES ($1, $2, 'loyalty', 10, 5000, 500)
|
||||
`, bookingID, userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
insertPaymentForBooking(t, bookingID, userID, 5000)
|
||||
|
||||
// Complete the booking — should not double-apply loyalty
|
||||
completeBooking(t, bookingID)
|
||||
|
||||
count := getDiscountRowCount(t, bookingID)
|
||||
assert.Equal(t, 1, count, "Loyalty should not be double-applied at completion")
|
||||
}
|
||||
|
||||
func TestProgressBooking_TimeBasedCampaignDedup(t *testing.T) {
|
||||
resetTestData(t)
|
||||
userID, _, bookingID := setupDedupTest(t)
|
||||
|
||||
// Create a time-based campaign
|
||||
campaignName := "Time Dedup Test"
|
||||
campaign := createTestCampaign(t, campaignName, "time_based", 10.0, nil, nil, nil, nil)
|
||||
|
||||
// Pre-apply the campaign (simulating early-payment auto-apply)
|
||||
_, err := db.DB.Exec(context.Background(), `
|
||||
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, discount_percent, original_total, discount_amount)
|
||||
VALUES ($1, $2, 'campaign', $3, 'time_based', 10, 5000, 500)
|
||||
`, bookingID, userID, campaign)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Increment campaign's times_redeemed as if payment-time applied it
|
||||
_, err = db.DB.Exec(context.Background(), `
|
||||
UPDATE discount_campaigns SET times_redeemed = 1 WHERE id = $1
|
||||
`, campaign)
|
||||
require.NoError(t, err)
|
||||
|
||||
insertPaymentForBooking(t, bookingID, userID, 5000)
|
||||
|
||||
// Complete the booking
|
||||
completeBooking(t, bookingID)
|
||||
|
||||
count := getDiscountRowCount(t, bookingID)
|
||||
assert.Equal(t, 1, count, "Time-based campaign should not be double-applied at completion")
|
||||
}
|
||||
|
||||
func TestProgressBooking_UserMilestoneDedup(t *testing.T) {
|
||||
resetTestData(t)
|
||||
userID, _, bookingID := setupDedupTest(t)
|
||||
|
||||
// Give user 5 completed bookings
|
||||
svcID := createTestService(t, 30.00)
|
||||
for i := 0; i < 5; i++ {
|
||||
bid := createPendingBooking(t, userID, svcID, time.Now().Add(-time.Duration(30-i)*24*time.Hour))
|
||||
insertPaymentForBooking(t, bid, userID, 3000)
|
||||
completeBooking(t, bid)
|
||||
}
|
||||
|
||||
val := 5
|
||||
maxRed := 1
|
||||
campaign := createTestCampaign(t, "5th Visit", "milestone", 15.0, strPtr("per_user_booking_count"), strPtr("bookings"), &val, &maxRed)
|
||||
|
||||
// Pre-apply the milestone (simulating early-payment)
|
||||
_, err := db.DB.Exec(context.Background(), `
|
||||
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, discount_percent, original_total, discount_amount)
|
||||
VALUES ($1, $2, 'campaign', $3, 'milestone', 15, 5000, 750)
|
||||
`, bookingID, userID, campaign)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Mark the campaign as already having 1 redemption
|
||||
_, err = db.DB.Exec(context.Background(), `
|
||||
UPDATE discount_campaigns SET times_redeemed = 1 WHERE id = $1
|
||||
`, campaign)
|
||||
require.NoError(t, err)
|
||||
|
||||
insertPaymentForBooking(t, bookingID, userID, 5000)
|
||||
completeBooking(t, bookingID)
|
||||
|
||||
count := getDiscountRowCount(t, bookingID)
|
||||
assert.Equal(t, 1, count, "User milestone should not be double-applied at completion")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// No-Show Tracking — ApplyDepositsIfNeeded at cancellation time
|
||||
// =============================================================================
|
||||
|
||||
func TestNoShowApplyDepositsIfNeeded(t *testing.T) {
|
||||
resetTestData(t)
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
userID := createTestUser(t, 0)
|
||||
serviceID := createTestService(t, 50.00)
|
||||
|
||||
// Create 2 no-show bookings (confirmed bookings cancelled <24h before start)
|
||||
now := time.Now()
|
||||
for i := 0; i < 2; i++ {
|
||||
bid := createPendingBooking(t, userID, serviceID, now.Add(-time.Duration(i)*time.Hour))
|
||||
insertPaymentForBooking(t, bid, userID, 5000)
|
||||
|
||||
_, err := db.DB.Exec(context.Background(), `
|
||||
UPDATE bookings SET status = 'no_show' WHERE id = $1
|
||||
`, bid)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Run ApplyDepositsIfNeeded
|
||||
applied, err := ApplyDepositsIfNeeded(context.Background(), userID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, applied, "Should have applied deposits_required = 3 after 2 no-shows")
|
||||
|
||||
var depositsRequired int
|
||||
db.DB.QueryRow(context.Background(),
|
||||
"SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&depositsRequired)
|
||||
assert.Equal(t, 3, depositsRequired, "Expected deposits_required = 3 after 2 no-shows in 6 months")
|
||||
}
|
||||
|
||||
func TestNoShowSingleNoShowDoesNotTrigger(t *testing.T) {
|
||||
resetTestData(t)
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
userID := createTestUser(t, 0)
|
||||
serviceID := createTestService(t, 50.00)
|
||||
|
||||
bid := createPendingBooking(t, userID, serviceID, time.Now().Add(-time.Hour))
|
||||
insertPaymentForBooking(t, bid, userID, 5000)
|
||||
|
||||
_, err := db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'no_show' WHERE id = $1", bid)
|
||||
require.NoError(t, err)
|
||||
|
||||
applied, err := ApplyDepositsIfNeeded(context.Background(), userID)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, applied, "Single no-show should not trigger deposits_required")
|
||||
|
||||
var depositsRequired int
|
||||
db.DB.QueryRow(context.Background(),
|
||||
"SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&depositsRequired)
|
||||
assert.Equal(t, 0, depositsRequired, "Expected deposits_required = 0 with only 1 no-show")
|
||||
}
|
||||
|
||||
func TestNoShowOldNoShowsExcluded(t *testing.T) {
|
||||
resetTestData(t)
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
userID := createTestUser(t, 0)
|
||||
serviceID := createTestService(t, 50.00)
|
||||
|
||||
// Create a no-show more than 6 months ago — should not count
|
||||
oldBid := createPendingBooking(t, userID, serviceID, time.Now().Add(-200*24*time.Hour))
|
||||
insertPaymentForBooking(t, oldBid, userID, 5000)
|
||||
|
||||
_, err := db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'no_show' WHERE id = $1", oldBid)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a recent no-show (within 6 months)
|
||||
recentBid := createPendingBooking(t, userID, serviceID, time.Now().Add(-time.Hour))
|
||||
insertPaymentForBooking(t, recentBid, userID, 5000)
|
||||
|
||||
_, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'no_show' WHERE id = $1", recentBid)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should only count 1 recent no-show, not trigger
|
||||
applied, err := ApplyDepositsIfNeeded(context.Background(), userID)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, applied, "1 old + 1 recent = 2 total but only 1 in 6-month window")
|
||||
}
|
||||
|
||||
func TestNoShowForgivenExcluded(t *testing.T) {
|
||||
resetTestData(t)
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
userID := createTestUser(t, 0)
|
||||
serviceID := createTestService(t, 50.00)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
bid := createPendingBooking(t, userID, serviceID, time.Now().Add(-time.Duration(i)*time.Hour))
|
||||
insertPaymentForBooking(t, bid, userID, 5000)
|
||||
_, err := db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'no_show' WHERE id = $1", bid)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Forgive the first one
|
||||
if i == 0 {
|
||||
_, err = db.DB.Exec(context.Background(), "INSERT INTO forgiven_no_shows (booking_id) VALUES ($1)", bid)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
|
||||
applied, err := ApplyDepositsIfNeeded(context.Background(), userID)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, applied, "1 forgiven + 1 unforgiven = should not trigger")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Discount + 3 paid bookings → no-show records cleared
|
||||
// =============================================================================
|
||||
|
||||
func TestThreePaidBookingsClearNoShows(t *testing.T) {
|
||||
resetTestData(t)
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
userID := createTestUser(t, 0)
|
||||
serviceID := createTestService(t, 50.00)
|
||||
|
||||
// Set deposits_required to 3 (simulating after 2 no-shows triggered it)
|
||||
_, err := db.DB.Exec(context.Background(),
|
||||
"UPDATE users SET deposits_required = 3 WHERE id = $1", userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create 2 no-show records
|
||||
for i := 0; i < 2; i++ {
|
||||
bid := createPendingBooking(t, userID, serviceID, time.Now().Add(-time.Duration(i+1)*time.Hour))
|
||||
insertPaymentForBooking(t, bid, userID, 5000)
|
||||
_, err := db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'no_show' WHERE id = $1", bid)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Complete 3 paid bookings — each should decrement deposits_required
|
||||
for i := 0; i < 3; i++ {
|
||||
bid := createPendingBooking(t, userID, serviceID, time.Now().Add(time.Duration(i+1)*time.Hour))
|
||||
insertPaymentForBooking(t, bid, userID, 5000)
|
||||
completeBooking(t, bid)
|
||||
}
|
||||
|
||||
// After 3 completions, deposits_required should be 0
|
||||
var depositsRequired int
|
||||
db.DB.QueryRow(context.Background(),
|
||||
"SELECT deposits_required FROM users WHERE id = $1", userID).Scan(&depositsRequired)
|
||||
assert.Equal(t, 0, depositsRequired, "Expected 0 after 3 paid bookings from 3")
|
||||
|
||||
// No-show records should be forgiven (inserted into forgiven_no_shows)
|
||||
var forgivenCount int
|
||||
db.DB.QueryRow(context.Background(), `
|
||||
SELECT COUNT(*) FROM forgiven_no_shows fns
|
||||
JOIN bookings b ON b.id = fns.booking_id
|
||||
WHERE b.user_id = $1
|
||||
`, userID).Scan(&forgivenCount)
|
||||
|
||||
// Try again — should NOT trigger deposits_required again since no-shows are forgiven
|
||||
applied, _ := ApplyDepositsIfNeeded(context.Background(), userID)
|
||||
assert.False(t, applied, "Should not trigger: no-shows were forgiven after 3 paid bookings")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
//go:build test
|
||||
// +build test
|
||||
//go:build test && dev
|
||||
// +build test,dev
|
||||
|
||||
package bookings
|
||||
|
||||
@@ -8,8 +8,10 @@ import (
|
||||
"testing"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/testutils/testdb"
|
||||
"crussell/handlers/payments"
|
||||
"crussell/internal/square"
|
||||
"crussell/testutils/jwt"
|
||||
"crussell/testutils/testdb"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
@@ -20,6 +22,8 @@ func TestMain(m *testing.M) {
|
||||
testdb.Migrate(&testing.T{}, pool)
|
||||
db.DB = pool
|
||||
jwt.Init()
|
||||
square.Client = square.NewDevClient()
|
||||
payments.SquareClient = square.Client
|
||||
code := m.Run()
|
||||
pool.Close()
|
||||
os.Exit(code)
|
||||
|
||||
Reference in New Issue
Block a user