mirror of
https://github.com/marcusDenslow/terminalShop.git
synced 2026-10-25 02:52:20 +02:00
feat: implement server-side cart persistance with Stripe PaymentIntents
feat: implement server-side cart persistence with Stripe PaymentIntents Replaced the client-only in-mem cart with a database-backed cart system - Add Cart and CartItem GORM models (two-table design) - Add 6 cart API endpoints (get, set item, set address, set card, clear, conver) - Switch from Stripe Charge API to PaymentIntents with confirm:true - Add 9 API client methods for cart and card operations - Refactor TUI cart from map[int] (slice index) to map[uint] (CoffeeID) - Replace single-shot checkout with saveCardAndConvert multi-step flow - Add CheckingOut state with "submitting order..." processing indicator - Fix Stripe tokenization to use secret key for server-side SSH apps - Remove admin product CRUD endpoints and old checkout handler
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -68,6 +68,8 @@ MODELS.md
|
||||
REFACTORING.md
|
||||
TODO.md
|
||||
|
||||
codebase_graph.html
|
||||
|
||||
# Don't commit the cloned reference repo (optional - you might want to keep it)
|
||||
# terminal-shop-source/
|
||||
|
||||
@@ -76,3 +78,7 @@ api/docs/
|
||||
|
||||
# Migration state (if using a migration tool that creates state files)
|
||||
migrations/*.state
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/utils"
|
||||
"terminalShop/pkg/validation"
|
||||
)
|
||||
|
||||
// AuthHandler handles authentication-related requests
|
||||
@@ -99,7 +98,7 @@ func (h *AuthHandler) RegisterWithSSHKey(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
errors := make(validation.ProductErrors)
|
||||
errors := map[string]string{}
|
||||
if req.SSHPublicKey == "" {
|
||||
errors["ssh_public_key"] = "SSH public key is required"
|
||||
}
|
||||
@@ -108,7 +107,7 @@ func (h *AuthHandler) RegisterWithSSHKey(w http.ResponseWriter, r *http.Request)
|
||||
errors["ssh_key_fingerprint"] = "SSH key fingerprint is required"
|
||||
}
|
||||
|
||||
if errors.HasErrors() {
|
||||
if len(errors) > 0 {
|
||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "validation failed", map[string]interface{}{
|
||||
"errors": errors,
|
||||
})
|
||||
|
||||
206
api/handlers/cards.go
Normal file
206
api/handlers/cards.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
"github.com/stripe/stripe-go/v78/customer"
|
||||
"github.com/stripe/stripe-go/v78/paymentmethod"
|
||||
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/utils"
|
||||
)
|
||||
|
||||
// CardHandler handles saved payment method CRUD.
|
||||
type CardHandler struct {
|
||||
stripeKey string
|
||||
}
|
||||
|
||||
// NewCardHandler creates a new card handler with the Stripe secret key.
|
||||
func NewCardHandler(stripeSecretKey string) *CardHandler {
|
||||
return &CardHandler{stripeKey: stripeSecretKey}
|
||||
}
|
||||
|
||||
// GetCards returns all saved cards for the authenticated user.
|
||||
func (h *CardHandler) GetCards(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
var cards []models.Card
|
||||
db.Where("user_id = ?", userID).Order("created_at DESC").Find(&cards)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"cards": cards,
|
||||
})
|
||||
}
|
||||
|
||||
// GetCard returns a single saved card by ID.
|
||||
func (h *CardHandler) GetCard(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid card id", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var card models.Card
|
||||
if err := db.Where("id = ? AND user_id = ?", id, userID).First(&card).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusNotFound, "CARD_NOT_FOUND", "card not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"card": card,
|
||||
})
|
||||
}
|
||||
|
||||
// SaveCard creates a saved card from a Stripe token. The token is converted to
|
||||
// a PaymentMethod and attached to the user's Stripe customer.
|
||||
func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
var req struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Token == "" {
|
||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "token is required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := db.First(&user, userID).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusNotFound, "USER_NOT_FOUND", "user not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
stripe.Key = h.stripeKey
|
||||
|
||||
// Ensure the user has a Stripe customer
|
||||
if user.StripeCustomerID == "" {
|
||||
params := &stripe.CustomerParams{
|
||||
Description: stripe.String(fmt.Sprintf("terminal.shop user %d", user.ID)),
|
||||
}
|
||||
if user.Email != "" {
|
||||
params.Email = stripe.String(user.Email)
|
||||
}
|
||||
if user.Name != "" {
|
||||
params.Name = stripe.String(user.Name)
|
||||
}
|
||||
|
||||
cust, err := customer.New(params)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil)
|
||||
return
|
||||
}
|
||||
user.StripeCustomerID = cust.ID
|
||||
db.Save(&user)
|
||||
}
|
||||
|
||||
// Convert the token to a PaymentMethod
|
||||
pm, err := paymentmethod.New(&stripe.PaymentMethodParams{
|
||||
Type: stripe.String("card"),
|
||||
Card: &stripe.PaymentMethodCardParams{
|
||||
Token: stripe.String(req.Token),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "STRIPE_ERROR", "invalid card token", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Check for duplicate cards by fingerprint
|
||||
listParams := &stripe.PaymentMethodListParams{
|
||||
Customer: stripe.String(user.StripeCustomerID),
|
||||
Type: stripe.String("card"),
|
||||
}
|
||||
iter := paymentmethod.List(listParams)
|
||||
for iter.Next() {
|
||||
existing := iter.PaymentMethod()
|
||||
if existing.Card != nil && pm.Card != nil &&
|
||||
existing.Card.Fingerprint == pm.Card.Fingerprint {
|
||||
utils.RespondError(w, http.StatusConflict, "ALREADY_EXISTS", "this card is already saved", nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Attach the PaymentMethod to the Stripe customer
|
||||
_, err = paymentmethod.Attach(pm.ID, &stripe.PaymentMethodAttachParams{
|
||||
Customer: stripe.String(user.StripeCustomerID),
|
||||
})
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to attach payment method", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Save the card locally
|
||||
card := models.Card{
|
||||
UserID: user.ID,
|
||||
StripePaymentID: pm.ID,
|
||||
Last4: pm.Card.Last4,
|
||||
Brand: string(pm.Card.Brand),
|
||||
ExpMonth: int(pm.Card.ExpMonth),
|
||||
ExpYear: int(pm.Card.ExpYear),
|
||||
}
|
||||
|
||||
if err := db.Create(&card).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to save card", nil)
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{
|
||||
"card": card,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteCard removes a saved card. If the card is currently set on the user's
|
||||
// cart, the cart's card selection is cleared.
|
||||
func (h *CardHandler) DeleteCard(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid card id", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var card models.Card
|
||||
if err := db.Where("id = ? AND user_id = ?", id, userID).First(&card).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusNotFound, "CARD_NOT_FOUND", "card not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Clear the card from any cart that references it
|
||||
cardID := uint(id)
|
||||
db.Model(&models.Cart{}).Where("card_id = ? AND user_id = ?", cardID, userID).Update("card_id", nil)
|
||||
|
||||
// Detach from Stripe if it's a PaymentMethod (pm_)
|
||||
if len(card.StripePaymentID) >= 3 && card.StripePaymentID[:3] == "pm_" {
|
||||
stripe.Key = h.stripeKey
|
||||
_, _ = paymentmethod.Detach(card.StripePaymentID, &stripe.PaymentMethodDetachParams{})
|
||||
}
|
||||
|
||||
// Soft-delete the local record
|
||||
db.Delete(&card)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "card deleted",
|
||||
})
|
||||
}
|
||||
412
api/handlers/cart.go
Normal file
412
api/handlers/cart.go
Normal file
@@ -0,0 +1,412 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/utils"
|
||||
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
"github.com/stripe/stripe-go/v78/customer"
|
||||
"github.com/stripe/stripe-go/v78/paymentintent"
|
||||
"github.com/stripe/stripe-go/v78/paymentmethod"
|
||||
)
|
||||
|
||||
type CartHandler struct {
|
||||
stripeKey string
|
||||
}
|
||||
|
||||
// NewCartHandler creates a new cart handler with the Stripe secret key
|
||||
func NewCartHandler(stripeSecretKey string) *CartHandler {
|
||||
return &CartHandler{stripeKey: stripeSecretKey}
|
||||
}
|
||||
|
||||
// getOrCreateCart finds the users cart or creates one.
|
||||
func getOrCreateCart(userID uint) (*models.Cart, error) {
|
||||
db := database.GetDB()
|
||||
var cart models.Cart
|
||||
err := db.Where("user_id = ?", userID).First(&cart).Error
|
||||
if err != nil {
|
||||
cart = models.Cart{UserID: userID}
|
||||
if createErr := db.Create(&cart).Error; createErr != nil {
|
||||
return nil, fmt.Errorf("failed to create cart: %w", createErr)
|
||||
}
|
||||
}
|
||||
return &cart, nil
|
||||
}
|
||||
|
||||
func cartResponse(cart *models.Cart) map[string]interface{} {
|
||||
db := database.GetDB()
|
||||
|
||||
var items []models.CartItem
|
||||
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&items)
|
||||
|
||||
subtotal := 0
|
||||
itemsData := make([]map[string]interface{}, 0, len(items))
|
||||
for _, item := range items {
|
||||
priceInCents := int(item.Coffee.Price * 100)
|
||||
lineTotal := priceInCents * item.Quantity
|
||||
subtotal += lineTotal
|
||||
itemsData = append(itemsData, map[string]interface{}{
|
||||
"id": item.ID,
|
||||
"coffee_id": item.CoffeeID,
|
||||
"quantity": item.Quantity,
|
||||
"subtotal": lineTotal,
|
||||
"coffee": item.Coffee,
|
||||
})
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"items": itemsData,
|
||||
"subtotal": subtotal,
|
||||
"address_id": cart.AddressID,
|
||||
"card_id": cart.CardID,
|
||||
"shipping_cost": cart.ShippingCost,
|
||||
"shipping_service": cart.ShippingService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *CartHandler) GetCart(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
cart, err := getOrCreateCart(userID)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"cart": cartResponse(cart),
|
||||
})
|
||||
}
|
||||
|
||||
type SetItemRequest struct {
|
||||
CoffeeID uint `json:"coffee_id"`
|
||||
Quantity int `json:"quantity"`
|
||||
}
|
||||
|
||||
func (h *CartHandler) SetItem(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
var req SetItemRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if req.CoffeeID == 0 {
|
||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "coffee_id is required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var coffee models.Coffee
|
||||
if err := db.First(&coffee, req.CoffeeID).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_PRODUCT", "product not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
cart, err := getOrCreateCart(userID)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Quantity <= 0 {
|
||||
db.Where("cart_id = ? AND coffee_id = ?", cart.ID, req.CoffeeID).Delete(&models.CartItem{})
|
||||
} else {
|
||||
var item models.CartItem
|
||||
result := db.Where("cart_id = ? AND coffee_id = ?", cart.ID, req.CoffeeID).First(&item)
|
||||
if result.Error != nil {
|
||||
item = models.CartItem{
|
||||
CartID: cart.ID,
|
||||
CoffeeID: req.CoffeeID,
|
||||
Quantity: req.Quantity,
|
||||
}
|
||||
db.Create(&item)
|
||||
} else {
|
||||
db.Model(&item).Update("quantity", req.Quantity)
|
||||
}
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"cart": cartResponse(cart),
|
||||
})
|
||||
}
|
||||
|
||||
type SetAddressRequest struct {
|
||||
AddressID uint `json:"address_id"`
|
||||
}
|
||||
|
||||
func (h *CartHandler) SetAddress(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
var req SetAddressRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var address models.Address
|
||||
if err := db.Where("id = ? AND user_id = ?", req.AddressID, userID).First(&address).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_ADDRESS", "address not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
cart, err := getOrCreateCart(userID)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
|
||||
return
|
||||
}
|
||||
|
||||
db.Model(cart).Update("address_id", req.AddressID)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "address set",
|
||||
})
|
||||
}
|
||||
|
||||
type SetCardRequest struct {
|
||||
CardID uint `json:"card_id"`
|
||||
}
|
||||
|
||||
func (h *CartHandler) SetCard(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
var req SetCardRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var card models.Card
|
||||
if err := db.Where("id = ? AND user_id = ?", req.CardID, userID).First(&card).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_CARD", "card not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
cart, err := getOrCreateCart(userID)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
|
||||
return
|
||||
}
|
||||
|
||||
db.Model(cart).Update("card_id", req.CardID)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "card set",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *CartHandler) ClearCart(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
cart, err := getOrCreateCart(userID)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
|
||||
return
|
||||
}
|
||||
|
||||
db.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{})
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "cart cleared",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
cart, err := getOrCreateCart(userID)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var items []models.CartItem
|
||||
db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&items)
|
||||
|
||||
if len(items) <= 0 {
|
||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "cart is empty", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if cart.AddressID == nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "shipping address is required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if cart.CardID == nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "payment card is required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var address models.Address
|
||||
if err := db.First(&address, *cart.AddressID).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_ADDRESS", "shipping address not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var card models.Card
|
||||
if err := db.First(&card, *cart.CardID).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_CARD", "payment card not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := db.First(&user, userID).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusNotFound, "USER_NOT_FOUND", "user not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
stripe.Key = h.stripeKey
|
||||
|
||||
if user.StripeCustomerID == "" {
|
||||
params := &stripe.CustomerParams{
|
||||
Description: stripe.String(fmt.Sprintf("terminal.shop user %d", user.ID)),
|
||||
}
|
||||
|
||||
if user.Email != "" {
|
||||
params.Email = stripe.String(user.Email)
|
||||
}
|
||||
|
||||
if user.Name != "" {
|
||||
params.Name = stripe.String(user.Name)
|
||||
}
|
||||
|
||||
cust, err := customer.New(params)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil)
|
||||
return
|
||||
}
|
||||
user.StripeCustomerID = cust.ID
|
||||
db.Save(&user)
|
||||
}
|
||||
|
||||
var subtotal int
|
||||
var orderItems []models.OrderItem
|
||||
for _, item := range items {
|
||||
priceInCents := int(item.Coffee.Price * 100)
|
||||
lineTotal := priceInCents * item.Quantity
|
||||
subtotal += lineTotal
|
||||
orderItems = append(orderItems, models.OrderItem{
|
||||
CoffeeID: item.CoffeeID,
|
||||
Name: item.Coffee.Name,
|
||||
Price: priceInCents,
|
||||
Quantity: item.Quantity,
|
||||
})
|
||||
}
|
||||
|
||||
total := subtotal + cart.ShippingCost
|
||||
|
||||
if total < 50 {
|
||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "order total must be at least $0.50", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// If the saved card has a token (tok_), convert to a PaymentMethod and attach
|
||||
paymentMethodID := card.StripePaymentID
|
||||
if len(paymentMethodID) >= 4 && paymentMethodID[:3] == "tok" {
|
||||
pm, err := paymentmethod.New(&stripe.PaymentMethodParams{
|
||||
Type: stripe.String("card"),
|
||||
Card: &stripe.PaymentMethodCardParams{
|
||||
Token: stripe.String(paymentMethodID),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create payment method", nil)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = paymentmethod.Attach(pm.ID, &stripe.PaymentMethodAttachParams{
|
||||
Customer: stripe.String(user.StripeCustomerID),
|
||||
})
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to attach payment method", nil)
|
||||
return
|
||||
}
|
||||
|
||||
paymentMethodID = pm.ID
|
||||
card.StripePaymentID = pm.ID
|
||||
db.Save(&card)
|
||||
}
|
||||
|
||||
// Charge via PaymentIntent (matching reference implementation)
|
||||
piParams := &stripe.PaymentIntentParams{
|
||||
Amount: stripe.Int64(int64(total)),
|
||||
Currency: stripe.String(string(stripe.CurrencyUSD)),
|
||||
Customer: stripe.String(user.StripeCustomerID),
|
||||
PaymentMethod: stripe.String(paymentMethodID),
|
||||
AutomaticPaymentMethods: &stripe.PaymentIntentAutomaticPaymentMethodsParams{
|
||||
Enabled: stripe.Bool(true),
|
||||
AllowRedirects: stripe.String("never"),
|
||||
},
|
||||
Confirm: stripe.Bool(true),
|
||||
Description: stripe.String(fmt.Sprintf("terminal.shop order for user %d", user.ID)),
|
||||
Shipping: &stripe.ShippingDetailsParams{
|
||||
Name: stripe.String(address.Name),
|
||||
Address: &stripe.AddressParams{
|
||||
Line1: stripe.String(address.Street),
|
||||
Line2: stripe.String(address.Street2),
|
||||
City: stripe.String(address.City),
|
||||
State: stripe.String(address.State),
|
||||
PostalCode: stripe.String(address.Zip),
|
||||
Country: stripe.String(address.Country),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
pi, err := paymentintent.New(piParams)
|
||||
if err != nil {
|
||||
if stripeErr, ok := err.(*stripe.Error); ok && stripeErr.Type == stripe.ErrorTypeCard {
|
||||
utils.RespondError(w, http.StatusPaymentRequired, "CARD_ERROR", stripeErr.Msg, nil)
|
||||
return
|
||||
}
|
||||
utils.RespondError(w, http.StatusPaymentRequired, "PAYMENT_FAILED", "payment failed", nil)
|
||||
return
|
||||
}
|
||||
order := models.Order{
|
||||
UserID: user.ID,
|
||||
CardID: card.ID,
|
||||
StripePaymentID: pi.ID,
|
||||
Status: models.OrderStatusPaid,
|
||||
Subtotal: subtotal,
|
||||
ShippingCost: cart.ShippingCost,
|
||||
Total: total,
|
||||
ShippingName: address.Name,
|
||||
ShippingStreet: address.Street,
|
||||
ShippingStreet2: address.Street2,
|
||||
ShippingCity: address.City,
|
||||
ShippingState: address.State,
|
||||
ShippingZip: address.Zip,
|
||||
ShippingCountry: address.Country,
|
||||
ShippingPhone: address.Phone,
|
||||
Items: orderItems,
|
||||
}
|
||||
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to create order", nil)
|
||||
return
|
||||
}
|
||||
|
||||
db.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{})
|
||||
|
||||
db.Model(cart).Updates(map[string]interface{}{
|
||||
"address_id": nil,
|
||||
"card_id": nil,
|
||||
})
|
||||
|
||||
db.Preload("Items").First(&order, order.ID)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"order": order,
|
||||
})
|
||||
}
|
||||
666
api/handlers/cart_test.go
Normal file
666
api/handlers/cart_test.go
Normal file
@@ -0,0 +1,666 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
|
||||
// setupCartTestDB creates a temp DB with migrated tables, seeded products,
|
||||
// and a test user. Returns the DB filename and the user.
|
||||
func setupCartTestDB(t *testing.T) (string, models.User) {
|
||||
t.Helper()
|
||||
testDB := "test_cart.db"
|
||||
os.Remove(testDB)
|
||||
database.ResetForTesting()
|
||||
|
||||
db, err := database.Connect(testDB)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to connect to test database: %v", err)
|
||||
}
|
||||
if err := database.Migrate(db); err != nil {
|
||||
t.Fatalf("Failed to migrate: %v", err)
|
||||
}
|
||||
if err := database.Seed(db); err != nil {
|
||||
t.Fatalf("Failed to seed: %v", err)
|
||||
}
|
||||
|
||||
user := models.User{
|
||||
SSHKeyFingerprint: "SHA256:testfingerprint",
|
||||
SSHPublicKey: "ssh-ed25519 AAAA testkey",
|
||||
Name: "Test User",
|
||||
Email: "test@example.com",
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("Failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
return testDB, user
|
||||
}
|
||||
|
||||
// authRequest creates an HTTP request with the test user ID in the context.
|
||||
func authRequest(method, url string, body []byte, userID uint) *http.Request {
|
||||
var req *http.Request
|
||||
if body != nil {
|
||||
req = httptest.NewRequest(method, url, bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
req = httptest.NewRequest(method, url, nil)
|
||||
}
|
||||
ctx := middleware.ContextWithUserID(req.Context(), userID)
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
func TestGetCartEmpty(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("")
|
||||
|
||||
req := authRequest("GET", "/api/v1/cart", nil, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.GetCart(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Cart struct {
|
||||
Items []interface{} `json:"items"`
|
||||
Subtotal int `json:"subtotal"`
|
||||
} `json:"cart"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
if !resp.Success {
|
||||
t.Error("Expected success to be true")
|
||||
}
|
||||
if len(resp.Data.Cart.Items) != 0 {
|
||||
t.Errorf("Expected empty cart, got %d items", len(resp.Data.Cart.Items))
|
||||
}
|
||||
if resp.Data.Cart.Subtotal != 0 {
|
||||
t.Errorf("Expected subtotal 0, got %d", resp.Data.Cart.Subtotal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetItemAndGetCart(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("")
|
||||
|
||||
// Add item (CoffeeID=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)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("SetItem expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify via GetCart
|
||||
req = authRequest("GET", "/api/v1/cart", nil, user.ID)
|
||||
w = httptest.NewRecorder()
|
||||
handler.GetCart(w, req)
|
||||
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Cart struct {
|
||||
Items []struct {
|
||||
CoffeeID uint `json:"coffee_id"`
|
||||
Quantity int `json:"quantity"`
|
||||
Subtotal int `json:"subtotal"`
|
||||
} `json:"items"`
|
||||
Subtotal int `json:"subtotal"`
|
||||
} `json:"cart"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if len(resp.Data.Cart.Items) != 1 {
|
||||
t.Fatalf("Expected 1 item, got %d", len(resp.Data.Cart.Items))
|
||||
}
|
||||
if resp.Data.Cart.Items[0].CoffeeID != 1 {
|
||||
t.Errorf("Expected coffee_id 1, got %d", resp.Data.Cart.Items[0].CoffeeID)
|
||||
}
|
||||
if resp.Data.Cart.Items[0].Quantity != 2 {
|
||||
t.Errorf("Expected quantity 2, got %d", resp.Data.Cart.Items[0].Quantity)
|
||||
}
|
||||
if resp.Data.Cart.Subtotal == 0 {
|
||||
t.Error("Expected non-zero subtotal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetItemUpdateQuantity(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("")
|
||||
|
||||
// Add item
|
||||
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]interface{}{"coffee_id": 1, "quantity": 5})
|
||||
req = authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||
w = httptest.NewRecorder()
|
||||
handler.SetItem(w, req)
|
||||
|
||||
// Verify
|
||||
req = authRequest("GET", "/api/v1/cart", nil, user.ID)
|
||||
w = httptest.NewRecorder()
|
||||
handler.GetCart(w, req)
|
||||
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Cart struct {
|
||||
Items []struct {
|
||||
CoffeeID uint `json:"coffee_id"`
|
||||
Quantity int `json:"quantity"`
|
||||
} `json:"items"`
|
||||
} `json:"cart"`
|
||||
} `json:"data"`
|
||||
}
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
|
||||
if len(resp.Data.Cart.Items) != 1 {
|
||||
t.Fatalf("Expected 1 item after update, got %d", len(resp.Data.Cart.Items))
|
||||
}
|
||||
if resp.Data.Cart.Items[0].Quantity != 5 {
|
||||
t.Errorf("Expected quantity 5, got %d", resp.Data.Cart.Items[0].Quantity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetItemRemove(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("")
|
||||
|
||||
// Add item
|
||||
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]interface{}{"coffee_id": 1, "quantity": 0})
|
||||
req = authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
||||
w = httptest.NewRecorder()
|
||||
handler.SetItem(w, req)
|
||||
|
||||
// Verify empty
|
||||
req = authRequest("GET", "/api/v1/cart", nil, user.ID)
|
||||
w = httptest.NewRecorder()
|
||||
handler.GetCart(w, req)
|
||||
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Cart struct {
|
||||
Items []interface{} `json:"items"`
|
||||
} `json:"cart"`
|
||||
} `json:"data"`
|
||||
}
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
|
||||
if len(resp.Data.Cart.Items) != 0 {
|
||||
t.Errorf("Expected 0 items after removal, got %d", len(resp.Data.Cart.Items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetItemInvalidProduct(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("")
|
||||
|
||||
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)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400 for invalid product, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetItemMissingCoffeeID(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("")
|
||||
|
||||
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)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400 for missing coffee_id, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearCart(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("")
|
||||
|
||||
// Add two items
|
||||
for _, coffeeID := range []uint{1, 2} {
|
||||
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)
|
||||
}
|
||||
|
||||
// Clear cart
|
||||
req := authRequest("DELETE", "/api/v1/cart", nil, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ClearCart(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("ClearCart expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Verify empty
|
||||
req = authRequest("GET", "/api/v1/cart", nil, user.ID)
|
||||
w = httptest.NewRecorder()
|
||||
handler.GetCart(w, req)
|
||||
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Cart struct {
|
||||
Items []interface{} `json:"items"`
|
||||
} `json:"cart"`
|
||||
} `json:"data"`
|
||||
}
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
|
||||
if len(resp.Data.Cart.Items) != 0 {
|
||||
t.Errorf("Expected 0 items after clear, got %d", len(resp.Data.Cart.Items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAddress(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
addr := models.Address{
|
||||
UserID: user.ID,
|
||||
Name: "Test Address",
|
||||
Street: "123 Main St",
|
||||
City: "Portland",
|
||||
State: "OR",
|
||||
Zip: "97201",
|
||||
Country: "US",
|
||||
}
|
||||
db.Create(&addr)
|
||||
|
||||
handler := NewCartHandler("")
|
||||
|
||||
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)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("SetAddress expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify cart has the address set
|
||||
req = authRequest("GET", "/api/v1/cart", nil, user.ID)
|
||||
w = httptest.NewRecorder()
|
||||
handler.GetCart(w, req)
|
||||
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Cart struct {
|
||||
AddressID *uint `json:"address_id"`
|
||||
} `json:"cart"`
|
||||
} `json:"data"`
|
||||
}
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
|
||||
if resp.Data.Cart.AddressID == nil || *resp.Data.Cart.AddressID != addr.ID {
|
||||
t.Errorf("Expected address_id %d, got %v", addr.ID, resp.Data.Cart.AddressID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAddressWrongUser(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
// Create address owned by a different user
|
||||
otherUser := models.User{
|
||||
SSHKeyFingerprint: "SHA256:otherfingerprint",
|
||||
SSHPublicKey: "ssh-ed25519 AAAA otherkey",
|
||||
}
|
||||
db.Create(&otherUser)
|
||||
addr := models.Address{
|
||||
UserID: otherUser.ID,
|
||||
Name: "Other Address",
|
||||
Street: "456 Other St",
|
||||
City: "Seattle",
|
||||
State: "WA",
|
||||
Zip: "98101",
|
||||
Country: "US",
|
||||
}
|
||||
db.Create(&addr)
|
||||
|
||||
handler := NewCartHandler("")
|
||||
|
||||
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)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400 for address not owned by user, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCard(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
card := models.Card{
|
||||
UserID: user.ID,
|
||||
StripePaymentID: "pm_test123",
|
||||
Last4: "4242",
|
||||
Brand: "Visa",
|
||||
ExpMonth: 12,
|
||||
ExpYear: 2030,
|
||||
}
|
||||
db.Create(&card)
|
||||
|
||||
handler := NewCartHandler("")
|
||||
|
||||
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)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("SetCard expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify cart has the card set
|
||||
req = authRequest("GET", "/api/v1/cart", nil, user.ID)
|
||||
w = httptest.NewRecorder()
|
||||
handler.GetCart(w, req)
|
||||
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Cart struct {
|
||||
CardID *uint `json:"card_id"`
|
||||
} `json:"cart"`
|
||||
} `json:"data"`
|
||||
}
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
|
||||
if resp.Data.Cart.CardID == nil || *resp.Data.Cart.CardID != card.ID {
|
||||
t.Errorf("Expected card_id %d, got %v", card.ID, resp.Data.Cart.CardID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCardWrongUser(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
otherUser := models.User{
|
||||
SSHKeyFingerprint: "SHA256:otherfingerprint2",
|
||||
SSHPublicKey: "ssh-ed25519 AAAA otherkey2",
|
||||
}
|
||||
db.Create(&otherUser)
|
||||
card := models.Card{
|
||||
UserID: otherUser.ID,
|
||||
StripePaymentID: "pm_other",
|
||||
Last4: "1234",
|
||||
Brand: "Mastercard",
|
||||
ExpMonth: 6,
|
||||
ExpYear: 2028,
|
||||
}
|
||||
db.Create(&card)
|
||||
|
||||
handler := NewCartHandler("")
|
||||
|
||||
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)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400 for card not owned by user, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCartMissingAddress(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("")
|
||||
|
||||
// Add item but no address or card
|
||||
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)
|
||||
|
||||
// Try to convert
|
||||
req = authRequest("POST", "/api/v1/cart/convert", nil, user.ID)
|
||||
w = httptest.NewRecorder()
|
||||
handler.ConvertCart(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400 for missing address, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCartMissingCard(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("")
|
||||
db := database.GetDB()
|
||||
|
||||
// Add item and address
|
||||
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]interface{}{"address_id": addr.ID})
|
||||
req = authRequest("PUT", "/api/v1/cart/address", body, user.ID)
|
||||
w = httptest.NewRecorder()
|
||||
handler.SetAddress(w, req)
|
||||
|
||||
// Try to convert (no card)
|
||||
req = authRequest("POST", "/api/v1/cart/convert", nil, user.ID)
|
||||
w = httptest.NewRecorder()
|
||||
handler.ConvertCart(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400 for missing card, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCartEmpty(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("")
|
||||
|
||||
// Empty cart convert
|
||||
req := authRequest("POST", "/api/v1/cart/convert", nil, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ConvertCart(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400 for empty cart, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipleItems(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewCartHandler("")
|
||||
|
||||
// Add three different items
|
||||
for i := uint(1); i <= 3; 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)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("SetItem for coffee %d failed with %d", i, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify
|
||||
req := authRequest("GET", "/api/v1/cart", nil, user.ID)
|
||||
w := httptest.NewRecorder()
|
||||
handler.GetCart(w, req)
|
||||
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Cart struct {
|
||||
Items []interface{} `json:"items"`
|
||||
Subtotal int `json:"subtotal"`
|
||||
} `json:"cart"`
|
||||
} `json:"data"`
|
||||
}
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
|
||||
if len(resp.Data.Cart.Items) != 3 {
|
||||
t.Errorf("Expected 3 items, got %d", len(resp.Data.Cart.Items))
|
||||
}
|
||||
if resp.Data.Cart.Subtotal == 0 {
|
||||
t.Error("Expected non-zero subtotal for 3 items")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCartIsolation(t *testing.T) {
|
||||
testDB, user1 := setupCartTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
user2 := models.User{
|
||||
SSHKeyFingerprint: "SHA256:user2fingerprint",
|
||||
SSHPublicKey: "ssh-ed25519 AAAA user2key",
|
||||
}
|
||||
db.Create(&user2)
|
||||
|
||||
handler := NewCartHandler("")
|
||||
r := chi.NewRouter()
|
||||
r.Put("/cart/item", handler.SetItem)
|
||||
r.Get("/cart", handler.GetCart)
|
||||
|
||||
// User 1 adds item
|
||||
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]interface{}{"coffee_id": 2, "quantity": 3})
|
||||
req = authRequest("PUT", "/cart/item", body, user2.ID)
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
// User 1 cart should have only their item
|
||||
req = authRequest("GET", "/cart", nil, user1.ID)
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Cart struct {
|
||||
Items []struct {
|
||||
CoffeeID uint `json:"coffee_id"`
|
||||
Quantity int `json:"quantity"`
|
||||
} `json:"items"`
|
||||
} `json:"cart"`
|
||||
} `json:"data"`
|
||||
}
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
|
||||
if len(resp.Data.Cart.Items) != 1 {
|
||||
t.Fatalf("User 1 expected 1 item, got %d", len(resp.Data.Cart.Items))
|
||||
}
|
||||
if resp.Data.Cart.Items[0].CoffeeID != 1 {
|
||||
t.Errorf("User 1 expected coffee_id 1, got %d", resp.Data.Cart.Items[0].CoffeeID)
|
||||
}
|
||||
if resp.Data.Cart.Items[0].Quantity != 5 {
|
||||
t.Errorf("User 1 expected quantity 5, got %d", resp.Data.Cart.Items[0].Quantity)
|
||||
}
|
||||
|
||||
// User 2 cart should have only their item
|
||||
req = authRequest("GET", "/cart", nil, user2.ID)
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
|
||||
if len(resp.Data.Cart.Items) != 1 {
|
||||
t.Fatalf("User 2 expected 1 item, got %d", len(resp.Data.Cart.Items))
|
||||
}
|
||||
if resp.Data.Cart.Items[0].CoffeeID != 2 {
|
||||
t.Errorf("User 2 expected coffee_id 2, got %d", resp.Data.Cart.Items[0].CoffeeID)
|
||||
}
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
|
||||
"github.com/stripe/stripe-go/v78"
|
||||
"github.com/stripe/stripe-go/v78/charge"
|
||||
"github.com/stripe/stripe-go/v78/customer"
|
||||
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/utils"
|
||||
)
|
||||
|
||||
// CheckoutHandler handles checkout and order creation.
|
||||
type CheckoutHandler struct {
|
||||
stripeKey string
|
||||
}
|
||||
|
||||
// NewCheckoutHandler creates a new checkout handler with the Stripe secret key.
|
||||
func NewCheckoutHandler(stripeSecretKey string) *CheckoutHandler {
|
||||
return &CheckoutHandler{stripeKey: stripeSecretKey}
|
||||
}
|
||||
|
||||
type CheckoutCartItem struct {
|
||||
CoffeeID uint `json:"coffee_id"`
|
||||
Quantity int `json:"quantity"`
|
||||
}
|
||||
|
||||
type CheckoutRequest struct {
|
||||
StripeToken string `json:"stripe_token"`
|
||||
Last4 string `json:"last4"`
|
||||
Brand string `json:"brand"`
|
||||
ExpMonth int `json:"exp_month"`
|
||||
ExpYear int `json:"exp_year"`
|
||||
Items []CheckoutCartItem `json:"items"`
|
||||
AddressID uint `json:"address_id"`
|
||||
}
|
||||
|
||||
func (h *CheckoutHandler) Checkout(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
var req CheckoutRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if req.StripeToken == "" {
|
||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "stripe token is required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Items) <= 0 {
|
||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "cart is empty", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if req.AddressID == 0 {
|
||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "address_id is required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var address models.Address
|
||||
if err := db.Where("id = ? AND user_id = ?", req.AddressID, userID).First(&address).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_ADDRESS", "address not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := db.First(&user, userID).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusNotFound, "USER_NOT_FOUND", "user not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
stripe.Key = h.stripeKey
|
||||
|
||||
if user.StripeCustomerID == "" {
|
||||
params := &stripe.CustomerParams{
|
||||
Description: stripe.String(fmt.Sprintf("terminal.shop user %d", user.ID)),
|
||||
}
|
||||
|
||||
if user.Email != "" {
|
||||
params.Email = stripe.String(user.Email)
|
||||
}
|
||||
if user.Name != "" {
|
||||
params.Name = stripe.String(user.Name)
|
||||
}
|
||||
cust, err := customer.New(params)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil)
|
||||
return
|
||||
}
|
||||
user.StripeCustomerID = cust.ID
|
||||
if err := db.Save(&user).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to save stripe customer", nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var subtotal int
|
||||
var orderItems []models.OrderItem
|
||||
|
||||
for _, item := range req.Items {
|
||||
if item.Quantity <= 0 {
|
||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "item quantity must be at least 1", nil)
|
||||
return
|
||||
}
|
||||
var coffee models.Coffee
|
||||
if err := db.First(&coffee, item.CoffeeID).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_PRODUCT", fmt.Sprintf("product %d not found", item.CoffeeID), nil)
|
||||
return
|
||||
}
|
||||
|
||||
priceInCents := int(math.Round(coffee.Price * 100))
|
||||
lineTotal := priceInCents * item.Quantity
|
||||
subtotal += lineTotal
|
||||
|
||||
orderItems = append(orderItems, models.OrderItem{
|
||||
CoffeeID: coffee.ID,
|
||||
Name: coffee.Name,
|
||||
Price: priceInCents,
|
||||
Quantity: item.Quantity,
|
||||
})
|
||||
}
|
||||
|
||||
total := subtotal
|
||||
|
||||
chargeParams := &stripe.ChargeParams{
|
||||
Amount: stripe.Int64(int64(total)),
|
||||
Currency: stripe.String(string(stripe.CurrencyUSD)),
|
||||
Customer: stripe.String(user.StripeCustomerID),
|
||||
Description: stripe.String(fmt.Sprintf("terminal.shop order for user %d", user.ID)),
|
||||
}
|
||||
chargeParams.SetSource(req.StripeToken)
|
||||
|
||||
// Stripe minimum charge is $0.50 (50 cents)
|
||||
if total < 50 {
|
||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "order total must be at least $0.50", nil)
|
||||
return
|
||||
}
|
||||
|
||||
ch, err := charge.New(chargeParams)
|
||||
if err != nil {
|
||||
log.Printf("stripe charge failed for user %d: %v", user.ID, err)
|
||||
utils.RespondError(w, http.StatusPaymentRequired, "PAYMENT_FAILED", "payment could not be processed", nil)
|
||||
return
|
||||
}
|
||||
|
||||
card := models.Card{
|
||||
UserID: user.ID,
|
||||
StripePaymentID: req.StripeToken,
|
||||
Last4: req.Last4,
|
||||
Brand: req.Brand,
|
||||
ExpMonth: req.ExpMonth,
|
||||
ExpYear: req.ExpYear,
|
||||
}
|
||||
|
||||
if err := db.Create(&card).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to save card", nil)
|
||||
return
|
||||
}
|
||||
|
||||
order := models.Order{
|
||||
UserID: user.ID,
|
||||
CardID: card.ID,
|
||||
StripePaymentID: ch.ID,
|
||||
Status: models.OrderStatusPaid,
|
||||
Subtotal: subtotal,
|
||||
Total: total,
|
||||
ShippingName: address.Name,
|
||||
ShippingStreet: address.Street,
|
||||
ShippingStreet2: address.Street2,
|
||||
ShippingCity: address.City,
|
||||
ShippingState: address.State,
|
||||
ShippingZip: address.Zip,
|
||||
ShippingCountry: address.Country,
|
||||
ShippingPhone: address.Phone,
|
||||
Items: orderItems,
|
||||
}
|
||||
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to create order", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.Preload("Items").First(&order, order.ID).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to reload order", nil)
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{
|
||||
"order": order,
|
||||
})
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
@@ -9,7 +8,6 @@ import (
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/utils"
|
||||
"terminalShop/pkg/validation"
|
||||
)
|
||||
|
||||
// ProductHandler handles product-related requests
|
||||
@@ -59,144 +57,4 @@ func (h *ProductHandler) GetProduct(w http.ResponseWriter, r *http.Request) {
|
||||
"product": product,
|
||||
})
|
||||
}
|
||||
|
||||
// CreateProduct creates a new product
|
||||
func (h *ProductHandler) CreateProduct(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
|
||||
// Parse request body
|
||||
var req validation.ProductRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate request
|
||||
if validationErrors := validation.ValidateProductRequest(req, false); validationErrors.HasErrors() {
|
||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "validation failed", map[string]interface{}{
|
||||
"errors": validationErrors,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Create product
|
||||
product := models.Coffee{
|
||||
Name: req.Name,
|
||||
RoastType: req.RoastType,
|
||||
Ounces: req.Ounces,
|
||||
BeanType: req.BeanType,
|
||||
Price: req.Price,
|
||||
Color: req.Color,
|
||||
Description: req.Description,
|
||||
}
|
||||
|
||||
if err := db.Create(&product).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to create product", nil)
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{
|
||||
"product": product,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateProduct updates an existing product
|
||||
func (h *ProductHandler) UpdateProduct(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
|
||||
// Get ID from URL parameter
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid product ID", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if product exists
|
||||
var product models.Coffee
|
||||
if err := db.First(&product, id).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusNotFound, "NOT_FOUND", "product not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
var req validation.ProductRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate request (isUpdate = true for partial updates)
|
||||
if validationErrors := validation.ValidateProductRequest(req, true); validationErrors.HasErrors() {
|
||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "validation failed", map[string]interface{}{
|
||||
"errors": validationErrors,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Update only provided fields
|
||||
updates := make(map[string]interface{})
|
||||
if req.Name != "" {
|
||||
updates["name"] = req.Name
|
||||
}
|
||||
if req.RoastType != "" {
|
||||
updates["roast_type"] = req.RoastType
|
||||
}
|
||||
if req.Ounces != 0 {
|
||||
updates["ounces"] = req.Ounces
|
||||
}
|
||||
if req.BeanType != "" {
|
||||
updates["bean_type"] = req.BeanType
|
||||
}
|
||||
if req.Price != 0 {
|
||||
updates["price"] = req.Price
|
||||
}
|
||||
if req.Color != "" {
|
||||
updates["color"] = req.Color
|
||||
}
|
||||
if req.Description != "" {
|
||||
updates["description"] = req.Description
|
||||
}
|
||||
|
||||
if err := db.Model(&product).Updates(updates).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to update product", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Reload product to get updated values
|
||||
db.First(&product, id)
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"product": product,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteProduct soft deletes a product
|
||||
func (h *ProductHandler) DeleteProduct(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
|
||||
// Get ID from URL parameter
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid product ID", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if product exists
|
||||
var product models.Coffee
|
||||
if err := db.First(&product, id).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusNotFound, "NOT_FOUND", "product not found", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Soft delete (sets deleted_at timestamp)
|
||||
if err := db.Delete(&product).Error; err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to delete product", nil)
|
||||
return
|
||||
}
|
||||
|
||||
utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{
|
||||
"message": "product deleted successfully",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,417 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
"terminalShop/pkg/validation"
|
||||
)
|
||||
|
||||
func TestGetProduct(t *testing.T) {
|
||||
testDB := setupTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewProductHandler()
|
||||
|
||||
// Create a router with chi to test URL parameters
|
||||
r := chi.NewRouter()
|
||||
r.Get("/products/{id}", handler.GetProduct)
|
||||
|
||||
// Test getting existing product
|
||||
req := httptest.NewRequest(http.MethodGet, "/products/1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Product models.Coffee `json:"product"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if !response.Success {
|
||||
t.Error("Expected success to be true")
|
||||
}
|
||||
|
||||
if response.Data.Product.Name != "Espresso" {
|
||||
t.Errorf("Expected Espresso, got %s", response.Data.Product.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProductNotFound(t *testing.T) {
|
||||
testDB := setupTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewProductHandler()
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/products/{id}", handler.GetProduct)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/products/999", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("Expected status 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProduct(t *testing.T) {
|
||||
testDB := "test_create.db"
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
database.ResetForTesting()
|
||||
db, err := database.Connect(testDB)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to connect: %v", err)
|
||||
}
|
||||
|
||||
if err := database.Migrate(db); err != nil {
|
||||
t.Fatalf("Failed to migrate: %v", err)
|
||||
}
|
||||
|
||||
handler := NewProductHandler()
|
||||
|
||||
productReq := validation.ProductRequest{
|
||||
Name: "Test Coffee",
|
||||
RoastType: "Medium Roast",
|
||||
Ounces: 10,
|
||||
BeanType: "Test Beans",
|
||||
Price: 6.99,
|
||||
Color: "#FF0000",
|
||||
Description: "A test coffee",
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(productReq)
|
||||
req := httptest.NewRequest(http.MethodPost, "/products", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.CreateProduct(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("Expected status 201, got %d. Body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Product models.Coffee `json:"product"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if !response.Success {
|
||||
t.Error("Expected success to be true")
|
||||
}
|
||||
|
||||
if response.Data.Product.Name != "Test Coffee" {
|
||||
t.Errorf("Expected Test Coffee, got %s", response.Data.Product.Name)
|
||||
}
|
||||
|
||||
if response.Data.Product.ID == 0 {
|
||||
t.Error("Expected product ID to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProductValidationError(t *testing.T) {
|
||||
testDB := "test_create_validation.db"
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
database.ResetForTesting()
|
||||
db, err := database.Connect(testDB)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to connect: %v", err)
|
||||
}
|
||||
|
||||
if err := database.Migrate(db); err != nil {
|
||||
t.Fatalf("Failed to migrate: %v", err)
|
||||
}
|
||||
|
||||
handler := NewProductHandler()
|
||||
|
||||
// Invalid product (missing name, negative price)
|
||||
productReq := validation.ProductRequest{
|
||||
Name: "",
|
||||
Price: -5.00,
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(productReq)
|
||||
req := httptest.NewRequest(http.MethodPost, "/products", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.CreateProduct(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected status 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateProduct(t *testing.T) {
|
||||
testDB := setupTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewProductHandler()
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Put("/products/{id}", handler.UpdateProduct)
|
||||
|
||||
// Update price of Espresso
|
||||
updateReq := validation.ProductRequest{
|
||||
Price: 4.50,
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(updateReq)
|
||||
req := httptest.NewRequest(http.MethodPut, "/products/1", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Product models.Coffee `json:"product"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if !response.Success {
|
||||
t.Error("Expected success to be true")
|
||||
}
|
||||
|
||||
if response.Data.Product.Price != 4.50 {
|
||||
t.Errorf("Expected price 4.50, got %.2f", response.Data.Product.Price)
|
||||
}
|
||||
|
||||
// Name should still be Espresso
|
||||
if response.Data.Product.Name != "Espresso" {
|
||||
t.Errorf("Expected name Espresso, got %s", response.Data.Product.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateProductNotFound(t *testing.T) {
|
||||
testDB := setupTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewProductHandler()
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Put("/products/{id}", handler.UpdateProduct)
|
||||
|
||||
updateReq := validation.ProductRequest{
|
||||
Price: 4.50,
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(updateReq)
|
||||
req := httptest.NewRequest(http.MethodPut, "/products/999", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("Expected status 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteProduct(t *testing.T) {
|
||||
testDB := setupTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewProductHandler()
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Delete("/products/{id}", handler.DeleteProduct)
|
||||
|
||||
// Delete product 1
|
||||
req := httptest.NewRequest(http.MethodDelete, "/products/1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Verify product is soft deleted
|
||||
db := database.GetDB()
|
||||
var product models.Coffee
|
||||
err := db.First(&product, 1).Error
|
||||
if err == nil {
|
||||
t.Error("Expected product to be soft deleted (not found in normal query)")
|
||||
}
|
||||
|
||||
// Verify it exists in unscoped query
|
||||
err = db.Unscoped().First(&product, 1).Error
|
||||
if err != nil {
|
||||
t.Error("Expected product to still exist in database (soft delete)")
|
||||
}
|
||||
|
||||
if product.DeletedAt.Time.IsZero() {
|
||||
t.Error("Expected DeletedAt to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteProductNotFound(t *testing.T) {
|
||||
testDB := setupTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewProductHandler()
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Delete("/products/{id}", handler.DeleteProduct)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/products/999", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("Expected status 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCRUDFlow(t *testing.T) {
|
||||
testDB := "test_crud_flow.db"
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
database.ResetForTesting()
|
||||
db, err := database.Connect(testDB)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to connect: %v", err)
|
||||
}
|
||||
|
||||
if err := database.Migrate(db); err != nil {
|
||||
t.Fatalf("Failed to migrate: %v", err)
|
||||
}
|
||||
|
||||
handler := NewProductHandler()
|
||||
|
||||
// 1. Create a product
|
||||
productReq := validation.ProductRequest{
|
||||
Name: "Flow Test Coffee",
|
||||
RoastType: "Dark Roast",
|
||||
Ounces: 8,
|
||||
BeanType: "Colombian",
|
||||
Price: 7.99,
|
||||
Color: "#000000",
|
||||
Description: "Testing full CRUD flow",
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(productReq)
|
||||
req := httptest.NewRequest(http.MethodPost, "/products", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
handler.CreateProduct(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("Failed to create product: %d", w.Code)
|
||||
}
|
||||
|
||||
var createResp struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Product models.Coffee `json:"product"`
|
||||
} `json:"data"`
|
||||
}
|
||||
json.NewDecoder(w.Body).Decode(&createResp)
|
||||
productID := createResp.Data.Product.ID
|
||||
|
||||
// 2. Get the product
|
||||
r := chi.NewRouter()
|
||||
r.Get("/products/{id}", handler.GetProduct)
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/products/%d", productID), nil)
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("Failed to get product: %d", w.Code)
|
||||
}
|
||||
|
||||
// 3. Update the product
|
||||
r = chi.NewRouter()
|
||||
r.Put("/products/{id}", handler.UpdateProduct)
|
||||
|
||||
updateReq := validation.ProductRequest{
|
||||
Price: 8.99,
|
||||
Name: "Updated Flow Test Coffee",
|
||||
}
|
||||
body, _ = json.Marshal(updateReq)
|
||||
req = httptest.NewRequest(http.MethodPut, fmt.Sprintf("/products/%d", productID), bytes.NewReader(body))
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("Failed to update product: %d", w.Code)
|
||||
}
|
||||
|
||||
var updateResp struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Product models.Coffee `json:"product"`
|
||||
} `json:"data"`
|
||||
}
|
||||
json.NewDecoder(w.Body).Decode(&updateResp)
|
||||
|
||||
if updateResp.Data.Product.Price != 8.99 {
|
||||
t.Errorf("Expected price 8.99, got %.2f", updateResp.Data.Product.Price)
|
||||
}
|
||||
|
||||
if updateResp.Data.Product.Name != "Updated Flow Test Coffee" {
|
||||
t.Errorf("Expected name Updated Flow Test Coffee, got %s", updateResp.Data.Product.Name)
|
||||
}
|
||||
|
||||
// 4. Delete the product
|
||||
r = chi.NewRouter()
|
||||
r.Delete("/products/{id}", handler.DeleteProduct)
|
||||
|
||||
req = httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/products/%d", productID), nil)
|
||||
w = httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("Failed to delete product: %d", w.Code)
|
||||
}
|
||||
|
||||
// 5. Verify product is gone
|
||||
var product models.Coffee
|
||||
err = db.First(&product, productID).Error
|
||||
if err == nil {
|
||||
t.Error("Expected product to be soft deleted")
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
@@ -36,26 +37,29 @@ func setupTestDB(t *testing.T) string {
|
||||
return testDB
|
||||
}
|
||||
|
||||
func TestGetProducts(t *testing.T) {
|
||||
func TestGetProduct(t *testing.T) {
|
||||
testDB := setupTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
handler := NewProductHandler()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/products", nil)
|
||||
r := chi.NewRouter()
|
||||
r.Get("/products/{id}", handler.GetProduct)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/products/1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.GetProducts(w, req)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Products []models.Coffee `json:"products"`
|
||||
Product models.Coffee `json:"product"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
@@ -67,27 +71,28 @@ func TestGetProducts(t *testing.T) {
|
||||
t.Error("Expected success to be true")
|
||||
}
|
||||
|
||||
if len(response.Data.Products) != 6 {
|
||||
t.Errorf("Expected 6 products, got %d", len(response.Data.Products))
|
||||
if response.Data.Product.Name != "Espresso" {
|
||||
t.Errorf("Expected Espresso, got %s", response.Data.Product.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify product fields
|
||||
for _, p := range response.Data.Products {
|
||||
if p.ID == 0 {
|
||||
t.Error("Product ID should not be 0")
|
||||
}
|
||||
if p.Name == "" {
|
||||
t.Error("Product name should not be empty")
|
||||
}
|
||||
if p.Price <= 0 {
|
||||
t.Errorf("Product %s has invalid price: %f", p.Name, p.Price)
|
||||
}
|
||||
}
|
||||
func TestGetProductNotFound(t *testing.T) {
|
||||
testDB := setupTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
// Verify Content-Type header
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if contentType != "application/json" {
|
||||
t.Errorf("Expected Content-Type application/json, got %s", contentType)
|
||||
handler := NewProductHandler()
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/products/{id}", handler.GetProduct)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/products/999", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("Expected status 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
9
api/middleware/testing.go
Normal file
9
api/middleware/testing.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package middleware
|
||||
|
||||
import "context"
|
||||
|
||||
// ContextWithUserID returns a context with the given user ID set.
|
||||
// This is intended for use in tests only.
|
||||
func ContextWithUserID(ctx context.Context, id uint) context.Context {
|
||||
return context.WithValue(ctx, userIDKey, id)
|
||||
}
|
||||
@@ -22,7 +22,8 @@ func SetupRoutes(version string, stripeSecretKey string, jwtManager *auth.JWTMan
|
||||
healthHandler := handlers.NewHealthHandler(version)
|
||||
productHandler := handlers.NewProductHandler()
|
||||
authHandler := handlers.NewAuthHandler(jwtManager, authFingerprintKey)
|
||||
checkoutHandler := handlers.NewCheckoutHandler(stripeSecretKey)
|
||||
cartHandler := handlers.NewCartHandler(stripeSecretKey)
|
||||
cardHandler := handlers.NewCardHandler(stripeSecretKey)
|
||||
orderHandler := handlers.NewOrderHandler()
|
||||
addressHandler := handlers.NewAddressHandler(shippoAPIKey)
|
||||
|
||||
@@ -45,13 +46,23 @@ func SetupRoutes(version string, stripeSecretKey string, jwtManager *auth.JWTMan
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.RequireAuth)
|
||||
|
||||
// Admin-only product management (TODO: add admin check)
|
||||
r.Post("/products", productHandler.CreateProduct)
|
||||
r.Put("/products/{id}", productHandler.UpdateProduct)
|
||||
r.Delete("/products/{id}", productHandler.DeleteProduct)
|
||||
// Cart
|
||||
r.Route("/cart", func(r chi.Router) {
|
||||
r.Get("/", cartHandler.GetCart)
|
||||
r.Put("/item", cartHandler.SetItem)
|
||||
r.Put("/address", cartHandler.SetAddress)
|
||||
r.Put("/card", cartHandler.SetCard)
|
||||
r.Delete("/", cartHandler.ClearCart)
|
||||
r.Post("/convert", cartHandler.ConvertCart)
|
||||
})
|
||||
|
||||
// Checkout
|
||||
r.Post("/checkout", checkoutHandler.Checkout)
|
||||
// Cards
|
||||
r.Route("/cards", func(r chi.Router) {
|
||||
r.Get("/", cardHandler.GetCards)
|
||||
r.Get("/{id}", cardHandler.GetCard)
|
||||
r.Post("/", cardHandler.SaveCard)
|
||||
r.Delete("/{id}", cardHandler.DeleteCard)
|
||||
})
|
||||
|
||||
// Orders
|
||||
r.Get("/orders", orderHandler.GetOrders)
|
||||
|
||||
@@ -167,23 +167,54 @@ func (c *Client) RegisterUser(username, sshPublicKey, sshKeyFingerprint string)
|
||||
return ®isterResp.Data.User, nil
|
||||
}
|
||||
|
||||
// CheckoutRequest represents the checkout payload sent to the API
|
||||
type CheckoutRequest struct {
|
||||
StripeToken string `json:"stripe_token"`
|
||||
Last4 string `json:"last4"`
|
||||
Brand string `json:"brand"`
|
||||
ExpMonth int `json:"exp_month"`
|
||||
ExpYear int `json:"exp_year"`
|
||||
Items []CheckoutCartItem `json:"items"`
|
||||
AddressID uint `json:"address_id"`
|
||||
// CartResponse represents the cart API response.
|
||||
type CartResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Cart CartData `json:"cart"`
|
||||
} `json:"data"`
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type CheckoutCartItem struct {
|
||||
CoffeeID uint `json:"coffee_id"`
|
||||
Quantity int `json:"quantity"`
|
||||
// CartData represents the cart payload from the API.
|
||||
type CartData struct {
|
||||
Items []CartItemData `json:"items"`
|
||||
Subtotal int `json:"subtotal"`
|
||||
AddressID *uint `json:"address_id"`
|
||||
CardID *uint `json:"card_id"`
|
||||
ShippingCost int `json:"shipping_cost"`
|
||||
ShippingService string `json:"shipping_service"`
|
||||
}
|
||||
|
||||
type CheckoutResponse struct {
|
||||
// CartItemData represents a single item in the cart response.
|
||||
type CartItemData struct {
|
||||
ID uint `json:"id"`
|
||||
CoffeeID uint `json:"coffee_id"`
|
||||
Quantity int `json:"quantity"`
|
||||
Subtotal int `json:"subtotal"`
|
||||
Coffee models.Coffee `json:"coffee"`
|
||||
}
|
||||
|
||||
// CardsResponse represents the cards list API response.
|
||||
type CardsResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Cards []models.Card `json:"cards"`
|
||||
} `json:"data"`
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// CardResponse represents a single card API response.
|
||||
type CardResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Card models.Card `json:"card"`
|
||||
} `json:"data"`
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ConvertCartResponse represents the cart conversion (checkout) API response.
|
||||
type ConvertCartResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Order models.Order `json:"order"`
|
||||
@@ -191,20 +222,252 @@ type CheckoutResponse struct {
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type OrderResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Orders []models.Order `json:"orders"`
|
||||
} `json:"data"`
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
// GetCart fetches the user's current cart.
|
||||
func (c *Client) GetCart() (*CartData, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/cart", c.BaseURL)
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.doRequest(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch cart: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var cartResp CartResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&cartResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode cart response: %w", err)
|
||||
}
|
||||
|
||||
if !cartResp.Success {
|
||||
if cartResp.Error != nil {
|
||||
return nil, fmt.Errorf("%s: %s", cartResp.Error.Code, cartResp.Error.Message)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to fetch cart")
|
||||
}
|
||||
|
||||
return &cartResp.Data.Cart, nil
|
||||
}
|
||||
|
||||
func (c *Client) Checkout(checkoutReq CheckoutRequest) (*models.Order, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/checkout", c.BaseURL)
|
||||
// SetCartItem upserts an item in the cart. Quantity <= 0 removes it.
|
||||
func (c *Client) SetCartItem(coffeeID uint, quantity int) (*CartData, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/cart/item", c.BaseURL)
|
||||
|
||||
jsonData, err := json.Marshal(checkoutReq)
|
||||
body := map[string]interface{}{
|
||||
"coffee_id": coffeeID,
|
||||
"quantity": quantity,
|
||||
}
|
||||
jsonData, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal checkout request: %w", err)
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("PUT", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.doRequest(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to set cart item: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var cartResp CartResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&cartResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode cart response: %w", err)
|
||||
}
|
||||
|
||||
if !cartResp.Success {
|
||||
if cartResp.Error != nil {
|
||||
return nil, fmt.Errorf("%s: %s", cartResp.Error.Code, cartResp.Error.Message)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to set cart item")
|
||||
}
|
||||
|
||||
return &cartResp.Data.Cart, nil
|
||||
}
|
||||
|
||||
// SetCartAddress assigns a saved address to the cart.
|
||||
func (c *Client) SetCartAddress(addressID uint) error {
|
||||
url := fmt.Sprintf("%s/api/v1/cart/address", c.BaseURL)
|
||||
|
||||
body := map[string]interface{}{
|
||||
"address_id": addressID,
|
||||
}
|
||||
jsonData, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("PUT", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.doRequest(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set cart address: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result APIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("%s: %s", result.Error.Code, result.Error.Message)
|
||||
}
|
||||
return fmt.Errorf("failed to set cart address")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetCartCard assigns a saved card to the cart.
|
||||
func (c *Client) SetCartCard(cardID uint) error {
|
||||
url := fmt.Sprintf("%s/api/v1/cart/card", c.BaseURL)
|
||||
|
||||
body := map[string]interface{}{
|
||||
"card_id": cardID,
|
||||
}
|
||||
jsonData, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("PUT", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.doRequest(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set cart card: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result APIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("%s: %s", result.Error.Code, result.Error.Message)
|
||||
}
|
||||
return fmt.Errorf("failed to set cart card")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearCart removes all items from the user's cart.
|
||||
func (c *Client) ClearCart() error {
|
||||
url := fmt.Sprintf("%s/api/v1/cart", c.BaseURL)
|
||||
|
||||
req, err := http.NewRequest("DELETE", url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.doRequest(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to clear cart: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result APIResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("%s: %s", result.Error.Code, result.Error.Message)
|
||||
}
|
||||
return fmt.Errorf("failed to clear cart")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConvertCart converts the cart into an order, charging the saved card.
|
||||
func (c *Client) ConvertCart() (*models.Order, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/cart/convert", c.BaseURL)
|
||||
|
||||
req, err := http.NewRequest("POST", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.doRequest(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert cart: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var convertResp ConvertCartResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&convertResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode convert response: %w", err)
|
||||
}
|
||||
|
||||
if !convertResp.Success {
|
||||
if convertResp.Error != nil {
|
||||
return nil, fmt.Errorf("%s: %s", convertResp.Error.Code, convertResp.Error.Message)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to convert cart")
|
||||
}
|
||||
|
||||
return &convertResp.Data.Order, 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)
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.doRequest(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch cards: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var cardsResp CardsResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&cardsResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode cards response: %w", err)
|
||||
}
|
||||
|
||||
if !cardsResp.Success {
|
||||
if cardsResp.Error != nil {
|
||||
return nil, fmt.Errorf("%s: %s", cardsResp.Error.Code, cardsResp.Error.Message)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to fetch cards")
|
||||
}
|
||||
|
||||
return cardsResp.Data.Cards, nil
|
||||
}
|
||||
|
||||
// SaveCard creates a saved card from a Stripe token.
|
||||
func (c *Client) SaveCard(token string) (*models.Card, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/cards", c.BaseURL)
|
||||
|
||||
body := map[string]string{"token": token}
|
||||
jsonData, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||
@@ -215,23 +478,53 @@ func (c *Client) Checkout(checkoutReq CheckoutRequest) (*models.Order, error) {
|
||||
|
||||
resp, err := c.doRequest(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("checkout request failed: %w", err)
|
||||
return nil, fmt.Errorf("failed to save card: %w", err)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
|
||||
var checkoutResp CheckoutResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&checkoutResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode checkout response: %w", err)
|
||||
var cardResp CardResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&cardResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode card response: %w", err)
|
||||
}
|
||||
|
||||
if !checkoutResp.Success {
|
||||
if checkoutResp.Error != nil {
|
||||
return nil, fmt.Errorf("%s: %s", checkoutResp.Error.Code, checkoutResp.Error.Message)
|
||||
if !cardResp.Success {
|
||||
if cardResp.Error != nil {
|
||||
return nil, fmt.Errorf("%s: %s", cardResp.Error.Code, cardResp.Error.Message)
|
||||
}
|
||||
return nil, fmt.Errorf("checkout failed")
|
||||
return nil, fmt.Errorf("failed to save card")
|
||||
}
|
||||
return &checkoutResp.Data.Order, nil
|
||||
|
||||
return &cardResp.Data.Card, nil
|
||||
}
|
||||
|
||||
// DeleteCard removes a saved card by ID.
|
||||
func (c *Client) DeleteCard(id uint) error {
|
||||
url := fmt.Sprintf("%s/api/v1/cards/%d", c.BaseURL, id)
|
||||
|
||||
req, err := http.NewRequest("DELETE", url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create delete request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.doRequest(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete card: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("delete card failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type OrderResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Orders []models.Order `json:"orders"`
|
||||
} `json:"data"`
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) GetOrders() ([]models.Order, error) {
|
||||
|
||||
@@ -15,12 +15,14 @@ func Migrate(db *gorm.DB) error {
|
||||
// AutoMigrate will create tables, missing columns, and indexes
|
||||
// It will NOT delete unused columns or change column types
|
||||
if err := db.AutoMigrate(
|
||||
&models.Cart{}, // Server-side shipping cart
|
||||
&models.CartItem{}, // Cart line items
|
||||
&models.Coffee{},
|
||||
&models.User{}, // SSH key-based authentication
|
||||
&models.Card{}, // Saved payment methods
|
||||
&models.Order{}, // Completed purchases
|
||||
&models.OrderItem{}, // Line items within orders
|
||||
&models.Address{}, // Saved addresses
|
||||
&models.Address{}, // Saved addresses
|
||||
); err != nil {
|
||||
return fmt.Errorf("migration failed: %w", err)
|
||||
}
|
||||
|
||||
42
pkg/models/cart.go
Normal file
42
pkg/models/cart.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Cart struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"uniqueIndex;not null" json:"user_id"`
|
||||
AddressID *uint `json:"address_id,omitempty"`
|
||||
CardID *uint `json:"card_id,omitempty"`
|
||||
ShippingCost int `gorm:"default:0" json:"shipping_cost"`
|
||||
ShippingService string `gorm:"size:255" json:"shipping_service,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
User User `gorm:"foreignKey:UserID" json:"-"`
|
||||
Address *Address `gorm:"foreignKey:AddressID" json:"address,omitempty"`
|
||||
Card *Card `gorm:"foreignKey:CardID" json:"card,omitempty"`
|
||||
Items []CartItem `gorm:"foreignKey:CartID" json:"items"`
|
||||
}
|
||||
|
||||
// TableName specifies the table name for the Cart model.
|
||||
func (Cart) TableName() string {
|
||||
return "carts"
|
||||
}
|
||||
|
||||
type CartItem struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CartID uint `gorm:"not null;index;uniqueIndex:idx_cart_coffee" json:"cart_id"`
|
||||
CoffeeID uint `gorm:"not null;uniqueIndex:idx_cart_coffee" json:"coffee_id"`
|
||||
Quantity int `gorm:"not null" json:"quantity"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
Cart Cart `gorm:"foreignKey:CartID" json:"-"`
|
||||
Coffee Coffee `gorm:"foreignKey:CoffeeID" json:"coffee"`
|
||||
}
|
||||
|
||||
func (CartItem) TableName() string {
|
||||
return "cart_items"
|
||||
}
|
||||
@@ -27,13 +27,7 @@ func (Coffee) TableName() string {
|
||||
}
|
||||
|
||||
|
||||
// CartItem represents an item in the cart
|
||||
type CartItem struct {
|
||||
Coffee Coffee
|
||||
Quantity int
|
||||
}
|
||||
|
||||
// Account menu items
|
||||
// AccountMenuItems something
|
||||
var AccountMenuItems = []string{
|
||||
"order history",
|
||||
"faq",
|
||||
|
||||
@@ -18,7 +18,7 @@ func TestModel_BuildMenuView(t *testing.T) {
|
||||
Username string
|
||||
Coffees []models.Coffee
|
||||
Cursor int
|
||||
Cart map[int]*models.CartItem
|
||||
Cart map[uint]*models.CartItem
|
||||
CartCursor int
|
||||
AccountCursor int
|
||||
CheckoutStep int
|
||||
|
||||
149
pkg/tui/model.go
149
pkg/tui/model.go
@@ -49,12 +49,13 @@ type Model struct {
|
||||
Username string
|
||||
Coffees []models.Coffee
|
||||
Cursor int
|
||||
Cart map[int]*models.CartItem // maps coffee index to cart item
|
||||
CartCursor int // cursor position in cart view
|
||||
Cart map[uint]*models.CartItem // maps CoffeeID to cart item
|
||||
CartCursor int // cursor position in cart view
|
||||
AccountCursor int
|
||||
CheckoutStep int // 0=cart, 1=shipping, 2=payment, 3=confirmation
|
||||
ScrollOffset int // scroll position for content
|
||||
ViewingCart bool // true when viewing cart details
|
||||
CheckingOut bool // true while saveCardAndConvert is running
|
||||
ViewingAccount bool
|
||||
|
||||
// Menu modal state
|
||||
@@ -335,15 +336,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
// When the payment form is active, route ALL messages to it.
|
||||
if m.ViewingCart && m.CheckoutStep == 2 && m.PaymentForm != nil {
|
||||
if m.ViewingCart && m.CheckoutStep == 2 {
|
||||
switch msg := msg.(type) {
|
||||
case PaymentFormCompleteMsg:
|
||||
// kick off Stripe tokenization (runs async)
|
||||
return m, m.tokenizeCard(msg)
|
||||
if m.PaymentForm != nil {
|
||||
return m, m.tokenizeCard(msg)
|
||||
}
|
||||
return m, nil
|
||||
case StripeTokenMsg:
|
||||
// Token obtained — save card, set on cart, set address, convert
|
||||
m.PaymentForm = nil
|
||||
return m, m.submitCheckout(msg)
|
||||
m.CheckingOut = true
|
||||
return m, m.saveCardAndConvert(msg)
|
||||
case CheckoutResultMsg:
|
||||
m.CheckingOut = false
|
||||
if msg.Err != nil {
|
||||
m.ErrorMsg = fmt.Sprintf("checkout failed: %v", msg.Err)
|
||||
m.CheckoutStep = 2
|
||||
@@ -354,10 +361,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
case StripeTokenErrMsg:
|
||||
// Tokenization failed, show error and let user retry
|
||||
m.PaymentForm.submitting = false
|
||||
m.ErrorMsg = fmt.Sprintf("Payment Failed: %v", msg.Err)
|
||||
m.PaymentForm.form = m.buildPaymentForm(m.PaymentForm)
|
||||
return m, m.PaymentForm.form.Init()
|
||||
if m.PaymentForm != nil {
|
||||
m.PaymentForm.submitting = false
|
||||
m.ErrorMsg = fmt.Sprintf("Payment failed: %v", msg.Err)
|
||||
m.PaymentForm.form = m.buildPaymentForm(m.PaymentForm)
|
||||
return m, m.PaymentForm.form.Init()
|
||||
}
|
||||
m.ErrorMsg = fmt.Sprintf("Payment failed: %v", msg.Err)
|
||||
return m, nil
|
||||
case PaymentFormErrorMsg:
|
||||
m.ErrorMsg = msg.Message
|
||||
return m, nil
|
||||
@@ -370,6 +381,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return resizeTickMsg{seq: seq}
|
||||
})
|
||||
case tea.KeyMsg:
|
||||
if m.CheckingOut {
|
||||
// Ignore all keys while submitting order
|
||||
return m, nil
|
||||
}
|
||||
if msg.String() == "esc" {
|
||||
// Go back to shipping
|
||||
m.CheckoutStep = 1
|
||||
@@ -394,8 +409,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.ErrorMsg = ""
|
||||
}
|
||||
|
||||
cmd := m.UpdatePaymentForm(msg, m.PaymentForm)
|
||||
return m, cmd
|
||||
if m.PaymentForm != nil {
|
||||
cmd := m.UpdatePaymentForm(msg, m.PaymentForm)
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
switch msg := msg.(type) {
|
||||
@@ -556,7 +574,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.ShippingForm = nil
|
||||
m.PaymentForm = nil
|
||||
m.ShippingInfo = nil
|
||||
m.Cart = make(map[int]*models.CartItem)
|
||||
m.CheckingOut = false
|
||||
m.Cart = make(map[uint]*models.CartItem)
|
||||
m.CartCursor = 0
|
||||
m.ScrollOffset = 0
|
||||
return m, nil
|
||||
@@ -628,11 +647,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case "+", "=":
|
||||
if !m.ViewingCart && !m.ViewingAccount {
|
||||
// Increment quantity in shop view
|
||||
if item, exists := m.Cart[m.Cursor]; exists {
|
||||
// Increment quantity in shop view (keyed by CoffeeID)
|
||||
coffeeID := m.Coffees[m.Cursor].ID
|
||||
if item, exists := m.Cart[coffeeID]; exists {
|
||||
item.Quantity++
|
||||
} else {
|
||||
m.Cart[m.Cursor] = &models.CartItem{
|
||||
m.Cart[coffeeID] = &models.CartItem{
|
||||
CoffeeID: coffeeID,
|
||||
Coffee: m.Coffees[m.Cursor],
|
||||
Quantity: 1,
|
||||
}
|
||||
@@ -647,11 +668,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case "-", "_":
|
||||
if !m.ViewingCart && !m.ViewingAccount {
|
||||
// Decrement quantity in shop view
|
||||
if item, exists := m.Cart[m.Cursor]; exists {
|
||||
// Decrement quantity in shop view (keyed by CoffeeID)
|
||||
coffeeID := m.Coffees[m.Cursor].ID
|
||||
if item, exists := m.Cart[coffeeID]; exists {
|
||||
item.Quantity--
|
||||
if item.Quantity <= 0 {
|
||||
delete(m.Cart, m.Cursor)
|
||||
delete(m.Cart, coffeeID)
|
||||
// Reset cart cursor if we deleted the last item
|
||||
if len(m.Cart) == 0 {
|
||||
m.CartCursor = 0
|
||||
@@ -666,13 +688,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.CartCursor >= 0 && m.CartCursor < len(cartItems) {
|
||||
cartItems[m.CartCursor].Quantity--
|
||||
if cartItems[m.CartCursor].Quantity <= 0 {
|
||||
// Find and delete this item from the cart
|
||||
for idx, item := range m.Cart {
|
||||
if item == cartItems[m.CartCursor] {
|
||||
delete(m.Cart, idx)
|
||||
break
|
||||
}
|
||||
}
|
||||
// Delete this item from the cart by CoffeeID
|
||||
delete(m.Cart, cartItems[m.CartCursor].CoffeeID)
|
||||
// Reset cart cursor
|
||||
if len(m.Cart) == 0 {
|
||||
m.CartCursor = 0
|
||||
@@ -778,11 +795,15 @@ func (m Model) deleteAddressCmd(id uint) tea.Cmd {
|
||||
|
||||
func (m Model) tokenizeCard(card PaymentFormCompleteMsg) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
// Use Stripe's HTTP API directly with the publishable key.
|
||||
// The Go SDK's token.New() is blocked for raw card numbers
|
||||
// unless your account has direct PCI compliance enabled.
|
||||
// This HTTP POST mimics what a browser does with Stripe.js.
|
||||
pubKey := m.StripeKey
|
||||
// Use the Stripe secret key for server-side tokenization.
|
||||
// This is a terminal app running over SSH — there is no browser,
|
||||
// so the publishable key / Stripe.js approach does not work.
|
||||
// The secret key is allowed to create tokens with raw card data.
|
||||
secretKey := os.Getenv("STRIPE_SECRET_KEY")
|
||||
if secretKey == "" {
|
||||
// Fall back to the publishable key (will fail on most accounts)
|
||||
secretKey = m.StripeKey
|
||||
}
|
||||
|
||||
data := fmt.Sprintf(
|
||||
"card[number]=%s&card[exp_month]=%s&card[exp_year]=%s&card[cvc]=%s",
|
||||
@@ -794,7 +815,7 @@ func (m Model) tokenizeCard(card PaymentFormCompleteMsg) tea.Cmd {
|
||||
return StripeTokenErrMsg{Err: err}
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", "Bearer "+pubKey)
|
||||
req.Header.Set("Authorization", "Bearer "+secretKey)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -828,29 +849,67 @@ func (m Model) tokenizeCard(card PaymentFormCompleteMsg) tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) submitCheckout(tok StripeTokenMsg) tea.Cmd {
|
||||
// saveCardAndConvert saves the tokenized card, syncs cart items to the server,
|
||||
// sets address and card on the server cart, then converts the cart to an order.
|
||||
func (m Model) saveCardAndConvert(tok StripeTokenMsg) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if m.APIClient == nil {
|
||||
return CheckoutResultMsg{Err: fmt.Errorf("API client not available")}
|
||||
}
|
||||
|
||||
// 1. Save the card
|
||||
card, err := m.APIClient.SaveCard(tok.TokenID)
|
||||
if err != nil {
|
||||
// Card may already exist — try fetching existing cards
|
||||
cards, listErr := m.APIClient.GetCards()
|
||||
if listErr != nil || len(cards) == 0 {
|
||||
return CheckoutResultMsg{Err: fmt.Errorf("failed to save card: %w", err)}
|
||||
}
|
||||
// Use the most recent card
|
||||
card = &cards[0]
|
||||
}
|
||||
|
||||
// 2. Sync cart items to the server
|
||||
items := m.GetCartItemsSlice()
|
||||
cartItems := make([]api.CheckoutCartItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
cartItems = append(cartItems, api.CheckoutCartItem{
|
||||
CoffeeID: item.Coffee.ID,
|
||||
Quantity: item.Quantity,
|
||||
_, err := m.APIClient.SetCartItem(item.CoffeeID, item.Quantity)
|
||||
if err != nil {
|
||||
return CheckoutResultMsg{Err: fmt.Errorf("failed to sync cart: %w", err)}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Set the shipping address on the cart
|
||||
if m.ShippingInfo != nil && m.ShippingInfo.ID != 0 {
|
||||
if err := m.APIClient.SetCartAddress(m.ShippingInfo.ID); err != nil {
|
||||
return CheckoutResultMsg{Err: fmt.Errorf("failed to set address: %w", err)}
|
||||
}
|
||||
} else if m.ShippingInfo != nil {
|
||||
// Address was entered fresh (not from saved list), save it first
|
||||
saved, err := m.APIClient.CreateAddress(api.CreateAddressRequest{
|
||||
Name: m.ShippingInfo.Name,
|
||||
Street: m.ShippingInfo.Street,
|
||||
Street2: m.ShippingInfo.Street2,
|
||||
City: m.ShippingInfo.City,
|
||||
State: m.ShippingInfo.State,
|
||||
Zip: m.ShippingInfo.Zip,
|
||||
Country: m.ShippingInfo.Country,
|
||||
Phone: m.ShippingInfo.Phone,
|
||||
})
|
||||
if err != nil {
|
||||
return CheckoutResultMsg{Err: fmt.Errorf("failed to save address: %w", err)}
|
||||
}
|
||||
if err := m.APIClient.SetCartAddress(saved.ID); err != nil {
|
||||
return CheckoutResultMsg{Err: fmt.Errorf("failed to set address: %w", err)}
|
||||
}
|
||||
}
|
||||
|
||||
req := api.CheckoutRequest{
|
||||
StripeToken: tok.TokenID,
|
||||
Last4: tok.Last4,
|
||||
Items: cartItems,
|
||||
AddressID: m.ShippingInfo.ID,
|
||||
// 4. Set the card on the cart
|
||||
if err := m.APIClient.SetCartCard(card.ID); err != nil {
|
||||
return CheckoutResultMsg{Err: fmt.Errorf("failed to set card: %w", err)}
|
||||
}
|
||||
|
||||
order, err := m.APIClient.Checkout(req)
|
||||
// 5. Convert the cart to an order
|
||||
order, err := m.APIClient.ConvertCart()
|
||||
if err != nil {
|
||||
return CheckoutResultMsg{Err: err}
|
||||
}
|
||||
@@ -862,10 +921,10 @@ func (m Model) submitCheckout(tok StripeTokenMsg) tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
// GetCartItemsSlice converts the cart map to a sorted slice for consistent iteration
|
||||
// GetCartItemsSlice converts the cart map to a sorted slice for consistent iteration.
|
||||
func (m Model) GetCartItemsSlice() []*models.CartItem {
|
||||
// Get keys and sort them for stable ordering
|
||||
keys := make([]int, 0, len(m.Cart))
|
||||
keys := make([]uint, 0, len(m.Cart))
|
||||
for key := range m.Cart {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
@@ -948,7 +1007,7 @@ func NewModel(username string) Model {
|
||||
},
|
||||
},
|
||||
Cursor: 0,
|
||||
Cart: make(map[int]*models.CartItem),
|
||||
Cart: make(map[uint]*models.CartItem),
|
||||
CartCursor: 0,
|
||||
AccountCursor: 0,
|
||||
ViewingCart: false,
|
||||
|
||||
@@ -18,7 +18,7 @@ func TestModel_updateLayout(t *testing.T) {
|
||||
Username string
|
||||
Coffees []models.Coffee
|
||||
Cursor int
|
||||
Cart map[int]*models.CartItem
|
||||
Cart map[uint]*models.CartItem
|
||||
CartCursor int
|
||||
AccountCursor int
|
||||
CheckoutStep int
|
||||
|
||||
@@ -77,9 +77,9 @@ func (m Model) BuildShopView() string {
|
||||
Foreground(lipgloss.Color("#FFFFFF")).
|
||||
Bold(true)
|
||||
|
||||
// Get current quantity
|
||||
// Get current quantity (look up by CoffeeID)
|
||||
quantity := 0
|
||||
if item, exists := m.Cart[m.Cursor]; exists {
|
||||
if item, exists := m.Cart[selectedCoffee.ID]; exists {
|
||||
quantity = item.Quantity
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,8 @@ func (m Model) View() string {
|
||||
} else if m.ShippingForm != nil {
|
||||
content = errorBanner + "\n" + m.RenderShippingForm(m.ShippingForm)
|
||||
}
|
||||
} else if m.ViewingCart && m.CheckoutStep == 2 && m.CheckingOut {
|
||||
content = errorBanner + "\n submitting order..."
|
||||
} else if m.ViewingCart && m.CheckoutStep == 2 && m.PaymentForm != nil {
|
||||
content = errorBanner + "\n" + m.RenderPaymentForm(m.PaymentForm)
|
||||
} else if m.ViewingCart && m.CheckoutStep == 3 {
|
||||
@@ -83,6 +85,8 @@ func (m Model) View() string {
|
||||
} else if m.ShippingForm != nil {
|
||||
content = m.RenderShippingForm(m.ShippingForm)
|
||||
}
|
||||
} else if m.ViewingCart && m.CheckoutStep == 2 && m.CheckingOut {
|
||||
content = " submitting order..."
|
||||
} else if m.ViewingCart && m.CheckoutStep == 2 && m.PaymentForm != nil {
|
||||
content = m.RenderPaymentForm(m.PaymentForm)
|
||||
} else if m.ViewingCart && m.CheckoutStep == 3 {
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
package validation
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ProductRequest represents the request body for creating/updating products
|
||||
type ProductRequest struct {
|
||||
Name string `json:"name"`
|
||||
RoastType string `json:"roast_type"`
|
||||
Ounces int `json:"ounces"`
|
||||
BeanType string `json:"bean_type"`
|
||||
Price float64 `json:"price"`
|
||||
Color string `json:"color"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// ProductErrors holds validation errors for a product request
|
||||
type ProductErrors map[string]string
|
||||
|
||||
// ValidateProductRequest validates a product creation/update request
|
||||
func ValidateProductRequest(req ProductRequest, isUpdate bool) ProductErrors {
|
||||
errors := make(ProductErrors)
|
||||
|
||||
// Name validation (required for create, optional for update)
|
||||
if !isUpdate || req.Name != "" {
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Name == "" && !isUpdate {
|
||||
errors["name"] = "Name is required"
|
||||
} else if len(req.Name) > 255 {
|
||||
errors["name"] = "Name must be less than 255 characters"
|
||||
}
|
||||
}
|
||||
|
||||
// Roast type validation
|
||||
if req.RoastType != "" {
|
||||
validRoastTypes := map[string]bool{
|
||||
"Light Roast": true,
|
||||
"Medium Roast": true,
|
||||
"Dark Roast": true,
|
||||
}
|
||||
if !validRoastTypes[req.RoastType] {
|
||||
errors["roast_type"] = "Roast type must be Light Roast, Medium Roast, or Dark Roast"
|
||||
}
|
||||
}
|
||||
|
||||
// Ounces validation
|
||||
if req.Ounces != 0 {
|
||||
if req.Ounces < 0 {
|
||||
errors["ounces"] = "Ounces must be positive"
|
||||
} else if req.Ounces > 64 {
|
||||
errors["ounces"] = "Ounces must be less than or equal to 64"
|
||||
}
|
||||
}
|
||||
|
||||
// Bean type validation
|
||||
if req.BeanType != "" && len(req.BeanType) > 100 {
|
||||
errors["bean_type"] = "Bean type must be less than 100 characters"
|
||||
}
|
||||
|
||||
// Price validation (required for create, optional for update)
|
||||
if !isUpdate || req.Price != 0 {
|
||||
if req.Price <= 0 && !isUpdate {
|
||||
errors["price"] = "Price must be greater than 0"
|
||||
} else if req.Price > 1000 {
|
||||
errors["price"] = "Price must be less than $1000"
|
||||
}
|
||||
}
|
||||
|
||||
// Color validation (hex color)
|
||||
if req.Color != "" {
|
||||
if !strings.HasPrefix(req.Color, "#") || len(req.Color) != 7 {
|
||||
errors["color"] = "Color must be a valid hex color (e.g., #8B4513)"
|
||||
}
|
||||
}
|
||||
|
||||
// Description validation
|
||||
if req.Description != "" && len(req.Description) > 1000 {
|
||||
errors["description"] = "Description must be less than 1000 characters"
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
// HasErrors returns true if there are any validation errors
|
||||
func (e ProductErrors) HasErrors() bool {
|
||||
return len(e) > 0
|
||||
}
|
||||
|
||||
// Error implements the error interface
|
||||
func (e ProductErrors) Error() string {
|
||||
var messages []string
|
||||
for field, msg := range e {
|
||||
messages = append(messages, fmt.Sprintf("%s: %s", field, msg))
|
||||
}
|
||||
return strings.Join(messages, "; ")
|
||||
}
|
||||
Submodule terminal-shop-source updated: 697730e73c...caa24e7ffa
BIN
terminalShop
Executable file
BIN
terminalShop
Executable file
Binary file not shown.
Reference in New Issue
Block a user