Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
package billing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func openStripeMockPool(t *testing.T) (*pgxpool.Pool, context.Context, context.CancelFunc) {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("DATABASE_URL not set")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
pg, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { pg.Close() })
|
||||
return pg, ctx, cancel
|
||||
}
|
||||
|
||||
func seedStripeMockCompany(t *testing.T, pg *pgxpool.Pool, ctx context.Context) uuid.UUID {
|
||||
t.Helper()
|
||||
companyID := uuid.New()
|
||||
_, err := pg.Exec(ctx, `INSERT INTO companies (id, name) VALUES ($1, $2)`, companyID, "stripe-mock-"+companyID.String()[:8])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cleanupCancel()
|
||||
_, _ = pg.Exec(cleanupCtx, `DELETE FROM stripe_webhook_events WHERE company_id = $1`, companyID)
|
||||
_, _ = pg.Exec(cleanupCtx, `DELETE FROM companies WHERE id = $1`, companyID)
|
||||
})
|
||||
return companyID
|
||||
}
|
||||
|
||||
func creditTotal(t *testing.T, pg *pgxpool.Pool, ctx context.Context, companyID uuid.UUID) int {
|
||||
t.Helper()
|
||||
var total int
|
||||
err := pg.QueryRow(ctx, `SELECT COALESCE(total_credits, 0) FROM credit_balances WHERE company_id = $1`, companyID).Scan(&total)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func activePlanName(t *testing.T, pg *pgxpool.Pool, ctx context.Context, companyID uuid.UUID) string {
|
||||
t.Helper()
|
||||
var name string
|
||||
err := pg.QueryRow(ctx, `
|
||||
SELECT lower(p.name) FROM company_plans cp
|
||||
JOIN plans p ON p.id = cp.plan_id
|
||||
WHERE cp.company_id = $1 AND cp.is_active = true
|
||||
ORDER BY cp.created_at DESC LIMIT 1`, companyID).Scan(&name)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func TestMockCheckoutPlanAssignsAndGrants(t *testing.T) {
|
||||
pg, ctx, cancel := openStripeMockPool(t)
|
||||
defer cancel()
|
||||
companyID := seedStripeMockCompany(t, pg, ctx)
|
||||
billing := &Service{Pool: pg}
|
||||
if err := billing.EnsureDefaultPlans(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := &StripeService{
|
||||
Pool: pg,
|
||||
Billing: billing,
|
||||
Cfg: StripeConfig{ForceMock: true, WebOrigin: "http://localhost:5174"},
|
||||
}
|
||||
res, err := s.CreateCheckoutSession(ctx, companyID, "mock@example.com", "Mock Co", CheckoutRequest{Plan: "starter", Term: "monthly"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !res.Mock || !res.Applied {
|
||||
t.Fatalf("expected mock applied checkout, got %#v", res)
|
||||
}
|
||||
if activePlanName(t, pg, ctx, companyID) != "starter" {
|
||||
t.Fatalf("plan=%q want starter", activePlanName(t, pg, ctx, companyID))
|
||||
}
|
||||
want := MonthlyCreditsForPlan("Starter", 0)
|
||||
if got := creditTotal(t, pg, ctx, companyID); got != want {
|
||||
t.Fatalf("credits=%d want %d", got, want)
|
||||
}
|
||||
var subID *string
|
||||
_ = pg.QueryRow(ctx, `
|
||||
SELECT stripe_subscription_id FROM company_plans
|
||||
WHERE company_id = $1 AND is_active = true`, companyID).Scan(&subID)
|
||||
if subID == nil || *subID == "" {
|
||||
t.Fatal("mock checkout must set stripe_subscription_id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMockCreditPackCheckoutGrants(t *testing.T) {
|
||||
pg, ctx, cancel := openStripeMockPool(t)
|
||||
defer cancel()
|
||||
companyID := seedStripeMockCompany(t, pg, ctx)
|
||||
billing := &Service{Pool: pg}
|
||||
s := &StripeService{
|
||||
Pool: pg,
|
||||
Billing: billing,
|
||||
Cfg: StripeConfig{ForceMock: true, WebOrigin: "http://localhost:5174"},
|
||||
}
|
||||
before := creditTotal(t, pg, ctx, companyID)
|
||||
res, err := s.CreateCreditPackCheckout(ctx, companyID, "mock@example.com", "Mock Co", "small")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !res.Mock || !res.Applied {
|
||||
t.Fatalf("expected mock applied pack, got %#v", res)
|
||||
}
|
||||
pack, _ := CreditPackByID("small")
|
||||
if got := creditTotal(t, pg, ctx, companyID); got != before+pack.Credits {
|
||||
t.Fatalf("credits=%d want %d", got, before+pack.Credits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookClaimIdempotentAndCreditGrant(t *testing.T) {
|
||||
pg, ctx, cancel := openStripeMockPool(t)
|
||||
defer cancel()
|
||||
companyID := seedStripeMockCompany(t, pg, ctx)
|
||||
billing := &Service{Pool: pg}
|
||||
s := &StripeService{
|
||||
Pool: pg,
|
||||
Billing: billing,
|
||||
Cfg: StripeConfig{ForceMock: true}, // unsigned allowed locally; no webhook secret
|
||||
}
|
||||
|
||||
eventID := "evt_mock_credit_" + companyID.String()[:8]
|
||||
payload, err := json.Marshal(map[string]any{
|
||||
"id": eventID,
|
||||
"type": "checkout.session.completed",
|
||||
"data": map[string]any{
|
||||
"object": map[string]any{
|
||||
"id": "cs_mock_1",
|
||||
"client_reference_id": companyID.String(),
|
||||
"metadata": map[string]string{
|
||||
"kind": "credit_pack",
|
||||
"pack": "tiny",
|
||||
"credits": "9999", // must be ignored for catalog pack
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := creditTotal(t, pg, ctx, companyID)
|
||||
if err := s.HandleWebhook(ctx, payload, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pack, _ := CreditPackByID("tiny")
|
||||
if got := creditTotal(t, pg, ctx, companyID); got != before+pack.Credits {
|
||||
t.Fatalf("after grant credits=%d want %d", got, before+pack.Credits)
|
||||
}
|
||||
mid := creditTotal(t, pg, ctx, companyID)
|
||||
if err := s.HandleWebhook(ctx, payload, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := creditTotal(t, pg, ctx, companyID); got != mid {
|
||||
t.Fatalf("idempotent claim must not double-grant: got %d mid %d", got, mid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookSubscriptionDeletedDowngrades(t *testing.T) {
|
||||
pg, ctx, cancel := openStripeMockPool(t)
|
||||
defer cancel()
|
||||
companyID := seedStripeMockCompany(t, pg, ctx)
|
||||
billing := &Service{Pool: pg}
|
||||
if err := billing.EnsureDefaultPlans(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := &StripeService{
|
||||
Pool: pg,
|
||||
Billing: billing,
|
||||
Cfg: StripeConfig{ForceMock: true},
|
||||
}
|
||||
if _, err := s.CreateCheckoutSession(ctx, companyID, "mock@example.com", "Mock Co", CheckoutRequest{Plan: "plus", Term: "monthly"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if activePlanName(t, pg, ctx, companyID) != "plus" {
|
||||
t.Fatalf("precondition plan=%q", activePlanName(t, pg, ctx, companyID))
|
||||
}
|
||||
|
||||
eventID := "evt_mock_del_" + companyID.String()[:8]
|
||||
payload, err := json.Marshal(map[string]any{
|
||||
"id": eventID,
|
||||
"type": "customer.subscription.deleted",
|
||||
"data": map[string]any{
|
||||
"object": map[string]any{
|
||||
"id": "sub_mock_del",
|
||||
"customer": "cus_mock",
|
||||
"status": "canceled",
|
||||
"metadata": map[string]string{"company_id": companyID.String()},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.HandleWebhook(ctx, payload, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := activePlanName(t, pg, ctx, companyID); got != "free" {
|
||||
t.Fatalf("after delete plan=%q want free", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookVerifyStillRequiredWithSecretUnderForceMock(t *testing.T) {
|
||||
pg, ctx, cancel := openStripeMockPool(t)
|
||||
defer cancel()
|
||||
companyID := seedStripeMockCompany(t, pg, ctx)
|
||||
secret := "whsec_mock_local"
|
||||
s := &StripeService{
|
||||
Pool: pg,
|
||||
Billing: &Service{Pool: pg},
|
||||
Cfg: StripeConfig{ForceMock: true, WebhookSecret: secret},
|
||||
}
|
||||
payload, err := json.Marshal(map[string]any{
|
||||
"id": "evt_signed_" + companyID.String()[:8],
|
||||
"type": "ping",
|
||||
"data": map[string]any{"object": map[string]any{}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.HandleWebhook(ctx, payload, ""); err == nil {
|
||||
t.Fatal("unsigned must fail when webhook secret set")
|
||||
}
|
||||
sig := signStripePayload(t, secret, payload)
|
||||
if err := s.HandleWebhook(ctx, payload, sig); err != nil {
|
||||
t.Fatalf("valid signature under ForceMock: %v", err)
|
||||
}
|
||||
// Second delivery is an idempotent no-op.
|
||||
if err := s.HandleWebhook(ctx, payload, sig); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var n int
|
||||
if err := pg.QueryRow(ctx, `SELECT COUNT(*) FROM stripe_webhook_events WHERE event_id = $1`,
|
||||
fmt.Sprintf("evt_signed_%s", companyID.String()[:8])).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("claim rows=%d want 1", n)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user