//go:build test package webhooks // Round 7 regression tests — webhook money paths found at 0% coverage: // // (a) handleDisputeStateUpdated's findPaymentByDisputeID fallback when a // dispute.state.updated payload carries no resolvable Square payment id. // (b) clawbackOneTillSale's non-gift-card branch (a definitively-failed charge // marks the till sale failed WITHOUT reversing any gift-card funding). // (c) handleDisputeEvidence / handleTerminalCheckout — informational dispatch // paths that must complete 200 + log without mutating state. import ( "bytes" "context" "encoding/json" "log" "net/http" "strings" "testing" "crussell/db" "crussell/testutils/fixtures" ) // ============================================================================= // Helpers // ============================================================================= // createWebhookTestGiftCard inserts a funded gift card and returns its id. func createWebhookTestGiftCard(t *testing.T, amount float64) string { t.Helper() adminID, err := fixtures.CreateTestAdminUser(db.Conn) if err != nil { t.Fatalf("failed to create admin user: %v", err) } var id string if err := db.Conn.QueryRow(context.Background(), ` INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase) VALUES ($1, $1, $2, FALSE, 'SPV') RETURNING id `, amount, adminID).Scan(&id); err != nil { t.Fatalf("failed to create gift card: %v", err) } return id } // createWebhookTestTillSale inserts a pending till sale with the given item // type and item id (nil for a NULL item_id) and returns the sale id. func createWebhookTestTillSale(t *testing.T, squarePaymentID, itemType string, itemID any) string { t.Helper() adminID, err := fixtures.CreateTestAdminUser(db.Conn) if err != nil { t.Fatalf("failed to create admin user: %v", err) } var saleID string if err := db.Conn.QueryRow(context.Background(), ` INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount, payment_method, status, square_payment_id, created_by, created_at, updated_at) VALUES ($1, $2, 'webhook round7 test', 1, 40.00, 40.00, 'online_square', 'pending', $3, $4, NOW(), NOW()) RETURNING id `, itemType, itemID, squarePaymentID, adminID).Scan(&saleID); err != nil { t.Fatalf("failed to create pending till sale: %v", err) } return saleID } // createWebhookTestBookingPayment creates a completed payment bound to a fresh // booking and returns the local payment id and booking id. A booking-scoped // payment makes the critical-notification assertions below attributable to one // booking, so they cannot race with other tests' NULL-booking rows. func createWebhookTestBookingPayment(t *testing.T) (payID, bookingID string) { t.Helper() userID, err := fixtures.CreateTestUser(db.Conn) if err != nil { t.Fatalf("failed to create test user: %v", err) } serviceID, err := fixtures.CreateTestService(db.Conn) if err != nil { t.Fatalf("failed to create test service: %v", err) } bookingID, err = fixtures.CreateTestBooking(db.Conn, userID, serviceID) if err != nil { t.Fatalf("failed to create test booking: %v", err) } payID, err = fixtures.CreateTestPayment(db.Conn, bookingID, 10.00, "online_square", "full", "completed") if err != nil { t.Fatalf("failed to create test payment: %v", err) } return payID, bookingID } // countUnackedCriticalNotificationsForBooking returns the number of // unacknowledged critical_payment_log notifications for a booking. func countUnackedCriticalNotificationsForBooking(t *testing.T, bookingID string) int { t.Helper() var n int if err := db.Conn.QueryRow(context.Background(), ` SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND booking_id = $1 AND acknowledged_at IS NULL `, bookingID).Scan(&n); err != nil { t.Fatalf("failed to count critical notifications for booking %s: %v", bookingID, err) } return n } // countUntrackedCriticalNotification returns the number of unacknowledged // NULL-booking critical_payment_log notifications for a dispute's deterministic // id (disputeNotificationID). func countUntrackedCriticalNotification(t *testing.T, disputeID string) int { t.Helper() var n int if err := db.Conn.QueryRow(context.Background(), ` SELECT COUNT(*) FROM admin_notifications WHERE id = $1 AND reason = 'critical_payment_log' AND booking_id IS NULL AND acknowledged_at IS NULL `, disputeNotificationID(disputeID)).Scan(&n); err != nil { t.Fatalf("failed to count critical notifications for dispute %s: %v", disputeID, err) } return n } // ============================================================================= // (a) handleDisputeStateUpdated — findPaymentByDisputeID fallback // ============================================================================= // TestWebhook_DisputeStateUpdated_Lost_EmptyPaymentID_FallsBackToDisputeRow // covers the findPaymentByDisputeID fallback: a dispute.state.updated event // whose disputed_payment.payment_id is empty cannot resolve the payment via // square_payment_id, so the handler recovers it from the seeded disputes row. // A LOST state must still mark the payment failed and raise a CRITICAL // notification — the fallback must not silently drop the chargeback. func TestWebhook_DisputeStateUpdated_Lost_EmptyPaymentID_FallsBackToDisputeRow(t *testing.T) { payID, bookingID := createWebhookTestBookingPayment(t) if _, err := db.Conn.Exec(context.Background(), ` INSERT INTO disputes (square_dispute_id, payment_id, status, amount, reason) VALUES ('dts_fallback_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_fallback_1", CreatedAt: nowInRFC3339(0), Data: json.RawMessage(`{ "type": "dispute", "id": "dts_fallback_1", "object": { "dispute": { "id": "dts_fallback_1", "state": "LOST", "amount_money": {"amount": 1234, "currency": "GBP"}, "reason": "NO_KNOWLEDGE", "disputed_payment": {"payment_id": ""} } } }`), } 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_fallback_1"); got != "lost" { t.Errorf("expected dispute status 'lost', got %q", got) } if got := getPaymentStatus(t, payID); got != "failed" { t.Errorf("expected payment 'failed' after lost dispute recovered via dispute row, got %q", got) } // The lost dispute is a CRITICAL money event — the admin notification // centre must surface it for the payment's booking. if n := countUnackedCriticalNotificationsForBooking(t, bookingID); n != 1 { t.Errorf("expected 1 unacknowledged critical_payment_log notification for booking %s, got %d", bookingID, n) } if n := countWebhookEvents(t, event.EventID); n != 1 { t.Errorf("expected 1 dedup row, got %d", n) } } // TestWebhook_DisputeStateUpdated_EmptyPaymentID_NoDisputeRow_RaisesCritical // covers the fallback's dead end: when neither the payload's (empty) Square // payment id nor the disputes table yields a payment, the chargeback is // UNTRACKED — the handler raises the same critical_payment_log notification as // dispute.created (booking_id NULL, the deterministic disputeNotificationID) so // a state.updated arriving without a prior created event is never a silent // money-loss path. No disputes row is written, the payment row is untouched, // and the dedup row still commits (Square's retry is acknowledged 200, not // re-dispatched forever). func TestWebhook_DisputeStateUpdated_EmptyPaymentID_NoDisputeRow_RaisesCritical(t *testing.T) { payID, _ := createWebhookTestBookingPayment(t) event := SquareWebhookEvent{ Type: "dispute.state.updated", EventID: "evt_dispute_no_row_1", CreatedAt: nowInRFC3339(0), Data: json.RawMessage(`{ "type": "dispute", "id": "dts_no_dispute_row_1", "object": { "dispute": { "id": "dts_no_dispute_row_1", "state": "LOST", "amount_money": {"amount": 1234, "currency": "GBP"}, "reason": "NO_KNOWLEDGE", "disputed_payment": {"payment_id": ""} } } }`), } 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_no_dispute_row_1'").Scan(&n); err != nil { t.Fatalf("failed to count disputes: %v", err) } if n != 0 { t.Errorf("expected no disputes row when the fallback finds no payment, got %d", n) } if got := getPaymentStatus(t, payID); got != "completed" { t.Errorf("expected payment untouched ('completed') when the fallback finds no dispute, got %q", got) } // The untracked chargeback MUST still surface in-app: exactly one // unacknowledged NULL-booking critical notification under the dispute's // deterministic id — the same contract as dispute.created's untracked // branch. if got := countUntrackedCriticalNotification(t, "dts_no_dispute_row_1"); got != 1 { t.Errorf("expected 1 unacknowledged NULL-booking critical notification for the untracked dispute, got %d", got) } if n := countWebhookEvents(t, event.EventID); n != 1 { t.Errorf("expected 1 dedup row (the no-op dispatch still commits), got %d", n) } } // ============================================================================= // (b) clawbackOneTillSale — non-gift-card branch // ============================================================================= // TestWebhook_PaymentUpdated_Failed_NonGiftCardSale_MarksFailedNoClawback // covers the non-gift-card branch of clawbackOneTillSale: a retail_product // till sale (item_type != "gift_card") funded by a definitively-failed Square // charge is marked failed WITHOUT reversing any gift-card funding — even when // the sale's item_id happens to reference a real, funded gift card (the LEFT // JOIN would find it; the item_type guard must short-circuit before any // reversal). func TestWebhook_PaymentUpdated_Failed_NonGiftCardSale_MarksFailedNoClawback(t *testing.T) { const squarePaymentID = "sqp_clawback_retail" giftCardID := createWebhookTestGiftCard(t, 60.00) saleID := createWebhookTestTillSale(t, squarePaymentID, "retail_product", giftCardID) w := deliverPaymentUpdatedFailed(t, squarePaymentID) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if got := getTillSaleStatus(t, saleID); got != "failed" { t.Errorf("expected till sale 'failed', got %q", got) } // No gift-card balance was reversed: the card referenced by the sale's // item_id keeps its full £60.00 funding. total, remaining := getGiftCardFunding(t, giftCardID) if total != 60.00 || remaining != 60.00 { t.Errorf("expected gift card funding untouched (no clawback for a non-gift-card sale), got total=%v remaining=%v", total, remaining) } } // TestWebhook_PaymentUpdated_Failed_GiftCardSaleMissingCard_MarksFailedNoClawback // covers the nil-isCreate variant of the same branch: a gift_card till sale // whose item_id points at NO gift card (dangling id) makes the LEFT JOIN yield // a NULL is_create — the branch must still mark the sale failed without // attempting any reversal. func TestWebhook_PaymentUpdated_Failed_GiftCardSaleMissingCard_MarksFailedNoClawback(t *testing.T) { const squarePaymentID = "sqp_clawback_dangling" saleID := createWebhookTestTillSale(t, squarePaymentID, "gift_card", "GCMISSING001") w := deliverPaymentUpdatedFailed(t, squarePaymentID) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if got := getTillSaleStatus(t, saleID); got != "failed" { t.Errorf("expected till sale 'failed', got %q", got) } // The missing card means there is nothing to claw back — and the handler // must not error out: the webhook acknowledges 200 with a dedup row. if n := countWebhookEvents(t, "evt_"+squarePaymentID); n != 1 { t.Errorf("expected 1 dedup row, got %d", n) } } // ============================================================================= // (c) handleDisputeEvidence / handleTerminalCheckout — informational dispatch // ============================================================================= // TestWebhook_DisputeEvidence_InformationalOnly covers both // dispute.evidence.created and dispute.evidence.deleted: the handler logs and // acknowledges 200 without writing a disputes row or raising any notification. func TestWebhook_DisputeEvidence_InformationalOnly(t *testing.T) { cases := []struct { eventType string disputeID string eventID string }{ {"dispute.evidence.created", "dts_evidence_created_1", "evt_evidence_created_1"}, {"dispute.evidence.deleted", "dts_evidence_deleted_1", "evt_evidence_deleted_1"}, } for _, tc := range cases { event := SquareWebhookEvent{ Type: tc.eventType, EventID: tc.eventID, CreatedAt: nowInRFC3339(0), Data: json.RawMessage(`{ "type": "dispute", "id": "` + tc.disputeID + `", "object": { "dispute": { "id": "` + tc.disputeID + `", "state": "EVIDENCE_REQUIRED" } } }`), } w := deliverWebhook(t, event) if w.Code != http.StatusOK { t.Fatalf("expected 200 for %s, got %d: %s", tc.eventType, w.Code, w.Body.String()) } // No state mutation: no disputes row is written for an evidence event. var n int if err := db.Conn.QueryRow(context.Background(), "SELECT COUNT(*) FROM disputes WHERE square_dispute_id = $1", tc.disputeID).Scan(&n); err != nil { t.Fatalf("failed to count disputes: %v", err) } if n != 0 { t.Errorf("expected no disputes row from %s, got %d", tc.eventType, n) } if got := countWebhookEvents(t, tc.eventID); got != 1 { t.Errorf("expected 1 dedup row for %s, got %d", tc.eventID, got) } } // The handler logs the evidence event (informational only). var buf bytes.Buffer oldOutput := log.Writer() log.SetOutput(&buf) defer log.SetOutput(oldOutput) event := SquareWebhookEvent{ Type: "dispute.evidence.created", EventID: "evt_evidence_log_1", CreatedAt: nowInRFC3339(0), Data: json.RawMessage(`{ "type": "dispute", "id": "dts_evidence_log_1", "object": {"dispute": {"id": "dts_evidence_log_1", "state": "UNDER_REVIEW"}} }`), } if w := deliverWebhook(t, event); w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if out := buf.String(); !strings.Contains(out, "dispute evidence event for dispute") { t.Errorf("expected an informational evidence log line, got:\n%s", out) } } // TestWebhook_TerminalCheckout_InformationalOnly covers both // terminal.checkout.created and terminal.checkout.updated: the handler logs // and acknowledges 200 without writing any terminal_checkouts row. func TestWebhook_TerminalCheckout_InformationalOnly(t *testing.T) { cases := []struct { eventType string checkoutID string eventID string }{ {"terminal.checkout.created", "chk_round7_created_1", "evt_terminal_created_1"}, {"terminal.checkout.updated", "chk_round7_updated_1", "evt_terminal_updated_1"}, } for _, tc := range cases { event := SquareWebhookEvent{ Type: tc.eventType, EventID: tc.eventID, CreatedAt: nowInRFC3339(0), Data: json.RawMessage(`{"type": "terminal.checkout", "id": "` + tc.checkoutID + `"}`), } w := deliverWebhook(t, event) if w.Code != http.StatusOK { t.Fatalf("expected 200 for %s, got %d: %s", tc.eventType, w.Code, w.Body.String()) } // No state mutation: no terminal_checkouts row is written. var n int if err := db.Conn.QueryRow(context.Background(), "SELECT COUNT(*) FROM terminal_checkouts WHERE checkout_id = $1", tc.checkoutID).Scan(&n); err != nil { t.Fatalf("failed to count terminal checkouts: %v", err) } if n != 0 { t.Errorf("expected no terminal_checkouts row from %s, got %d", tc.eventType, n) } if got := countWebhookEvents(t, tc.eventID); got != 1 { t.Errorf("expected 1 dedup row for %s, got %d", tc.eventID, got) } } var buf bytes.Buffer oldOutput := log.Writer() log.SetOutput(&buf) defer log.SetOutput(oldOutput) event := SquareWebhookEvent{ Type: "terminal.checkout.updated", EventID: "evt_terminal_log_1", CreatedAt: nowInRFC3339(0), Data: json.RawMessage(`{"type": "terminal.checkout", "id": "chk_round7_log_1"}`), } if w := deliverWebhook(t, event); w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if out := buf.String(); !strings.Contains(out, "terminal.checkout event received") { t.Errorf("expected an informational terminal.checkout log line, got:\n%s", out) } }