//go:build test package webhooks import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "crussell/db" ) // ============================================================================= // Helpers — DB-backed state assertions // ============================================================================= // createWebhookTestPayment inserts a payment row with the given Square charge // id and returns the local payment id. The test DB is fresh per package run, // so no cleanup is needed. func createWebhookTestPayment(t *testing.T, squarePaymentID, status string) string { t.Helper() var id string err := db.Conn.QueryRow(context.Background(), ` INSERT INTO payments (payment_type, payment_method, status, amount, square_payment_id, created_at, updated_at) VALUES ('full', 'online_square', $2, 10.00, $1, NOW(), NOW()) RETURNING id `, squarePaymentID, status).Scan(&id) if err != nil { t.Fatalf("failed to create webhook test payment: %v", err) } return id } func createWebhookTestRefund(t *testing.T, paymentID, squareRefundID, status string) string { t.Helper() var id string err := db.Conn.QueryRow(context.Background(), ` INSERT INTO refunds (payment_id, amount, reason, status, square_refund_id, created_at) VALUES ($1, 5.00, 'webhook test refund', $3, $2, NOW()) RETURNING id `, paymentID, squareRefundID, status).Scan(&id) if err != nil { t.Fatalf("failed to create webhook test refund: %v", err) } return id } func getPaymentStatus(t *testing.T, id string) string { t.Helper() var status string if err := db.Conn.QueryRow(context.Background(), "SELECT status FROM payments WHERE id = $1", id).Scan(&status); err != nil { t.Fatalf("failed to read payment status: %v", err) } return status } func getRefundStatus(t *testing.T, id string) string { t.Helper() var status string if err := db.Conn.QueryRow(context.Background(), "SELECT status FROM refunds WHERE id = $1", id).Scan(&status); err != nil { t.Fatalf("failed to read refund status: %v", err) } return status } func getDisputeStatus(t *testing.T, squareDisputeID string) string { t.Helper() var status string if err := db.Conn.QueryRow(context.Background(), "SELECT status FROM disputes WHERE square_dispute_id = $1", squareDisputeID).Scan(&status); err != nil { t.Fatalf("failed to read dispute status: %v", err) } return status } func countCriticalNotifications(t *testing.T) int { t.Helper() var n int if err := db.Conn.QueryRow(context.Background(), "SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log'").Scan(&n); err != nil { t.Fatalf("failed to count critical_payment_log notifications: %v", err) } return n } // deliverWebhook signs and dispatches a Square event through the full handler. func deliverWebhook(t *testing.T, event SquareWebhookEvent) *httptest.ResponseRecorder { t.Helper() body, err := json.Marshal(event) if err != nil { t.Fatalf("failed to marshal webhook event: %v", err) } sig := webhookTestEnv(t, body) return makeWebhookRequest(body, sig, context.Background()) } // ============================================================================= // Dispute handling — dispute.created // ============================================================================= func TestWebhook_DisputeCreated_InsertsDisputeRow(t *testing.T) { const squarePaymentID = "sqp_dispute_created" payID := createWebhookTestPayment(t, squarePaymentID, "completed") event := SquareWebhookEvent{ Type: "dispute.created", EventID: "evt_dispute_created_1", CreatedAt: "2025-01-01T00:00:00Z", Data: json.RawMessage(`{ "type": "dispute", "id": "dts_dispute_created_1", "object": { "dispute": { "id": "dts_dispute_created_1", "state": "UNDER_REVIEW", "amount_money": {"amount": 1234, "currency": "GBP"}, "reason": "NO_KNOWLEDGE", "disputed_payment": {"payment_id": "` + squarePaymentID + `"} } } }`), } w := deliverWebhook(t, event) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var ( status string amount float64 reason string pid string ) err := db.Conn.QueryRow(context.Background(), ` SELECT status, amount, reason, payment_id FROM disputes WHERE square_dispute_id = 'dts_dispute_created_1' `).Scan(&status, &amount, &reason, &pid) if err != nil { t.Fatalf("expected a disputes row to be inserted, got: %v", err) } if status != "open" { t.Errorf("expected dispute status 'open', got %q", status) } if amount != 12.34 { t.Errorf("expected dispute amount 12.34, got %v", amount) } if reason != "NO_KNOWLEDGE" { t.Errorf("expected dispute reason 'NO_KNOWLEDGE', got %q", reason) } if pid != payID { t.Errorf("expected dispute payment_id %s, got %s", payID, pid) } // A dispute is a CRITICAL money event — the admin notification centre must // surface it. if got := countCriticalNotifications(t); got < 1 { t.Errorf("expected at least 1 critical_payment_log admin notification, got %d", got) } } func TestWebhook_DisputeCreated_NoLocalPayment_NoRow(t *testing.T) { event := SquareWebhookEvent{ Type: "dispute.created", EventID: "evt_dispute_orphan_1", CreatedAt: "2025-01-01T00:00:00Z", Data: json.RawMessage(`{ "type": "dispute", "id": "dts_orphan_1", "object": { "dispute": { "id": "dts_orphan_1", "state": "UNDER_REVIEW", "amount_money": {"amount": 1000, "currency": "GBP"}, "disputed_payment": {"payment_id": "sqp_never_seen"} } } }`), } w := deliverWebhook(t, event) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var n int if err := db.Conn.QueryRow(context.Background(), "SELECT COUNT(*) FROM disputes WHERE square_dispute_id = 'dts_orphan_1'").Scan(&n); err != nil { t.Fatalf("failed to count disputes: %v", err) } if n != 0 { t.Errorf("expected no disputes row for an unknown square payment, got %d", n) } } func TestWebhook_DisputeCreated_LongReason_Truncated(t *testing.T) { const squarePaymentID = "sqp_dispute_longreason" _ = createWebhookTestPayment(t, squarePaymentID, "completed") longReason := strings.Repeat("z", 300) event := SquareWebhookEvent{ Type: "dispute.created", EventID: "evt_dispute_longreason_1", CreatedAt: "2025-01-01T00:00:00Z", Data: json.RawMessage(`{ "type": "dispute", "id": "dts_longreason_1", "object": { "dispute": { "id": "dts_longreason_1", "state": "UNDER_REVIEW", "amount_money": {"amount": 1234, "currency": "GBP"}, "reason": "` + longReason + `", "disputed_payment": {"payment_id": "` + squarePaymentID + `"} } } }`), } w := deliverWebhook(t, event) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } // disputes.reason is VARCHAR(192): the over-long reason must be truncated // so the INSERT succeeds instead of failing (and, after the dedup row // commits, silently dropping the dispute). var storedReason string if err := db.Conn.QueryRow(context.Background(), "SELECT reason FROM disputes WHERE square_dispute_id = 'dts_longreason_1'").Scan(&storedReason); err != nil { t.Fatalf("expected a disputes row to be inserted, got: %v", err) } if len(storedReason) > 192 { t.Errorf("expected reason truncated to <=192 chars, got %d", len(storedReason)) } if storedReason != strings.Repeat("z", 192) { t.Errorf("expected reason truncated to exactly 192 'z' chars, got %q", storedReason) } } // ============================================================================= // Dispute handling — dispute.state.updated // ============================================================================= func TestWebhook_DisputeStateUpdated_Lost_MarksPaymentFailed(t *testing.T) { const squarePaymentID = "sqp_dispute_lost" payID := createWebhookTestPayment(t, squarePaymentID, "completed") // Seed the dispute row as dispute.created would have. if _, err := db.Conn.Exec(context.Background(), ` INSERT INTO disputes (square_dispute_id, payment_id, status, amount, reason) VALUES ('dts_lost_1', $1, 'open', 12.34, 'NO_KNOWLEDGE') `, payID); err != nil { t.Fatalf("failed to seed dispute row: %v", err) } event := SquareWebhookEvent{ Type: "dispute.state.updated", EventID: "evt_dispute_lost_1", CreatedAt: "2025-01-01T00:00:00Z", Data: json.RawMessage(`{ "type": "dispute", "id": "dts_lost_1", "object": { "dispute": { "id": "dts_lost_1", "state": "LOST", "amount_money": {"amount": 1234, "currency": "GBP"}, "reason": "NO_KNOWLEDGE", "disputed_payment": {"payment_id": "` + squarePaymentID + `"} } } }`), } w := deliverWebhook(t, event) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if got := getDisputeStatus(t, "dts_lost_1"); got != "lost" { t.Errorf("expected dispute status 'lost', got %q", got) } if got := getPaymentStatus(t, payID); got != "failed" { t.Errorf("expected payment status 'failed' after lost dispute, got %q", got) } if got := countCriticalNotifications(t); got < 1 { t.Errorf("expected a critical_payment_log notification for the lost dispute, got %d", got) } } func TestWebhook_DisputeStateUpdated_Won_KeepsPaymentCompleted(t *testing.T) { const squarePaymentID = "sqp_dispute_won" payID := createWebhookTestPayment(t, squarePaymentID, "completed") // No seeded dispute row: state.updated arriving before dispute.created must // upsert the row. event := SquareWebhookEvent{ Type: "dispute.state.updated", EventID: "evt_dispute_won_1", CreatedAt: "2025-01-01T00:00:00Z", Data: json.RawMessage(`{ "type": "dispute", "id": "dts_won_1", "object": { "dispute": { "id": "dts_won_1", "state": "WON", "amount_money": {"amount": 1234, "currency": "GBP"}, "disputed_payment": {"payment_id": "` + squarePaymentID + `"} } } }`), } w := deliverWebhook(t, event) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if got := getDisputeStatus(t, "dts_won_1"); got != "won" { t.Errorf("expected dispute status 'won', got %q", got) } if got := getPaymentStatus(t, payID); got != "completed" { t.Errorf("expected payment to stay 'completed' after won dispute, got %q", got) } } func TestWebhook_DisputeStateUpdated_Open_KeepsOpen(t *testing.T) { const squarePaymentID = "sqp_dispute_open" payID := createWebhookTestPayment(t, squarePaymentID, "completed") if _, err := db.Conn.Exec(context.Background(), ` INSERT INTO disputes (square_dispute_id, payment_id, status, amount, reason) VALUES ('dts_open_1', $1, 'open', 12.34, 'NO_KNOWLEDGE') `, payID); err != nil { t.Fatalf("failed to seed dispute row: %v", err) } event := SquareWebhookEvent{ Type: "dispute.state.updated", EventID: "evt_dispute_open_1", CreatedAt: "2025-01-01T00:00:00Z", Data: json.RawMessage(`{ "type": "dispute", "id": "dts_open_1", "object": { "dispute": { "id": "dts_open_1", "state": "EVIDENCE_REQUIRED", "amount_money": {"amount": 1234, "currency": "GBP"}, "disputed_payment": {"payment_id": "` + squarePaymentID + `"} } } }`), } w := deliverWebhook(t, event) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if got := getDisputeStatus(t, "dts_open_1"); got != "open" { t.Errorf("expected dispute to stay 'open' on EVIDENCE_REQUIRED, got %q", got) } if got := getPaymentStatus(t, payID); got != "completed" { t.Errorf("expected payment to stay 'completed', got %q", got) } } // ============================================================================= // State mutation — payment.updated // ============================================================================= func TestWebhook_PaymentUpdated_UpdatesPaymentStatus(t *testing.T) { const squarePaymentID = "sqp_updated_completed" payID := createWebhookTestPayment(t, squarePaymentID, "pending") event := SquareWebhookEvent{ Type: "payment.updated", EventID: "evt_payment_updated_completed_1", CreatedAt: "2025-01-01T00:00:00Z", Data: json.RawMessage(`{ "type": "payment", "id": "` + squarePaymentID + `", "object": { "payment": { "id": "` + squarePaymentID + `", "status": "COMPLETED" } } }`), } w := deliverWebhook(t, event) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if got := getPaymentStatus(t, payID); got != "completed" { t.Errorf("expected payment status 'completed', got %q", got) } } func TestWebhook_PaymentUpdated_FailedStatus(t *testing.T) { const squarePaymentID = "sqp_updated_failed" payID := createWebhookTestPayment(t, squarePaymentID, "pending") event := SquareWebhookEvent{ Type: "payment.updated", EventID: "evt_payment_updated_failed_1", CreatedAt: "2025-01-01T00:00:00Z", Data: json.RawMessage(`{ "type": "payment", "id": "` + squarePaymentID + `", "object": { "payment": { "id": "` + squarePaymentID + `", "status": "FAILED" } } }`), } w := deliverWebhook(t, event) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if got := getPaymentStatus(t, payID); got != "failed" { t.Errorf("expected payment status 'failed', got %q", got) } } func TestWebhook_PaymentUpdated_NonTerminal_LeavesPending(t *testing.T) { const squarePaymentID = "sqp_updated_approved" payID := createWebhookTestPayment(t, squarePaymentID, "pending") event := SquareWebhookEvent{ Type: "payment.updated", EventID: "evt_payment_updated_approved_1", CreatedAt: "2025-01-01T00:00:00Z", Data: json.RawMessage(`{ "type": "payment", "id": "` + squarePaymentID + `", "object": { "payment": { "id": "` + squarePaymentID + `", "status": "APPROVED" } } }`), } w := deliverWebhook(t, event) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if got := getPaymentStatus(t, payID); got != "pending" { t.Errorf("expected payment to stay 'pending' on non-terminal APPROVED, got %q", got) } } // TestWebhook_PaymentUpdated_DoesNotRevertRefunded guards the pending-only // transition: Square fires payment.updated for ANY field change (e.g. a fee // recalculation on a fully refunded charge), and that must not flip the local // row back from 'refunded' to 'completed' — which would reopen the // over-refund guard. func TestWebhook_PaymentUpdated_DoesNotRevertRefunded(t *testing.T) { const squarePaymentID = "sqp_updated_refunded" payID := createWebhookTestPayment(t, squarePaymentID, "refunded") event := SquareWebhookEvent{ Type: "payment.updated", EventID: "evt_payment_updated_refunded_1", CreatedAt: "2025-01-01T00:00:00Z", Data: json.RawMessage(`{ "type": "payment", "id": "` + squarePaymentID + `", "object": { "payment": { "id": "` + squarePaymentID + `", "status": "COMPLETED" } } }`), } w := deliverWebhook(t, event) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if got := getPaymentStatus(t, payID); got != "refunded" { t.Errorf("expected refunded payment to stay 'refunded', got %q", got) } } func TestWebhook_PaymentUpdated_IdempotentReplay(t *testing.T) { const squarePaymentID = "sqp_updated_idem" payID := createWebhookTestPayment(t, squarePaymentID, "pending") event := SquareWebhookEvent{ Type: "payment.updated", EventID: "evt_payment_updated_idem_1", CreatedAt: "2025-01-01T00:00:00Z", Data: json.RawMessage(`{ "type": "payment", "id": "` + squarePaymentID + `", "object": { "payment": { "id": "` + squarePaymentID + `", "status": "COMPLETED" } } }`), } // Two deliveries of the SAME event_id: the second is dropped by dedup, the // state mutation applies exactly once. w1 := deliverWebhook(t, event) if w1.Code != http.StatusOK { t.Fatalf("expected first delivery 200, got %d: %s", w1.Code, w1.Body.String()) } w2 := deliverWebhook(t, event) if w2.Code != http.StatusOK { t.Fatalf("expected replay 200, got %d: %s", w2.Code, w2.Body.String()) } if got := getPaymentStatus(t, payID); got != "completed" { t.Errorf("expected payment status 'completed' after idempotent replay, got %q", got) } if n := countWebhookEvents(t, event.EventID); n != 1 { t.Errorf("expected exactly 1 dedup row after replay, got %d", n) } } // ============================================================================= // State mutation — refund.updated // ============================================================================= func TestWebhook_RefundUpdated_UpdatesRefundStatus(t *testing.T) { const ( squarePaymentID = "sqp_refund_pay" squareRefundID = "sqr_updated_completed" ) payID := createWebhookTestPayment(t, squarePaymentID, "completed") refundID := createWebhookTestRefund(t, payID, squareRefundID, "pending") event := SquareWebhookEvent{ Type: "refund.updated", EventID: "evt_refund_updated_completed_1", CreatedAt: "2025-01-01T00:00:00Z", Data: json.RawMessage(`{ "type": "refund", "id": "` + squareRefundID + `", "object": { "refund": { "id": "` + squareRefundID + `", "status": "COMPLETED", "payment_id": "` + squarePaymentID + `" } } }`), } w := deliverWebhook(t, event) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if got := getRefundStatus(t, refundID); got != "completed" { t.Errorf("expected refund status 'completed', got %q", got) } } func TestWebhook_RefundUpdated_FailedStatus(t *testing.T) { const ( squarePaymentID = "sqp_refund_pay_fail" squareRefundID = "sqr_updated_failed" ) payID := createWebhookTestPayment(t, squarePaymentID, "completed") refundID := createWebhookTestRefund(t, payID, squareRefundID, "pending") event := SquareWebhookEvent{ Type: "refund.updated", EventID: "evt_refund_updated_failed_1", CreatedAt: "2025-01-01T00:00:00Z", Data: json.RawMessage(`{ "type": "refund", "id": "` + squareRefundID + `", "object": { "refund": { "id": "` + squareRefundID + `", "status": "FAILED", "payment_id": "` + squarePaymentID + `" } } }`), } w := deliverWebhook(t, event) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if got := getRefundStatus(t, refundID); got != "failed" { t.Errorf("expected refund status 'failed', got %q", got) } } func TestWebhook_RefundUpdated_NonTerminal_LeavesPending(t *testing.T) { const ( squarePaymentID = "sqp_refund_pay_pending" squareRefundID = "sqr_updated_pending" ) payID := createWebhookTestPayment(t, squarePaymentID, "completed") refundID := createWebhookTestRefund(t, payID, squareRefundID, "pending") event := SquareWebhookEvent{ Type: "refund.updated", EventID: "evt_refund_updated_pending_1", CreatedAt: "2025-01-01T00:00:00Z", Data: json.RawMessage(`{ "type": "refund", "id": "` + squareRefundID + `", "object": { "refund": { "id": "` + squareRefundID + `", "status": "PENDING", "payment_id": "` + squarePaymentID + `" } } }`), } w := deliverWebhook(t, event) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if got := getRefundStatus(t, refundID); got != "pending" { t.Errorf("expected refund to stay 'pending' on non-terminal PENDING, got %q", got) } } // TestWebhook_RefundUpdated_DoesNotDemoteCompleted guards the FAILED // transition: a completed refund must never be demoted to 'failed' by a late // webhook, since the over-refund guard counts 'completed' refunds — demoting // would let the guard exclude money that already moved. func TestWebhook_RefundUpdated_DoesNotDemoteCompleted(t *testing.T) { const ( squarePaymentID = "sqp_refund_pay_demote" squareRefundID = "sqr_demote" ) payID := createWebhookTestPayment(t, squarePaymentID, "completed") refundID := createWebhookTestRefund(t, payID, squareRefundID, "completed") event := SquareWebhookEvent{ Type: "refund.updated", EventID: "evt_refund_demote_1", CreatedAt: "2025-01-01T00:00:00Z", Data: json.RawMessage(`{ "type": "refund", "id": "` + squareRefundID + `", "object": { "refund": { "id": "` + squareRefundID + `", "status": "FAILED", "payment_id": "` + squarePaymentID + `" } } }`), } w := deliverWebhook(t, event) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if got := getRefundStatus(t, refundID); got != "completed" { t.Errorf("expected completed refund to stay 'completed', got %q", got) } }