Compare commits

..

2 Commits

11 changed files with 70 additions and 300 deletions

View File

@@ -22,15 +22,6 @@ jobs:
key: ${{ secrets.SERVER_SSH_KEY }}
port: ${{ secrets.SERVER_PORT }}
script: |
set -euo pipefail
cd ~/terminalShop
git fetch origin master
git reset --hard origin/master
deployed=$(git rev-parse HEAD)
expected="${{ github.sha }}"
if [ "$deployed" != "$expected" ]; then
echo "::error::deploy HEAD mismatch: box=$deployed expected=$expected"
exit 1
fi
git pull
docker compose -f docker-compose.prod.yml up -d --build
echo "deploy verified: HEAD=$deployed"

View File

@@ -1,7 +1,6 @@
package handlers
import (
"context"
"os"
"testing"
"time"
@@ -59,7 +58,7 @@ func TestReconcileExpiredCards_RemovesPastDeadlineOnly(t *testing.T) {
t.Fatalf("seed cart: %v", err)
}
ReconcileExpiredCards(context.Background())
ReconcileExpiredCards()
var remaining []models.Card
if err := db.Where("user_id = ?", user.ID).Find(&remaining).Error; err != nil {
@@ -118,7 +117,7 @@ func TestReconcileExpiredCards_SweepsAcrossUsers(t *testing.T) {
}
}
ReconcileExpiredCards(context.Background())
ReconcileExpiredCards()
var count int64
if err := db.Model(&models.Card{}).Count(&count).Error; err != nil {

View File

@@ -1,12 +1,10 @@
package handlers
import (
"context"
"fmt"
"log/slog"
"time"
"terminalShop/api/middleware"
"terminalShop/pkg/audit"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
@@ -28,8 +26,8 @@ func reconcileLog() *slog.Logger { return slog.With("component", "reconcile") }
// and checks whether Stripe has a succeeded PaymentIntent for them. This fixes
// the case where the server crashed after charging but before the DB transaction
// completed.
func ReconcileOrders(ctx context.Context) {
db := database.GetDB().WithContext(ctx)
func ReconcileOrders() {
db := database.GetDB()
var orders []models.Order
cutoff := time.Now().Add(-10 * time.Minute)
@@ -43,14 +41,9 @@ func ReconcileOrders(ctx context.Context) {
reconcileLog().Info("checking pending orders", "count", len(orders))
for _, order := range orders {
if err := ctx.Err(); err != nil {
reconcileLog().Info("reconcile orders cancelled mid-loop", "error", err)
return
}
params := &stripe.PaymentIntentSearchParams{
SearchParams: stripe.SearchParams{
Context: ctx,
Query: fmt.Sprintf("metadata['order_id']:'%d' AND status:'succeeded'", order.ID),
Query: fmt.Sprintf("metadata['order_id']:'%d' AND status:'succeeded'", order.ID),
},
}
@@ -96,8 +89,8 @@ func ReconcileOrders(ctx context.Context) {
// from ReconcileOrders: the empty-PI predicate there is load-bearing for
// the crash-mid-create case (see sca-psd2-compliance.md caveat #20).
// stripe.Key is wired once at startup in api/main.go.
func ReconcileStale3DSOrders(ctx context.Context, threshold time.Duration) {
db := database.GetDB().WithContext(ctx)
func ReconcileStale3DSOrders(threshold time.Duration) {
db := database.GetDB()
var orders []models.Order
// Predicate keys off updated_at, not created_at: respondRequiresAction
@@ -116,26 +109,19 @@ func ReconcileStale3DSOrders(ctx context.Context, threshold time.Duration) {
reconcileLog().Info("checking stale requires_action orders", "count", len(orders))
piParams := &stripe.PaymentIntentParams{Params: stripe.Params{Context: ctx}}
for i := range orders {
if err := ctx.Err(); err != nil {
reconcileLog().Info("stale 3ds sweep cancelled mid-loop", "error", err)
return
}
order := &orders[i]
if order.StripePaymentID == "" {
// respondRequiresAction writes stripe_payment_id and status in
// one UPDATE so this should be unreachable; guard anyway so a
// future regression noisily logs instead of 4xx'ing Stripe.
reconcileLog().Warn("requires_action row missing PI id; skipping", "order_id", order.ID)
middleware.RecordAbandoned3DSSweep("missing_pi")
continue
}
actual, err := paymentIntentGetFn(order.StripePaymentID, piParams)
actual, err := paymentIntentGetFn(order.StripePaymentID, nil)
if err != nil {
reconcileLog().Error("stripe get failed",
"order_id", order.ID, "pi", order.StripePaymentID, "error", err)
middleware.RecordAbandoned3DSSweep("stripe_error")
continue
}
switch actual.Status {
@@ -147,12 +133,10 @@ func ReconcileStale3DSOrders(ctx context.Context, threshold time.Duration) {
// tick re-checks and surfaces the dropped webhook if it persists
reconcileLog().Warn("stripe reports succeeded for requires_action row; deferring to webhook",
"order_id", order.ID, "pi", order.StripePaymentID)
middleware.RecordAbandoned3DSSweep("succeeded_skip")
case stripe.PaymentIntentStatusProcessing,
stripe.PaymentIntentStatusRequiresCapture:
reconcileLog().Info("payment intent still transitioning; skipping",
"order_id", order.ID, "pi", order.StripePaymentID, "stripe_status", string(actual.Status))
middleware.RecordAbandoned3DSSweep("processing_skip")
case stripe.PaymentIntentStatusRequiresAction,
stripe.PaymentIntentStatusRequiresPaymentMethod,
stripe.PaymentIntentStatusRequiresConfirmation,
@@ -161,7 +145,6 @@ func ReconcileStale3DSOrders(ctx context.Context, threshold time.Duration) {
default:
reconcileLog().Warn("unrecognized stripe status; skipping",
"order_id", order.ID, "pi", order.StripePaymentID, "stripe_status", string(actual.Status))
middleware.RecordAbandoned3DSSweep("unrecognized_status")
}
}
}
@@ -179,27 +162,24 @@ func flipStale3DSToFailed(db *gorm.DB, order *models.Order, stripeStatus string)
Updates(map[string]any{"status": models.OrderStatusFailed})
if res.Error != nil {
reconcileLog().Error("flip stale 3ds failed", "order_id", order.ID, "error", res.Error)
middleware.RecordAbandoned3DSSweep("flip_failed")
return
}
if res.RowsAffected == 0 {
reconcileLog().Info("stale 3ds row already transitioned; sweep no-op",
"order_id", order.ID)
middleware.RecordAbandoned3DSSweep("flip_noop")
return
}
audit.Order3DSAbandoned(order.UserID, order.ID, order.StripePaymentID)
reconcileLog().Info("3ds abandoned order flipped to failed",
"order_id", order.ID, "pi", order.StripePaymentID, "stripe_status", stripeStatus)
middleware.RecordAbandoned3DSSweep("flipped")
}
// ReconcileExpiredCards removes saved cards whose retention deadline elapsed.
// Read paths filter out expired rows so users never see them; this job drives
// the Stripe detach + audit + physical row delete out of band so request
// handlers never block on Stripe API calls during a card sweep.
func ReconcileExpiredCards(ctx context.Context) {
db := database.GetDB().WithContext(ctx)
func ReconcileExpiredCards() {
db := database.GetDB()
var cards []models.Card
if err := db.Where(
@@ -215,20 +195,16 @@ func ReconcileExpiredCards(ctx context.Context) {
reconcileLog().Info("expiring inactive saved cards", "count", len(cards))
for i := range cards {
if err := ctx.Err(); err != nil {
reconcileLog().Info("card sweep cancelled mid-loop", "error", err)
return
}
if err := expireStoredCard(db, &cards[i]); err != nil {
reconcileLog().Error("expire card failed", "card_id", cards[i].ID, "error", err)
}
}
}
// ReconcileUnshipped finds paid orders older than 24h with no tracking
// number and posts a single Slack reminder. Pure read + notify.
func ReconcileUnshipped(ctx context.Context) {
db := database.GetDB().WithContext(ctx)
// ReconcileUnshipped finds paid order orlder than 24h with no tracking
// number and posts single Slack reminder. Pure read + notif
func ReconcileUnshipped() {
db := database.GetDB()
var orders []models.Order
cutoff := time.Now().Add(-24 * time.Hour)

View File

@@ -1,19 +1,16 @@
package handlers
import (
"context"
"fmt"
"os"
"testing"
"time"
"terminalShop/api/middleware"
"github.com/stripe/stripe-go/v78"
"terminalShop/pkg/audit"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
"github.com/prometheus/client_golang/prometheus/testutil"
"github.com/stripe/stripe-go/v78"
)
// TestReconcileStale3DSOrders_FlipsAbandonedToFailed verifies the predicate
@@ -42,10 +39,7 @@ func TestReconcileStale3DSOrders_FlipsAbandonedToFailed(t *testing.T) {
}, nil
}
flipped := middleware.Abandoned3DSSweepCounter().WithLabelValues("flipped")
before := testutil.ToFloat64(flipped)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
ReconcileStale3DSOrders(30 * time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
@@ -63,9 +57,6 @@ func TestReconcileStale3DSOrders_FlipsAbandonedToFailed(t *testing.T) {
if len(events) != 1 {
t.Fatalf("want 1 order_3ds_abandoned audit row, got %d", len(events))
}
if got := testutil.ToFloat64(flipped) - before; got != 1 {
t.Fatalf("want 1 flipped sweep counter increment, got %v", got)
}
}
// TestReconcileStale3DSOrders_SkipsRecent verifies the threshold predicate
@@ -94,7 +85,7 @@ func TestReconcileStale3DSOrders_SkipsRecent(t *testing.T) {
}, nil
}
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
ReconcileStale3DSOrders(30 * time.Minute)
if called {
t.Fatalf("stripe Get called for in-window row")
@@ -133,10 +124,7 @@ func TestReconcileStale3DSOrders_SkipsWhenStripeSaysSucceeded(t *testing.T) {
}, nil
}
skip := middleware.Abandoned3DSSweepCounter().WithLabelValues("succeeded_skip")
before := testutil.ToFloat64(skip)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
ReconcileStale3DSOrders(30 * time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
@@ -145,9 +133,6 @@ func TestReconcileStale3DSOrders_SkipsWhenStripeSaysSucceeded(t *testing.T) {
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("must defer to webhook on succeeded; got %q", reloaded.Status)
}
if got := testutil.ToFloat64(skip) - before; got != 1 {
t.Fatalf("want 1 succeeded_skip sweep counter increment, got %v", got)
}
}
// TestReconcileStale3DSOrders_GuardsAgainstWebhookRace covers the conditional
@@ -176,10 +161,7 @@ func TestReconcileStale3DSOrders_GuardsAgainstWebhookRace(t *testing.T) {
}, nil
}
noop := middleware.Abandoned3DSSweepCounter().WithLabelValues("flip_noop")
before := testutil.ToFloat64(noop)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
ReconcileStale3DSOrders(30 * time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
@@ -188,9 +170,6 @@ func TestReconcileStale3DSOrders_GuardsAgainstWebhookRace(t *testing.T) {
if reloaded.Status != models.OrderStatusPaid {
t.Fatalf("conditional update must preserve paid; got %q", reloaded.Status)
}
if got := testutil.ToFloat64(noop) - before; got != 1 {
t.Fatalf("want 1 flip_noop sweep counter increment, got %v", got)
}
}
// TestReconcileStale3DSOrders_FlipsCanceledPI covers the canceled-PI branch:
@@ -217,7 +196,7 @@ func TestReconcileStale3DSOrders_FlipsCanceledPI(t *testing.T) {
}, nil
}
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
ReconcileStale3DSOrders(30 * time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
@@ -253,7 +232,7 @@ func TestReconcileStale3DSOrders_DoesNotConsumeRetryCap(t *testing.T) {
}, nil
}
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
ReconcileStale3DSOrders(30 * time.Minute)
var retryCount int64
if err := db.Model(&models.OrderEvent{}).
@@ -296,10 +275,7 @@ func TestReconcileStale3DSOrders_SkipsTransitioningStatuses(t *testing.T) {
return &stripe.PaymentIntent{ID: id, Status: tc.status}, nil
}
skip := middleware.Abandoned3DSSweepCounter().WithLabelValues("processing_skip")
before := testutil.ToFloat64(skip)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
ReconcileStale3DSOrders(30 * time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
@@ -308,9 +284,6 @@ func TestReconcileStale3DSOrders_SkipsTransitioningStatuses(t *testing.T) {
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("%s must stay requires_action; got %q", tc.name, reloaded.Status)
}
if got := testutil.ToFloat64(skip) - before; got != 1 {
t.Fatalf("want 1 processing_skip increment, got %v", got)
}
})
}
}
@@ -339,10 +312,7 @@ func TestReconcileStale3DSOrders_SkipsUnknownStatus(t *testing.T) {
}, nil
}
unknown := middleware.Abandoned3DSSweepCounter().WithLabelValues("unrecognized_status")
before := testutil.ToFloat64(unknown)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
ReconcileStale3DSOrders(30 * time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
@@ -351,9 +321,6 @@ func TestReconcileStale3DSOrders_SkipsUnknownStatus(t *testing.T) {
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("unknown status must not flip; got %q", reloaded.Status)
}
if got := testutil.ToFloat64(unknown) - before; got != 1 {
t.Fatalf("want 1 unrecognized_status increment, got %v", got)
}
}
// TestReconcileStale3DSOrders_SkipsOnStripeError verifies a Stripe Get
@@ -377,10 +344,7 @@ func TestReconcileStale3DSOrders_SkipsOnStripeError(t *testing.T) {
return nil, fmt.Errorf("simulated stripe outage")
}
stripeErr := middleware.Abandoned3DSSweepCounter().WithLabelValues("stripe_error")
before := testutil.ToFloat64(stripeErr)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
ReconcileStale3DSOrders(30 * time.Minute)
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
@@ -389,9 +353,6 @@ func TestReconcileStale3DSOrders_SkipsOnStripeError(t *testing.T) {
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("stripe error must not flip; got %q", reloaded.Status)
}
if got := testutil.ToFloat64(stripeErr) - before; got != 1 {
t.Fatalf("want 1 stripe_error sweep counter increment, got %v", got)
}
}
// TestReconcileStale3DSOrders_SkipsEmptyPaymentIntentID guards the
@@ -420,10 +381,7 @@ func TestReconcileStale3DSOrders_SkipsEmptyPaymentIntentID(t *testing.T) {
return &stripe.PaymentIntent{Status: stripe.PaymentIntentStatusRequiresPaymentMethod}, nil
}
missing := middleware.Abandoned3DSSweepCounter().WithLabelValues("missing_pi")
before := testutil.ToFloat64(missing)
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
ReconcileStale3DSOrders(30 * time.Minute)
if called {
t.Fatalf("stripe Get called for empty-PI row")
@@ -435,55 +393,6 @@ func TestReconcileStale3DSOrders_SkipsEmptyPaymentIntentID(t *testing.T) {
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("empty-PI guard must leave status alone; got %q", reloaded.Status)
}
if got := testutil.ToFloat64(missing) - before; got != 1 {
t.Fatalf("want 1 missing_pi increment, got %v", got)
}
}
// TestReconcileStale3DSOrders_BailsMidLoopOnCancel verifies the per-iteration
// ctx.Err() check stops the sweep once the supervising context cancels, so a
// SIGTERM during a large stale-3DS batch does not block the binary past the
// 30s graceful-shutdown window in api/main.go
func TestReconcileStale3DSOrders_BailsMidLoopOnCancel(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
audit.SetDB(db)
defer audit.SetDB(nil)
seedRequiresActionOrder(t, user.ID, "pi_first", time.Now().Add(-1*time.Hour))
seedRequiresActionOrder(t, user.ID, "pi_second", time.Now().Add(-1*time.Hour))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
calls := 0
orig := paymentIntentGetFn
defer func() { paymentIntentGetFn = orig }()
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
calls++
if calls == 1 {
cancel()
}
return &stripe.PaymentIntent{
ID: id,
Status: stripe.PaymentIntentStatusRequiresPaymentMethod,
}, nil
}
ReconcileStale3DSOrders(ctx, 30*time.Minute)
if calls != 1 {
t.Fatalf("want exactly 1 stripe Get call (loop bails before row 2); got %d", calls)
}
var stillPending int64
if err := db.Model(&models.Order{}).Where("status = ?", models.OrderStatusRequiresAction).Count(&stillPending).Error; err != nil {
t.Fatalf("count requires_action rows: %v", err)
}
if stillPending < 1 {
t.Fatalf("want at least 1 row left in requires_action after mid-loop cancel; got %d", stillPending)
}
}
// seedRequiresActionOrder inserts a card + order pair in the requires_action

View File

@@ -114,7 +114,7 @@ func main() {
for {
select {
case <-ticker.C:
handlers.ReconcileOrders(ctx)
handlers.ReconcileOrders()
case <-ctx.Done():
return
}
@@ -122,18 +122,14 @@ func main() {
}()
go func() {
select {
case <-time.After(30 * time.Second):
case <-ctx.Done():
return
}
handlers.ReconcileUnshipped(ctx)
time.Sleep(30 * time.Second)
handlers.ReconcileUnshipped()
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ticker.C:
handlers.ReconcileUnshipped(ctx)
handlers.ReconcileUnshipped()
case <-ctx.Done():
return
}
@@ -149,7 +145,7 @@ func main() {
for {
select {
case <-ticker.C:
handlers.ReconcileExpiredCards(ctx)
handlers.ReconcileExpiredCards()
case <-ctx.Done():
return
}
@@ -163,7 +159,7 @@ func main() {
for {
select {
case <-ticker.C:
handlers.ReconcileStale3DSOrders(ctx, threshold)
handlers.ReconcileStale3DSOrders(threshold)
case <-ctx.Done():
return
}
@@ -178,10 +174,10 @@ func main() {
log.Println("Server is shutting down...")
// Graceful shutdown with 30 second timeout
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel()
ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
if err := server.Shutdown(ctx); err != nil {
log.Fatalf("Server forced to shutdown: %v", err)
}

View File

@@ -70,14 +70,6 @@ var (
[]string{"outcome"},
)
abandoned3DSSweepTotal = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "abandoned_3ds_sweep_total",
Help: "ReconcileStale3DSOrders outcomes per row processed (flipped, flip_noop, flip_failed, succeeded_skip, processing_skip, stripe_error, missing_pi, unrecognized_status)",
},
[]string{"outcome"},
)
orderValueDollars = promauto.NewHistogram(
prometheus.HistogramOpts{
Name: "order_value_dollars",
@@ -181,19 +173,6 @@ func ObserveOrderValueCents(cents int) {
orderValueDollars.Observe(float64(cents) / 100.0)
}
// RecordAbandoned3DSSweep records the outcome of one row processed by
// ReconcileStale3DSOrders. Bounded enumeration documented on the counter's
// Help string.
func RecordAbandoned3DSSweep(outcome string) {
abandoned3DSSweepTotal.WithLabelValues(outcome).Inc()
}
// Abandoned3DSSweepCounter exposes the sweep counter so tests can snapshot
// per-label values via prometheus testutil. Not used in production code.
func Abandoned3DSSweepCounter() *prometheus.CounterVec {
return abandoned3DSSweepTotal
}
// ObserveStripeRequest records latency of a Stripe API call.
func ObserveStripeRequest(op, outcome string, durSec float64) {
stripeRequestDuration.WithLabelValues(op, outcome).Observe(durSec)

1
go.mod
View File

@@ -81,7 +81,6 @@ require (
github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect

View File

@@ -137,13 +137,11 @@ func getEnvOrDefault(key, defaultValue string) string {
return defaultValue
}
// loadMaxOrderCents reads MAX_ORDER_CENTS. Default $500 — aligns with the
// PSD2 TRA exemption ceiling (above this, Stripe always requests SCA, so the
// cap is only meaningful below it). Explicit 0 disables the cap. Garbage or
// negative values fall back to the default with a warning so an operator
// typo cannot silently disable a fraud control.
// loadMaxOrderCents reads MAX_ORDER_CENTS. Default $200. Explicit 0 disables
// the cap. Garbage or negative values fall back to the default with a warning
// so an operator typo cannot silently disable a fraud control.
func loadMaxOrderCents() int {
const defaultCap = 50000
const defaultCap = 20000
raw := os.Getenv("MAX_ORDER_CENTS")
if raw == "" {
return defaultCap

View File

@@ -24,12 +24,12 @@ func TestLoadMaxOrderCents(t *testing.T) {
env string // "" means unset
want int
}{
{"empty falls back to default", "", 50000},
{"empty falls back to default", "", 20000},
{"explicit zero disables", "0", 0},
{"valid override", "30000", 30000},
{"negative falls back to default", "-1", 50000},
{"garbage falls back to default", "two hundred", 50000},
{"empty after trim falls back to default", "", 50000},
{"valid override", "50000", 50000},
{"negative falls back to default", "-1", 20000},
{"garbage falls back to default", "two hundred", 20000},
{"empty after trim falls back to default", "", 20000},
}
for _, tc := range cases {

View File

@@ -355,16 +355,11 @@ func (m Model) RenderCardList() string {
boxes = append(boxes, box)
}
// SSH inline card-add is dormant until i have a norwegian org
// number wired to stripe, something i dont have since its $300+. Keep the row visible so the option is
// discoverable, but render it dimmed and exclude it from cursor space
// (handled in PaymentUpdate below) so it can not be picked
dim := m.theme.TextDim().Render
sshLabel := dim("+ add card via ssh") + " " + dim("(WIP)")
sshBox := m.CreateBox(m.formatListItem(sshLabel, false), false)
sshFocused := m.payment.cardCursor == len(m.SavedCards)
sshBox := m.CreateBox(m.formatListItem("+ add card via ssh", sshFocused), sshFocused)
boxes = append(boxes, sshBox)
httpsFocused := m.payment.cardCursor == len(m.SavedCards)
httpsFocused := m.payment.cardCursor == len(m.SavedCards)+1
httpsBox := m.CreateBox(m.formatListItem("+ add payment via browser", httpsFocused), httpsFocused)
boxes = append(boxes, httpsBox)
@@ -506,7 +501,7 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
m.payment.cardCursor--
}
case "down", "j":
if m.payment.cardCursor < len(m.SavedCards) {
if m.payment.cardCursor < len(m.SavedCards)+1 {
m.payment.cardCursor++
}
@@ -516,8 +511,17 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
m.SelectedCard = &selected
return m.ReviewSwitch()
}
// Browser flow, the only reachable add-card path while the SSH
// inline form is dormant (see RenderCardList)
if m.payment.cardCursor == len(m.SavedCards) {
m.payment.view = 1
m.payment.form = m.InitPaymentForm()
m.footer = []footerCommand{
{key: "tab", value: "next"},
{key: "enter", value: "submit"},
{key: "esc", value: "back"},
}
return m, m.payment.form.form.Init()
}
// Browser payment
m.payment.view = 2
m.payment.collectURL = nil
m.payment.collectTimeOut = false
@@ -542,6 +546,16 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
if m.payment.cardCursor >= len(m.SavedCards) && m.payment.cardCursor > 0 {
m.payment.cardCursor--
}
if len(m.SavedCards) == 0 {
m.payment.view = 1
m.payment.form = m.InitPaymentForm()
m.footer = []footerCommand{
{key: "tab", value: "next"},
{key: "enter", value: "submit"},
{key: "esc", value: "back"},
}
return m, tea.Batch(m.payment.form.form.Init(), m.deleteCardCmd(card.ID))
}
return m, m.deleteCardCmd(card.ID)
}
}

View File

@@ -1,91 +0,0 @@
package tui
import (
"strings"
"testing"
"terminalShop/pkg/models"
tea "charm.land/bubbletea/v2"
)
// newPaymentCardListModel parks a Model on the payment page card-list view
// with n synthetic saved cards. Baseline for assertint that the dormant
// "+ add card via ssh" row is dimmed and unreachable by cursor navigation.
func newPaymentCardListModel(t *testing.T, n int) Model {
t.Helper()
m := NewModel("test")
m = m.updateLayout(120, 40)
m.currentPage = paymentPage
m.payment.view = 0
m.payment.form = nil
m.payment.cardCursor = 0
for i := range n {
m.SavedCards = append(m.SavedCards, models.Card{
ID: uint(i + 1),
Brand: "visa",
Last4: "4242",
ExpMonth: 12,
ExpYear: 2030,
})
}
return m
}
func keyEnter() tea.KeyMsg { return tea.KeyPressMsg{Code: tea.KeyEnter} }
func TestRenderCardList_SshOptionComingSoon(t *testing.T) {
m := newPaymentCardListModel(t, 1)
out := m.RenderCardList()
if !strings.Contains(out, "add card via ssh") {
t.Fatalf("ssh option missing from card list output:\n%s", out)
}
if !strings.Contains(out, "WIP") {
t.Fatalf("want 'WIP' marked on ssh option:\n%s", out)
}
}
func TestPaymentUpdate_DownNavigationStopsAtBrowserSlot(t *testing.T) {
m := newPaymentCardListModel(t, 2)
for range 10 {
var cmd tea.Cmd
m, cmd = m.PaymentUpdate(keyJ())
_ = cmd
}
want := len(m.SavedCards) // browser slot; ssh slot in unreachable
if m.payment.cardCursor != want {
t.Fatalf("cursor stopped at %d, want %d (browser slot)", m.payment.cardCursor, want)
}
}
func TestPaymentUpdate_EnterOnBrowserSlotLaunchesBrowserFlow(t *testing.T) {
m := newPaymentCardListModel(t, 1)
m.payment.cardCursor = len(m.SavedCards) // browser slot
newM, cmd := m.PaymentUpdate(keyEnter())
if newM.payment.view != 2 {
t.Fatalf("payment.view = %d, want 2 (browser flow)", newM.payment.view)
}
if newM.payment.form != nil {
t.Fatalf("payment.form must be nil after enter; ssh inline form is dormant")
}
if cmd == nil {
t.Fatalf("expected collectCardCmd, got nil")
}
}
func TestPaymentUpdate_EnterWithZeroCardsLaunchesBrowserFlow(t *testing.T) {
m := newPaymentCardListModel(t, 0)
if m.payment.cardCursor != 0 {
t.Fatalf("default cursor = %d, want 0 ", m.payment.cardCursor)
}
newM, cmd := m.PaymentUpdate(keyEnter())
if newM.payment.view != 2 {
t.Fatalf("payment.view = %d, want 2 (browser flow)", newM.payment.view)
}
if newM.payment.form != nil {
t.Fatalf("payment.form must be nil; ssh inline form is dormant")
}
if cmd == nil {
t.Fatalf("expected collectCardCmd, got nil")
}
}