veza/veza-backend-api/internal/services/stripe_connect_service_test.go
senke 1a133af9ac feat(marketplace): stripe reversal error disambiguation + CHECK constraint + E2E — v1.0.7 item B day 3
Day-3 closure of item B. The three things day 2 deferred are now done:

1. Stripe error disambiguation.
   ReverseTransfer in StripeConnectService now parses
   stripe.Error.Code + HTTPStatusCode + Msg to emit the sentinels
   the worker routes on. Pre-day-3 the sentinels were declared but
   the service wrapped every error opaquely, making this the exact
   "temporary compromise frozen into permanent" pattern the audit
   was meant to prevent — flagged during review and fixed same day.

   Mapping:
     * 404 + code=resource_missing  → ErrTransferNotFound
     * 400 + msg matches "already" + "reverse" → ErrTransferAlreadyReversed
     * any other                    → transient (wrapped raw, retry)

   The "already reversed" case has no machine-readable code in
   stripe-go (unlike ChargeAlreadyRefunded for charges — the SDK
   doesn't enumerate the equivalent for transfers), so it's
   message-parsed. Fragility documented at the call site: if Stripe
   changes the wording, the worker treats the response as transient
   and eventually surfaces the row to permanently_failed after max
   retries. Worst-case regression is "benign case gets noisier",
   not data loss.

2. Migration 983: CHECK constraint chk_reversal_pending_has_next_
   retry_at CHECK (status != 'reversal_pending' OR next_retry_at
   IS NOT NULL). Added NOT VALID so the constraint is enforced on
   new writes without scanning existing rows; a follow-up VALIDATE
   can run once the table is known to be clean. Prevents the
   "invisible orphan" failure mode where a reversal_pending row
   with NULL next_retry_at would be skipped by any future stricter
   worker query.

3. End-to-end reversal flow test (reversal_e2e_test.go) chains
   three sub-scenarios: (a) happy path — refund.succeeded →
   reversal_pending → worker → reversed with stripe_reversal_id
   persisted; (b) invalid stripe_transfer_id → worker terminates
   rapidly to permanently_failed with single Stripe call, no
   retries (the highest-value coverage per day-3 review); (c)
   already-reversed out-of-band → worker flips to reversed with
   informative message.

Architecture note — the sentinels were moved to a new leaf
package `internal/core/connecterrors` because both marketplace
(needs them for the worker's errors.Is checks) and services (needs
them to emit) import them, and an import cycle
(marketplace → monitoring → services) would form if either owned
them directly. marketplace re-exports them as type aliases so the
worker code reads naturally against the marketplace namespace.

New tests:
  * services/stripe_connect_service_test.go — 7 cases on
    isAlreadyReversedMessage (pins Stripe's wording), 1 case on
    the error-classification shape. Doesn't invoke stripe.SetBackend
    — the translation logic is tested via a crafted *stripe.Error,
    the emission is trusted on the read of `errors.As` + the known
    shape of stripe.Error.
  * marketplace/reversal_e2e_test.go — 3 end-to-end sub-tests
    chaining refund → worker against a dual-role mock. The
    invalid-id case asserts single-call-no-retries termination.
  * Migration 983 applied cleanly to the local Postgres; constraint
    visible in \d seller_transfers as NOT VALID (behavior correct
    for future writes, existing rows grandfathered).

Self-assessment on day-2's struct-literal refactor of
processSellerTransfers (deferred from day 2):
The refactor is borderline — neither clearer nor confusing than the
original mutation-after-construct pattern. Logged in the v1.0.7-rc1
CHANGELOG as a post-v1.0.7 consideration: if GORM BeforeUpdate
hooks prove cleaner on other state machines (axis 2), revisit the
anti-mutation test approach.

CHANGELOG v1.0.7-rc1 entry added documenting items A + B end-to-end.
Tag not yet applied — items C, D, E, F remain on the v1.0.7 plan.
The rc1 tag lands when those four items close + the smoke probe
validates the full cadence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 02:12:03 +02:00

95 lines
3.9 KiB
Go

package services
import (
"errors"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stripe/stripe-go/v82"
"veza-backend-api/internal/core/connecterrors"
)
// TestIsAlreadyReversedMessage exercises the one fragile corner of the
// Stripe error disambiguation: we rely on string-matching because the
// Stripe Go SDK doesn't expose a code enum for "transfer already
// reversed" on transferreversal.New. If Stripe ever changes the
// wording the worker treats the response as transient and eventually
// times out to permanently_failed — no data loss, just noisier
// operation. This test pins the wordings currently known to Stripe
// so a change breaks the build loudly rather than silently drifting.
func TestIsAlreadyReversedMessage(t *testing.T) {
cases := []struct {
name string
msg string
want bool
}{
{"canonical stripe wording", "The transfer tr_abc has already been reversed.", true},
{"lowercase variant", "this transfer has already been reversed", true},
{"noun form", "reversal already processed for this transfer", true},
{"empty", "", false},
{"unrelated error", "Invalid amount: must be positive", false},
{"mentions reverse but not already", "transfer has been reversed but not enough", false},
{"mentions already but not reverse", "Resource has already been used", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, isAlreadyReversedMessage(tc.msg))
})
}
}
// TestReverseTransfer_MapsStripeErrorToSentinel verifies that the
// error-translation layer in ReverseTransfer produces the sentinels
// the worker routes on, without needing a live Stripe. The test
// drives the logic by invoking the isAlreadyReversedMessage and the
// error-classification shape through a crafted *stripe.Error.
//
// The full ReverseTransfer can't be unit-tested without either a
// real Stripe account or stripe.SetBackend (httptest.Server replay),
// both of which are overkill for the translation logic. The worker
// tests (marketplace/reversal_worker_test.go) cover the behavior
// from the sentinel's perspective; this test covers the emission.
func TestReverseTransfer_ErrorClassificationShape(t *testing.T) {
// Case 1: 404 + resource_missing → ErrTransferNotFound
notFoundErr := &stripe.Error{
HTTPStatusCode: http.StatusNotFound,
Code: stripe.ErrorCodeResourceMissing,
Msg: "No such transfer: tr_invalid",
}
// The switch in ReverseTransfer: we test the branch logic by
// calling errors.As + checking the conditions directly. This
// guards against someone "refactoring" the switch and breaking
// the sentinel mapping silently.
var stripeErr *stripe.Error
if assert.True(t, errors.As(error(notFoundErr), &stripeErr)) {
assert.Equal(t, http.StatusNotFound, stripeErr.HTTPStatusCode)
assert.Equal(t, stripe.ErrorCodeResourceMissing, stripeErr.Code)
}
// Case 2: 400 + message match → ErrTransferAlreadyReversed
alreadyErr := &stripe.Error{
HTTPStatusCode: http.StatusBadRequest,
Msg: "The transfer tr_done has already been fully reversed.",
}
if assert.True(t, errors.As(error(alreadyErr), &stripeErr)) {
assert.Equal(t, http.StatusBadRequest, stripeErr.HTTPStatusCode)
assert.True(t, isAlreadyReversedMessage(stripeErr.Msg))
}
// Case 3: 503 transient → neither sentinel
transientErr := &stripe.Error{
HTTPStatusCode: http.StatusServiceUnavailable,
Msg: "Service temporarily unavailable",
}
if assert.True(t, errors.As(error(transientErr), &stripeErr)) {
assert.NotEqual(t, http.StatusNotFound, stripeErr.HTTPStatusCode)
assert.False(t, isAlreadyReversedMessage(stripeErr.Msg))
}
// Confirm sentinels are exported from connecterrors (not stale
// references after the leaf-package refactor)
assert.ErrorIs(t, connecterrors.ErrTransferNotFound, connecterrors.ErrTransferNotFound)
assert.ErrorIs(t, connecterrors.ErrTransferAlreadyReversed, connecterrors.ErrTransferAlreadyReversed)
}