feat(backend): add idempotency key cleanup scheduler
CleanupOldIdempotencyKeys clears stale idempotency keys from bookings, payments, and till_sales older than 24h (non-pending status). Wired into daily cleanup cycle in default-hours.go. Tests: clears old bookings/payments/till_sales, preserves recent and pending entries. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -17,6 +17,7 @@ package scheduling
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -2508,3 +2509,145 @@ func TestCleanupIdleAccounts_SkipAdminGuest(t *testing.T) {
|
||||
t.Errorf("expected guest to NOT be anonymized, got email '%s'", guestEmail)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tests for CleanupOldIdempotencyKeys ---
|
||||
|
||||
func TestCleanupOldIdempotencyKeys_ClearsOldBookings(t *testing.T) {
|
||||
resetTestData(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create an old booking (created 48h ago, status = 'completed') with idempotency_key
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
|
||||
var oldBookingID string
|
||||
err = db.DB.QueryRow(ctx, `
|
||||
INSERT INTO bookings (user_id, start_time, status, idempotency_key, created_at)
|
||||
VALUES ($1, NOW() - INTERVAL '1 hour', 'completed', 'old-key-001', NOW() - INTERVAL '48 hours')
|
||||
RETURNING id
|
||||
`, userID).Scan(&oldBookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create old booking: %v", err)
|
||||
}
|
||||
|
||||
// Create a recent booking (< 24h) with idempotency_key
|
||||
var recentBookingID string
|
||||
err = db.DB.QueryRow(ctx, `
|
||||
INSERT INTO bookings (user_id, start_time, status, idempotency_key, created_at)
|
||||
VALUES ($1, NOW(), 'completed', 'recent-key-002', NOW() - INTERVAL '2 hours')
|
||||
RETURNING id
|
||||
`, userID).Scan(&recentBookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create recent booking: %v", err)
|
||||
}
|
||||
|
||||
// Create a pending old booking (should NOT be cleared)
|
||||
var pendingBookingID string
|
||||
err = db.DB.QueryRow(ctx, `
|
||||
INSERT INTO bookings (user_id, start_time, status, idempotency_key, created_at)
|
||||
VALUES ($1, NOW() - INTERVAL '1 hour', 'pending', 'pending-key-003', NOW() - INTERVAL '48 hours')
|
||||
RETURNING id
|
||||
`, userID).Scan(&pendingBookingID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create pending booking: %v", err)
|
||||
}
|
||||
|
||||
// Run cleanup
|
||||
err = CleanupOldIdempotencyKeys(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CleanupOldIdempotencyKeys failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify old booking's key was cleared
|
||||
var oldKey sql.NullString
|
||||
err = db.DB.QueryRow(ctx, `SELECT idempotency_key FROM bookings WHERE id = $1`, oldBookingID).Scan(&oldKey)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query old booking: %v", err)
|
||||
}
|
||||
if oldKey.Valid {
|
||||
t.Error("expected old booking's idempotency_key to be cleared")
|
||||
}
|
||||
|
||||
// Verify recent booking's key was preserved
|
||||
var recentKey sql.NullString
|
||||
err = db.DB.QueryRow(ctx, `SELECT idempotency_key FROM bookings WHERE id = $1`, recentBookingID).Scan(&recentKey)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query recent booking: %v", err)
|
||||
}
|
||||
if !recentKey.Valid || recentKey.String != "recent-key-002" {
|
||||
t.Errorf("expected recent booking's idempotency_key to be preserved, got %v", recentKey)
|
||||
}
|
||||
|
||||
// Verify pending old booking's key was preserved
|
||||
var pendingKey sql.NullString
|
||||
err = db.DB.QueryRow(ctx, `SELECT idempotency_key FROM bookings WHERE id = $1`, pendingBookingID).Scan(&pendingKey)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query pending booking: %v", err)
|
||||
}
|
||||
if !pendingKey.Valid || pendingKey.String != "pending-key-003" {
|
||||
t.Errorf("expected pending booking's idempotency_key to be preserved, got %v", pendingKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupOldIdempotencyKeys_ClearsOldPayments(t *testing.T) {
|
||||
resetTestData(t)
|
||||
ctx := context.Background()
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
|
||||
// Create an old completed payment with idempotency_key
|
||||
_, err = db.DB.Exec(ctx, `
|
||||
INSERT INTO payments (payment_type, payment_method, status, amount, idempotency_key, created_by, created_at)
|
||||
VALUES ('full', 'online_square', 'completed', 50.00, 'old-pay-key', $1, NOW() - INTERVAL '48 hours')
|
||||
`, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create old payment: %v", err)
|
||||
}
|
||||
|
||||
err = CleanupOldIdempotencyKeys(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CleanupOldIdempotencyKeys failed: %v", err)
|
||||
}
|
||||
|
||||
var key sql.NullString
|
||||
err = db.DB.QueryRow(ctx, `SELECT idempotency_key FROM payments WHERE idempotency_key = 'old-pay-key'`).Scan(&key)
|
||||
if err == nil {
|
||||
t.Error("expected old payment's idempotency_key to be cleared")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupOldIdempotencyKeys_ClearsOldTillSales(t *testing.T) {
|
||||
resetTestData(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create an admin user for till_sales.created_by
|
||||
adminID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin: %v", err)
|
||||
}
|
||||
|
||||
// Create an old till_sale with idempotency_key
|
||||
_, err = db.DB.Exec(ctx, `
|
||||
INSERT INTO till_sales (item_type, total_amount, unit_price, status, payment_method, idempotency_key, created_by, created_at)
|
||||
VALUES ('gift_card', 25.00, 25.00, 'completed', 'cash', 'old-till-key', $1, NOW() - INTERVAL '48 hours')
|
||||
`, adminID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create old till_sale: %v", err)
|
||||
}
|
||||
|
||||
err = CleanupOldIdempotencyKeys(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CleanupOldIdempotencyKeys failed: %v", err)
|
||||
}
|
||||
|
||||
var key sql.NullString
|
||||
err = db.DB.QueryRow(ctx, `SELECT idempotency_key FROM till_sales WHERE idempotency_key = 'old-till-key'`).Scan(&key)
|
||||
if err == nil {
|
||||
t.Error("expected old till_sale's idempotency_key to be cleared")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user