test(payment): cover ErrOrderNotFound sentinel contract

Service layer (payment_fulfillment_order_not_found_test.go):
- TestHandlePaymentNotification_UnknownOrder_ReturnsSentinel: in-memory
  sqlite ent client, query for a non-existent out_trade_no → errors.Is
  must recognise ErrOrderNotFound (handler relies on this to ack 200).
- TestHandlePaymentNotification_NonSuccessStatus_Skips: non-success
  notification short-circuits before DB lookup → nil error.
- TestErrOrderNotFound_DistinctFromOtherErrors: generic errors must not
  match the sentinel (prevents silently swallowing DB failures).

Handler layer (payment_webhook_handler_test.go):
- TestUnknownOrderWebhookAcksWithSuccess: locks the two ingredients the
  handleNotify ack path depends on — fmt.Errorf %w wrapping preserves
  errors.Is recognition, and writeSuccessResponse(stripe) returns an
  empty 200 body that Stripe treats as acknowledged.
This commit is contained in:
erio
2026-04-23 19:22:43 +08:00
parent 75e1b40fb4
commit d5dac84e12
2 changed files with 145 additions and 0 deletions

View File

@@ -6,11 +6,13 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/payment"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -91,6 +93,43 @@ func TestWriteSuccessResponse(t *testing.T) {
}
}
// TestUnknownOrderWebhookAcksWithSuccess exercises the response contract that
// handleNotify relies on when HandlePaymentNotification returns ErrOrderNotFound:
// we still need to emit the provider-specific 2xx so the provider stops
// retrying. We can't easily drive handleNotify end-to-end without mocking the
// concrete *service.PaymentService, so this test locks down the two ingredients
// the fix depends on:
// 1. errors.Is recognises the sentinel through fmt.Errorf %w wrapping (which
// is how service layer wraps it with the out_trade_no context).
// 2. writeSuccessResponse produces the provider-specific body for Stripe
// (empty 200) — matching what handleNotify calls on the ack path.
//
// If either contract breaks, the Stripe "unknown order → 500 loop" regresses.
func TestUnknownOrderWebhookAcksWithSuccess(t *testing.T) {
gin.SetMode(gin.TestMode)
// 1) Sentinel recognition through wrapping.
wrapped := fmt.Errorf("%w: out_trade_no=sub2_missing_42", service.ErrOrderNotFound)
require.True(t, errors.Is(wrapped, service.ErrOrderNotFound),
"handleNotify uses errors.Is on the wrapped service error; regression here "+
"would mean unknown-order webhooks go back to returning 500 and looping forever")
// A distinct error must NOT match — otherwise a DB failure would be silently
// swallowed as an ack.
other := errors.New("lookup order failed: connection refused")
require.False(t, errors.Is(other, service.ErrOrderNotFound))
// 2) Provider-specific success body is what handleNotify emits on the
// ack path. Asserted again here because this is the shape Stripe expects
// to consider the webhook acknowledged.
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
writeSuccessResponse(c, payment.TypeStripe)
require.Equal(t, http.StatusOK, w.Code,
"Stripe requires 2xx to stop retrying; anything else restarts the retry loop")
require.Empty(t, w.Body.String(), "Stripe expects an empty body on the ack path")
}
func TestWebhookConstants(t *testing.T) {
t.Run("maxWebhookBodySize is 1MB", func(t *testing.T) {
assert.Equal(t, int64(1<<20), int64(maxWebhookBodySize))