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>
95 lines
3.9 KiB
Go
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)
|
|
}
|