Compare commits
27 Commits
master
...
chore/back
| Author | SHA1 | Date | |
|---|---|---|---|
| c353821780 | |||
| 2f65cf0d90 | |||
| c3e6eb2b84 | |||
| 03009ea6df | |||
| c8b7545ca6 | |||
| 3a3c7ee80b | |||
| 7c8a2b562f | |||
| 1c5765d25b | |||
| e6301e662f | |||
| 8b2889a524 | |||
| c2b2b6e834 | |||
| bba5a0dfa0 | |||
| a8e45bc82b | |||
| 95b9fa91b1 | |||
| 0fe013c399 | |||
| d9404ab31e | |||
| a21485f91d | |||
| bce94b6d10 | |||
| dee6172875 | |||
| b404303f84 | |||
| 48d65c48fa | |||
| c78021f520 | |||
| c429c1966e | |||
| 50e1ba32f3 | |||
| 85b0c53c0a | |||
| 366d80a9ee | |||
| 60f52f9851 |
28
.github/workflows/ci.yml
vendored
28
.github/workflows/ci.yml
vendored
@@ -35,31 +35,5 @@ jobs:
|
||||
with:
|
||||
version: v2.4.0
|
||||
|
||||
- name: Install tparse
|
||||
run: go install github.com/mfridman/tparse@v0.18.0
|
||||
|
||||
- name: Test (race detector)
|
||||
run: |
|
||||
set +e
|
||||
go tool gotestsum \
|
||||
--format pkgname \
|
||||
--format-icons hivis \
|
||||
--hide-summary=skipped,output,errors,failed \
|
||||
--jsonfile test-output.json \
|
||||
--junitfile test-report.xml \
|
||||
--packages="./..." \
|
||||
-- -count=1 -race -covermode=atomic -coverprofile=coverage.out
|
||||
rc=$?
|
||||
echo
|
||||
tparse -all -notests -slow 10 -file=test-output.json || true
|
||||
exit $rc
|
||||
|
||||
- name: Upload test report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-report
|
||||
path: |
|
||||
test-output.json
|
||||
test-report.xml
|
||||
coverage.out
|
||||
run: go test -race ./...
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -33,8 +33,6 @@ api/api-server
|
||||
*.out
|
||||
coverage.txt
|
||||
coverage.html
|
||||
test-output.json
|
||||
test-report.xml
|
||||
|
||||
# Dependency directories
|
||||
vendor/
|
||||
|
||||
55
Makefile
55
Makefile
@@ -1,55 +0,0 @@
|
||||
.PHONY: test test-pretty test-watch test-ci test-summary cover
|
||||
|
||||
PKGS ?= ./...
|
||||
JSON_OUT ?= test-output.json
|
||||
JUNIT ?= test-report.xml
|
||||
COVER ?= coverage.out
|
||||
|
||||
# Plain go test. Fastest, no extra deps loaded
|
||||
test:
|
||||
go test $(PKGS)
|
||||
|
||||
# Live pretty output. Groups by test name, reruns flakes once.
|
||||
# Hides gotestsum's plain-text summary; tparse renders failures instead.
|
||||
test-pretty:
|
||||
@set +e; \
|
||||
go tool gotestsum \
|
||||
--format testname \
|
||||
--format-icons hivis \
|
||||
--hide-summary=skipped,output,errors,failed \
|
||||
--rerun-fails=1 \
|
||||
--jsonfile $(JSON_OUT) \
|
||||
--packages="$(PKGS)" \
|
||||
-- -count=1; \
|
||||
rc=$$?; \
|
||||
echo; \
|
||||
tparse -all -notests -file=$(JSON_OUT) || true; \
|
||||
exit $$rc
|
||||
|
||||
# TDD loop: re-runs affected packages on file save.
|
||||
test-watch:
|
||||
go tool gotestsum --watch --format testname --packages="$(PKGS)"
|
||||
|
||||
# CI / pre-push: race + coverage + JSON + JUnit + tparse summary table
|
||||
test-ci:
|
||||
@set +e; \
|
||||
go tool gotestsum \
|
||||
--format pkgname \
|
||||
--format-icons hivis \
|
||||
--hide-summary=skipped,output,errors,failed \
|
||||
--jsonfile $(JSON_OUT) \
|
||||
--junitfile $(JUNIT) \
|
||||
--packages="$(PKGS)" \
|
||||
-- -count=1 -race -covermode=atomic -coverprofile=$(COVER); \
|
||||
rc=$$?; \
|
||||
echo; \
|
||||
tparse -all -notests -slow 10 -file=$(JSON_OUT) || true; \
|
||||
exit $$rc
|
||||
|
||||
# Replay last summary without re-running tests
|
||||
test-summary:
|
||||
tparse -all -notests -file=$(JSON_OUT)
|
||||
|
||||
# Open HTML coverage report from last `test-ci` run
|
||||
cover:
|
||||
go tool cover -html=$(COVER)
|
||||
@@ -42,7 +42,7 @@ func (h *AddressHandler) GetAddresses(w http.ResponseWriter, r *http.Request) {
|
||||
var addresses []models.Address
|
||||
db.Where("user_id = ?", userID).Order("is_default DESC, created_at DESC").Find(&addresses)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"addresses": addresses,
|
||||
})
|
||||
}
|
||||
@@ -154,7 +154,7 @@ func (h *AddressHandler) CreateAddress(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusCreated, map[string]any{
|
||||
utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{
|
||||
"address": address,
|
||||
})
|
||||
}
|
||||
@@ -183,7 +183,7 @@ func (h *AddressHandler) DeleteAddress(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "address successfully deleted",
|
||||
})
|
||||
}
|
||||
@@ -218,7 +218,7 @@ func (h *AddressHandler) SetDefaultAddress(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"address": address,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ func TestGetAddressesEmpty(t *testing.T) {
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Addresses []any `json:"addresses"`
|
||||
Addresses []interface{} `json:"addresses"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
|
||||
@@ -108,7 +108,7 @@ func (h *AuthHandler) GetToken(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
middleware.RecordAuthAttempt("success")
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"access_token": token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 1800, // 30 minutes in seconds
|
||||
@@ -146,7 +146,7 @@ func (h *AuthHandler) RegisterWithSSHKey(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "validation failed", map[string]any{
|
||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "validation failed", map[string]interface{}{
|
||||
"errors": errors,
|
||||
})
|
||||
return
|
||||
@@ -173,7 +173,7 @@ func (h *AuthHandler) RegisterWithSSHKey(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusCreated, map[string]any{
|
||||
utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{
|
||||
"user": user.ToPublic(),
|
||||
})
|
||||
}
|
||||
@@ -195,7 +195,7 @@ func (h *AuthHandler) GetUserBySSHKey(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"user": user.ToPublic(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// TestGetCards_FiltersExpiredRows drives the SQL WHERE claude used by
|
||||
// GetCards. a column-name typo (e.g used_id instead of user_id) surfaces
|
||||
// here as a sqlite error, not in prod
|
||||
func TestGetCards_FiltersExpiredRows(t *testing.T) {
|
||||
testDB, user := setupCardsExpireTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
now := time.Now()
|
||||
past := now.Add(-1 * time.Hour)
|
||||
future := now.Add(24 * time.Hour)
|
||||
|
||||
expired := models.Card{
|
||||
UserID: user.ID, StripePaymentID: "pm_expired", Last4: "1111",
|
||||
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &past,
|
||||
}
|
||||
fresh := models.Card{
|
||||
UserID: user.ID, StripePaymentID: "pm_fresh", Last4: "2222",
|
||||
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &future,
|
||||
}
|
||||
legacy := models.Card{
|
||||
UserID: user.ID, StripePaymentID: "pm_legacy", Last4: "3333",
|
||||
Brand: "Visa", ExpMonth: 1, ExpYear: 2030,
|
||||
}
|
||||
for _, card := range []*models.Card{&expired, &fresh, &legacy} {
|
||||
if err := db.Create(card).Error; err != nil {
|
||||
t.Fatalf("seed card: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
handler := NewCardHandler("", "")
|
||||
req := authRequest("GET", "/api/v1/cards", nil, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.GetCards(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GetCards status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Cards []models.Card `json:"cards"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(resp.Data.Cards) != 2 {
|
||||
t.Fatalf("want 2 visible cards (fresh + legacy NULL), got %d", len(resp.Data.Cards))
|
||||
}
|
||||
for _, card := range resp.Data.Cards {
|
||||
if card.ID == expired.ID {
|
||||
t.Fatalf("expired card leaked into GetCards response")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetDefaultCard_ExpiredReturns410 verifies the sync-expire branch when
|
||||
// the user acts on a card whose retention deadline already elapsed
|
||||
func TestSetDefaultCard_ExpiredReturns410(t *testing.T) {
|
||||
testDB, user := setupCardsExpireTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
past := time.Now().Add(-1 * time.Hour)
|
||||
card := models.Card{
|
||||
UserID: user.ID, StripePaymentID: "pm_expired", Last4: "1111",
|
||||
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &past,
|
||||
}
|
||||
if err := db.Create(&card).Error; err != nil {
|
||||
t.Fatalf("seed card: %v", err)
|
||||
}
|
||||
|
||||
handler := NewCardHandler("", "")
|
||||
idStr := strconv.FormatUint(uint64(card.ID), 10)
|
||||
req := authRequest("PUT", "/api/v1/cards/"+idStr+"/default", nil, user.ID)
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("id", idStr)
|
||||
req = req.WithContext(contextWithRoute(req.Context(), routeCtx))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetDefaultCard(w, req)
|
||||
|
||||
if w.Code != http.StatusGone {
|
||||
t.Fatalf("want 410 gone, got %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
_ = json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp.Error.Code != "CARD_STORAGE_EXPIRED" {
|
||||
t.Fatalf("want CARD_STORAGE_EXPIRED code, got %q", resp.Error.Code)
|
||||
}
|
||||
|
||||
var soft models.Card
|
||||
if err := db.Unscoped().First(&soft, card.ID).Error; err != nil {
|
||||
t.Fatalf("reload card unscoped: %v", err)
|
||||
}
|
||||
if !soft.DeletedAt.Valid {
|
||||
t.Fatalf("expired card was not soft-deleted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshCardStorageTTL_BumpLastUsedAndExpiry exercises the SQL Updates
|
||||
// map used by both the payment_intent.succeede webhook and the duplicate-card
|
||||
// add path. Column-name typos in the map surface here
|
||||
func TestRefreshCardStorageTTL_BumpLastUsedAndExpiry(t *testing.T) {
|
||||
testDB, user := setupCardsExpireTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
old := time.Now().Add(-30 * 24 * time.Hour)
|
||||
card := models.Card{
|
||||
UserID: user.ID, StripePaymentID: "pm_refresh", Last4: "4242",
|
||||
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &old,
|
||||
}
|
||||
if err := db.Create(&card).Error; err != nil {
|
||||
t.Fatalf("seed card: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if err := refreshCardStorageTTL(db, card.ID, now); err != nil {
|
||||
t.Fatalf("refreshCardStorageTTL: %v", err)
|
||||
}
|
||||
|
||||
var reloaded models.Card
|
||||
if err := db.First(&reloaded, card.ID).Error; err != nil {
|
||||
t.Fatalf("reload card: %v", err)
|
||||
}
|
||||
if reloaded.LastUsedAt == nil || !reloaded.StorageExpiresAt.After(now.Add(179*24*time.Hour)) {
|
||||
t.Fatalf("storage_expires_at not extended, got %v", reloaded.StorageExpiresAt)
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,8 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -77,24 +75,19 @@ func NewCardHandler(stripeSecretKey string, appURL string) *CardHandler {
|
||||
}
|
||||
|
||||
// GetCards returns all saved cards for the authenticated user.
|
||||
// Cards past their retention deadline are filtered out at read time;
|
||||
// physical deletion runs in handlers.ReconcileExpiredCards.
|
||||
func (h *CardHandler) GetCards(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
var cards []models.Card
|
||||
db.Where(
|
||||
"user_id = ? AND (storage_expires_at IS NULL OR storage_expires_at > ?)",
|
||||
userID, time.Now(),
|
||||
).Order("is_default DESC, created_at DESC").Find(&cards)
|
||||
db.Where("user_id = ?", userID).Order("is_default DESC, created_at DESC").Find(&cards)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"cards": cards,
|
||||
})
|
||||
}
|
||||
|
||||
// func utils.RespondError(w http.ResponseWriter, statusCode int, code string, message string, details map[string]any)
|
||||
// func utils.RespondError(w http.ResponseWriter, statusCode int, code string, message string, details map[string]interface{})
|
||||
|
||||
// GetCard returns a single saved card by ID.
|
||||
func (h *CardHandler) GetCard(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -114,7 +107,7 @@ func (h *CardHandler) GetCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"card": card,
|
||||
})
|
||||
}
|
||||
@@ -136,15 +129,6 @@ func (h *CardHandler) SetDefaultCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if card.IsStorageExpired(time.Now()) {
|
||||
if err := expireStoredCard(db, &card, h.stripeKey); err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
|
||||
return
|
||||
}
|
||||
respondCardStorageExpired(w)
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.Model(&models.Card{}).Where("user_id = ?", userID).Update("is_default", false).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to update card", nil)
|
||||
return
|
||||
@@ -156,7 +140,7 @@ func (h *CardHandler) SetDefaultCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"card": card,
|
||||
})
|
||||
}
|
||||
@@ -197,7 +181,7 @@ func (h *CardHandler) CollectCard(w http.ResponseWriter, r *http.Request) {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to generate redirect token", nil)
|
||||
return
|
||||
}
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"url": fmt.Sprintf("%s/pay/%s", h.appURL, token),
|
||||
})
|
||||
}
|
||||
@@ -302,7 +286,7 @@ func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
audit.CardAdded(userID, card.ID, card.Brand, card.Last4)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusCreated, map[string]any{
|
||||
utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{
|
||||
"card": card,
|
||||
})
|
||||
}
|
||||
@@ -347,56 +331,11 @@ func (h *CardHandler) DeleteCard(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
audit.CardDeleted(userID, card.ID)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "card deleted",
|
||||
})
|
||||
}
|
||||
|
||||
// expireStoredCard removes a card whose retention deadline elapsed.
|
||||
// Local state (cart references + card row) is committed atomically;
|
||||
// Stripe detach and audit run only after the local commit so retries
|
||||
// cannot double-fire the audit event.
|
||||
func expireStoredCard(db *gorm.DB, card *models.Card, stripeKey string) error {
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&models.Cart{}).Where("card_id = ? AND user_id = ?", card.ID, card.UserID).Update("card_id", nil).Error; err != nil {
|
||||
return fmt.Errorf("failed to clear expired card from cart: %w", err)
|
||||
}
|
||||
if err := tx.Delete(card).Error; err != nil {
|
||||
return fmt.Errorf("failed to delete expired card: %w", err)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if stripeKey != "" && strings.HasPrefix(card.StripePaymentID, "pm_") {
|
||||
stripe.Key = stripeKey
|
||||
if _, err := paymentmethod.Detach(card.StripePaymentID, &stripe.PaymentMethodDetachParams{}); err != nil {
|
||||
slog.Warn("failed to detach expired card", "card_id", card.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
audit.CardExpired(card.UserID, card.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func refreshCardStorageTTL(db *gorm.DB, cardID uint, now time.Time) error {
|
||||
return db.Model(&models.Card{}).Where("id = ?", cardID).Updates(map[string]any{
|
||||
"last_used_at": now,
|
||||
"storage_expires_at": models.CardStorageExpiresAt(now),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func respondCardStorageExpired(w http.ResponseWriter) {
|
||||
utils.RespondError(
|
||||
w,
|
||||
http.StatusGone,
|
||||
"CARD_STORAGE_EXPIRED",
|
||||
"saved card expired, add it again",
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
// ensureStripeCustomer creates a Stripe Customer for the user if they don't
|
||||
// already have one and persists the customer ID. Safe to call concurrently —
|
||||
// a duplicate Stripe customer is harmless; the local record reflects the first
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
|
||||
// TestReconcileExpiredCards_RemovesPastDeadlineOnly drives the real SQL path
|
||||
// so a column-name typo in the WHERE clause surfaces here, not in production.
|
||||
func TestReconcileExpiredCards_RemovesPastDeadlineOnly(t *testing.T) {
|
||||
testDB, user := setupCardsExpireTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
now := time.Now()
|
||||
past := now.Add(-1 * time.Hour)
|
||||
future := now.Add(24 * time.Hour)
|
||||
|
||||
expired := models.Card{
|
||||
UserID: user.ID,
|
||||
StripePaymentID: "tok_expired",
|
||||
Last4: "1111",
|
||||
Brand: "Visa",
|
||||
ExpMonth: 1,
|
||||
ExpYear: 2030,
|
||||
StorageExpiresAt: &past,
|
||||
}
|
||||
fresh := models.Card{
|
||||
UserID: user.ID,
|
||||
StripePaymentID: "tok_fresh",
|
||||
Last4: "2222",
|
||||
Brand: "Visa",
|
||||
ExpMonth: 1,
|
||||
ExpYear: 2030,
|
||||
StorageExpiresAt: &future,
|
||||
}
|
||||
noTTL := models.Card{
|
||||
UserID: user.ID,
|
||||
StripePaymentID: "tok_legacy",
|
||||
Last4: "3333",
|
||||
Brand: "Visa",
|
||||
ExpMonth: 1,
|
||||
ExpYear: 2030,
|
||||
}
|
||||
for _, c := range []*models.Card{&expired, &fresh, &noTTL} {
|
||||
if err := db.Create(c).Error; err != nil {
|
||||
t.Fatalf("seed card: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
cart := models.Cart{UserID: user.ID, CardID: &expired.ID}
|
||||
if err := db.Create(&cart).Error; err != nil {
|
||||
t.Fatalf("seed cart: %v", err)
|
||||
}
|
||||
|
||||
ReconcileExpiredCards("")
|
||||
|
||||
var remaining []models.Card
|
||||
if err := db.Where("user_id = ?", user.ID).Find(&remaining).Error; err != nil {
|
||||
t.Fatalf("list cards: %v", err)
|
||||
}
|
||||
if len(remaining) != 2 {
|
||||
t.Fatalf("want 2 cards remaining, got %d", len(remaining))
|
||||
}
|
||||
for _, c := range remaining {
|
||||
if c.ID == expired.ID {
|
||||
t.Fatalf("expired card was not deleted")
|
||||
}
|
||||
}
|
||||
|
||||
var refreshed models.Cart
|
||||
if err := db.First(&refreshed, cart.ID).Error; err != nil {
|
||||
t.Fatalf("reload cart: %v", err)
|
||||
}
|
||||
if refreshed.CardID != nil {
|
||||
t.Fatalf("cart still references expired card: %v", refreshed.CardID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcileExpiredCards_SweepsAcrossUsers verifies the sweeper is not
|
||||
// scoped to one user; it must collect every past-deadline row in one pass.
|
||||
func TestReconcileExpiredCards_SweepsAcrossUsers(t *testing.T) {
|
||||
testDB, user := setupCardsExpireTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
past := time.Now().Add(-1 * time.Hour)
|
||||
|
||||
other := models.User{
|
||||
SSHKeyFingerprint: "SHA256:other",
|
||||
SSHPublicKey: "ssh-ed25519 AAAA other",
|
||||
Name: "Other",
|
||||
Email: "other@example.com",
|
||||
}
|
||||
if err := db.Create(&other).Error; err != nil {
|
||||
t.Fatalf("seed other user: %v", err)
|
||||
}
|
||||
|
||||
for _, uid := range []uint{user.ID, other.ID} {
|
||||
c := models.Card{
|
||||
UserID: uid,
|
||||
StripePaymentID: "tok_expired",
|
||||
Last4: "9999",
|
||||
Brand: "Visa",
|
||||
ExpMonth: 1,
|
||||
ExpYear: 2030,
|
||||
StorageExpiresAt: &past,
|
||||
}
|
||||
if err := db.Create(&c).Error; err != nil {
|
||||
t.Fatalf("seed card: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
ReconcileExpiredCards("")
|
||||
|
||||
var count int64
|
||||
if err := db.Model(&models.Card{}).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count cards: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("want 0 cards after sweep, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func setupCardsExpireTestDB(t *testing.T) (string, models.User) {
|
||||
t.Helper()
|
||||
testDB := "test_cards_expire.db"
|
||||
_ = os.Remove(testDB)
|
||||
database.ResetForTesting()
|
||||
|
||||
db, err := database.Connect(testDB)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
if err := database.Migrate(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
user := models.User{
|
||||
SSHKeyFingerprint: "SHA256:cards-expire",
|
||||
SSHPublicKey: "ssh-ed25519 AAAA cardsexpire",
|
||||
Name: "Test User",
|
||||
Email: "cards-expire@example.com",
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
return testDB, user
|
||||
}
|
||||
@@ -5,10 +5,8 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
"github.com/stripe/stripe-go/v78/paymentintent"
|
||||
"github.com/stripe/stripe-go/v78/paymentmethod"
|
||||
@@ -30,12 +28,6 @@ type CartHandler struct {
|
||||
maxOrderCents int
|
||||
}
|
||||
|
||||
// maxAuthIssuances caps how many times a single ordes's PaymentIntent can be
|
||||
// pushed into requires_action; one initial challende plus up to three retries
|
||||
// before we tell the customer to use a different card. Counted via existing
|
||||
// audit rows (audit.EventOrderRequiresAction) so the cap stays migration-free.
|
||||
const maxAuthIssuances = 4
|
||||
|
||||
// NewCartHandler creates a new cart handler with the Stripe secret key
|
||||
func NewCartHandler(stripeSecretKey, appURL string, maxOrderCents int) *CartHandler {
|
||||
return &CartHandler{stripeKey: stripeSecretKey, appURL: appURL, maxOrderCents: maxOrderCents}
|
||||
@@ -55,18 +47,18 @@ func getOrCreateCart(ctx context.Context, userID uint) (*models.Cart, error) {
|
||||
return &cart, nil
|
||||
}
|
||||
|
||||
func cartResponse(ctx context.Context, cart *models.Cart) map[string]any {
|
||||
func cartResponse(ctx context.Context, cart *models.Cart) map[string]interface{} {
|
||||
db := database.GetDB().WithContext(ctx)
|
||||
|
||||
var items []models.CartItem
|
||||
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&items)
|
||||
|
||||
subtotal := 0
|
||||
itemsData := make([]map[string]any, 0, len(items))
|
||||
itemsData := make([]map[string]interface{}, 0, len(items))
|
||||
for _, item := range items {
|
||||
lineTotal := item.Coffee.Price * item.Quantity
|
||||
subtotal += lineTotal
|
||||
itemsData = append(itemsData, map[string]any{
|
||||
itemsData = append(itemsData, map[string]interface{}{
|
||||
"id": item.ID,
|
||||
"coffee_id": item.CoffeeID,
|
||||
"quantity": item.Quantity,
|
||||
@@ -75,7 +67,7 @@ func cartResponse(ctx context.Context, cart *models.Cart) map[string]any {
|
||||
})
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
return map[string]interface{}{
|
||||
"items": itemsData,
|
||||
"subtotal": subtotal,
|
||||
"address_id": cart.AddressID,
|
||||
@@ -94,7 +86,7 @@ func (h *CartHandler) GetCart(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"cart": cartResponse(r.Context(), cart),
|
||||
})
|
||||
}
|
||||
@@ -157,7 +149,7 @@ func (h *CartHandler) SetItem(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"cart": cartResponse(r.Context(), cart),
|
||||
})
|
||||
}
|
||||
@@ -193,7 +185,7 @@ func (h *CartHandler) SetAddress(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "address set",
|
||||
})
|
||||
}
|
||||
@@ -218,15 +210,6 @@ func (h *CartHandler) SetCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if card.IsStorageExpired(time.Now()) {
|
||||
if err := expireStoredCard(db, &card, h.stripeKey); err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
|
||||
return
|
||||
}
|
||||
respondCardStorageExpired(w)
|
||||
return
|
||||
}
|
||||
|
||||
cart, err := getOrCreateCart(r.Context(), userID)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
|
||||
@@ -238,7 +221,7 @@ func (h *CartHandler) SetCard(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "card set",
|
||||
})
|
||||
}
|
||||
@@ -258,7 +241,7 @@ func (h *CartHandler) ClearCart(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "cart cleared",
|
||||
})
|
||||
}
|
||||
@@ -313,16 +296,6 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if card.IsStorageExpired(time.Now()) {
|
||||
middleware.RecordCartConversion("validation_card_expired")
|
||||
if err := expireStoredCard(db, &card, h.stripeKey); err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
|
||||
return
|
||||
}
|
||||
respondCardStorageExpired(w)
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := db.First(&user, userID).Error; err != nil {
|
||||
middleware.RecordCartConversion("user_not_found")
|
||||
@@ -386,7 +359,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
middleware.RecordCartConversion("validation_over_limit")
|
||||
utils.RespondError(w, http.StatusBadRequest, "CART_OVER_LIMIT",
|
||||
fmt.Sprintf("order total must be at most $%.2f", float64(h.maxOrderCents)/100),
|
||||
map[string]any{
|
||||
map[string]interface{}{
|
||||
"total_cents": total,
|
||||
"limit_cents": h.maxOrderCents,
|
||||
})
|
||||
@@ -507,7 +480,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(cart).Updates(map[string]any{
|
||||
return tx.Model(cart).Updates(map[string]interface{}{
|
||||
"address_id": nil,
|
||||
"card_id": nil,
|
||||
}).Error
|
||||
@@ -534,7 +507,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
go notify.SlackOrderPaid(&order)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"order": order,
|
||||
})
|
||||
}
|
||||
@@ -630,147 +603,3 @@ func appURLForHandler(r *http.Request) string {
|
||||
}
|
||||
return fmt.Sprintf("%s://%s", scheme, host)
|
||||
}
|
||||
|
||||
// RetryAuth re-Confirms an in-flight PaymentIntent that the customer failed
|
||||
// or abandoned during the 3DS challenge, recovering the existing order rather
|
||||
// than starting a new checkout. Bound to POST /api/v1/orders/{id}/retry-auth.
|
||||
//
|
||||
// Stripe's PI state machine bounces back to requires_payment_method after a
|
||||
// failed or abandoned 3DS, so paymentintent.Confirm(pi.id, …) mints a fresh
|
||||
// next_action.redirect_to_url — the same shape respondRequiresAction already
|
||||
// handles. No idempotency key is set: Confirm mutates an existing PI and
|
||||
// Stripe collapses by id (see sca-psd2-compliance.md caveat #5).
|
||||
func (h *CartHandler) RetryAuth(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB().WithContext(r.Context())
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id64, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid order id", nil)
|
||||
return
|
||||
}
|
||||
orderID := uint(id64)
|
||||
|
||||
var order models.Order
|
||||
if err := db.Where("id = ? AND user_id = ?", orderID, userID).First(&order).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusNotFound, "ORDER_NOT_FOUND", "order not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Only pending (initial attempt mid-flight) and failed (webhook flipped it
|
||||
// after a non-recoverable error) are retryable. paid, refundedn shipped,
|
||||
// delivered, cancelled are terminal - refuse
|
||||
if order.Status != models.OrderStatusPending && order.Status != models.OrderStatusFailed {
|
||||
utils.RespondError(w, http.StatusConflict, "INVALID_STATE", "order is already finalized", nil)
|
||||
return
|
||||
}
|
||||
if order.StripePaymentID == "" {
|
||||
utils.RespondError(w, http.StatusConflict, "INVALID_STATE", "order has no payment intent to retry", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Cap is best-effort: two concurrent retries at issuances=cap-1 will both
|
||||
// pass this check and both Confirm before either writes a new event row.
|
||||
// Per-user RateLimitByUser(10/min) on the route bounds the worst case.
|
||||
// SQLite-WAL has no SELECT ... FOR UPDATE, so a stricter guard would need
|
||||
// a serializing INSERT - not worth the cimplexity at current traffic.
|
||||
var issuances int64
|
||||
if err := db.Model(&models.OrderEvent{}).
|
||||
Where("order_id = ? AND type = ?", order.ID, audit.EventOrderRequiresAction).
|
||||
Count(&issuances).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to count auth attempts", nil)
|
||||
return
|
||||
}
|
||||
if issuances >= maxAuthIssuances {
|
||||
middleware.RecordCartConversion("retry_max_attempts")
|
||||
utils.RespondError(w, http.StatusTooManyRequests, "RETRY_LIMIT", "too many authentication attempts, please use a different card", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// scoped card lookup - match the initial-flow policy in ConvertCart so a
|
||||
// card the user deleted (or that the storage-TTL reconciler removed)
|
||||
// cannot be silently re-charged through retry. The ownership clause is
|
||||
// defensive: order.CardID is enforced at SetCard time today but binding
|
||||
// the retry path to that invariant explicitly costs nothing.
|
||||
var card models.Card
|
||||
if err := db.Where("id = ? AND user_id = ?", order.CardID, userID).First(&card).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusConflict, "CARD_NO_LONGER_AVAILABLE", "saved card is no longer available, add it again to retry", nil)
|
||||
return
|
||||
}
|
||||
if card.IsStorageExpired(time.Now()) {
|
||||
if err := expireStoredCard(db, &card, h.stripeKey); err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil)
|
||||
return
|
||||
}
|
||||
respondCardStorageExpired(w)
|
||||
return
|
||||
}
|
||||
|
||||
stripe.Key = h.stripeKey
|
||||
|
||||
stripeStart := time.Now()
|
||||
confirmed, cerr := paymentintent.Confirm(order.StripePaymentID, &stripe.PaymentIntentConfirmParams{
|
||||
PaymentMethod: stripe.String(card.StripePaymentID),
|
||||
OffSession: stripe.Bool(false),
|
||||
ReturnURL: stripe.String(h.appURL + "/post-3ds"),
|
||||
})
|
||||
stripeDur := time.Since(stripeStart).Seconds()
|
||||
if cerr != nil {
|
||||
middleware.ObserveStripeRequest("payment_intent_confirm", "error", stripeDur)
|
||||
if stripeErr, ok := cerr.(*stripe.Error); ok && stripeErr.Type == stripe.ErrorTypeCard {
|
||||
db.Model(&order).Update("status", models.OrderStatusFailed)
|
||||
audit.OrderFailed(userID, order.ID, order.Total, stripeErr.Msg)
|
||||
middleware.RecordCartConversion("retry_card_declined")
|
||||
utils.RespondError(w, http.StatusPaymentRequired, mapStripeCardErrCode(stripeErr.Code), stripeErr.Msg, nil)
|
||||
return
|
||||
}
|
||||
if stripeErr, ok := cerr.(*stripe.Error); ok && stripeErr.Code == "payment_intent_unexpected_state" {
|
||||
actual, gerr := paymentintent.Get(order.StripePaymentID, nil)
|
||||
if gerr != nil {
|
||||
middleware.RecordCartConversion("retry_stripe_error")
|
||||
utils.RespondError(w, http.StatusBadGateway, "PAYMENT_FAILED", "payment failed", nil)
|
||||
return
|
||||
}
|
||||
switch actual.Status {
|
||||
case stripe.PaymentIntentStatusSucceeded:
|
||||
middleware.RecordCartConversion("retry_race_already_done")
|
||||
utils.RespondSuccess(w, http.StatusAccepted, map[string]any{
|
||||
"order_id": order.ID,
|
||||
"status": "succeeded",
|
||||
})
|
||||
return
|
||||
case stripe.PaymentIntentStatusCanceled:
|
||||
db.Model(&order).Update("status", models.OrderStatusFailed)
|
||||
audit.OrderFailed(userID, order.ID, order.Total, "payment intent canceled")
|
||||
middleware.RecordCartConversion("retry_pi_canceled")
|
||||
utils.RespondError(w, http.StatusConflict, "INVALID_STATE", "payment intent is canceled, please start a new order", nil)
|
||||
return
|
||||
default:
|
||||
middleware.RecordCartConversion("retry_pi_unexpected_state")
|
||||
utils.RespondError(w, http.StatusBadGateway, "PAYMENT_FAILED", "payment intent in unexpected state", nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
middleware.RecordCartConversion("retry_stripe_error")
|
||||
utils.RespondError(w, http.StatusBadGateway, "PAYMENT_FAILED", "payment failed", nil)
|
||||
return
|
||||
}
|
||||
middleware.ObserveStripeRequest("payment_intent_confirm", "success", stripeDur)
|
||||
|
||||
// frictionless 3DS on retry, stripe approved without a new challenge.
|
||||
// the payment_intent.succeeded webhook owns the order.status=paid
|
||||
// transition (matches the initial-flow contract); tell the TUI to keep
|
||||
// polling /orders/{id}/status until it lands
|
||||
if confirmed.Status == stripe.PaymentIntentStatusSucceeded {
|
||||
middleware.RecordCartConversion("retry_frictionless")
|
||||
utils.RespondSuccess(w, http.StatusAccepted, map[string]any{
|
||||
"order_id": order.ID,
|
||||
"status": "succeeded",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
h.respondRequiresAction(w, &order, card.StripePaymentID, confirmed)
|
||||
}
|
||||
|
||||
@@ -3,17 +3,14 @@ package handlers
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/pkg/audit"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
@@ -83,8 +80,8 @@ func TestGetCartEmpty(t *testing.T) {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Cart struct {
|
||||
Items []any `json:"items"`
|
||||
Subtotal int `json:"subtotal"`
|
||||
Items []interface{} `json:"items"`
|
||||
Subtotal int `json:"subtotal"`
|
||||
} `json:"cart"`
|
||||
} `json:"data"`
|
||||
}
|
||||
@@ -110,7 +107,7 @@ func TestSetItemAndGetCart(t *testing.T) {
|
||||
handler := NewCartHandler("", "", 0)
|
||||
|
||||
// Add item (CoffeeID=1, Quantity=2)
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"coffee_id": 1,
|
||||
"quantity": 2,
|
||||
})
|
||||
@@ -166,13 +163,13 @@ func TestSetItemUpdateQuantity(t *testing.T) {
|
||||
handler := NewCartHandler("", "", 0)
|
||||
|
||||
// Add item
|
||||
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 2})
|
||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 2})
|
||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetItem(w, req)
|
||||
|
||||
// Update quantity
|
||||
body, _ = json.Marshal(map[string]any{"coffee_id": 1, "quantity": 5})
|
||||
body, _ = json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 5})
|
||||
req = authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||
w = httptest.NewRecorder()
|
||||
handler.SetItem(w, req)
|
||||
@@ -211,13 +208,13 @@ func TestSetItemRemove(t *testing.T) {
|
||||
handler := NewCartHandler("", "", 0)
|
||||
|
||||
// Add item
|
||||
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 3})
|
||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 3})
|
||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetItem(w, req)
|
||||
|
||||
// Remove item (quantity 0)
|
||||
body, _ = json.Marshal(map[string]any{"coffee_id": 1, "quantity": 0})
|
||||
body, _ = json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 0})
|
||||
req = authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||
w = httptest.NewRecorder()
|
||||
handler.SetItem(w, req)
|
||||
@@ -231,7 +228,7 @@ func TestSetItemRemove(t *testing.T) {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Cart struct {
|
||||
Items []any `json:"items"`
|
||||
Items []interface{} `json:"items"`
|
||||
} `json:"cart"`
|
||||
} `json:"data"`
|
||||
}
|
||||
@@ -249,7 +246,7 @@ func TestSetItemInvalidProduct(t *testing.T) {
|
||||
|
||||
handler := NewCartHandler("", "", 0)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{"coffee_id": 999, "quantity": 1})
|
||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 999, "quantity": 1})
|
||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetItem(w, req)
|
||||
@@ -266,7 +263,7 @@ func TestSetItemMissingCoffeeID(t *testing.T) {
|
||||
|
||||
handler := NewCartHandler("", "", 0)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{"quantity": 1})
|
||||
body, _ := json.Marshal(map[string]interface{}{"quantity": 1})
|
||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetItem(w, req)
|
||||
@@ -285,7 +282,7 @@ func TestClearCart(t *testing.T) {
|
||||
|
||||
// Add two items
|
||||
for _, coffeeID := range []uint{1, 2} {
|
||||
body, _ := json.Marshal(map[string]any{"coffee_id": coffeeID, "quantity": 1})
|
||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": coffeeID, "quantity": 1})
|
||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetItem(w, req)
|
||||
@@ -309,7 +306,7 @@ func TestClearCart(t *testing.T) {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Cart struct {
|
||||
Items []any `json:"items"`
|
||||
Items []interface{} `json:"items"`
|
||||
} `json:"cart"`
|
||||
} `json:"data"`
|
||||
}
|
||||
@@ -339,7 +336,7 @@ func TestSetAddress(t *testing.T) {
|
||||
|
||||
handler := NewCartHandler("", "", 0)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{"address_id": addr.ID})
|
||||
body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID})
|
||||
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetAddress(w, req)
|
||||
@@ -393,7 +390,7 @@ func TestSetAddressWrongUser(t *testing.T) {
|
||||
|
||||
handler := NewCartHandler("", "", 0)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{"address_id": addr.ID})
|
||||
body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID})
|
||||
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetAddress(w, req)
|
||||
@@ -421,7 +418,7 @@ func TestSetCard(t *testing.T) {
|
||||
|
||||
handler := NewCartHandler("", "", 0)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{"card_id": card.ID})
|
||||
body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID})
|
||||
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetCard(w, req)
|
||||
@@ -473,7 +470,7 @@ func TestSetCardWrongUser(t *testing.T) {
|
||||
|
||||
handler := NewCartHandler("", "", 0)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{"card_id": card.ID})
|
||||
body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID})
|
||||
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetCard(w, req)
|
||||
@@ -491,7 +488,7 @@ func TestConvertCartMissingAddress(t *testing.T) {
|
||||
handler := NewCartHandler("", "", 0)
|
||||
|
||||
// Add item but no address or card
|
||||
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 1})
|
||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 1})
|
||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetItem(w, req)
|
||||
@@ -515,14 +512,14 @@ func TestConvertCartMissingCard(t *testing.T) {
|
||||
db := database.GetDB()
|
||||
|
||||
// Add item and address
|
||||
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 1})
|
||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 1})
|
||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetItem(w, req)
|
||||
|
||||
addr := models.Address{UserID: user.ID, Name: "Test", Street: "123 St", City: "PDX", State: "OR", Zip: "97201", Country: "US"}
|
||||
db.Create(&addr)
|
||||
body, _ = json.Marshal(map[string]any{"address_id": addr.ID})
|
||||
body, _ = json.Marshal(map[string]interface{}{"address_id": addr.ID})
|
||||
req = authRequest("PUT", "/api/v1/cart/address", body, user.ID)
|
||||
w = httptest.NewRecorder()
|
||||
handler.SetAddress(w, req)
|
||||
@@ -563,7 +560,7 @@ func TestMultipleItems(t *testing.T) {
|
||||
|
||||
// Add three different items
|
||||
for i := uint(1); i <= 3; i++ {
|
||||
body, _ := json.Marshal(map[string]any{"coffee_id": i, "quantity": int(i)})
|
||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": i, "quantity": int(i)})
|
||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetItem(w, req)
|
||||
@@ -581,8 +578,8 @@ func TestMultipleItems(t *testing.T) {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Cart struct {
|
||||
Items []any `json:"items"`
|
||||
Subtotal int `json:"subtotal"`
|
||||
Items []interface{} `json:"items"`
|
||||
Subtotal int `json:"subtotal"`
|
||||
} `json:"cart"`
|
||||
} `json:"data"`
|
||||
}
|
||||
@@ -614,13 +611,13 @@ func TestCartIsolation(t *testing.T) {
|
||||
r.Get("/cart", handler.GetCart)
|
||||
|
||||
// User 1 adds item
|
||||
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 5})
|
||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 5})
|
||||
req := authRequest("PUT", "/cart/item", body, user1.ID)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
// User 2 adds different item
|
||||
body, _ = json.Marshal(map[string]any{"coffee_id": 2, "quantity": 3})
|
||||
body, _ = json.Marshal(map[string]interface{}{"coffee_id": 2, "quantity": 3})
|
||||
req = authRequest("PUT", "/cart/item", body, user2.ID)
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
@@ -709,17 +706,9 @@ func TestConvertCartLimit(t *testing.T) {
|
||||
t.Fatalf("seed card: %v", err)
|
||||
}
|
||||
|
||||
// Pin coffee_id=4's price so cart-cap math stays stable across
|
||||
// seed-price changes. The cap test is about (price * quantity)
|
||||
// vs cap, not which coffee or what the menu currently charges.
|
||||
const pinnedPrice = 500
|
||||
if err := db.Model(&models.Coffee{}).Where("id = ?", 4).Update("price", pinnedPrice).Error; err != nil {
|
||||
t.Fatalf("pin coffee price: %v", err)
|
||||
}
|
||||
|
||||
handler := NewCartHandler("", "", tc.capCents)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{"coffee_id": 4, "quantity": tc.quantity})
|
||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 4, "quantity": tc.quantity})
|
||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetItem(w, req)
|
||||
@@ -728,7 +717,7 @@ func TestConvertCartLimit(t *testing.T) {
|
||||
}
|
||||
|
||||
// set address
|
||||
body, _ = json.Marshal(map[string]any{"address_id": addr.ID})
|
||||
body, _ = json.Marshal(map[string]interface{}{"address_id": addr.ID})
|
||||
req = authRequest("PUT", "/api/v1/cart/address", body, user.ID)
|
||||
w = httptest.NewRecorder()
|
||||
handler.SetAddress(w, req)
|
||||
@@ -737,7 +726,7 @@ func TestConvertCartLimit(t *testing.T) {
|
||||
}
|
||||
|
||||
// set card
|
||||
body, _ = json.Marshal(map[string]any{"card_id": card.ID})
|
||||
body, _ = json.Marshal(map[string]interface{}{"card_id": card.ID})
|
||||
req = authRequest("PUT", "/api/v1/cart/card", body, user.ID)
|
||||
w = httptest.NewRecorder()
|
||||
handler.SetCard(w, req)
|
||||
@@ -753,9 +742,9 @@ func TestConvertCartLimit(t *testing.T) {
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details map[string]any `json:"details"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details map[string]interface{} `json:"details"`
|
||||
} `json:"error"`
|
||||
}
|
||||
_ = json.NewDecoder(w.Body).Decode(&resp)
|
||||
@@ -768,257 +757,10 @@ func TestConvertCartLimit(t *testing.T) {
|
||||
if got, _ := resp.Error.Details["limit_cents"].(float64); int(got) != tc.capCents {
|
||||
t.Errorf("limit_cents in details: want %d, got %v", tc.capCents, resp.Error.Details["limit_cents"])
|
||||
}
|
||||
if got, _ := resp.Error.Details["total_cents"].(float64); int(got) != tc.quantity*pinnedPrice {
|
||||
t.Errorf("total_cents in details: want %d, got %v", tc.quantity*pinnedPrice, resp.Error.Details["total_cents"])
|
||||
if got, _ := resp.Error.Details["total_cents"].(float64); int(got) != tc.quantity*500 {
|
||||
t.Errorf("total_cents in details: want %d, got %v", tc.quantity*500, resp.Error.Details["total_cents"])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetCard_ExpiredReturns410 verifies that selecting an already-expired
|
||||
// card on the cart returns 410 + CARD_STORAGE_EXPIRED and soft-deletes the
|
||||
// card. Exercises cart.go SetCard expired-card branch
|
||||
func TestSetCard_ExpiredReturns410(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
past := time.Now().Add(-1 * time.Hour)
|
||||
card := models.Card{
|
||||
UserID: user.ID, StripePaymentID: "pm_expired", Last4: "1111",
|
||||
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &past,
|
||||
}
|
||||
if err := db.Create(&card).Error; err != nil {
|
||||
t.Fatalf("seed card: %v", err)
|
||||
}
|
||||
|
||||
handler := NewCartHandler("", "", 0)
|
||||
body, _ := json.Marshal(map[string]any{"card_id": card.ID})
|
||||
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.SetCard(w, req)
|
||||
|
||||
if w.Code != http.StatusGone {
|
||||
t.Fatalf("want 410, got %d, body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
_ = json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp.Error.Code != "CARD_STORAGE_EXPIRED" {
|
||||
t.Fatalf("want CARD_STORAGE_EXPIRED, got %q", resp.Error.Code)
|
||||
}
|
||||
|
||||
var soft models.Card
|
||||
if err := db.Unscoped().First(&soft, card.ID).Error; err != nil {
|
||||
t.Fatalf("reload card unscoped: %v", err)
|
||||
}
|
||||
if !soft.DeletedAt.Valid {
|
||||
t.Fatalf("expired card was not soft-deleted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConvertCart_ExpiredReturns410 verifies that attempting checkout with an
|
||||
// expired saved card shor-circuits at the validation step with 410 +
|
||||
// CARD_STORAGE_EXPIRED before any stripe interaction
|
||||
func TestConvertCart_ExpiredReturns410(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
|
||||
body, _ := json.Marshal(map[string]any{"coffee_id": 1, "quantity": 1})
|
||||
req := authRequest("PUT", "/api/v1/vart/item", body, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
NewCartHandler("", "", 0).SetItem(w, req)
|
||||
|
||||
addr := models.Address{
|
||||
UserID: user.ID, Name: "Test", Street: "123 St",
|
||||
City: "PDX", State: "OR", Zip: "97201", Country: "US",
|
||||
}
|
||||
db.Create(&addr)
|
||||
body, _ = json.Marshal(map[string]any{"address_id": addr.ID})
|
||||
req = authRequest("PUT", "/api/v1/cart/address", body, user.ID)
|
||||
w = httptest.NewRecorder()
|
||||
NewCartHandler("", "", 0).SetAddress(w, req)
|
||||
|
||||
past := time.Now().Add(-1 * time.Hour)
|
||||
card := models.Card{
|
||||
UserID: user.ID, StripePaymentID: "pm_expired", Last4: "1111",
|
||||
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &past,
|
||||
}
|
||||
if err := db.Create(&card).Error; err != nil {
|
||||
t.Fatalf("seed card: %v", err)
|
||||
}
|
||||
if err := db.Model(&models.Cart{}).Where("user_id = ?", user.ID).Update("card_id", card.ID).Error; err != nil {
|
||||
t.Fatalf("attach card to card: %v", err)
|
||||
}
|
||||
|
||||
req = authRequest("POST", "/api/v1/cart/convert", nil, user.ID)
|
||||
w = httptest.NewRecorder()
|
||||
NewCartHandler("", "", 0).ConvertCart(w, req)
|
||||
|
||||
if w.Code != http.StatusGone {
|
||||
t.Fatalf("want 410, got %d, body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
_ = json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp.Error.Code != "CARD_STORAGE_EXPIRED" {
|
||||
t.Fatalf("want CARD_STORAGE_EXPIRED, got %q", resp.Error.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetryAuth exercises the pre-Stripe validation branches of
|
||||
// CartHandler.RetryAuth. The happy paths (Confirm → requires_action /
|
||||
// succeeded) hit the live Stripe API and aren't covered here — see
|
||||
// sca-psd2-compliance.md "Test plan" for manual coverage with the
|
||||
// 4000 0027 6000 3184 test card. Caveat #14 in that doc still applies:
|
||||
// NewCartHandler takes 3 args and the seeded user must have a non-empty
|
||||
// StripeCustomerID so the Convert/Retry paths stay hermetic.
|
||||
func TestRetryAuth(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
ownedByUser bool
|
||||
seedStatus models.OrderStatus
|
||||
seedPayment string
|
||||
seedEvents int
|
||||
wantStatus int
|
||||
wantErrCode string
|
||||
}{
|
||||
{
|
||||
name: "wrong user returns not found",
|
||||
ownedByUser: false, seedStatus: models.OrderStatusFailed,
|
||||
seedPayment: "pi_test_wrong_user", seedEvents: 1,
|
||||
wantStatus: http.StatusNotFound, wantErrCode: "ORDER_NOT_FOUND",
|
||||
},
|
||||
{
|
||||
name: "missing payment intent rejects",
|
||||
ownedByUser: true, seedStatus: models.OrderStatusPending,
|
||||
seedPayment: "", seedEvents: 0,
|
||||
wantStatus: http.StatusConflict, wantErrCode: "INVALID_STATE",
|
||||
},
|
||||
{
|
||||
name: "paid order is not retryable",
|
||||
ownedByUser: true, seedStatus: models.OrderStatusPaid,
|
||||
seedPayment: "pi_test_paid", seedEvents: 1,
|
||||
wantStatus: http.StatusConflict, wantErrCode: "INVALID_STATE",
|
||||
},
|
||||
{
|
||||
name: "refunded order is not retryable",
|
||||
ownedByUser: true, seedStatus: models.OrderStatusRefunded,
|
||||
seedPayment: "pi_test_refunded", seedEvents: 1,
|
||||
wantStatus: http.StatusConflict, wantErrCode: "INVALID_STATE",
|
||||
},
|
||||
{
|
||||
name: "retry cap reached returns 429",
|
||||
ownedByUser: true, seedStatus: models.OrderStatusFailed,
|
||||
seedPayment: "pi_test_capped", seedEvents: maxAuthIssuances,
|
||||
wantStatus: http.StatusTooManyRequests, wantErrCode: "RETRY_LIMIT",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
user.StripeCustomerID = "cus_test_retry"
|
||||
if err := db.Save(&user).Error; err != nil {
|
||||
t.Fatalf("save user: %v", err)
|
||||
}
|
||||
|
||||
ownerID := user.ID
|
||||
if !tc.ownedByUser {
|
||||
other := models.User{
|
||||
SSHKeyFingerprint: "SHA256:retryother",
|
||||
SSHPublicKey: "ssh-ed25519 AAAA retryother",
|
||||
}
|
||||
if err := db.Create(&other).Error; err != nil {
|
||||
t.Fatalf("seed other user: %v", err)
|
||||
}
|
||||
ownerID = other.ID
|
||||
}
|
||||
|
||||
card := models.Card{
|
||||
UserID: ownerID, StripePaymentID: "pm_test_retry",
|
||||
Last4: "4242", Brand: "Visa", ExpMonth: 12, ExpYear: 2030,
|
||||
}
|
||||
if err := db.Create(&card).Error; err != nil {
|
||||
t.Fatalf("seed card: %v", err)
|
||||
}
|
||||
|
||||
order := models.Order{
|
||||
UserID: ownerID,
|
||||
CardID: card.ID,
|
||||
StripePaymentID: tc.seedPayment,
|
||||
Status: tc.seedStatus,
|
||||
Subtotal: 500, Total: 500,
|
||||
ShippingName: "Test", ShippingStreet: "1 Main St",
|
||||
ShippingCity: "PDX", ShippingState: "OR",
|
||||
ShippingZip: "97201", ShippingCountry: "US",
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatalf("seed order: %v", err)
|
||||
}
|
||||
|
||||
for i := 0; i < tc.seedEvents; i++ {
|
||||
evt := models.OrderEvent{
|
||||
OrderID: order.ID,
|
||||
Type: audit.EventOrderRequiresAction,
|
||||
Actor: fmt.Sprintf("user:%d", ownerID),
|
||||
Payload: `{}`,
|
||||
}
|
||||
if err := db.Create(&evt).Error; err != nil {
|
||||
t.Fatalf("seed event: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
handler := NewCartHandler("", "http://localhost", 0)
|
||||
req := orderRequest("POST",
|
||||
fmt.Sprintf("/api/v1/orders/%d/retry-auth", order.ID), nil, user.ID, fmt.Sprintf("%d", order.ID))
|
||||
w := httptest.NewRecorder()
|
||||
handler.RetryAuth(w, req)
|
||||
|
||||
if w.Code != tc.wantStatus {
|
||||
t.Fatalf("status: want %d, got %d body=%s", tc.wantStatus, w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
_ = json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp.Error.Code != tc.wantErrCode {
|
||||
t.Fatalf("err code: want %q, got %q, body=%s", tc.wantErrCode, resp.Error.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryAuth_OrderNotFound(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("", "http://localhost", 0)
|
||||
req := orderRequest("POST", "/api/v1/orders/9999/retry-auth", nil, user.ID, "9999")
|
||||
w := httptest.NewRecorder()
|
||||
handler.RetryAuth(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("want 404, got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,7 +221,7 @@ func (h *OrderHandler) RefundOrder(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
audit.OrderRefunded(userID, order.ID, order.Total, rf.ID)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"refund_id": rf.ID,
|
||||
"status": "refunded",
|
||||
})
|
||||
@@ -287,7 +287,7 @@ func (h *OrderHandler) UpdateTracking(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
audit.OrderShipped(order.UserID, order.ID, req.Carrier, req.TrackingNumber)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"order_id": order.ID,
|
||||
"carrier": req.Carrier,
|
||||
"tracking_number": req.TrackingNumber,
|
||||
|
||||
@@ -30,7 +30,7 @@ func (h *ProductHandler) GetProducts(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"products": products,
|
||||
})
|
||||
}
|
||||
@@ -53,7 +53,7 @@ func (h *ProductHandler) GetProduct(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"product": product,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ func ReconcileOrders(stripeKey string) {
|
||||
"order_id", order.ID, "pi", pi.ID)
|
||||
|
||||
txErr := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&order).Updates(map[string]any{
|
||||
if err := tx.Model(&order).Updates(map[string]interface{}{
|
||||
"status": models.OrderStatusPaid,
|
||||
"stripe_payment_id": pi.ID,
|
||||
}).Error; err != nil {
|
||||
@@ -62,7 +62,7 @@ func ReconcileOrders(stripeKey string) {
|
||||
if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&cart).Updates(map[string]any{
|
||||
return tx.Model(&cart).Updates(map[string]interface{}{
|
||||
"address_id": nil,
|
||||
"card_id": nil,
|
||||
}).Error
|
||||
@@ -77,33 +77,6 @@ func ReconcileOrders(stripeKey string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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(stripeKey string) {
|
||||
db := database.GetDB()
|
||||
|
||||
var cards []models.Card
|
||||
if err := db.Where(
|
||||
"storage_expires_at IS NOT NULL AND storage_expires_at <= ?",
|
||||
time.Now(),
|
||||
).Find(&cards).Error; err != nil {
|
||||
reconcileLog().Error("expired card scan failed", "error", err)
|
||||
return
|
||||
}
|
||||
if len(cards) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
reconcileLog().Info("expiring inactive saved cards", "count", len(cards))
|
||||
for i := range cards {
|
||||
if err := expireStoredCard(db, &cards[i], stripeKey); err != nil {
|
||||
reconcileLog().Error("expire card failed", "card_id", cards[i].ID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ReconcileUnshipped finds paid order orlder than 24h with no tracking
|
||||
// number and posts single Slack reminder. Pure read + notif
|
||||
func ReconcileUnshipped() {
|
||||
|
||||
@@ -2,7 +2,6 @@ package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/pkg/database"
|
||||
@@ -12,7 +11,6 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ViewHandler handles bulk TUI bootstrap data
|
||||
type ViewHandler struct{}
|
||||
|
||||
func NewViewHandler() *ViewHandler {
|
||||
@@ -39,10 +37,7 @@ func (h *ViewHandler) GetViewInit(w http.ResponseWriter, r *http.Request) {
|
||||
db.Where("user_id = ?", userID).Find(&addresses)
|
||||
|
||||
var cards []models.Card
|
||||
db.Where(
|
||||
"user_id = ? AND (storage_expires_at IS NULL OR storage_expires_at > ?)",
|
||||
userID, time.Now(),
|
||||
).Order("is_default DESC, created_at DESC").Find(&cards)
|
||||
db.Where("user_id = ?", userID).Find(&cards)
|
||||
|
||||
var orders []models.Order
|
||||
db.Where("user_id = ?", userID).
|
||||
@@ -54,7 +49,7 @@ func (h *ViewHandler) GetViewInit(w http.ResponseWriter, r *http.Request) {
|
||||
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&cartItems)
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]any{
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"user": user.ToPublic(),
|
||||
"products": products,
|
||||
"cart": cartItems,
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
|
||||
// TestGetViewInit_FiltersExpiredCardsAndOrders drives the WHERE + ORDER BY
|
||||
// clauses in GetViewInit. a column-name typo in either clause (e.g.
|
||||
// id_default instead of is_default) surfaces here as a sqlite error
|
||||
func TestGetViewInit_FiltersExpiredCardsAndOrders(t *testing.T) {
|
||||
testDB, user := setupCardsExpireTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
now := time.Now()
|
||||
past := now.Add(-1 * time.Hour)
|
||||
future := now.Add((24 * time.Hour))
|
||||
|
||||
expired := models.Card{
|
||||
UserID: user.ID, StripePaymentID: "pm_expired", Last4: "1111",
|
||||
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &past,
|
||||
}
|
||||
freshDefault := models.Card{
|
||||
UserID: user.ID, StripePaymentID: "pm_fresh_d", Last4: "2222",
|
||||
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &future,
|
||||
IsDefault: true,
|
||||
}
|
||||
freshOther := models.Card{
|
||||
UserID: user.ID, StripePaymentID: "pm_fresh_o", Last4: "3333",
|
||||
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &future,
|
||||
}
|
||||
for _, card := range []*models.Card{&expired, &freshDefault, &freshOther} {
|
||||
if err := db.Create(card).Error; err != nil {
|
||||
t.Fatalf("seed card: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
handler := NewViewHandler()
|
||||
req := authRequest("GET", "/api/v1/view/init", nil, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.GetViewInit(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GetViewInit status %d, body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Data struct {
|
||||
Cards []models.Card `json:"cards"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(resp.Data.Cards) != 2 {
|
||||
t.Fatalf("want 2 visible cards, got %d", len(resp.Data.Cards))
|
||||
}
|
||||
if resp.Data.Cards[0].ID != freshDefault.ID {
|
||||
t.Fatalf("default card should sort first, got id=%d (want %d)", resp.Data.Cards[0].ID, freshDefault.ID)
|
||||
}
|
||||
}
|
||||
@@ -125,7 +125,7 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event
|
||||
}
|
||||
|
||||
txErr := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&order).Updates(map[string]any{
|
||||
if err := tx.Model(&order).Updates(map[string]interface{}{
|
||||
"status": models.OrderStatusPaid,
|
||||
"stripe_payment_id": pi.ID,
|
||||
}).Error; err != nil {
|
||||
@@ -139,7 +139,7 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event
|
||||
if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil {
|
||||
return fmt.Errorf("failed to delete cart item: %w", err)
|
||||
}
|
||||
return tx.Model(&cart).Updates(map[string]any{
|
||||
return tx.Model(&cart).Updates(map[string]interface{}{
|
||||
"address_id": nil,
|
||||
"card_id": nil,
|
||||
}).Error
|
||||
@@ -150,12 +150,6 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event
|
||||
return
|
||||
}
|
||||
|
||||
if order.CardID != 0 {
|
||||
if err := refreshCardStorageTTL(db, order.CardID, time.Now()); err != nil {
|
||||
webhookLog().Warn("failed to refresh card storage ttl", "card_id", order.CardID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
audit.OrderPaid(order.UserID, order.ID, int(pi.Amount), pi.ID)
|
||||
go notify.SlackOrderPaid(&order)
|
||||
webhookLog().Info("order marked paid", "order_id", orderIDStr, "pi", pi.ID)
|
||||
@@ -261,14 +255,8 @@ func (h *WebhookHandler) handleCheckoutSessionCompleted(ctx context.Context, eve
|
||||
webhookLog().Warn("failed to detach duplicate pm", "user_id", user.ID, "error", derr)
|
||||
}
|
||||
// Bump UpdatedAt so the TUI poll signal can detect that the webhook
|
||||
// fired even though no new row was inserted. storage_expires_at is
|
||||
// reset because the user just re-added the card; this is a card-add
|
||||
// path, not a payment-success refresh, so last_used_at stays as-is.
|
||||
now := time.Now()
|
||||
if uerr := db.Model(&dup).Updates(map[string]any{
|
||||
"updated_at": now,
|
||||
"storage_expires_at": models.CardStorageExpiresAt(now),
|
||||
}).Error; uerr != nil {
|
||||
// fired even though no new row was inserted.
|
||||
if uerr := db.Model(&dup).Update("updated_at", time.Now()).Error; uerr != nil {
|
||||
webhookLog().Warn("failed to bump dup card updated_at", "card_id", dup.ID, "error", uerr)
|
||||
}
|
||||
webhookLog().Info("skipped duplicate card via local-db match", "user_id", user.ID, "existing_card_id", dup.ID)
|
||||
@@ -284,7 +272,6 @@ func (h *WebhookHandler) handleCheckoutSessionCompleted(ctx context.Context, eve
|
||||
ExpMonth: int(pm.Card.ExpMonth),
|
||||
ExpYear: int(pm.Card.ExpYear),
|
||||
}
|
||||
card.InitializeStorageTTL(time.Now())
|
||||
|
||||
if err := db.Create(&card).Error; err != nil {
|
||||
webhookLog().Error("failed to save card", "user_id", user.ID, "error", err)
|
||||
|
||||
16
api/main.go
16
api/main.go
@@ -128,22 +128,6 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
// Sweep saved cards whose retention deadline elapsed. Read paths filter
|
||||
// expired rows from query results, so this sweeper only drives the Stripe
|
||||
// detach + audit + physical row delete asynchronously.
|
||||
go func() {
|
||||
ticker := time.NewTicker(1 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
handlers.ReconcileExpiredCards(cfg.StripeSecretKey)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
log.Println("API server started. Press Ctrl+C to shutdown.")
|
||||
|
||||
// Block until signal received
|
||||
|
||||
@@ -132,7 +132,6 @@ func SetupRoutes(
|
||||
// Orders
|
||||
r.Get("/orders", orderHandler.GetOrders)
|
||||
r.Get("/orders/{id}/status", orderHandler.GetOrderStatus)
|
||||
r.With(middleware.RateLimitByUser(10, time.Minute)).Post("/orders/{id}/retry-auth", cartHandler.RetryAuth)
|
||||
r.Post("/orders/{id}/refund-request", orderHandler.CreateRefundRequest)
|
||||
r.Post("/orders/{id}/refund", orderHandler.RefundOrder)
|
||||
|
||||
|
||||
17
go.mod
17
go.mod
@@ -39,7 +39,6 @@ require (
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bitfield/gotestdox v0.2.2 // indirect
|
||||
github.com/catppuccin/go v0.3.0 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
@@ -57,20 +56,15 @@ require (
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/creack/pty v1.1.24 // indirect
|
||||
github.com/dnephin/pflag v1.0.7 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-faster/city v1.0.1 // indirect
|
||||
github.com/go-faster/errors v0.7.1 // indirect
|
||||
github.com/go-logfmt/logfmt v0.6.1 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gotesttools/gotestfmt/v2 v2.5.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
||||
github.com/hashicorp/go-version v1.6.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
@@ -82,8 +76,6 @@ require (
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // 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
|
||||
github.com/mattn/go-runewidth v0.0.23 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.34 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
@@ -106,13 +98,10 @@ require (
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/mod v0.34.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/term v0.42.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/tools v0.43.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/grpc v1.80.0 // indirect
|
||||
@@ -120,10 +109,4 @@ require (
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gorm.io/driver/clickhouse v0.7.0 // indirect
|
||||
gorm.io/driver/mysql v1.5.7 // indirect
|
||||
gotest.tools/gotestsum v1.13.0 // indirect
|
||||
)
|
||||
|
||||
tool (
|
||||
github.com/gotesttools/gotestfmt/v2/cmd/gotestfmt
|
||||
gotest.tools/gotestsum
|
||||
)
|
||||
|
||||
27
go.sum
27
go.sum
@@ -26,8 +26,6 @@ github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ
|
||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bitfield/gotestdox v0.2.2 h1:x6RcPAbBbErKLnapz1QeAlf3ospg8efBsedU93CDsnE=
|
||||
github.com/bitfield/gotestdox v0.2.2/go.mod h1:D+gwtS0urjBrzguAkTM2wodsTQYFHdpx8eqRJ3N+9pY=
|
||||
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
||||
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
@@ -71,18 +69,12 @@ github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfv
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dnephin/pflag v1.0.7 h1:oxONGlWxhmUct0YzKTgrpQv9AUA1wtPBn7zuSjJqptk=
|
||||
github.com/dnephin/pflag v1.0.7/go.mod h1:uxE91IoWURlOiTUIA8Mq5ZZkAv3dPUfZNaT80Zm7OQE=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c h1:NhHTf8Ewd3NS46DylZB75Zi0UeATvDbDwFEmsO5ZVpQ=
|
||||
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c/go.mod h1:0ghtL4RhKBjyWMkHCWosp2aHSEXShQeQMLQ0qBCytfU=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||
@@ -113,12 +105,8 @@ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gotesttools/gotestfmt/v2 v2.5.0 h1:fSU3MnR+E+fvuXdw1l8xbufKhDxY3Tfjsjx/I1WerB4=
|
||||
github.com/gotesttools/gotestfmt/v2 v2.5.0/go.mod h1:oQJg2KZ2aGoqEbMC2PDaAeBYm0tOkocgixK9FzsCdp4=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||
github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=
|
||||
@@ -155,11 +143,6 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
|
||||
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||
@@ -256,8 +239,6 @@ golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
@@ -278,8 +259,6 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
@@ -295,8 +274,6 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -333,9 +310,5 @@ gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
gorm.io/plugin/opentelemetry v0.1.16 h1:Kypj2YYAliJqkIczDZDde6P6sFMhKSlG5IpngMFQGpc=
|
||||
gorm.io/plugin/opentelemetry v0.1.16/go.mod h1:P3RmTeZXT+9n0F1ccUqR5uuTvEXDxF8k2UpO7mTIB2Y=
|
||||
gotest.tools/gotestsum v1.13.0 h1:+Lh454O9mu9AMG1APV4o0y7oDYKyik/3kBOiCqiEpRo=
|
||||
gotest.tools/gotestsum v1.13.0/go.mod h1:7f0NS5hFb0dWr4NtcsAsF0y1kzjEFfAil0HiBQJE03Q=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
||||
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
||||
|
||||
1
main.go
1
main.go
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
@@ -104,16 +104,16 @@ func (c *Client) RefreshToken(fingerprint, clientSecret string) (string, error)
|
||||
|
||||
// APIResponse represents the standard API response format
|
||||
type APIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data map[string]any `json:"data"`
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// APIError represents an error from the API
|
||||
type APIError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details map[string]interface{} `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// ProductsResponse represents the products API response
|
||||
@@ -339,7 +339,7 @@ func (c *Client) GetCart() (*CartData, error) {
|
||||
func (c *Client) SetCartItem(coffeeID uint, quantity int) (*CartData, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/cart/item", c.BaseURL)
|
||||
|
||||
body := map[string]any{
|
||||
body := map[string]interface{}{
|
||||
"coffee_id": coffeeID,
|
||||
"quantity": quantity,
|
||||
}
|
||||
@@ -379,7 +379,7 @@ func (c *Client) SetCartItem(coffeeID uint, quantity int) (*CartData, error) {
|
||||
func (c *Client) SetCartAddress(addressID uint) error {
|
||||
url := fmt.Sprintf("%s/api/v1/cart/address", c.BaseURL)
|
||||
|
||||
body := map[string]any{
|
||||
body := map[string]interface{}{
|
||||
"address_id": addressID,
|
||||
}
|
||||
jsonData, err := json.Marshal(body)
|
||||
@@ -418,7 +418,7 @@ func (c *Client) SetCartAddress(addressID uint) error {
|
||||
func (c *Client) SetCartCard(cardID uint) error {
|
||||
url := fmt.Sprintf("%s/api/v1/cart/card", c.BaseURL)
|
||||
|
||||
body := map[string]any{
|
||||
body := map[string]interface{}{
|
||||
"card_id": cardID,
|
||||
}
|
||||
jsonData, err := json.Marshal(body)
|
||||
@@ -563,58 +563,6 @@ func (c *Client) GetOrderStatus(id uint) (string, error) {
|
||||
return sResp.Data.Status, nil
|
||||
}
|
||||
|
||||
// RetryAuthOutcome is the result of POST /orders/{id}/retry-auth.
|
||||
// status=="requires_action" -> RedirectURL is a fresh QR target; resume polling.
|
||||
// Status=="succeeded" -> stripe approved frictionless; keep polling for the
|
||||
// webhook to flip status=paid (RedirectURL will be empty)
|
||||
type RetryAuthOutcome struct {
|
||||
OrderID uint
|
||||
Status string
|
||||
RedirectURL string
|
||||
}
|
||||
|
||||
type retryAuthResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
OrderID uint `json:"order_id"`
|
||||
Status string `json:"status"`
|
||||
RedirectURL string `json:"redirect_url,omitempty"`
|
||||
} `json:"data"`
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// RetryAuth asks the server to re-Confirm the existing PaymentIntent for the
|
||||
// order, producing a new 3DS challenge URL. Used by the TUI's authFailed
|
||||
// screen so the customer can recover without re-entering the checkout flow.
|
||||
func (c *Client) RetryAuth(orderID uint) (*RetryAuthOutcome, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/orders/%d/retry-auth", c.BaseURL, orderID)
|
||||
req, err := http.NewRequest("POST", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create retry request: %w", err)
|
||||
}
|
||||
resp, err := c.doRequest(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send retry request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var rResp retryAuthResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode retry response: %w", err)
|
||||
}
|
||||
if !rResp.Success {
|
||||
if rResp.Error != nil {
|
||||
return nil, fmt.Errorf("%s: %s", rResp.Error.Code, rResp.Error.Message)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to retry authentication")
|
||||
}
|
||||
return &RetryAuthOutcome{
|
||||
OrderID: rResp.Data.OrderID,
|
||||
Status: rResp.Data.Status,
|
||||
RedirectURL: rResp.Data.RedirectURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetCards fetches all saved cards for the user.
|
||||
func (c *Client) GetCards() ([]models.Card, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/cards", c.BaseURL)
|
||||
|
||||
@@ -13,22 +13,16 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// EventOrderRequiresAction is the audit event-type string emitted whenever a
|
||||
// PaymentIntent is pushed into requires_action. explored so cart-flow handler
|
||||
// can count retry attempts without hardcoding the string literal.
|
||||
const EventOrderRequiresAction = "order_requires_action"
|
||||
|
||||
type eventType string
|
||||
|
||||
const (
|
||||
eventCardAdded eventType = "card_added"
|
||||
eventCardDeleted eventType = "card_deleted"
|
||||
eventCardExpired eventType = "card_expired"
|
||||
eventOrderCreated eventType = "order_created"
|
||||
eventOrderPaid eventType = "order_paid"
|
||||
eventOrderShipped eventType = "order_shipped"
|
||||
eventOrderFailed eventType = "order_failed"
|
||||
eventOrderRequiresAction eventType = EventOrderRequiresAction
|
||||
eventOrderRequiresAction eventType = "order_requires_action"
|
||||
eventOrderRefunded eventType = "order_refunded"
|
||||
eventPaymentCritical eventType = "payment_critical"
|
||||
eventLabelPurchased eventType = "label_purchased"
|
||||
@@ -119,17 +113,6 @@ func CardDeleted(userID, cardID uint) {
|
||||
persist(0, eventCardDeleted, e)
|
||||
}
|
||||
|
||||
// CardExpired records that a saved payment method expired from inactivity
|
||||
func CardExpired(userID, cardID uint) {
|
||||
e := event{
|
||||
Event: eventCardExpired,
|
||||
UserID: userID,
|
||||
CardID: cardID,
|
||||
}
|
||||
slog.Info("audit", e.attrs()...)
|
||||
persist(0, eventCardExpired, e)
|
||||
}
|
||||
|
||||
// OrderCreated records that an order record was created (before payment).
|
||||
func OrderCreated(userID, orderID uint, amountCents int) {
|
||||
e := event{
|
||||
@@ -318,6 +301,7 @@ func attrsToMap(a []any) map[string]any {
|
||||
return m
|
||||
}
|
||||
|
||||
//nolint:unused // called from ConvertCart in the next update
|
||||
func OrderRequiresAction(userID, orderID uint, paymentIntentID string) {
|
||||
e := event{
|
||||
Event: eventOrderRequiresAction,
|
||||
|
||||
@@ -53,7 +53,7 @@ func (m *JWTManager) GenerateToken(userID uint, email, username string) (string,
|
||||
|
||||
// ValidateToken validates a JWT token and returns the claims
|
||||
func (m *JWTManager) ValidateToken(tokenString string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (any, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
// Verify signing method
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
|
||||
@@ -3,7 +3,6 @@ package database
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"terminalShop/pkg/models"
|
||||
|
||||
@@ -31,18 +30,6 @@ func Migrate(db *gorm.DB) error {
|
||||
return fmt.Errorf("migration failed: %w", err)
|
||||
}
|
||||
|
||||
if err := backfillCardStorageExpiration(db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Println("Database migrations completed successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func backfillCardStorageExpiration(db *gorm.DB) error {
|
||||
expiresAt := models.CardStorageExpiresAt(time.Now())
|
||||
if err := db.Model(&models.Card{}).Where("storage_expires_at IS NULL").Update("storage_expires_at", expiresAt).Error; err != nil {
|
||||
return fmt.Errorf("card storage ttl backfill failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
|
||||
func TestBackfillCardStorageExpiration_FillsNullRowsOnly(t *testing.T) {
|
||||
testDB := "test_backfill.db"
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer ResetForTesting()
|
||||
|
||||
db, err := Connect(testDB)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
if err := Migrate(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
user := models.User{
|
||||
SSHKeyFingerprint: "SHA256:backfill",
|
||||
SSHPublicKey: "ssh-ed25519 AAAA backfill",
|
||||
Name: "Backfill",
|
||||
Email: "backfill@example.com",
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
|
||||
legacy := models.Card{
|
||||
UserID: user.ID, StripePaymentID: "pm_legacy", Last4: "0000",
|
||||
Brand: "Visa", ExpMonth: 1, ExpYear: 2030,
|
||||
}
|
||||
if err := db.Create(&legacy).Error; err != nil {
|
||||
t.Fatalf("seed legacy: %v", err)
|
||||
}
|
||||
|
||||
fixed := time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
preset := models.Card{
|
||||
UserID: user.ID, StripePaymentID: "pm_preset", Last4: "9999",
|
||||
Brand: "Visa", ExpMonth: 1, ExpYear: 2030, StorageExpiresAt: &fixed,
|
||||
}
|
||||
if err := db.Create(&preset).Error; err != nil {
|
||||
t.Fatalf("ssed preset: %v", err)
|
||||
}
|
||||
if err := backfillCardStorageExpiration(db); err != nil {
|
||||
t.Fatalf("seed preset: %v", err)
|
||||
}
|
||||
|
||||
var reloadedLegacy, reloadedPreset models.Card
|
||||
if err := db.First(&reloadedLegacy, legacy.ID).Error; err != nil {
|
||||
t.Fatalf("reload legacy: %v", err)
|
||||
}
|
||||
if err := db.First(&reloadedPreset, preset.ID).Error; err != nil {
|
||||
t.Fatalf("reload preset: %v", err)
|
||||
}
|
||||
if reloadedLegacy.StorageExpiresAt == nil {
|
||||
t.Fatalf("legacy card storage_expires_at still NULL after backfill")
|
||||
}
|
||||
if reloadedPreset.StorageExpiresAt == nil || !reloadedPreset.StorageExpiresAt.Equal(fixed) {
|
||||
t.Fatalf("preset card storage_expires_at was overwritten: %v", reloadedPreset.StorageExpiresAt)
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,8 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"terminalShop/pkg/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
|
||||
// Seed populates the database with initial data
|
||||
@@ -28,7 +27,7 @@ func Seed(db *gorm.DB) error {
|
||||
RoastType: "Dark Roast",
|
||||
Ounces: 2,
|
||||
BeanType: "Arabica",
|
||||
Price: 1800, // $18.00
|
||||
Price: 350, // $3.50
|
||||
Color: "#8B4513",
|
||||
Description: "Bold and intense coffee with a rich crema",
|
||||
},
|
||||
@@ -37,7 +36,7 @@ func Seed(db *gorm.DB) error {
|
||||
RoastType: "Medium Roast",
|
||||
Ounces: 8,
|
||||
BeanType: "Arabica",
|
||||
Price: 2200, // $22.00
|
||||
Price: 400, // $4.00
|
||||
Color: "#A0522D",
|
||||
Description: "Smooth and balanced espresso diluted with hot water",
|
||||
},
|
||||
@@ -46,7 +45,7 @@ func Seed(db *gorm.DB) error {
|
||||
RoastType: "Medium Roast",
|
||||
Ounces: 6,
|
||||
BeanType: "Arabica",
|
||||
Price: 2200, // $22.00
|
||||
Price: 450, // $4.50
|
||||
Color: "#C4A574",
|
||||
Description: "Espresso with steamed milk and a thick layer of foam",
|
||||
},
|
||||
@@ -55,7 +54,7 @@ func Seed(db *gorm.DB) error {
|
||||
RoastType: "Medium Roast",
|
||||
Ounces: 12,
|
||||
BeanType: "Arabica",
|
||||
Price: 2500, // $25.00
|
||||
Price: 500, // $5.00
|
||||
Color: "#D2B48C",
|
||||
Description: "Creamy espresso with plenty of steamed milk and a touch of foam",
|
||||
},
|
||||
@@ -64,7 +63,7 @@ func Seed(db *gorm.DB) error {
|
||||
RoastType: "Dark Roast",
|
||||
Ounces: 12,
|
||||
BeanType: "Arabica",
|
||||
Price: 3000, // $30.00
|
||||
Price: 550, // $5.50
|
||||
Color: "#8B4513",
|
||||
Description: "Chocolate and espresso combined with steamed milk. Sweet and indulgent.",
|
||||
},
|
||||
@@ -73,7 +72,7 @@ func Seed(db *gorm.DB) error {
|
||||
RoastType: "Dark Roast",
|
||||
Ounces: 3,
|
||||
BeanType: "Robusta Blend",
|
||||
Price: 1800, // $18.00
|
||||
Price: 425, // $4.25
|
||||
Color: "#DAA520",
|
||||
Description: "Espresso 'marked' with a dollop of foamed milk. Small but mighty, like a tiny caffeinated warrior.",
|
||||
},
|
||||
|
||||
@@ -6,25 +6,21 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const cardStorageTTL = 180 * 24 * time.Hour
|
||||
|
||||
// Card represents a saved payment method linked to a Stripe token.
|
||||
// The actual card details live on Stripe's servers — we only store
|
||||
// a reference and display info (last 4 digits, brand).
|
||||
type Card struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||
StripePaymentID string `gorm:"size:255;not null" json:"-"` // Stripe payment method or token ID
|
||||
Last4 string `gorm:"size:4;not null" json:"last4"` // Last 4 digits for display
|
||||
Brand string `gorm:"size:50" json:"brand"` // Visa, Mastercard, etc.
|
||||
ExpMonth int `gorm:"not null" json:"exp_month"`
|
||||
ExpYear int `gorm:"not null" json:"exp_year"`
|
||||
IsDefault bool `gorm:"default:false" json:"is_default"` // User's preferred card
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
StorageExpiresAt *time.Time `gorm:"index" json:"storage_expires_at,omitempty"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||
StripePaymentID string `gorm:"size:255;not null" json:"-"` // Stripe payment method or token ID
|
||||
Last4 string `gorm:"size:4;not null" json:"last4"` // Last 4 digits for display
|
||||
Brand string `gorm:"size:50" json:"brand"` // Visa, Mastercard, etc.
|
||||
ExpMonth int `gorm:"not null" json:"exp_month"`
|
||||
ExpYear int `gorm:"not null" json:"exp_year"`
|
||||
IsDefault bool `gorm:"default:false" json:"is_default"` // User's preferred card
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
// Associations
|
||||
User User `gorm:"foreignKey:UserID" json:"-"`
|
||||
@@ -34,20 +30,3 @@ type Card struct {
|
||||
func (Card) TableName() string {
|
||||
return "cards"
|
||||
}
|
||||
|
||||
// CardStorageExpiresAt returns the inactivity expiration for a saved card
|
||||
func CardStorageExpiresAt(now time.Time) *time.Time {
|
||||
expiresAt := now.Add(cardStorageTTL)
|
||||
return &expiresAt
|
||||
}
|
||||
|
||||
// InitializeStorageTTL sets the initial retention deadline without marking
|
||||
// the card as used for payment
|
||||
func (c *Card) InitializeStorageTTL(now time.Time) {
|
||||
c.StorageExpiresAt = CardStorageExpiresAt(now)
|
||||
}
|
||||
|
||||
// IsStorageExpired reports whether the saved card retention deadline elapsed
|
||||
func (c *Card) IsStorageExpired(now time.Time) bool {
|
||||
return c.StorageExpiresAt != nil && !c.StorageExpiresAt.After(now)
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCardStorageTTL(t *testing.T) {
|
||||
now := time.Date(2026, 6, 2, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
var card Card
|
||||
card.InitializeStorageTTL(now)
|
||||
|
||||
if card.LastUsedAt != nil {
|
||||
t.Fatalf("InitializeStorageTTL should not mark card as used")
|
||||
}
|
||||
if card.StorageExpiresAt == nil || !card.StorageExpiresAt.Equal(now.Add(cardStorageTTL)) {
|
||||
t.Fatalf("unexpected initial expiration: %v", card.StorageExpiresAt)
|
||||
}
|
||||
if card.IsStorageExpired(now.Add(cardStorageTTL - time.Second)) {
|
||||
t.Fatalf("card expired before ttl elapsed")
|
||||
}
|
||||
if !card.IsStorageExpired(now.Add(cardStorageTTL)) {
|
||||
t.Fatalf("card should expire at ttl boundary")
|
||||
}
|
||||
}
|
||||
@@ -170,20 +170,8 @@ func (m Model) ReviewView() string {
|
||||
if m.CheckingOut {
|
||||
return m.theme.TextAccent().Bold(true).Padding(1).Render(" submitting order...")
|
||||
}
|
||||
if m.review.retrying {
|
||||
return m.theme.TextAccent().Bold(true).Padding(1).Render(" retrying authentication...")
|
||||
}
|
||||
if m.review.retryExhausted {
|
||||
content := m.theme.TextError().Bold(true).Padding(1).Render("too many authentication attempts") + "\n" + m.theme.TextLabel().Padding(0, 1).Render("press esc to start a new order")
|
||||
return lipgloss.Place(
|
||||
m.widthContainer,
|
||||
lipgloss.Height(content),
|
||||
lipgloss.Center, lipgloss.Center,
|
||||
content,
|
||||
)
|
||||
}
|
||||
if m.review.authFailed {
|
||||
content := m.theme.TextError().Bold(true).Padding(1).Render("authentication failed — press r to retry, esc to go back")
|
||||
content := m.theme.TextError().Bold(true).Padding(1).Render("authentication failed — press esc to try again")
|
||||
return lipgloss.Place(
|
||||
m.widthContainer,
|
||||
lipgloss.Height(content),
|
||||
@@ -220,7 +208,7 @@ func (m Model) renderRequiresActionView() string {
|
||||
header := m.theme.TextAccent().Bold(true).Render("your bank wants to verify this charge")
|
||||
|
||||
if m.review.redirectURL == "" {
|
||||
return base.Render(" verifying with your bank...")
|
||||
return base.Render(" waiting for authentication link...")
|
||||
}
|
||||
|
||||
qr, qrSize, err := qrfefe.Generate(m.review.redirectURL)
|
||||
@@ -245,11 +233,12 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
case CheckoutResultMsg:
|
||||
m.CheckingOut = false
|
||||
if msg.Err != nil {
|
||||
var cmd tea.Cmd
|
||||
m, cmd = m.switchForCheckoutError(msg.Err)
|
||||
m = m.SwitchPage(paymentPage)
|
||||
m.error = &VisibleError{message: friendlyCheckoutError(msg.Err)}
|
||||
m.review.cardJustAdded = false
|
||||
return m, cmd
|
||||
m.payment.view = 0
|
||||
m.payment.form = nil
|
||||
return m, nil
|
||||
}
|
||||
if msg.RequiresAction {
|
||||
m.review.requiresAction = true
|
||||
@@ -286,65 +275,17 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
m.review.requiresAction = false
|
||||
m.review.redirectURL = ""
|
||||
m.review.authFailed = true
|
||||
m.footer = []footerCommand{{key: "r", value: "retry"}, {key: "esc", value: "back"}}
|
||||
return m, nil
|
||||
default:
|
||||
return m, m.schedulePollOrderStatus(msg.OrderID)
|
||||
}
|
||||
|
||||
case RetryAuthResultMsg:
|
||||
if m.review.orderID != 0 && msg.OrderID != m.review.orderID {
|
||||
return m, nil
|
||||
}
|
||||
m.review.retrying = false
|
||||
if msg.Err != nil {
|
||||
s := msg.Err.Error()
|
||||
switch {
|
||||
case strings.Contains(s, "RETRY_LIMIT"):
|
||||
m.review.authFailed = false
|
||||
m.review.retryExhausted = true
|
||||
m.footer = []footerCommand{{key: "esc", value: "back"}}
|
||||
return m, nil
|
||||
case strings.Contains(s, "CARD_NO_LONGER_AVAILABLE"):
|
||||
m.review.authFailed = false
|
||||
m.review.requiresAction = false
|
||||
m.review.redirectURL = ""
|
||||
m.review.orderID = 0
|
||||
m = m.SwitchPage(paymentPage)
|
||||
m.payment.view = 0
|
||||
m.payment.form = nil
|
||||
m.error = &VisibleError{message: "saved card is no longer available. add it again to try."}
|
||||
return m, nil
|
||||
default:
|
||||
m.error = &VisibleError{message: friendlyCheckoutError(msg.Err)}
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
m.review.authFailed = false
|
||||
m.review.retryExhausted = false
|
||||
m.review.requiresAction = true
|
||||
m.review.redirectURL = msg.RedirectURL
|
||||
m.review.orderID = msg.OrderID
|
||||
m.footer = []footerCommand{{key: "esc", value: "cancel"}}
|
||||
return m, m.schedulePollOrderStatus(msg.OrderID)
|
||||
|
||||
case tea.KeyMsg:
|
||||
if m.CheckingOut {
|
||||
return m, nil
|
||||
}
|
||||
if m.review.retrying {
|
||||
return m, nil
|
||||
}
|
||||
if m.review.authFailed {
|
||||
switch msg.String() {
|
||||
case "r":
|
||||
if m.review.orderID == 0 {
|
||||
return m, nil
|
||||
}
|
||||
m.review.retrying = true
|
||||
m.error = nil
|
||||
return m, m.retryAuthCmd(m.review.orderID)
|
||||
case "esc":
|
||||
if msg.String() == "esc" || msg.String() == "enter" {
|
||||
m.review.authFailed = false
|
||||
m = m.SwitchPage(paymentPage)
|
||||
m.payment.view = 0
|
||||
@@ -353,17 +294,6 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
if m.review.retryExhausted {
|
||||
if msg.String() == "esc" {
|
||||
m.review.retryExhausted = false
|
||||
m.review.orderID = 0
|
||||
m = m.SwitchPage(paymentPage)
|
||||
m.payment.view = 0
|
||||
m.payment.form = nil
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
if m.review.requiresAction {
|
||||
if msg.String() == "esc" {
|
||||
m.review.requiresAction = false
|
||||
@@ -400,18 +330,6 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) switchForCheckoutError(err error) (Model, tea.Cmd) {
|
||||
switch {
|
||||
case strings.Contains(err.Error(), "CART_OVER_LIMIT"):
|
||||
return m.CartSwitch()
|
||||
default:
|
||||
m = m.SwitchPage(paymentPage)
|
||||
m.payment.view = 0
|
||||
m.payment.form = nil
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
func friendlyCheckoutError(raw error) string {
|
||||
s := raw.Error()
|
||||
switch {
|
||||
@@ -421,8 +339,6 @@ func friendlyCheckoutError(raw error) string {
|
||||
return "your bank wants to verify this charge, authentication step coming soon."
|
||||
case strings.Contains(s, "AUTHENTICATION_NOT_SUPPORTED"):
|
||||
return "this card doesn't support online authentication. Try another card"
|
||||
case strings.Contains(s, "CARD_STORAGE_EXPIRED"):
|
||||
return "saved card expired. Add it again to verify the card is still yours"
|
||||
case strings.Contains(s, "CARD_EXPIRED"):
|
||||
return "card expired. Add a new card and try again"
|
||||
case strings.Contains(s, "CARD_CVC_FAILED"):
|
||||
|
||||
@@ -1,394 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
// newReviewModel returns a Model sitting on the review page with the default
|
||||
// layout already applied. Use as the starting point for every retry-auth
|
||||
// state machine test so transitions assert from a realistic baseline.
|
||||
func newReviewModel(t *testing.T) Model {
|
||||
t.Helper()
|
||||
m := NewModel("test")
|
||||
m.currentPage = reviewPage
|
||||
return m
|
||||
}
|
||||
|
||||
// keyR / keyEsc / keyJ build the bubbletea v2 key events Update expects.
|
||||
// KeyMsg is an interface; KeyPressMsg satisfies it. Key.String() returns
|
||||
// Text when non-empty, otherwise the keystroke (which is why esc relies on
|
||||
// Code: tea.KeyEsc rather than Text).
|
||||
func keyR() tea.KeyMsg { return tea.KeyPressMsg{Text: "r", Code: 'r'} }
|
||||
func keyEsc() tea.KeyMsg { return tea.KeyPressMsg{Code: tea.KeyEsc} }
|
||||
func keyJ() tea.KeyMsg { return tea.KeyPressMsg{Text: "j", Code: 'j'} }
|
||||
|
||||
func TestRetryAuthCmd_NilClient(t *testing.T) {
|
||||
m := NewModel("test")
|
||||
m.APIClient = nil
|
||||
cmd := m.retryAuthCmd(42)
|
||||
if cmd == nil {
|
||||
t.Fatal("retryAuthCmd returned nil cmd")
|
||||
}
|
||||
msg := cmd()
|
||||
rm, ok := msg.(RetryAuthResultMsg)
|
||||
if !ok {
|
||||
t.Fatalf("expected RetryAuthResultMsg, got %T", msg)
|
||||
}
|
||||
if rm.OrderID != 42 {
|
||||
t.Errorf("OrderID = %d, want 42", rm.OrderID)
|
||||
}
|
||||
if rm.Err == nil || !strings.Contains(rm.Err.Error(), "api client unavailable") {
|
||||
t.Errorf("expected api-client-unavailable err, got %v", rm.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewView_RetryingShowsCopy(t *testing.T) {
|
||||
m := newReviewModel(t)
|
||||
m.review.retrying = true
|
||||
out := m.ReviewView()
|
||||
if !strings.Contains(out, "retrying authentication") {
|
||||
t.Fatalf("missing retrying copy in: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewView_RetryExhaustedShowsCopy(t *testing.T) {
|
||||
m := newReviewModel(t)
|
||||
m.review.retryExhausted = true
|
||||
out := m.ReviewView()
|
||||
if !strings.Contains(out, "too many authentication attempts") {
|
||||
t.Fatalf("missing retry-exhausted copy in: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "press esc to start a new order") {
|
||||
t.Fatalf("missing recovery hint in: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewView_AuthFailedAdvertisesRetry(t *testing.T) {
|
||||
m := newReviewModel(t)
|
||||
m.review.authFailed = true
|
||||
out := m.ReviewView()
|
||||
if !strings.Contains(out, "press r to retry") {
|
||||
t.Fatalf("authFailed copy must advertise r: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "esc to go back") {
|
||||
t.Fatalf("authFailed copy must advertise esc: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRequiresActionView_FrictionlessShowsVerifying(t *testing.T) {
|
||||
m := newReviewModel(t)
|
||||
m.review.requiresAction = true
|
||||
m.review.redirectURL = ""
|
||||
out := m.renderRequiresActionView()
|
||||
if !strings.Contains(out, "verifying with your bank") {
|
||||
t.Fatalf("frictionless retry-success path must show verifying copy: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewUpdate_AuthFailed_PressR_StartsRetry(t *testing.T) {
|
||||
m := newReviewModel(t)
|
||||
m.review.authFailed = true
|
||||
m.review.orderID = 7
|
||||
m.error = &VisibleError{message: "leftover banner"}
|
||||
|
||||
out, cmd := m.ReviewUpdate(keyR())
|
||||
if !out.review.retrying {
|
||||
t.Fatal("expected retrying=true after r press")
|
||||
}
|
||||
if out.error != nil {
|
||||
t.Fatal("stale banner should be cleared when retry starts")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("expected non-nil cmd to call retryAuth")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewUpdate_AuthFailed_PressR_ZeroOrderID_NoOp(t *testing.T) {
|
||||
m := newReviewModel(t)
|
||||
m.review.authFailed = true
|
||||
m.review.orderID = 0
|
||||
|
||||
out, cmd := m.ReviewUpdate(keyR())
|
||||
if out.review.retrying {
|
||||
t.Fatal("retry must not start without an order id")
|
||||
}
|
||||
if cmd != nil {
|
||||
t.Fatal("expected nil cmd when orderID=0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewUpdate_AuthFailed_PressEsc_BouncesToPayment(t *testing.T) {
|
||||
m := newReviewModel(t)
|
||||
m.review.authFailed = true
|
||||
m.review.orderID = 7
|
||||
|
||||
out, _ := m.ReviewUpdate(keyEsc())
|
||||
if out.review.authFailed {
|
||||
t.Fatal("authFailed must clear on esc")
|
||||
}
|
||||
if out.currentPage != paymentPage {
|
||||
t.Fatalf("currentPage = %v, want paymentPage", out.currentPage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewUpdate_AuthFailed_OtherKey_NoOp(t *testing.T) {
|
||||
m := newReviewModel(t)
|
||||
m.review.authFailed = true
|
||||
m.review.orderID = 7
|
||||
|
||||
out, cmd := m.ReviewUpdate(keyJ())
|
||||
if !out.review.authFailed {
|
||||
t.Fatal("unrelated key must not clear authFailed")
|
||||
}
|
||||
if out.review.retrying {
|
||||
t.Fatal("unrelated key must not start retry")
|
||||
}
|
||||
if cmd != nil {
|
||||
t.Fatal("unrelated key must not produce a cmd")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewUpdate_Retrying_BlocksKeys(t *testing.T) {
|
||||
m := newReviewModel(t)
|
||||
m.review.retrying = true
|
||||
m.review.authFailed = true
|
||||
m.review.orderID = 7
|
||||
|
||||
out, cmd := m.ReviewUpdate(keyR())
|
||||
if !out.review.retrying {
|
||||
t.Fatal("retrying flag should persist while a retry is in flight")
|
||||
}
|
||||
if cmd != nil {
|
||||
t.Fatal("r press while retrying must not spawn a second cmd")
|
||||
}
|
||||
|
||||
out, cmd = m.ReviewUpdate(keyEsc())
|
||||
if out.currentPage != reviewPage {
|
||||
t.Fatalf("currentPage changed while retrying: %v", out.currentPage)
|
||||
}
|
||||
if cmd != nil {
|
||||
t.Fatal("esc while retrying must not navigate away")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewUpdate_RetryAuthResult_RequiresAction(t *testing.T) {
|
||||
m := newReviewModel(t)
|
||||
m.review.retrying = true
|
||||
m.review.authFailed = true
|
||||
m.review.orderID = 7
|
||||
|
||||
out, cmd := m.ReviewUpdate(RetryAuthResultMsg{
|
||||
OrderID: 7,
|
||||
Status: "requires_action",
|
||||
RedirectURL: "https://stripe/auth/abc",
|
||||
})
|
||||
|
||||
if out.review.retrying {
|
||||
t.Fatal("retrying must clear when the result arrives")
|
||||
}
|
||||
if out.review.authFailed {
|
||||
t.Fatal("authFailed must clear on retry success")
|
||||
}
|
||||
if !out.review.requiresAction {
|
||||
t.Fatal("expected requiresAction=true so the QR view renders")
|
||||
}
|
||||
if out.review.redirectURL != "https://stripe/auth/abc" {
|
||||
t.Fatalf("redirectURL = %q", out.review.redirectURL)
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("expected schedulePollOrderStatus cmd")
|
||||
}
|
||||
if len(out.footer) != 1 || out.footer[0].key != "esc" || out.footer[0].value != "cancel" {
|
||||
t.Fatalf("footer = %+v, want only esc=cancel", out.footer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewUpdate_RetryAuthResult_FrictionlessSuccess(t *testing.T) {
|
||||
m := newReviewModel(t)
|
||||
m.review.retrying = true
|
||||
m.review.authFailed = true
|
||||
m.review.orderID = 9
|
||||
|
||||
out, cmd := m.ReviewUpdate(RetryAuthResultMsg{
|
||||
OrderID: 9,
|
||||
Status: "succeeded",
|
||||
})
|
||||
|
||||
if !out.review.requiresAction {
|
||||
t.Fatal("frictionless retry must set requiresAction=true to render the verifying view")
|
||||
}
|
||||
if out.review.redirectURL != "" {
|
||||
t.Fatalf("redirectURL must stay empty for frictionless retry, got %q", out.review.redirectURL)
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Fatal("expected poll cmd to wait for the webhook")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewUpdate_RetryAuthResult_RetryLimit(t *testing.T) {
|
||||
m := newReviewModel(t)
|
||||
m.review.retrying = true
|
||||
m.review.authFailed = true
|
||||
m.review.orderID = 7
|
||||
|
||||
out, cmd := m.ReviewUpdate(RetryAuthResultMsg{
|
||||
OrderID: 7,
|
||||
Err: errors.New("RETRY_LIMIT: too many authentication attempts, please use a different card"),
|
||||
})
|
||||
|
||||
if out.review.retrying {
|
||||
t.Fatal("retrying must clear after a result")
|
||||
}
|
||||
if !out.review.retryExhausted {
|
||||
t.Fatal("expected retryExhausted=true on RETRY_LIMIT")
|
||||
}
|
||||
if out.review.authFailed {
|
||||
t.Fatal("authFailed must clear when retryExhausted takes over")
|
||||
}
|
||||
if cmd != nil {
|
||||
t.Fatal("RETRY_LIMIT is terminal — no follow-up cmd")
|
||||
}
|
||||
if len(out.footer) != 1 || out.footer[0].key != "esc" {
|
||||
t.Fatalf("footer = %+v, want only esc=back", out.footer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewUpdate_RetryAuthResult_CardGone(t *testing.T) {
|
||||
m := newReviewModel(t)
|
||||
m.review.retrying = true
|
||||
m.review.authFailed = true
|
||||
m.review.orderID = 7
|
||||
|
||||
out, _ := m.ReviewUpdate(RetryAuthResultMsg{
|
||||
OrderID: 7,
|
||||
Err: errors.New("CARD_NO_LONGER_AVAILABLE: saved card is no longer available, add it again to retry"),
|
||||
})
|
||||
|
||||
if out.currentPage != paymentPage {
|
||||
t.Fatalf("currentPage = %v, want paymentPage", out.currentPage)
|
||||
}
|
||||
if out.review.authFailed || out.review.requiresAction {
|
||||
t.Fatal("auth state must reset when bouncing to payment")
|
||||
}
|
||||
if out.review.orderID != 0 {
|
||||
t.Fatalf("orderID should reset to 0, got %d", out.review.orderID)
|
||||
}
|
||||
if out.error == nil || !strings.Contains(out.error.message, "saved card is no longer available") {
|
||||
t.Fatalf("error banner missing or wrong copy: %+v", out.error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewUpdate_RetryAuthResult_Transient(t *testing.T) {
|
||||
m := newReviewModel(t)
|
||||
m.review.retrying = true
|
||||
m.review.authFailed = true
|
||||
m.review.orderID = 7
|
||||
|
||||
out, cmd := m.ReviewUpdate(RetryAuthResultMsg{
|
||||
OrderID: 7,
|
||||
Err: errors.New("CARD_DECLINED: card declined"),
|
||||
})
|
||||
|
||||
if out.review.retrying {
|
||||
t.Fatal("retrying must clear after a result")
|
||||
}
|
||||
if !out.review.authFailed {
|
||||
t.Fatal("authFailed must persist so the user can retry again")
|
||||
}
|
||||
if out.review.retryExhausted {
|
||||
t.Fatal("transient errors must not flip retryExhausted")
|
||||
}
|
||||
if out.error == nil {
|
||||
t.Fatal("expected friendly banner for transient error")
|
||||
}
|
||||
if cmd != nil {
|
||||
t.Fatal("transient error must not start a poll")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewUpdate_RetryAuthResult_OrderIDMismatch(t *testing.T) {
|
||||
m := newReviewModel(t)
|
||||
m.review.retrying = true
|
||||
m.review.authFailed = true
|
||||
m.review.orderID = 7
|
||||
|
||||
out, cmd := m.ReviewUpdate(RetryAuthResultMsg{
|
||||
OrderID: 99,
|
||||
Status: "requires_action",
|
||||
RedirectURL: "https://wrong",
|
||||
})
|
||||
|
||||
if !out.review.retrying {
|
||||
t.Fatal("mismatched orderID must leave retrying unchanged")
|
||||
}
|
||||
if out.review.requiresAction {
|
||||
t.Fatal("mismatched orderID must not flip requiresAction")
|
||||
}
|
||||
if out.review.redirectURL == "https://wrong" {
|
||||
t.Fatal("mismatched orderID must not adopt the foreign URL")
|
||||
}
|
||||
if cmd != nil {
|
||||
t.Fatal("mismatched orderID must not start a poll")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewUpdate_RetryExhausted_PressEsc_BouncesToPayment(t *testing.T) {
|
||||
m := newReviewModel(t)
|
||||
m.review.retryExhausted = true
|
||||
m.review.orderID = 7
|
||||
|
||||
out, _ := m.ReviewUpdate(keyEsc())
|
||||
if out.review.retryExhausted {
|
||||
t.Fatal("retryExhausted must clear on esc")
|
||||
}
|
||||
if out.review.orderID != 0 {
|
||||
t.Fatalf("orderID should reset to 0, got %d", out.review.orderID)
|
||||
}
|
||||
if out.currentPage != paymentPage {
|
||||
t.Fatalf("currentPage = %v, want paymentPage", out.currentPage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewUpdate_RetryExhausted_OtherKey_NoOp(t *testing.T) {
|
||||
m := newReviewModel(t)
|
||||
m.review.retryExhausted = true
|
||||
|
||||
out, cmd := m.ReviewUpdate(keyR())
|
||||
if !out.review.retryExhausted {
|
||||
t.Fatal("unrelated key must not clear retryExhausted")
|
||||
}
|
||||
if cmd != nil {
|
||||
t.Fatal("unrelated key must not produce a cmd")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewUpdate_OrderStatusFailed_SetsRetryFooter(t *testing.T) {
|
||||
m := newReviewModel(t)
|
||||
m.review.requiresAction = true
|
||||
m.review.orderID = 7
|
||||
|
||||
out, _ := m.ReviewUpdate(OrderStatusMsg{OrderID: 7, Status: "failed"})
|
||||
|
||||
if !out.review.authFailed {
|
||||
t.Fatal("expected authFailed=true after failed status")
|
||||
}
|
||||
if out.review.requiresAction {
|
||||
t.Fatal("requiresAction must clear when poll resolves to failed")
|
||||
}
|
||||
hasR, hasEsc := false, false
|
||||
for _, c := range out.footer {
|
||||
if c.key == "r" && c.value == "retry" {
|
||||
hasR = true
|
||||
}
|
||||
if c.key == "esc" && c.value == "back" {
|
||||
hasEsc = true
|
||||
}
|
||||
}
|
||||
if !hasR || !hasEsc {
|
||||
t.Fatalf("footer missing r/esc retry hints: %+v", out.footer)
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFriendlyCheckoutError_CardStorageExpired(t *testing.T) {
|
||||
got := friendlyCheckoutError(errors.New("CARD_STORAGE_EXPIRED: saved card epired, add it again"))
|
||||
if !strings.Contains(got, "saved card expired") {
|
||||
t.Fatalf("want saved-card-expired copy, got %q", got)
|
||||
}
|
||||
if strings.Contains(got, "card expired. Add a new card") {
|
||||
t.Fatalf("CARD_STORAGE_EXPIRED was matched by the CARD_EXPIRED branch: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFriendlyCheckoutError_CardExpiredStillReachable(t *testing.T) {
|
||||
got := friendlyCheckoutError(errors.New("CARD_EXPIRED: card expired"))
|
||||
if !strings.Contains(got, "card expired. Add a new card") {
|
||||
t.Fatalf("want plain card-expired copy, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -211,8 +211,6 @@ type reviewState struct {
|
||||
redirectURL string
|
||||
orderID uint
|
||||
authFailed bool
|
||||
retrying bool
|
||||
retryExhausted bool
|
||||
}
|
||||
|
||||
type confirmState struct {
|
||||
@@ -444,18 +442,6 @@ type OrderStatusMsg struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
// RetryAuthResultMsg carries the outcome of POST /orders/{id}/retry-auth.
|
||||
// Status "requires_action" -> RedirectURL is a fresh challenge URL; resume
|
||||
// the QR + poll loop. Status "succeeded" -> frictionless retry approved;
|
||||
// server's APIError string ("RETRY_LIMIT" / "CARD_NO_LONGER_AVAILABLE" /
|
||||
// transient) - the Update branch matches on substring.
|
||||
type RetryAuthResultMsg struct {
|
||||
OrderID uint
|
||||
Status string
|
||||
RedirectURL string
|
||||
Err error
|
||||
}
|
||||
|
||||
// CartSyncedMsg is recieved when the server responds to a cart item update
|
||||
type CartSyncedMsg struct {
|
||||
UpdateID int64
|
||||
@@ -696,23 +682,6 @@ func (m Model) schedulePollOrderStatus(orderID uint) tea.Cmd {
|
||||
})
|
||||
}
|
||||
|
||||
func (m Model) retryAuthCmd(orderID uint) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if m.APIClient == nil {
|
||||
return RetryAuthResultMsg{OrderID: orderID, Err: fmt.Errorf("api client unavailable")}
|
||||
}
|
||||
outcome, err := m.APIClient.RetryAuth(orderID)
|
||||
if err != nil {
|
||||
return RetryAuthResultMsg{OrderID: orderID, Err: err}
|
||||
}
|
||||
return RetryAuthResultMsg{
|
||||
OrderID: orderID,
|
||||
Status: outcome.Status,
|
||||
RedirectURL: outcome.RedirectURL,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) syncCartItemCmd(coffeeID uint, quantity int) (Model, tea.Cmd) {
|
||||
if m.APIClient == nil || m.User == nil {
|
||||
return m, nil
|
||||
|
||||
@@ -129,7 +129,7 @@ func (m Model) RefundUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
||||
state.form = f
|
||||
}
|
||||
if state.form.State == huh.StateCompleted {
|
||||
state.reason = state.form.GetString("reason")
|
||||
state.form = m.buildRefundForm(state)
|
||||
return m, m.setRefundFocus(state, refundFocusMessage)
|
||||
}
|
||||
return m, cmd
|
||||
@@ -335,7 +335,7 @@ func (m Model) renderRefundTextarea(state *refundState) string {
|
||||
}
|
||||
|
||||
wrapper := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
Border(lipgloss.ThickBorder()).
|
||||
BorderForeground(border)
|
||||
|
||||
contentWidth := m.refundContentWidth()
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
|
||||
// SuccessResponse represents a successful API response
|
||||
type SuccessResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data any `json:"data"`
|
||||
Success bool `json:"success"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
// ErrorResponse represents an error API response
|
||||
@@ -20,13 +20,13 @@ type ErrorResponse struct {
|
||||
|
||||
// Error contains error details
|
||||
type Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details map[string]any `json:"details,omitempty"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details map[string]interface{} `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// RespondSuccess sends a successful JSON response
|
||||
func RespondSuccess(w http.ResponseWriter, statusCode int, data any) {
|
||||
func RespondSuccess(w http.ResponseWriter, statusCode int, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
|
||||
@@ -39,7 +39,7 @@ func RespondSuccess(w http.ResponseWriter, statusCode int, data any) {
|
||||
}
|
||||
|
||||
// RespondError sends an error JSON response
|
||||
func RespondError(w http.ResponseWriter, statusCode int, code, message string, details map[string]any) {
|
||||
func RespondError(w http.ResponseWriter, statusCode int, code, message string, details map[string]interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user