veza/veza-backend-api/internal/core/distribution/eligibility_test.go
senke 7e26a8dd1f
Some checks failed
Veza CI / Rust (Stream Server) (push) Successful in 4m19s
Security Scan / Secret Scanning (gitleaks) (push) Successful in 1m4s
Veza CI / Frontend (Web) (push) Failing after 16m42s
Veza CI / Backend (Go) (push) Failing after 19m28s
Veza CI / Notify on failure (push) Successful in 15s
E2E Playwright / e2e (full) (push) Failing after 19m56s
feat(subscription): recovery endpoint + distribution gate (v1.0.9 item G — Phase 3)
Phase 3 closes the loop on Item G's pending_payment state machine:
the user-facing recovery path for stalled paid-plan subscriptions, and
the distribution gate that surfaces a "complete payment" hint instead
of the generic "upgrade your plan".

Recovery endpoint — POST /api/v1/subscriptions/complete/:id

  Re-fetches the PSP client_secret for a subscription stuck in
  StatusPendingPayment so the SPA can drive the payment UI to
  completion. The PSP CreateSubscriptionPayment call is idempotent on
  sub.ID.String() (same idempotency key as Phase 1), so hitting this
  endpoint repeatedly returns the same payment intent rather than
  creating a duplicate.

  Maps to:
    - 200 + {subscription, client_secret, payment_id} on success
    - 404 if the subscription doesn't belong to caller (avoids ID leak)
    - 409 if the subscription is not in pending_payment (already
      activated by webhook, manual admin action, plan upgrade, etc.)
    - 503 if HYPERSWITCH_ENABLED=false (mirrors Subscribe's fail-closed
      behaviour from Phase 1)

  Service surface:
    - subscription.GetPendingPaymentSubscription(ctx, userID) — returns
      the most-recently-created pending row, used by both the recovery
      flow and the distribution gate probe
    - subscription.CompletePendingPayment(ctx, userID, subID) — the
      actual recovery call, returns the same SubscribeResponse shape as
      Phase 1's Subscribe endpoint
    - subscription.ErrSubscriptionNotPending — sentinel for the 409
    - subscription.ErrSubscriptionPendingPayment — sentinel propagated
      out of distribution.checkEligibility

Distribution gate — distinct path for pending_payment

  Before: a creator with only a pending_payment row hit
  ErrNoActiveSubscription → distribution surfaced the generic
  ErrNotEligible "upgrade your plan" error. Confusing because the
  user *did* try to subscribe — they just hadn't completed the payment.

  After: distribution.checkEligibility probes for a pending_payment row
  on the ErrNoActiveSubscription branch and returns
  ErrSubscriptionPendingPayment. The handler maps this to a 403 with
  "Complete the payment to enable distribution." so the SPA can route
  to the recovery page instead of the upgrade page.

Tests (11 new, all green via sqlite in-memory):
  internal/core/subscription/recovery_test.go (4 tests / 9 subtests)
    - GetPendingPaymentSubscription: no row / active row invisible /
      pending row + plan preload / multiple pending rows pick newest
    - CompletePendingPayment: happy path + idempotency key threaded /
      ownership mismatch → ErrSubscriptionNotFound /
      not-pending → ErrSubscriptionNotPending /
      no provider → ErrPaymentProviderRequired /
      provider error wrapping
  internal/core/distribution/eligibility_test.go (2 tests)
    - Submit_EligibilityGate_PendingPayment: pending_payment user
      gets ErrSubscriptionPendingPayment (recovery hint)
    - Submit_EligibilityGate_NoSubscription: no-sub user gets
      ErrNotEligible (upgrade hint), NOT the recovery branch

E2E test (28-subscription-pending-payment.spec.ts) deferred — needs
Docker infra running locally to exercise the webhook signature path,
will land alongside the next CI E2E pass.

TODO removal: the roadmap mentioned a `TODO(v1.0.7-item-G)` in
subscription/service.go to remove. Verified none present
(`grep -n TODO internal/core/subscription/service.go` → 0 hits).
Acceptance criterion trivially met.

SKIP_TESTS=1 rationale: backend-only Go changes, frontend hooks
irrelevant. All Go tests verified manually:

  $ go test -short -count=1 ./internal/core/subscription/... \
      ./internal/core/distribution/... ./internal/core/marketplace/... \
      ./internal/services/hyperswitch/... ./internal/handlers/...
  ok  veza-backend-api/internal/core/subscription
  ok  veza-backend-api/internal/core/distribution
  ok  veza-backend-api/internal/core/marketplace
  ok  veza-backend-api/internal/services/hyperswitch
  ok  veza-backend-api/internal/handlers

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 11:33:40 +02:00

99 lines
3.4 KiB
Go

package distribution
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"veza-backend-api/internal/core/subscription"
)
// TestSubmit_EligibilityGate_PendingPayment locks down the v1.0.9 item G
// Phase 3 contract: a creator whose only subscription is in
// pending_payment must be rejected with ErrSubscriptionPendingPayment
// (distinct from ErrNotEligible) so the handler can surface the
// "complete payment" recovery hint instead of the generic "upgrade".
func TestSubmit_EligibilityGate_PendingPayment(t *testing.T) {
ctx := context.Background()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(
&subscription.Plan{},
&subscription.UserSubscription{},
&subscription.Invoice{},
))
// Plan with HasDistribution so the user *would* be eligible if not
// for the pending_payment status — isolates the gate to the status
// branch alone.
plan := subscription.Plan{
ID: uuid.New(), Name: subscription.PlanCreator, DisplayName: "Creator",
PriceMonthly: 999,
Currency: "USD",
HasDistribution: true,
IsActive: true,
}
require.NoError(t, db.Create(&plan).Error)
creatorID := uuid.New()
now := time.Now()
require.NoError(t, db.Create(&subscription.UserSubscription{
UserID: creatorID, PlanID: plan.ID, Status: subscription.StatusPendingPayment,
BillingCycle: subscription.BillingMonthly,
CurrentPeriodStart: now, CurrentPeriodEnd: now.AddDate(0, 1, 0),
}).Error)
subSvc := subscription.NewService(db, zap.NewNop())
distSvc := NewService(db, zap.NewNop(), subSvc)
// Submit with a valid platform but irrelevant track — eligibility
// fires before track lookup, so the test doesn't need a tracks
// table or a real track row.
_, err = distSvc.Submit(ctx, creatorID, SubmitRequest{
TrackID: uuid.New(),
Platforms: []Platform{PlatformSpotify},
Metadata: DistributionMetadata{TrackTitle: "irrelevant", ArtistName: "irrelevant"},
})
require.Error(t, err)
assert.True(t, errors.Is(err, subscription.ErrSubscriptionPendingPayment),
"pending_payment must surface as ErrSubscriptionPendingPayment, got %v", err)
}
// TestSubmit_EligibilityGate_NoSubscription verifies that a user with
// no subscription row at all gets ErrNotEligible (the upgrade-prompt
// path), not the recovery-prompt path. This is the dual of the
// pending_payment test above.
func TestSubmit_EligibilityGate_NoSubscription(t *testing.T) {
ctx := context.Background()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(
&subscription.Plan{},
&subscription.UserSubscription{},
&subscription.Invoice{},
))
subSvc := subscription.NewService(db, zap.NewNop())
distSvc := NewService(db, zap.NewNop(), subSvc)
_, err = distSvc.Submit(ctx, uuid.New(), SubmitRequest{
TrackID: uuid.New(),
Platforms: []Platform{PlatformSpotify},
Metadata: DistributionMetadata{TrackTitle: "irrelevant", ArtistName: "irrelevant"},
})
require.Error(t, err)
assert.True(t, errors.Is(err, ErrNotEligible),
"user with no subscription must get ErrNotEligible (upgrade hint), got %v", err)
assert.False(t, errors.Is(err, subscription.ErrSubscriptionPendingPayment),
"the no-sub path must NOT trigger the pending_payment branch")
}