add profile actors
This commit is contained in:
@@ -18,6 +18,7 @@ import (
|
|||||||
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
|
||||||
promotionmcp "git.k6n.net/mats/go-cart-actor/pkg/promotions/mcp"
|
promotionmcp "git.k6n.net/mats/go-cart-actor/pkg/promotions/mcp"
|
||||||
"git.k6n.net/mats/go-cart-actor/internal/ucp"
|
"git.k6n.net/mats/go-cart-actor/internal/ucp"
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
|
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/voucher"
|
"git.k6n.net/mats/go-cart-actor/pkg/voucher"
|
||||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
||||||
@@ -296,6 +297,38 @@ func main() {
|
|||||||
|
|
||||||
syncedServer.Serve(mux)
|
syncedServer.Serve(mux)
|
||||||
|
|
||||||
|
// ── Profile / Customer UCP endpoint ───────────────────────────────────
|
||||||
|
profileReg := profile.NewProfileMutationRegistry()
|
||||||
|
profileDir := getEnv("PROFILE_DIR", "data/profiles")
|
||||||
|
_ = os.MkdirAll(profileDir, 0755)
|
||||||
|
profileStorage := actor.NewDiskStorage[profile.ProfileGrain](profileDir, profileReg)
|
||||||
|
|
||||||
|
profilePoolConfig := actor.GrainPoolConfig[profile.ProfileGrain]{
|
||||||
|
MutationRegistry: profileReg,
|
||||||
|
Storage: profileStorage,
|
||||||
|
Spawn: func(ctx context.Context, id uint64) (actor.Grain[profile.ProfileGrain], error) {
|
||||||
|
ret := profile.NewProfileGrain(id, time.Now())
|
||||||
|
err := profileStorage.LoadEvents(ctx, id, ret)
|
||||||
|
return ret, err
|
||||||
|
},
|
||||||
|
TTL: 10 * time.Minute,
|
||||||
|
PoolSize: 65535,
|
||||||
|
Hostname: podIp,
|
||||||
|
}
|
||||||
|
|
||||||
|
profilePool, poolErr := actor.NewSimpleGrainPool(profilePoolConfig)
|
||||||
|
if poolErr != nil {
|
||||||
|
log.Fatalf("Error creating profile pool: %v\n", poolErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UCP Customer REST adapter
|
||||||
|
customerUCP := ucp.CustomerHandler(profilePool)
|
||||||
|
if signer := loadUCPCartSigner(); signer != nil {
|
||||||
|
customerUCP = ucp.WithSigning(customerUCP, signer)
|
||||||
|
log.Print("ucp customer signing enabled")
|
||||||
|
}
|
||||||
|
mux.Handle("/ucp/v1/customers/", http.StripPrefix("/ucp/v1/customers", customerUCP))
|
||||||
|
|
||||||
// Promotion MCP edge: list/get/upsert/status/delete promotions and preview
|
// Promotion MCP edge: list/get/upsert/status/delete promotions and preview
|
||||||
// discounts. Mutations persist to data/promotions.json and are picked up by
|
// discounts. Mutations persist to data/promotions.json and are picked up by
|
||||||
// the cart's totals processor on the next mutation (it reads a fresh snapshot).
|
// the cart's totals processor on the next mutation (it reads a fresh snapshot).
|
||||||
|
|||||||
+20
-3
@@ -3,10 +3,12 @@ package ucp
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||||
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
@@ -14,8 +16,9 @@ import (
|
|||||||
|
|
||||||
// CartServer is the UCP REST adapter over the cart grain pool.
|
// CartServer is the UCP REST adapter over the cart grain pool.
|
||||||
type CartServer struct {
|
type CartServer struct {
|
||||||
applier CartApplier
|
applier CartApplier
|
||||||
newCartId func() (cart.CartId, error)
|
profileApplier ProfileApplier // optional; when set, links carts to customer profiles
|
||||||
|
newCartId func() (cart.CartId, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCartServer builds a UCP REST adapter over a cart grain pool.
|
// NewCartServer builds a UCP REST adapter over a cart grain pool.
|
||||||
@@ -26,6 +29,11 @@ func NewCartServer(applier CartApplier) *CartServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetProfileApplier enables identity linking on this server.
|
||||||
|
func (s *CartServer) SetProfileApplier(pa ProfileApplier) {
|
||||||
|
s.profileApplier = pa
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// UCP Cart endpoints
|
// UCP Cart endpoints
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -113,12 +121,21 @@ func (s *CartServer) handleUpdateCart(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set user ID.
|
// Set user ID and auto-link cart to customer profile.
|
||||||
if req.UserId != nil && *req.UserId != "" {
|
if req.UserId != nil && *req.UserId != "" {
|
||||||
if _, err := s.applier.Apply(r.Context(), id, &messages.SetUserId{UserId: *req.UserId}); err != nil {
|
if _, err := s.applier.Apply(r.Context(), id, &messages.SetUserId{UserId: *req.UserId}); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "failed to set user: "+err.Error())
|
writeError(w, http.StatusInternalServerError, "failed to set user: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-link this cart to the customer's profile if identity linking is enabled.
|
||||||
|
if s.profileApplier != nil {
|
||||||
|
if pid, ok := profile.ParseProfileId(*req.UserId); ok {
|
||||||
|
if err := linkCartToProfile(s.profileApplier, r.Context(), uint64(pid), id); err != nil {
|
||||||
|
log.Printf("identity: failed to link cart %d to profile %d: %v", id, uint64(pid), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
g, err := s.applier.Get(r.Context(), id)
|
g, err := s.applier.Get(r.Context(), id)
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
|
profilePkg "git.k6n.net/mats/go-cart-actor/pkg/profile"
|
||||||
checkoutMessages "git.k6n.net/mats/go-cart-actor/proto/checkout"
|
checkoutMessages "git.k6n.net/mats/go-cart-actor/proto/checkout"
|
||||||
"google.golang.org/protobuf/types/known/anypb"
|
"google.golang.org/protobuf/types/known/anypb"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
@@ -19,8 +21,9 @@ import (
|
|||||||
|
|
||||||
// CheckoutServer is the UCP REST adapter over the checkout grain pool.
|
// CheckoutServer is the UCP REST adapter over the checkout grain pool.
|
||||||
type CheckoutServer struct {
|
type CheckoutServer struct {
|
||||||
applier CheckoutApplier
|
applier CheckoutApplier
|
||||||
orderSvc OrderApplier // optional; when set creates real orders on complete
|
profileApplier ProfileApplier // optional; when set links checkouts/orders to profiles
|
||||||
|
orderSvc OrderApplier // optional; when set creates real orders on complete
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCheckoutServer builds a UCP REST adapter over a checkout grain pool.
|
// NewCheckoutServer builds a UCP REST adapter over a checkout grain pool.
|
||||||
@@ -32,6 +35,11 @@ func NewCheckoutServer(applier CheckoutApplier, orderSvc ...OrderApplier) *Check
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetProfileApplier enables identity linking on this server.
|
||||||
|
func (s *CheckoutServer) SetProfileApplier(pa ProfileApplier) {
|
||||||
|
s.profileApplier = pa
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// UCP Checkout endpoints
|
// UCP Checkout endpoints
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -180,6 +188,15 @@ func (s *CheckoutServer) handleCreateCheckout(w http.ResponseWriter, r *http.Req
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Link checkout to customer profile if identity linking is enabled.
|
||||||
|
if s.profileApplier != nil && req.CustomerId != "" {
|
||||||
|
if pid, ok := profilePkg.ParseProfileId(req.CustomerId); ok {
|
||||||
|
if err := linkCheckoutToProfile(s.profileApplier, r.Context(), uint64(pid), uint64(checkoutId), uint64(cartId)); err != nil {
|
||||||
|
log.Printf("identity: failed to link checkout %d to profile %s: %v", uint64(checkoutId), req.CustomerId, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusCreated, checkoutGrainToResponse(checkoutId.String(), g))
|
writeJSON(w, http.StatusCreated, checkoutGrainToResponse(checkoutId.String(), g))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,6 +353,15 @@ func (s *CheckoutServer) handleCompleteCheckout(w http.ResponseWriter, r *http.R
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-link order to customer profile if identity linking is enabled.
|
||||||
|
if s.profileApplier != nil && orderID != "" && req.CustomerId != "" {
|
||||||
|
if pid, ok := profilePkg.ParseProfileId(req.CustomerId); ok {
|
||||||
|
if err := linkOrderToProfile(s.profileApplier, r.Context(), uint64(pid), orderID, uint64(g.CartId), "placed"); err != nil {
|
||||||
|
log.Printf("identity: failed to link order %s to profile %s: %v", orderID, req.CustomerId, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, checkoutGrainToResponse(r.PathValue("id"), g))
|
writeJSON(w, http.StatusOK, checkoutGrainToResponse(r.PathValue("id"), g))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,351 @@
|
|||||||
|
package ucp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
||||||
|
messages "git.k6n.net/mats/go-cart-actor/proto/profile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CustomerServer is the UCP REST adapter over the profile grain pool.
|
||||||
|
type CustomerServer struct {
|
||||||
|
applier ProfileApplier
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCustomerServer builds a UCP REST adapter over a profile grain pool.
|
||||||
|
func NewCustomerServer(applier ProfileApplier) *CustomerServer {
|
||||||
|
return &CustomerServer{applier: applier}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// UCP Customer endpoints
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// handleGetCustomer handles GET /customers/{id}.
|
||||||
|
func (s *CustomerServer) handleGetCustomer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := parseCustomerID(r)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid customer id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
g, err := s.applier.Get(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "customer not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, customerGrainToResponse(r.PathValue("id"), g))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUpdateCustomer handles PUT /customers/{id}.
|
||||||
|
func (s *CustomerServer) handleUpdateCustomer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := parseCustomerID(r)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid customer id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req CustomerUpdateRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := &messages.SetProfile{
|
||||||
|
Name: req.Name,
|
||||||
|
Email: req.Email,
|
||||||
|
Phone: req.Phone,
|
||||||
|
Language: req.Language,
|
||||||
|
Currency: req.Currency,
|
||||||
|
AvatarUrl: req.AvatarUrl,
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to update customer: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
g, err := s.applier.Get(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to read customer: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, customerGrainToResponse(r.PathValue("id"), g))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAddAddress handles POST /customers/{id}/addresses.
|
||||||
|
func (s *CustomerServer) handleAddAddress(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := parseCustomerID(r)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid customer id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req AddAddressRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.AddressLine1 == "" || req.City == "" || req.Zip == "" || req.Country == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "addressLine1, city, zip, and country are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := &messages.Address{
|
||||||
|
Label: req.Label,
|
||||||
|
FullName: req.FullName,
|
||||||
|
AddressLine1: req.AddressLine1,
|
||||||
|
City: req.City,
|
||||||
|
State: req.State,
|
||||||
|
Zip: req.Zip,
|
||||||
|
Country: req.Country,
|
||||||
|
IsDefaultShipping: req.IsDefaultShipping,
|
||||||
|
IsDefaultBilling: req.IsDefaultBilling,
|
||||||
|
}
|
||||||
|
if req.AddressLine2 != "" {
|
||||||
|
addr.AddressLine2 = &req.AddressLine2
|
||||||
|
}
|
||||||
|
if req.Phone != "" {
|
||||||
|
addr.Phone = &req.Phone
|
||||||
|
}
|
||||||
|
msg := &messages.AddAddress{Address: addr}
|
||||||
|
|
||||||
|
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to add address: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
g, err := s.applier.Get(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to read customer: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, customerGrainToResponse(r.PathValue("id"), g))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUpdateAddress handles PUT /customers/{id}/addresses/{addressId}.
|
||||||
|
func (s *CustomerServer) handleUpdateAddress(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := parseCustomerID(r)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid customer id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
addressID, ok := parseAddressID(r)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid address id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req UpdateAddressRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := &messages.UpdateAddress{
|
||||||
|
Id: addressID,
|
||||||
|
Label: req.Label,
|
||||||
|
FullName: req.FullName,
|
||||||
|
AddressLine1: req.AddressLine1,
|
||||||
|
AddressLine2: req.AddressLine2,
|
||||||
|
City: req.City,
|
||||||
|
State: req.State,
|
||||||
|
Zip: req.Zip,
|
||||||
|
Country: req.Country,
|
||||||
|
Phone: req.Phone,
|
||||||
|
IsDefaultShipping: req.IsDefaultShipping,
|
||||||
|
IsDefaultBilling: req.IsDefaultBilling,
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to update address: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
g, err := s.applier.Get(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to read customer: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, customerGrainToResponse(r.PathValue("id"), g))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRemoveAddress handles DELETE /customers/{id}/addresses/{addressId}.
|
||||||
|
func (s *CustomerServer) handleRemoveAddress(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := parseCustomerID(r)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid customer id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
addressID, ok := parseAddressID(r)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid address id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := &messages.RemoveAddress{Id: addressID}
|
||||||
|
|
||||||
|
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
|
||||||
|
// Check if the error is a "not found" error.
|
||||||
|
errStr := err.Error()
|
||||||
|
if containsNotFound(errStr) {
|
||||||
|
writeError(w, http.StatusNotFound, fmt.Sprintf("address %d not found", addressID))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to remove address: "+errStr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
g, err := s.applier.Get(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to read customer: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, customerGrainToResponse(r.PathValue("id"), g))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Identity linking helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// linkCartToProfile links a cart to the given profile by applying a LinkCart mutation.
|
||||||
|
func linkCartToProfile(applier ProfileApplier, ctx context.Context, profileID uint64, cartID uint64) error {
|
||||||
|
if applier == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := applier.Apply(ctx, profileID, &messages.LinkCart{
|
||||||
|
CartId: cartID,
|
||||||
|
Label: "current cart",
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// linkCheckoutToProfile links a checkout to the given profile by applying a LinkCheckout mutation.
|
||||||
|
func linkCheckoutToProfile(applier ProfileApplier, ctx context.Context, profileID uint64, checkoutID uint64, cartID uint64) error {
|
||||||
|
if applier == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := applier.Apply(ctx, profileID, &messages.LinkCheckout{
|
||||||
|
CheckoutId: checkoutID,
|
||||||
|
CartId: cartID,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// linkOrderToProfile links an order to the given profile by applying a LinkOrder mutation.
|
||||||
|
func linkOrderToProfile(applier ProfileApplier, ctx context.Context, profileID uint64, orderReference string, cartID uint64, status string) error {
|
||||||
|
if applier == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := applier.Apply(ctx, profileID, &messages.LinkOrder{
|
||||||
|
OrderReference: orderReference,
|
||||||
|
CartId: cartID,
|
||||||
|
Status: status,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// parseCustomerID parses the base62 customer id from the path.
|
||||||
|
func parseCustomerID(r *http.Request) (uint64, bool) {
|
||||||
|
raw := r.PathValue("id")
|
||||||
|
if raw == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
cid, ok := profile.ParseProfileId(raw)
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return uint64(cid), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAddressID parses the numeric address id from the path.
|
||||||
|
func parseAddressID(r *http.Request) (uint32, bool) {
|
||||||
|
raw := r.PathValue("addressId")
|
||||||
|
if raw == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
// Address IDs are simple numeric strings.
|
||||||
|
var id uint32
|
||||||
|
for _, c := range raw {
|
||||||
|
if c < '0' || c > '9' {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
id = id*10 + uint32(c-'0')
|
||||||
|
}
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// containsNotFound reports whether errStr indicates a "not found" condition.
|
||||||
|
func containsNotFound(errStr string) bool {
|
||||||
|
// Check for common not-found patterns in the error message.
|
||||||
|
for _, pattern := range []string{"not found", "not_found", "notfound"} {
|
||||||
|
if len(errStr) >= len(pattern) {
|
||||||
|
for i := 0; i <= len(errStr)-len(pattern); i++ {
|
||||||
|
match := true
|
||||||
|
for j := 0; j < len(pattern); j++ {
|
||||||
|
c1 := errStr[i+j]
|
||||||
|
c2 := pattern[j]
|
||||||
|
if c1 >= 'A' && c1 <= 'Z' {
|
||||||
|
c1 += 32
|
||||||
|
}
|
||||||
|
if c1 != c2 {
|
||||||
|
match = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if match {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// customerGrainToResponse converts a ProfileGrain to a CustomerResponse.
|
||||||
|
func customerGrainToResponse(id string, g *profile.ProfileGrain) CustomerResponse {
|
||||||
|
resp := CustomerResponse{
|
||||||
|
Id: id,
|
||||||
|
Name: g.Name,
|
||||||
|
Email: g.Email,
|
||||||
|
Phone: g.Phone,
|
||||||
|
Language: g.Language,
|
||||||
|
Currency: g.Currency,
|
||||||
|
AvatarUrl: g.AvatarUrl,
|
||||||
|
Addresses: make([]AddressResponse, 0, len(g.Addresses)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, a := range g.Addresses {
|
||||||
|
resp.Addresses = append(resp.Addresses, AddressResponse{
|
||||||
|
Id: a.Id,
|
||||||
|
Label: a.Label,
|
||||||
|
FullName: a.FullName,
|
||||||
|
AddressLine1: a.AddressLine1,
|
||||||
|
AddressLine2: a.AddressLine2,
|
||||||
|
City: a.City,
|
||||||
|
State: a.State,
|
||||||
|
Zip: a.Zip,
|
||||||
|
Country: a.Country,
|
||||||
|
Phone: a.Phone,
|
||||||
|
IsDefaultShipping: a.IsDefaultShipping,
|
||||||
|
IsDefaultBilling: a.IsDefaultBilling,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp
|
||||||
|
}
|
||||||
@@ -0,0 +1,498 @@
|
|||||||
|
package ucp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
||||||
|
messages "git.k6n.net/mats/go-cart-actor/proto/profile"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testProfileApplier implements ProfileApplier with in-memory grains.
|
||||||
|
type testProfileApplier struct {
|
||||||
|
grains map[uint64]*profile.ProfileGrain
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestProfileApplier() *testProfileApplier {
|
||||||
|
return &testProfileApplier{
|
||||||
|
grains: make(map[uint64]*profile.ProfileGrain),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *testProfileApplier) Get(_ context.Context, id uint64) (*profile.ProfileGrain, error) {
|
||||||
|
g, ok := a.grains[id]
|
||||||
|
if !ok {
|
||||||
|
g = profile.NewProfileGrain(id, time.UnixMilli(1000000))
|
||||||
|
a.grains[id] = g
|
||||||
|
}
|
||||||
|
return g, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *testProfileApplier) Apply(ctx context.Context, id uint64, msgs ...proto.Message) (*actor.MutationResult[profile.ProfileGrain], error) {
|
||||||
|
g, err := a.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, msg := range msgs {
|
||||||
|
switch m := msg.(type) {
|
||||||
|
case *messages.SetProfile:
|
||||||
|
if m.Name != nil {
|
||||||
|
g.Name = *m.Name
|
||||||
|
}
|
||||||
|
if m.Email != nil {
|
||||||
|
g.Email = *m.Email
|
||||||
|
}
|
||||||
|
if m.Phone != nil {
|
||||||
|
g.Phone = *m.Phone
|
||||||
|
}
|
||||||
|
if m.Language != nil {
|
||||||
|
g.Language = *m.Language
|
||||||
|
}
|
||||||
|
if m.Currency != nil {
|
||||||
|
g.Currency = *m.Currency
|
||||||
|
}
|
||||||
|
if m.AvatarUrl != nil {
|
||||||
|
g.AvatarUrl = *m.AvatarUrl
|
||||||
|
}
|
||||||
|
case *messages.AddAddress:
|
||||||
|
if m.Address == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
g.Addresses = append(g.Addresses, profile.StoredAddress{
|
||||||
|
Id: g.NextAddrId(),
|
||||||
|
Label: m.Address.Label,
|
||||||
|
FullName: m.Address.FullName,
|
||||||
|
AddressLine1: m.Address.AddressLine1,
|
||||||
|
AddressLine2: m.Address.GetAddressLine2(),
|
||||||
|
City: m.Address.City,
|
||||||
|
State: m.Address.State,
|
||||||
|
Zip: m.Address.Zip,
|
||||||
|
Country: m.Address.Country,
|
||||||
|
Phone: m.Address.GetPhone(),
|
||||||
|
IsDefaultShipping: m.Address.IsDefaultShipping,
|
||||||
|
IsDefaultBilling: m.Address.IsDefaultBilling,
|
||||||
|
})
|
||||||
|
case *messages.UpdateAddress:
|
||||||
|
for i := range g.Addresses {
|
||||||
|
if g.Addresses[i].Id == m.Id {
|
||||||
|
if m.Label != nil {
|
||||||
|
g.Addresses[i].Label = *m.Label
|
||||||
|
}
|
||||||
|
if m.AddressLine1 != nil {
|
||||||
|
g.Addresses[i].AddressLine1 = *m.AddressLine1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case *messages.RemoveAddress:
|
||||||
|
for i := range g.Addresses {
|
||||||
|
if g.Addresses[i].Id == m.Id {
|
||||||
|
g.Addresses = append(g.Addresses[:i], g.Addresses[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case *messages.LinkCart:
|
||||||
|
g.Carts = append(g.Carts, profile.LinkedCart{CartId: m.CartId, Label: m.Label})
|
||||||
|
case *messages.LinkCheckout:
|
||||||
|
g.Checkouts = append(g.Checkouts, profile.LinkedCheckout{CheckoutId: m.CheckoutId, CartId: m.CartId})
|
||||||
|
case *messages.LinkOrder:
|
||||||
|
g.Orders = append(g.Orders, profile.LinkedOrder{OrderReference: m.OrderReference, CartId: m.CartId, Status: m.Status})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &actor.MutationResult[profile.ProfileGrain]{
|
||||||
|
Result: *g,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// testCartApplier is a minimal CartApplier mock for combined handler tests.
|
||||||
|
type testCartApplier struct{}
|
||||||
|
|
||||||
|
func (a *testCartApplier) Get(ctx context.Context, id uint64) (*cart.CartGrain, error) {
|
||||||
|
return nil, fmt.Errorf("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *testCartApplier) Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[cart.CartGrain], error) {
|
||||||
|
return nil, fmt.Errorf("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to get a test profile ID as a base62 string.
|
||||||
|
func testProfileID(t *testing.T, applier *testProfileApplier) string {
|
||||||
|
t.Helper()
|
||||||
|
pid, err := profile.NewProfileId()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to generate profile id: %v", err)
|
||||||
|
}
|
||||||
|
// Seed the grain.
|
||||||
|
applier.Get(context.Background(), uint64(pid))
|
||||||
|
return pid.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetCustomer(t *testing.T) {
|
||||||
|
applier := newTestProfileApplier()
|
||||||
|
id := testProfileID(t, applier)
|
||||||
|
handler := CustomerHandler(applier)
|
||||||
|
|
||||||
|
// Set up some profile data
|
||||||
|
pid, _ := profile.ParseProfileId(id)
|
||||||
|
applier.Apply(context.Background(), uint64(pid), &messages.SetProfile{
|
||||||
|
Name: proto.String("Alice"),
|
||||||
|
Email: proto.String("alice@example.com"),
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET /customers/{id}
|
||||||
|
req := httptest.NewRequest("GET", fmt.Sprintf("/%s", id), nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200 OK, got %d. Body: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp CustomerResponse
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Id != id {
|
||||||
|
t.Fatalf("expected id %q, got %q", id, resp.Id)
|
||||||
|
}
|
||||||
|
if resp.Name != "Alice" {
|
||||||
|
t.Fatalf("expected Name 'Alice', got %q", resp.Name)
|
||||||
|
}
|
||||||
|
if resp.Email != "alice@example.com" {
|
||||||
|
t.Fatalf("expected Email 'alice@example.com', got %q", resp.Email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetCustomerNotFound(t *testing.T) {
|
||||||
|
applier := newTestProfileApplier()
|
||||||
|
handler := CustomerHandler(applier)
|
||||||
|
|
||||||
|
// "nonexistent" is actually valid base62, so use an ID with characters outside the alphabet.
|
||||||
|
req := httptest.NewRequest("GET", "/!invalid!", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400 Bad Request for invalid id, got %d. Body: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateCustomer(t *testing.T) {
|
||||||
|
applier := newTestProfileApplier()
|
||||||
|
id := testProfileID(t, applier)
|
||||||
|
handler := CustomerHandler(applier)
|
||||||
|
|
||||||
|
body := `{"name": "Bob", "email": "bob@example.com", "phone": "+46701234567", "language": "sv", "currency": "SEK"}`
|
||||||
|
req := httptest.NewRequest("PUT", fmt.Sprintf("/%s", id), strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200 OK, got %d. Body: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp CustomerResponse
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Name != "Bob" {
|
||||||
|
t.Fatalf("expected Name 'Bob', got %q", resp.Name)
|
||||||
|
}
|
||||||
|
if resp.Email != "bob@example.com" {
|
||||||
|
t.Fatalf("expected Email 'bob@example.com', got %q", resp.Email)
|
||||||
|
}
|
||||||
|
if resp.Phone != "+46701234567" {
|
||||||
|
t.Fatalf("expected Phone '+46701234567', got %q", resp.Phone)
|
||||||
|
}
|
||||||
|
if resp.Language != "sv" {
|
||||||
|
t.Fatalf("expected Language 'sv', got %q", resp.Language)
|
||||||
|
}
|
||||||
|
if resp.Currency != "SEK" {
|
||||||
|
t.Fatalf("expected Currency 'SEK', got %q", resp.Currency)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddAddress(t *testing.T) {
|
||||||
|
applier := newTestProfileApplier()
|
||||||
|
id := testProfileID(t, applier)
|
||||||
|
handler := CustomerHandler(applier)
|
||||||
|
|
||||||
|
body := `{
|
||||||
|
"label": "Home",
|
||||||
|
"fullName": "Alice",
|
||||||
|
"addressLine1": "Storgatan 1",
|
||||||
|
"addressLine2": "Lgh 101",
|
||||||
|
"city": "Stockholm",
|
||||||
|
"zip": "111 22",
|
||||||
|
"country": "SE",
|
||||||
|
"phone": "+46701234567"
|
||||||
|
}`
|
||||||
|
req := httptest.NewRequest("POST", fmt.Sprintf("/%s/addresses", id), strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("expected 201 Created, got %d. Body: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp CustomerResponse
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Addresses) != 1 {
|
||||||
|
t.Fatalf("expected 1 address, got %d", len(resp.Addresses))
|
||||||
|
}
|
||||||
|
if resp.Addresses[0].Label != "Home" {
|
||||||
|
t.Fatalf("expected address label 'Home', got %q", resp.Addresses[0].Label)
|
||||||
|
}
|
||||||
|
if resp.Addresses[0].AddressLine1 != "Storgatan 1" {
|
||||||
|
t.Fatalf("expected addressLine1 'Storgatan 1', got %q", resp.Addresses[0].AddressLine1)
|
||||||
|
}
|
||||||
|
if resp.Addresses[0].AddressLine2 != "Lgh 101" {
|
||||||
|
t.Fatalf("expected addressLine2 'Lgh 101', got %q", resp.Addresses[0].AddressLine2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddAddressMissingRequired(t *testing.T) {
|
||||||
|
applier := newTestProfileApplier()
|
||||||
|
id := testProfileID(t, applier)
|
||||||
|
handler := CustomerHandler(applier)
|
||||||
|
|
||||||
|
// Missing addressLine1
|
||||||
|
body := `{"city": "Stockholm", "zip": "111 22", "country": "SE"}`
|
||||||
|
req := httptest.NewRequest("POST", fmt.Sprintf("/%s/addresses", id), strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400 Bad Request for missing required fields, got %d. Body: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateAddress(t *testing.T) {
|
||||||
|
applier := newTestProfileApplier()
|
||||||
|
id := testProfileID(t, applier)
|
||||||
|
handler := CustomerHandler(applier)
|
||||||
|
|
||||||
|
// First add an address
|
||||||
|
pid, _ := profile.ParseProfileId(id)
|
||||||
|
applier.Apply(context.Background(), uint64(pid), &messages.AddAddress{
|
||||||
|
Address: &messages.Address{
|
||||||
|
Label: "Old Home",
|
||||||
|
FullName: "Alice",
|
||||||
|
AddressLine1: "Old Street 1",
|
||||||
|
City: "Old City",
|
||||||
|
Zip: "000 00",
|
||||||
|
Country: "SE",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get the address id from the grain
|
||||||
|
grain, _ := applier.Get(context.Background(), uint64(pid))
|
||||||
|
if len(grain.Addresses) == 0 {
|
||||||
|
t.Fatal("expected at least one address")
|
||||||
|
}
|
||||||
|
addrID := grain.Addresses[0].Id
|
||||||
|
|
||||||
|
body := fmt.Sprintf(`{"label": "New Home", "addressLine1": "New Street 2"}`)
|
||||||
|
req := httptest.NewRequest("PUT", fmt.Sprintf("/%s/addresses/%d", id, addrID), strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200 OK, got %d. Body: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp CustomerResponse
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Addresses) != 1 {
|
||||||
|
t.Fatalf("expected 1 address, got %d", len(resp.Addresses))
|
||||||
|
}
|
||||||
|
if resp.Addresses[0].Label != "New Home" {
|
||||||
|
t.Fatalf("expected label 'New Home', got %q", resp.Addresses[0].Label)
|
||||||
|
}
|
||||||
|
if resp.Addresses[0].AddressLine1 != "New Street 2" {
|
||||||
|
t.Fatalf("expected addressLine1 'New Street 2', got %q", resp.Addresses[0].AddressLine1)
|
||||||
|
}
|
||||||
|
// Unchanged fields should still be there
|
||||||
|
if resp.Addresses[0].City != "Old City" {
|
||||||
|
t.Fatalf("expected City 'Old City', got %q", resp.Addresses[0].City)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateAddressInvalidID(t *testing.T) {
|
||||||
|
applier := newTestProfileApplier()
|
||||||
|
id := testProfileID(t, applier)
|
||||||
|
handler := CustomerHandler(applier)
|
||||||
|
|
||||||
|
body := `{"label": "New Home"}`
|
||||||
|
req := httptest.NewRequest("PUT", fmt.Sprintf("/%s/addresses/abc", id), strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400 Bad Request for invalid address id, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveAddress(t *testing.T) {
|
||||||
|
applier := newTestProfileApplier()
|
||||||
|
id := testProfileID(t, applier)
|
||||||
|
handler := CustomerHandler(applier)
|
||||||
|
|
||||||
|
// First add an address
|
||||||
|
pid, _ := profile.ParseProfileId(id)
|
||||||
|
applier.Apply(context.Background(), uint64(pid), &messages.AddAddress{
|
||||||
|
Address: &messages.Address{
|
||||||
|
Label: "Remove Me",
|
||||||
|
FullName: "Alice",
|
||||||
|
AddressLine1: "Storgatan 1",
|
||||||
|
City: "Stockholm",
|
||||||
|
Zip: "111 22",
|
||||||
|
Country: "SE",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
grain, _ := applier.Get(context.Background(), uint64(pid))
|
||||||
|
if len(grain.Addresses) == 0 {
|
||||||
|
t.Fatal("expected at least one address")
|
||||||
|
}
|
||||||
|
addrID := grain.Addresses[0].Id
|
||||||
|
|
||||||
|
// DELETE /customers/{id}/addresses/{addressId}
|
||||||
|
req := httptest.NewRequest("DELETE", fmt.Sprintf("/%s/addresses/%d", id, addrID), nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200 OK, got %d. Body: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
grain, _ = applier.Get(context.Background(), uint64(pid))
|
||||||
|
if len(grain.Addresses) != 0 {
|
||||||
|
t.Fatalf("expected 0 addresses after removal, got %d", len(grain.Addresses))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveAddressNotFound(t *testing.T) {
|
||||||
|
applier := newTestProfileApplier()
|
||||||
|
pid, err := profile.NewProfileId()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to generate profile id: %v", err)
|
||||||
|
}
|
||||||
|
applier.Get(context.Background(), uint64(pid))
|
||||||
|
|
||||||
|
id := pid.String()
|
||||||
|
handler := CustomerHandler(applier)
|
||||||
|
req := httptest.NewRequest("DELETE", fmt.Sprintf("/%s/addresses/999", id), nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
// Test applier silently ignores not-found removals (returns no error).
|
||||||
|
// In production, HandleRemoveAddress returns an error which maps to 404.
|
||||||
|
// Accept 200 here since the test applier is a simplified mock.
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200 (test applier limitation), got %d. Body: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRemoveAddressNotFoundRealistic tests the real mutation handler path via the registry.
|
||||||
|
// It uses the real HandleRemoveAddress mutation to verify 404 behavior.
|
||||||
|
func TestRemoveAddressNotFoundMutation(t *testing.T) {
|
||||||
|
// Test the real HandleRemoveAddress mutation directly.
|
||||||
|
g := profile.NewProfileGrain(1, time.Now())
|
||||||
|
|
||||||
|
// Add an address via the real handler
|
||||||
|
if err := profile.HandleAddAddress(g, &messages.AddAddress{
|
||||||
|
Address: &messages.Address{
|
||||||
|
Label: "Test",
|
||||||
|
FullName: "Alice",
|
||||||
|
AddressLine1: "Storgatan 1",
|
||||||
|
City: "Stockholm",
|
||||||
|
Zip: "111 22",
|
||||||
|
Country: "SE",
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("HandleAddAddress failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to remove address 999 (doesn't exist)
|
||||||
|
err := profile.HandleRemoveAddress(g, &messages.RemoveAddress{Id: 999})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for non-existent address, got nil")
|
||||||
|
}
|
||||||
|
expected := "RemoveAddress: address 999 not found"
|
||||||
|
if err.Error() != expected {
|
||||||
|
t.Fatalf("expected error %q, got %q", expected, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCustomerHandlerInvalidID(t *testing.T) {
|
||||||
|
applier := newTestProfileApplier()
|
||||||
|
handler := CustomerHandler(applier)
|
||||||
|
|
||||||
|
// Path "/" doesn't match the /{id} pattern — falls through to 404.
|
||||||
|
// Paths with invalid base62 characters should get 400.
|
||||||
|
tests := []struct {
|
||||||
|
path string
|
||||||
|
expectedStatus int
|
||||||
|
}{
|
||||||
|
{"/", http.StatusNotFound}, // doesn't match /{id}
|
||||||
|
{"/!invalid!", http.StatusBadRequest}, // has non-base62 chars
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
req := httptest.NewRequest("GET", tt.path, nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != tt.expectedStatus {
|
||||||
|
t.Fatalf("for path %q: expected %d, got %d. Body: %s", tt.path, tt.expectedStatus, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCustomerHandlerThroughCombinedMount(t *testing.T) {
|
||||||
|
// Test that the combined Handler correctly mounts customer endpoints.
|
||||||
|
// Use a mock CartApplier (bare minimum — never called in this test).
|
||||||
|
mockCart := &testCartApplier{}
|
||||||
|
profileApplier := newTestProfileApplier()
|
||||||
|
|
||||||
|
pid, _ := profile.NewProfileId()
|
||||||
|
profileApplier.Get(context.Background(), uint64(pid))
|
||||||
|
|
||||||
|
handler := Handler(mockCart, Options{
|
||||||
|
ProfileApplier: profileApplier,
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET /customers/{id} should work
|
||||||
|
id := pid.String()
|
||||||
|
req := httptest.NewRequest("GET", fmt.Sprintf("/customers/%s", id), nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200 OK, got %d. Body: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp CustomerResponse
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Id != id {
|
||||||
|
t.Fatalf("expected id %q, got %q", id, resp.Id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,6 +73,30 @@ func CheckoutHandler(applier CheckoutApplier, orderSvc ...OrderApplier) http.Han
|
|||||||
return withUCPAgent(withJSONContentType(mux))
|
return withUCPAgent(withJSONContentType(mux))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Customer HTTP handler mounting
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// CustomerHandler returns an http.Handler that routes UCP customer/profile
|
||||||
|
// REST endpoints. Mount it under any prefix (e.g. /ucp/v1) in a ServeMux:
|
||||||
|
//
|
||||||
|
// mux.Handle("/ucp/v1/customers", ucp.CustomerHandler(pool))
|
||||||
|
// mux.Handle("/ucp/v1/customers/", ucp.CustomerHandler(pool))
|
||||||
|
//
|
||||||
|
// To sign responses with RFC 9421 HTTP Message Signatures, wrap with WithSigning:
|
||||||
|
//
|
||||||
|
// mux.Handle("/ucp/v1/customers", ucp.WithSigning(ucp.CustomerHandler(pool), signer))
|
||||||
|
func CustomerHandler(applier ProfileApplier) http.Handler {
|
||||||
|
s := NewCustomerServer(applier)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("GET /{id}", s.handleGetCustomer)
|
||||||
|
mux.HandleFunc("PUT /{id}", s.handleUpdateCustomer)
|
||||||
|
mux.HandleFunc("POST /{id}/addresses", s.handleAddAddress)
|
||||||
|
mux.HandleFunc("PUT /{id}/addresses/{addressId}", s.handleUpdateAddress)
|
||||||
|
mux.HandleFunc("DELETE /{id}/addresses/{addressId}", s.handleRemoveAddress)
|
||||||
|
return withUCPAgent(withJSONContentType(mux))
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Combined handler for mounting all UCP endpoints under one prefix
|
// Combined handler for mounting all UCP endpoints under one prefix
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -81,6 +105,7 @@ func CheckoutHandler(applier CheckoutApplier, orderSvc ...OrderApplier) http.Han
|
|||||||
type Options struct {
|
type Options struct {
|
||||||
CheckoutApplier CheckoutApplier // when set, mounts /checkout-sessions
|
CheckoutApplier CheckoutApplier // when set, mounts /checkout-sessions
|
||||||
OrderApplier OrderApplier // optional order creation for complete endpoint
|
OrderApplier OrderApplier // optional order creation for complete endpoint
|
||||||
|
ProfileApplier ProfileApplier // when set, mounts /customers + enables identity linking
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handler returns an http.Handler that serves all mounted UCP endpoints under
|
// Handler returns an http.Handler that serves all mounted UCP endpoints under
|
||||||
@@ -88,12 +113,16 @@ type Options struct {
|
|||||||
//
|
//
|
||||||
// mux.Handle("/ucp/v1/", ucp.Handler(pool, ucp.Options{
|
// mux.Handle("/ucp/v1/", ucp.Handler(pool, ucp.Options{
|
||||||
// CheckoutApplier: checkoutPool,
|
// CheckoutApplier: checkoutPool,
|
||||||
|
// ProfileApplier: profilePool,
|
||||||
// }))
|
// }))
|
||||||
func Handler(cartApplier CartApplier, opts Options) http.Handler {
|
func Handler(cartApplier CartApplier, opts Options) http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
// Cart endpoints
|
// Cart endpoints
|
||||||
cartSrv := NewCartServer(cartApplier)
|
cartSrv := NewCartServer(cartApplier)
|
||||||
|
if opts.ProfileApplier != nil {
|
||||||
|
cartSrv.SetProfileApplier(opts.ProfileApplier)
|
||||||
|
}
|
||||||
mux.HandleFunc("POST /carts", cartSrv.handleCreateCart)
|
mux.HandleFunc("POST /carts", cartSrv.handleCreateCart)
|
||||||
mux.HandleFunc("GET /carts/{id}", cartSrv.handleGetCart)
|
mux.HandleFunc("GET /carts/{id}", cartSrv.handleGetCart)
|
||||||
mux.HandleFunc("PUT /carts/{id}", cartSrv.handleUpdateCart)
|
mux.HandleFunc("PUT /carts/{id}", cartSrv.handleUpdateCart)
|
||||||
@@ -106,6 +135,9 @@ func Handler(cartApplier CartApplier, opts Options) http.Handler {
|
|||||||
orderOpts = []OrderApplier{opts.OrderApplier}
|
orderOpts = []OrderApplier{opts.OrderApplier}
|
||||||
}
|
}
|
||||||
checkoutSrv := NewCheckoutServer(opts.CheckoutApplier, orderOpts...)
|
checkoutSrv := NewCheckoutServer(opts.CheckoutApplier, orderOpts...)
|
||||||
|
if opts.ProfileApplier != nil {
|
||||||
|
checkoutSrv.SetProfileApplier(opts.ProfileApplier)
|
||||||
|
}
|
||||||
mux.HandleFunc("POST /checkout-sessions", checkoutSrv.handleCreateCheckout)
|
mux.HandleFunc("POST /checkout-sessions", checkoutSrv.handleCreateCheckout)
|
||||||
mux.HandleFunc("GET /checkout-sessions/{id}", checkoutSrv.handleGetCheckout)
|
mux.HandleFunc("GET /checkout-sessions/{id}", checkoutSrv.handleGetCheckout)
|
||||||
mux.HandleFunc("PUT /checkout-sessions/{id}", checkoutSrv.handleUpdateCheckout)
|
mux.HandleFunc("PUT /checkout-sessions/{id}", checkoutSrv.handleUpdateCheckout)
|
||||||
@@ -113,6 +145,16 @@ func Handler(cartApplier CartApplier, opts Options) http.Handler {
|
|||||||
mux.HandleFunc("POST /checkout-sessions/{id}/cancel", checkoutSrv.handleCancelCheckout)
|
mux.HandleFunc("POST /checkout-sessions/{id}/cancel", checkoutSrv.handleCancelCheckout)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Customer endpoints (optional)
|
||||||
|
if opts.ProfileApplier != nil {
|
||||||
|
customerSrv := NewCustomerServer(opts.ProfileApplier)
|
||||||
|
mux.HandleFunc("GET /customers/{id}", customerSrv.handleGetCustomer)
|
||||||
|
mux.HandleFunc("PUT /customers/{id}", customerSrv.handleUpdateCustomer)
|
||||||
|
mux.HandleFunc("POST /customers/{id}/addresses", customerSrv.handleAddAddress)
|
||||||
|
mux.HandleFunc("PUT /customers/{id}/addresses/{addressId}", customerSrv.handleUpdateAddress)
|
||||||
|
mux.HandleFunc("DELETE /customers/{id}/addresses/{addressId}", customerSrv.handleRemoveAddress)
|
||||||
|
}
|
||||||
|
|
||||||
return withUCPAgent(withJSONContentType(mux))
|
return withUCPAgent(withJSONContentType(mux))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+86
-5
@@ -22,6 +22,7 @@ import (
|
|||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
"git.k6n.net/mats/go-cart-actor/pkg/order"
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
)
|
)
|
||||||
@@ -49,6 +50,12 @@ type OrderReadApplier interface {
|
|||||||
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[order.OrderGrain], error)
|
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[order.OrderGrain], error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProfileApplier is the minimal grain-pool interface the UCP customer adapter needs.
|
||||||
|
type ProfileApplier interface {
|
||||||
|
Get(ctx context.Context, id uint64) (*profile.ProfileGrain, error)
|
||||||
|
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[profile.ProfileGrain], error)
|
||||||
|
}
|
||||||
|
|
||||||
// OrderApplier is the interface the UCP checkout adapter uses to create a real
|
// OrderApplier is the interface the UCP checkout adapter uses to create a real
|
||||||
// order from a completed checkout session. It abstracts over the transport
|
// order from a completed checkout session. It abstracts over the transport
|
||||||
// (direct grain pool, HTTP, etc.) so the adapter is portable.
|
// (direct grain pool, HTTP, etc.) so the adapter is portable.
|
||||||
@@ -143,11 +150,12 @@ type Totals struct {
|
|||||||
|
|
||||||
// CreateCheckoutRequest is the body for POST /checkout-sessions.
|
// CreateCheckoutRequest is the body for POST /checkout-sessions.
|
||||||
type CreateCheckoutRequest struct {
|
type CreateCheckoutRequest struct {
|
||||||
CartId string `json:"cartId"`
|
CartId string `json:"cartId"`
|
||||||
Items []CartItemInput `json:"items,omitempty"`
|
Items []CartItemInput `json:"items,omitempty"`
|
||||||
LineItems []CartItemInput `json:"line_items,omitempty"`
|
LineItems []CartItemInput `json:"line_items,omitempty"`
|
||||||
Country string `json:"country,omitempty"`
|
Country string `json:"country,omitempty"`
|
||||||
Currency string `json:"currency,omitempty"`
|
Currency string `json:"currency,omitempty"`
|
||||||
|
CustomerId string `json:"customerId,omitempty"` // profile ID for identity linking
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateCheckoutRequest is the body for PUT /checkout-sessions/{id}.
|
// UpdateCheckoutRequest is the body for PUT /checkout-sessions/{id}.
|
||||||
@@ -187,6 +195,7 @@ type CompleteCheckoutRequest struct {
|
|||||||
Provider string `json:"provider,omitempty"`
|
Provider string `json:"provider,omitempty"`
|
||||||
Currency string `json:"currency,omitempty"`
|
Currency string `json:"currency,omitempty"`
|
||||||
Country string `json:"country,omitempty"`
|
Country string `json:"country,omitempty"`
|
||||||
|
CustomerId string `json:"customerId,omitempty"` // profile ID for identity linking
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckoutResponse is the UCP checkout session response.
|
// CheckoutResponse is the UCP checkout session response.
|
||||||
@@ -316,6 +325,78 @@ type OrderLineEntry struct {
|
|||||||
Quantity int `json:"quantity"`
|
Quantity int `json:"quantity"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// UCP Customer types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// CustomerResponse is the UCP customer profile response body.
|
||||||
|
type CustomerResponse struct {
|
||||||
|
Id string `json:"id"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
Phone string `json:"phone,omitempty"`
|
||||||
|
Language string `json:"language,omitempty"`
|
||||||
|
Currency string `json:"currency,omitempty"`
|
||||||
|
AvatarUrl string `json:"avatarUrl,omitempty"`
|
||||||
|
Addresses []AddressResponse `json:"addresses,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddressResponse is the UCP wire format for an address.
|
||||||
|
type AddressResponse struct {
|
||||||
|
Id uint32 `json:"id"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
FullName string `json:"fullName,omitempty"`
|
||||||
|
AddressLine1 string `json:"addressLine1"`
|
||||||
|
AddressLine2 string `json:"addressLine2,omitempty"`
|
||||||
|
City string `json:"city"`
|
||||||
|
State string `json:"state,omitempty"`
|
||||||
|
Zip string `json:"zip"`
|
||||||
|
Country string `json:"country"`
|
||||||
|
Phone string `json:"phone,omitempty"`
|
||||||
|
IsDefaultShipping bool `json:"isDefaultShipping"`
|
||||||
|
IsDefaultBilling bool `json:"isDefaultBilling"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CustomerUpdateRequest is the body for PUT /customers/{id}.
|
||||||
|
type CustomerUpdateRequest struct {
|
||||||
|
Name *string `json:"name,omitempty"`
|
||||||
|
Email *string `json:"email,omitempty"`
|
||||||
|
Phone *string `json:"phone,omitempty"`
|
||||||
|
Language *string `json:"language,omitempty"`
|
||||||
|
Currency *string `json:"currency,omitempty"`
|
||||||
|
AvatarUrl *string `json:"avatarUrl,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddAddressRequest is the body for POST /customers/{id}/addresses.
|
||||||
|
type AddAddressRequest struct {
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
FullName string `json:"fullName,omitempty"`
|
||||||
|
AddressLine1 string `json:"addressLine1"`
|
||||||
|
AddressLine2 string `json:"addressLine2,omitempty"`
|
||||||
|
City string `json:"city"`
|
||||||
|
State string `json:"state,omitempty"`
|
||||||
|
Zip string `json:"zip"`
|
||||||
|
Country string `json:"country"`
|
||||||
|
Phone string `json:"phone,omitempty"`
|
||||||
|
IsDefaultShipping bool `json:"isDefaultShipping,omitempty"`
|
||||||
|
IsDefaultBilling bool `json:"isDefaultBilling,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAddressRequest is the body for PUT /customers/{id}/addresses/{addressId}.
|
||||||
|
type UpdateAddressRequest struct {
|
||||||
|
Label *string `json:"label,omitempty"`
|
||||||
|
FullName *string `json:"fullName,omitempty"`
|
||||||
|
AddressLine1 *string `json:"addressLine1,omitempty"`
|
||||||
|
AddressLine2 *string `json:"addressLine2,omitempty"`
|
||||||
|
City *string `json:"city,omitempty"`
|
||||||
|
State *string `json:"state,omitempty"`
|
||||||
|
Zip *string `json:"zip,omitempty"`
|
||||||
|
Country *string `json:"country,omitempty"`
|
||||||
|
Phone *string `json:"phone,omitempty"`
|
||||||
|
IsDefaultShipping *bool `json:"isDefaultShipping,omitempty"`
|
||||||
|
IsDefaultBilling *bool `json:"isDefaultBilling,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// UCP Profile handler (/.well-known/ucp)
|
// UCP Profile handler (/.well-known/ucp)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
actor "git.k6n.net/mats/go-cart-actor/pkg/actor"
|
actor "git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||||
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
||||||
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
|
||||||
"git.k6n.net/mats/slask-finder/pkg/messaging"
|
"git.k6n.net/mats/slask-finder/pkg/messaging"
|
||||||
amqp "github.com/rabbitmq/amqp091-go"
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
@@ -49,6 +50,8 @@ type Config struct {
|
|||||||
DataDir string
|
DataDir string
|
||||||
// CheckoutDataDir holds the checkout event logs (default "checkout-data").
|
// CheckoutDataDir holds the checkout event logs (default "checkout-data").
|
||||||
CheckoutDataDir string
|
CheckoutDataDir string
|
||||||
|
// ProfileDataDir holds the profile event logs (optional, default "" — customer endpoints disabled).
|
||||||
|
ProfileDataDir string
|
||||||
// RedisAddress / RedisPassword reach the inventory store.
|
// RedisAddress / RedisPassword reach the inventory store.
|
||||||
RedisAddress string
|
RedisAddress string
|
||||||
RedisPassword string
|
RedisPassword string
|
||||||
@@ -61,6 +64,7 @@ type App struct {
|
|||||||
hub *Hub
|
hub *Hub
|
||||||
rdb *redis.Client
|
rdb *redis.Client
|
||||||
inv *inventory.RedisInventoryService
|
inv *inventory.RedisInventoryService
|
||||||
|
cs *CustomerServer
|
||||||
}
|
}
|
||||||
|
|
||||||
// New constructs the commerce admin: it opens the Redis inventory service, the
|
// New constructs the commerce admin: it opens the Redis inventory service, the
|
||||||
@@ -93,11 +97,21 @@ func New(cfg Config) (*App, error) {
|
|||||||
regCheckout := checkout.NewCheckoutMutationRegistry(checkout.NewCheckoutMutationContext())
|
regCheckout := checkout.NewCheckoutMutationRegistry(checkout.NewCheckoutMutationContext())
|
||||||
diskStorageCheckout := actor.NewDiskStorage[checkout.CheckoutGrain](cfg.CheckoutDataDir, regCheckout)
|
diskStorageCheckout := actor.NewDiskStorage[checkout.CheckoutGrain](cfg.CheckoutDataDir, regCheckout)
|
||||||
|
|
||||||
|
// Optional customer/profile storage.
|
||||||
|
var customerSrv *CustomerServer
|
||||||
|
if cfg.ProfileDataDir != "" {
|
||||||
|
_ = os.MkdirAll(cfg.ProfileDataDir, 0755)
|
||||||
|
regProfile := profile.NewProfileMutationRegistry()
|
||||||
|
profileStorage := actor.NewDiskStorage[profile.ProfileGrain](cfg.ProfileDataDir, regProfile)
|
||||||
|
customerSrv = NewCustomerServer(cfg.ProfileDataDir, profileStorage)
|
||||||
|
}
|
||||||
|
|
||||||
return &App{
|
return &App{
|
||||||
fs: NewFileServer(cfg.DataDir, cfg.CheckoutDataDir, diskStorage, diskStorageCheckout),
|
fs: NewFileServer(cfg.DataDir, cfg.CheckoutDataDir, diskStorage, diskStorageCheckout),
|
||||||
hub: NewHub(),
|
hub: NewHub(),
|
||||||
rdb: rdb,
|
rdb: rdb,
|
||||||
inv: inventoryService,
|
inv: inventoryService,
|
||||||
|
cs: customerSrv,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +128,10 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/vouchers", a.fs.VoucherHandler)
|
mux.HandleFunc("/vouchers", a.fs.VoucherHandler)
|
||||||
mux.HandleFunc("/promotion/{id}", a.fs.PromotionPartHandler)
|
mux.HandleFunc("/promotion/{id}", a.fs.PromotionPartHandler)
|
||||||
mux.HandleFunc("/ws", a.hub.ServeWS)
|
mux.HandleFunc("/ws", a.hub.ServeWS)
|
||||||
|
// Optional customer endpoints (only mounted when ProfileDataDir is configured).
|
||||||
|
if a.cs != nil {
|
||||||
|
a.cs.RegisterRoutes(mux)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) updateInventory(w http.ResponseWriter, r *http.Request) {
|
func (a *App) updateInventory(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
package backofficeadmin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CustomerFileInfo is the metadata returned by the customer listing endpoint.
|
||||||
|
type CustomerFileInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Modified time.Time `json:"modified"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CustomerServer serves backoffice admin endpoints for customer profiles.
|
||||||
|
type CustomerServer struct {
|
||||||
|
dataDir string
|
||||||
|
storage actor.LogStorage[profile.ProfileGrain]
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCustomerServer builds a CustomerServer that lists and reads profile event logs.
|
||||||
|
func NewCustomerServer(dataDir string, storage actor.LogStorage[profile.ProfileGrain]) *CustomerServer {
|
||||||
|
return &CustomerServer{
|
||||||
|
dataDir: dataDir,
|
||||||
|
storage: storage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterRoutes mounts the customer admin endpoints on the given mux.
|
||||||
|
func (s *CustomerServer) RegisterRoutes(mux *http.ServeMux) {
|
||||||
|
mux.HandleFunc("GET /customers", s.handleListCustomers)
|
||||||
|
mux.HandleFunc("GET /customer/{id}", s.handleGetCustomer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CustomerInfoResponse is the per-customer info for the listing endpoint.
|
||||||
|
type CustomerInfoResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Modified string `json:"modified"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CustomerDetailResponse is the full customer detail including linked carts/checkouts/orders/addresses.
|
||||||
|
type CustomerDetailResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
Phone string `json:"phone,omitempty"`
|
||||||
|
Language string `json:"language,omitempty"`
|
||||||
|
Currency string `json:"currency,omitempty"`
|
||||||
|
AvatarUrl string `json:"avatarUrl,omitempty"`
|
||||||
|
Carts []profile.LinkedCart `json:"carts,omitempty"`
|
||||||
|
Checkouts []profile.LinkedCheckout `json:"checkouts,omitempty"`
|
||||||
|
Orders []profile.LinkedOrder `json:"orders,omitempty"`
|
||||||
|
Addresses []profile.StoredAddress `json:"addresses,omitempty"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Modified string `json:"modified"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListCustomers lists all customer profiles.
|
||||||
|
func (s *CustomerServer) handleListCustomers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
list, err := s.listProfileFiles()
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"count": len(list),
|
||||||
|
"customers": list,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetCustomer returns full detail for a single customer profile.
|
||||||
|
func (s *CustomerServer) handleGetCustomer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
idStr := r.PathValue("id")
|
||||||
|
if idStr == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, JsonError{Error: "missing id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := isValidProfileId(idStr)
|
||||||
|
if !ok {
|
||||||
|
writeJSON(w, http.StatusBadRequest, JsonError{Error: "invalid id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconstruct profile from event log.
|
||||||
|
grain := profile.NewProfileGrain(id, time.Now())
|
||||||
|
if err := s.storage.LoadEvents(r.Context(), id, grain); err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(s.dataDir, fmt.Sprintf("%d.events.log", id))
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil && errors.Is(err, os.ErrNotExist) {
|
||||||
|
writeJSON(w, http.StatusNotFound, JsonError{Error: "customer not found"})
|
||||||
|
return
|
||||||
|
} else if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, JsonError{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := CustomerDetailResponse{
|
||||||
|
ID: idStr,
|
||||||
|
Name: grain.Name,
|
||||||
|
Email: grain.Email,
|
||||||
|
Phone: grain.Phone,
|
||||||
|
Language: grain.Language,
|
||||||
|
Currency: grain.Currency,
|
||||||
|
AvatarUrl: grain.AvatarUrl,
|
||||||
|
Carts: grain.Carts,
|
||||||
|
Checkouts: grain.Checkouts,
|
||||||
|
Orders: grain.Orders,
|
||||||
|
Addresses: grain.Addresses,
|
||||||
|
Size: info.Size(),
|
||||||
|
Modified: info.ModTime().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"customer": resp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// isValidProfileId tries to parse a numeric or base62-encoded profile id.
|
||||||
|
func isValidProfileId(id string) (uint64, bool) {
|
||||||
|
if nr, err := strconv.ParseUint(id, 10, 64); err == nil {
|
||||||
|
return nr, true
|
||||||
|
}
|
||||||
|
if nr, err := strconv.ParseUint(id, 36, 64); err == nil {
|
||||||
|
return nr, true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// listProfileFiles lists all profile event log files in the data directory.
|
||||||
|
func (s *CustomerServer) listProfileFiles() ([]CustomerInfoResponse, error) {
|
||||||
|
entries, err := os.ReadDir(s.dataDir)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return []CustomerInfoResponse{}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]CustomerInfoResponse, 0)
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := e.Name()
|
||||||
|
parts := strings.Split(name, ".")
|
||||||
|
if len(parts) < 2 || parts[1] != "events" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseUint(parts[0], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := e.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick peek at the profile for email/name by loading the grain
|
||||||
|
grain := profile.NewProfileGrain(id, info.ModTime())
|
||||||
|
_ = s.storage.LoadEvents(context.Background(), id, grain)
|
||||||
|
|
||||||
|
out = append(out, CustomerInfoResponse{
|
||||||
|
ID: fmt.Sprintf("%d", id),
|
||||||
|
Email: grain.Email,
|
||||||
|
Name: grain.Name,
|
||||||
|
Size: info.Size(),
|
||||||
|
Modified: info.ModTime().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
return out[i].Modified > out[j].Modified
|
||||||
|
})
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package profile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProfileId is a 64-bit profile identifier with a compact base62 string form,
|
||||||
|
// the same scheme the cart and order use so ids are consistent across services.
|
||||||
|
type ProfileId uint64
|
||||||
|
|
||||||
|
const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||||
|
|
||||||
|
var base62Rev [256]byte
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
for i := range base62Rev {
|
||||||
|
base62Rev[i] = 0xFF
|
||||||
|
}
|
||||||
|
for i := 0; i < len(base62Alphabet); i++ {
|
||||||
|
base62Rev[base62Alphabet[i]] = byte(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns the canonical base62 encoding.
|
||||||
|
func (id ProfileId) String() string { return encodeBase62(uint64(id)) }
|
||||||
|
|
||||||
|
// NewProfileId generates a cryptographically random non-zero id.
|
||||||
|
func NewProfileId() (ProfileId, error) {
|
||||||
|
var b [8]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
return 0, fmt.Errorf("NewProfileId: %w", err)
|
||||||
|
}
|
||||||
|
u := uint64(b[0])<<56 | uint64(b[1])<<48 | uint64(b[2])<<40 | uint64(b[3])<<32 |
|
||||||
|
uint64(b[4])<<24 | uint64(b[5])<<16 | uint64(b[6])<<8 | uint64(b[7])
|
||||||
|
if u == 0 {
|
||||||
|
return NewProfileId()
|
||||||
|
}
|
||||||
|
return ProfileId(u), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseProfileId parses a base62 string into a ProfileId.
|
||||||
|
func ParseProfileId(s string) (ProfileId, bool) {
|
||||||
|
if len(s) == 0 || len(s) > 11 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
var v uint64
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
d := base62Rev[s[i]]
|
||||||
|
if d == 0xFF {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
v = v*62 + uint64(d)
|
||||||
|
}
|
||||||
|
return ProfileId(v), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func encodeBase62(u uint64) string {
|
||||||
|
if u == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
var buf [11]byte
|
||||||
|
i := len(buf)
|
||||||
|
for u > 0 {
|
||||||
|
i--
|
||||||
|
buf[i] = base62Alphabet[u%62]
|
||||||
|
u /= 62
|
||||||
|
}
|
||||||
|
return string(buf[i:])
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package profile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewProfileMutationRegistry creates a mutation registry with all profile
|
||||||
|
// mutation handlers registered.
|
||||||
|
func NewProfileMutationRegistry() actor.MutationRegistry {
|
||||||
|
reg := actor.NewMutationRegistry()
|
||||||
|
reg.RegisterMutations(
|
||||||
|
actor.NewMutation(HandleSetProfile),
|
||||||
|
actor.NewMutation(HandleAddAddress),
|
||||||
|
actor.NewMutation(HandleUpdateAddress),
|
||||||
|
actor.NewMutation(HandleRemoveAddress),
|
||||||
|
actor.NewMutation(HandleLinkCart),
|
||||||
|
actor.NewMutation(HandleLinkCheckout),
|
||||||
|
actor.NewMutation(HandleLinkOrder),
|
||||||
|
)
|
||||||
|
return reg
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package profile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
messages "git.k6n.net/mats/go-cart-actor/proto/profile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleAddAddress adds a new address to the user's address book.
|
||||||
|
func HandleAddAddress(p *ProfileGrain, m *messages.AddAddress) error {
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("AddAddress: nil payload")
|
||||||
|
}
|
||||||
|
if m.Address == nil {
|
||||||
|
return fmt.Errorf("AddAddress: address is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
id := p.NextAddrId()
|
||||||
|
addr := protoToStored(id, m.Address)
|
||||||
|
if addr.IsDefaultShipping {
|
||||||
|
p.clearDefaultShipping()
|
||||||
|
}
|
||||||
|
if addr.IsDefaultBilling {
|
||||||
|
p.clearDefaultBilling()
|
||||||
|
}
|
||||||
|
p.Addresses = append(p.Addresses, *addr)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleUpdateAddress updates an existing address (matched by id).
|
||||||
|
func HandleUpdateAddress(p *ProfileGrain, m *messages.UpdateAddress) error {
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("UpdateAddress: nil payload")
|
||||||
|
}
|
||||||
|
existing := p.findAddress(m.Id)
|
||||||
|
if existing == nil {
|
||||||
|
return fmt.Errorf("UpdateAddress: address %d not found", m.Id)
|
||||||
|
}
|
||||||
|
if m.Label != nil {
|
||||||
|
existing.Label = *m.Label
|
||||||
|
}
|
||||||
|
if m.FullName != nil {
|
||||||
|
existing.FullName = *m.FullName
|
||||||
|
}
|
||||||
|
if m.AddressLine1 != nil {
|
||||||
|
existing.AddressLine1 = *m.AddressLine1
|
||||||
|
}
|
||||||
|
if m.AddressLine2 != nil {
|
||||||
|
existing.AddressLine2 = *m.AddressLine2
|
||||||
|
}
|
||||||
|
if m.City != nil {
|
||||||
|
existing.City = *m.City
|
||||||
|
}
|
||||||
|
if m.State != nil {
|
||||||
|
existing.State = *m.State
|
||||||
|
}
|
||||||
|
if m.Zip != nil {
|
||||||
|
existing.Zip = *m.Zip
|
||||||
|
}
|
||||||
|
if m.Country != nil {
|
||||||
|
existing.Country = *m.Country
|
||||||
|
}
|
||||||
|
if m.Phone != nil {
|
||||||
|
existing.Phone = *m.Phone
|
||||||
|
}
|
||||||
|
if m.IsDefaultShipping != nil {
|
||||||
|
if *m.IsDefaultShipping {
|
||||||
|
p.clearDefaultShipping()
|
||||||
|
}
|
||||||
|
existing.IsDefaultShipping = *m.IsDefaultShipping
|
||||||
|
}
|
||||||
|
if m.IsDefaultBilling != nil {
|
||||||
|
if *m.IsDefaultBilling {
|
||||||
|
p.clearDefaultBilling()
|
||||||
|
}
|
||||||
|
existing.IsDefaultBilling = *m.IsDefaultBilling
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleRemoveAddress removes an address from the address book.
|
||||||
|
func HandleRemoveAddress(p *ProfileGrain, m *messages.RemoveAddress) error {
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("RemoveAddress: nil payload")
|
||||||
|
}
|
||||||
|
idx := -1
|
||||||
|
for i, a := range p.Addresses {
|
||||||
|
if a.Id == m.Id {
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx == -1 {
|
||||||
|
return fmt.Errorf("RemoveAddress: address %d not found", m.Id)
|
||||||
|
}
|
||||||
|
p.Addresses = append(p.Addresses[:idx], p.Addresses[idx+1:]...)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearDefaultShipping unsets IsDefaultShipping on all addresses.
|
||||||
|
func (p *ProfileGrain) clearDefaultShipping() {
|
||||||
|
for i := range p.Addresses {
|
||||||
|
p.Addresses[i].IsDefaultShipping = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearDefaultBilling unsets IsDefaultBilling on all addresses.
|
||||||
|
func (p *ProfileGrain) clearDefaultBilling() {
|
||||||
|
for i := range p.Addresses {
|
||||||
|
p.Addresses[i].IsDefaultBilling = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// protoToStored converts a proto Address to a stored StoredAddress.
|
||||||
|
func protoToStored(id uint32, m *messages.Address) *StoredAddress {
|
||||||
|
return &StoredAddress{
|
||||||
|
Id: id,
|
||||||
|
Label: m.Label,
|
||||||
|
FullName: m.FullName,
|
||||||
|
AddressLine1: m.AddressLine1,
|
||||||
|
AddressLine2: m.GetAddressLine2(),
|
||||||
|
City: m.City,
|
||||||
|
State: m.State,
|
||||||
|
Zip: m.Zip,
|
||||||
|
Country: m.Country,
|
||||||
|
Phone: m.GetPhone(),
|
||||||
|
IsDefaultShipping: m.IsDefaultShipping,
|
||||||
|
IsDefaultBilling: m.IsDefaultBilling,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package profile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
messages "git.k6n.net/mats/go-cart-actor/proto/profile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleLinkCart links a cart grain to this user profile.
|
||||||
|
func HandleLinkCart(p *ProfileGrain, m *messages.LinkCart) error {
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("LinkCart: nil payload")
|
||||||
|
}
|
||||||
|
if m.CartId == 0 {
|
||||||
|
return fmt.Errorf("LinkCart: cartId is required")
|
||||||
|
}
|
||||||
|
// Check for duplicate
|
||||||
|
for _, c := range p.Carts {
|
||||||
|
if c.CartId == m.CartId {
|
||||||
|
return nil // already linked; idempotent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.Carts = append(p.Carts, LinkedCart{
|
||||||
|
CartId: m.CartId,
|
||||||
|
Label: m.Label,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleLinkCheckout links a checkout grain to this user profile.
|
||||||
|
func HandleLinkCheckout(p *ProfileGrain, m *messages.LinkCheckout) error {
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("LinkCheckout: nil payload")
|
||||||
|
}
|
||||||
|
if m.CheckoutId == 0 {
|
||||||
|
return fmt.Errorf("LinkCheckout: checkoutId is required")
|
||||||
|
}
|
||||||
|
// Check for duplicate
|
||||||
|
for _, ch := range p.Checkouts {
|
||||||
|
if ch.CheckoutId == m.CheckoutId {
|
||||||
|
return nil // already linked; idempotent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.Checkouts = append(p.Checkouts, LinkedCheckout{
|
||||||
|
CheckoutId: m.CheckoutId,
|
||||||
|
CartId: m.CartId,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleLinkOrder links an order to this user profile.
|
||||||
|
func HandleLinkOrder(p *ProfileGrain, m *messages.LinkOrder) error {
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("LinkOrder: nil payload")
|
||||||
|
}
|
||||||
|
if m.OrderReference == "" {
|
||||||
|
return fmt.Errorf("LinkOrder: orderReference is required")
|
||||||
|
}
|
||||||
|
// Check for duplicate (by index so we can mutate)
|
||||||
|
for i := range p.Orders {
|
||||||
|
if p.Orders[i].OrderReference == m.OrderReference {
|
||||||
|
// Update status snapshot
|
||||||
|
p.Orders[i].Status = m.Status
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.Orders = append(p.Orders, LinkedOrder{
|
||||||
|
OrderReference: m.OrderReference,
|
||||||
|
CartId: m.CartId,
|
||||||
|
Status: m.Status,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package profile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
messages "git.k6n.net/mats/go-cart-actor/proto/profile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleSetProfile updates the user's core profile information.
|
||||||
|
func HandleSetProfile(p *ProfileGrain, m *messages.SetProfile) error {
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("SetProfile: nil payload")
|
||||||
|
}
|
||||||
|
if m.Name != nil {
|
||||||
|
p.Name = *m.Name
|
||||||
|
}
|
||||||
|
if m.Email != nil {
|
||||||
|
p.Email = *m.Email
|
||||||
|
}
|
||||||
|
if m.Phone != nil {
|
||||||
|
p.Phone = *m.Phone
|
||||||
|
}
|
||||||
|
if m.Language != nil {
|
||||||
|
p.Language = *m.Language
|
||||||
|
}
|
||||||
|
if m.Currency != nil {
|
||||||
|
p.Currency = *m.Currency
|
||||||
|
}
|
||||||
|
if m.AvatarUrl != nil {
|
||||||
|
p.AvatarUrl = *m.AvatarUrl
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
// Package profile implements a user-profile actor grain (same event-driven
|
||||||
|
// framework as the cart, checkout, and order grains). Mutations are the proto
|
||||||
|
// messages in proto/profile; the grain's event log is the source of truth and
|
||||||
|
// ProfileGrain is the projection rebuilt by replaying them.
|
||||||
|
package profile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LinkedCart records a cart id linked to this profile.
|
||||||
|
type LinkedCart struct {
|
||||||
|
CartId uint64 `json:"cartId"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinkedCheckout records a checkout id linked to this profile.
|
||||||
|
type LinkedCheckout struct {
|
||||||
|
CheckoutId uint64 `json:"checkoutId"`
|
||||||
|
CartId uint64 `json:"cartId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinkedOrder records an order linked to this profile.
|
||||||
|
type LinkedOrder struct {
|
||||||
|
OrderReference string `json:"orderReference"`
|
||||||
|
CartId uint64 `json:"cartId"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StoredAddress is a saved address in the user's address book.
|
||||||
|
type StoredAddress struct {
|
||||||
|
Id uint32 `json:"id"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
FullName string `json:"fullName,omitempty"`
|
||||||
|
AddressLine1 string `json:"addressLine1"`
|
||||||
|
AddressLine2 string `json:"addressLine2,omitempty"`
|
||||||
|
City string `json:"city"`
|
||||||
|
State string `json:"state,omitempty"`
|
||||||
|
Zip string `json:"zip"`
|
||||||
|
Country string `json:"country"`
|
||||||
|
Phone string `json:"phone,omitempty"`
|
||||||
|
IsDefaultShipping bool `json:"isDefaultShipping,omitempty"`
|
||||||
|
IsDefaultBilling bool `json:"isDefaultBilling,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProfileGrain is the projected current state of a user profile. It implements
|
||||||
|
// actor.Grain[ProfileGrain].
|
||||||
|
type ProfileGrain struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
lastAccess time.Time
|
||||||
|
lastChange time.Time
|
||||||
|
nextAddrId uint32
|
||||||
|
|
||||||
|
Id uint64 `json:"id"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
Phone string `json:"phone,omitempty"`
|
||||||
|
Language string `json:"language,omitempty"`
|
||||||
|
Currency string `json:"currency,omitempty"`
|
||||||
|
|
||||||
|
AvatarUrl string `json:"avatarUrl,omitempty"`
|
||||||
|
Carts []LinkedCart `json:"carts,omitempty"`
|
||||||
|
Checkouts []LinkedCheckout `json:"checkouts,omitempty"`
|
||||||
|
Orders []LinkedOrder `json:"orders,omitempty"`
|
||||||
|
Addresses []StoredAddress `json:"addresses,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProfileGrain returns an empty profile grain for id.
|
||||||
|
func NewProfileGrain(id uint64, ts time.Time) *ProfileGrain {
|
||||||
|
return &ProfileGrain{
|
||||||
|
Id: id,
|
||||||
|
lastAccess: ts,
|
||||||
|
lastChange: ts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- actor.Grain[ProfileGrain] ---
|
||||||
|
|
||||||
|
func (p *ProfileGrain) GetId() uint64 { return p.Id }
|
||||||
|
|
||||||
|
func (p *ProfileGrain) GetLastAccess() time.Time { return p.lastAccess }
|
||||||
|
|
||||||
|
func (p *ProfileGrain) GetLastChange() time.Time { return p.lastChange }
|
||||||
|
|
||||||
|
func (p *ProfileGrain) GetCurrentState() (*ProfileGrain, error) {
|
||||||
|
p.lastAccess = time.Now()
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ProfileGrain) GetState() ([]byte, error) { return json.Marshal(p) }
|
||||||
|
|
||||||
|
// NextAddrId returns the next address id and advances the counter.
|
||||||
|
func (p *ProfileGrain) NextAddrId() uint32 {
|
||||||
|
p.nextAddrId++
|
||||||
|
return p.nextAddrId
|
||||||
|
}
|
||||||
|
|
||||||
|
// findAddress returns a pointer to the address with the given id, or nil.
|
||||||
|
func (p *ProfileGrain) findAddress(id uint32) *StoredAddress {
|
||||||
|
for i := range p.Addresses {
|
||||||
|
if p.Addresses[i].Id == id {
|
||||||
|
return &p.Addresses[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,633 @@
|
|||||||
|
package profile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||||
|
messages "git.k6n.net/mats/go-cart-actor/proto/profile"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- helpers (mirroring order/order_test.go) ---
|
||||||
|
|
||||||
|
func apply(t *testing.T, reg actor.MutationRegistry, g *ProfileGrain, msg proto.Message) error {
|
||||||
|
t.Helper()
|
||||||
|
results, err := reg.Apply(context.Background(), g, msg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(results) != 1 {
|
||||||
|
t.Fatalf("expected 1 result, got %d", len(results))
|
||||||
|
}
|
||||||
|
return results[0].Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustApply(t *testing.T, reg actor.MutationRegistry, g *ProfileGrain, msg proto.Message) {
|
||||||
|
t.Helper()
|
||||||
|
if err := apply(t, reg, g, msg); err != nil {
|
||||||
|
t.Fatalf("apply %T: %v", msg, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRegistry() actor.MutationRegistry {
|
||||||
|
return NewProfileMutationRegistry()
|
||||||
|
}
|
||||||
|
|
||||||
|
// strPtr is a helper for proto optional string fields.
|
||||||
|
func strPtr(s string) *string { return &s }
|
||||||
|
func boolPtr(b bool) *bool { return &b }
|
||||||
|
|
||||||
|
// --- SetProfile ---
|
||||||
|
|
||||||
|
func TestSetProfileAllFields(t *testing.T) {
|
||||||
|
reg := newRegistry()
|
||||||
|
g := NewProfileGrain(1, time.Now())
|
||||||
|
|
||||||
|
mustApply(t, reg, g, &messages.SetProfile{
|
||||||
|
Name: strPtr("Jane Doe"),
|
||||||
|
Email: strPtr("jane@example.com"),
|
||||||
|
Phone: strPtr("+46 70 123 45 67"),
|
||||||
|
Language: strPtr("sv"),
|
||||||
|
Currency: strPtr("SEK"),
|
||||||
|
AvatarUrl: strPtr("https://cdn.example.com/avatar/jane.jpg"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if g.Name != "Jane Doe" {
|
||||||
|
t.Fatalf("Name = %q, want %q", g.Name, "Jane Doe")
|
||||||
|
}
|
||||||
|
if g.Email != "jane@example.com" {
|
||||||
|
t.Fatalf("Email = %q, want %q", g.Email, "jane@example.com")
|
||||||
|
}
|
||||||
|
if g.Phone != "+46 70 123 45 67" {
|
||||||
|
t.Fatalf("Phone = %q, want %q", g.Phone, "+46 70 123 45 67")
|
||||||
|
}
|
||||||
|
if g.Language != "sv" {
|
||||||
|
t.Fatalf("Language = %q, want %q", g.Language, "sv")
|
||||||
|
}
|
||||||
|
if g.Currency != "SEK" {
|
||||||
|
t.Fatalf("Currency = %q, want %q", g.Currency, "SEK")
|
||||||
|
}
|
||||||
|
if g.AvatarUrl != "https://cdn.example.com/avatar/jane.jpg" {
|
||||||
|
t.Fatalf("AvatarUrl = %q, want %q", g.AvatarUrl, "https://cdn.example.com/avatar/jane.jpg")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetProfilePartialUpdate(t *testing.T) {
|
||||||
|
reg := newRegistry()
|
||||||
|
g := NewProfileGrain(2, time.Now())
|
||||||
|
g.Name = "Old Name"
|
||||||
|
g.Email = "old@example.com"
|
||||||
|
|
||||||
|
mustApply(t, reg, g, &messages.SetProfile{
|
||||||
|
Email: strPtr("updated@example.com"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if g.Name != "Old Name" {
|
||||||
|
t.Fatalf("Name should be unchanged, got %q", g.Name)
|
||||||
|
}
|
||||||
|
if g.Email != "updated@example.com" {
|
||||||
|
t.Fatalf("Email = %q, want %q", g.Email, "updated@example.com")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetProfileNilMessage(t *testing.T) {
|
||||||
|
g := NewProfileGrain(3, time.Now())
|
||||||
|
if err := HandleSetProfile(g, nil); err == nil {
|
||||||
|
t.Fatal("expected error for nil SetProfile, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- AddAddress ---
|
||||||
|
|
||||||
|
func addrProto(id *uint32) *messages.Address {
|
||||||
|
return &messages.Address{
|
||||||
|
Label: "Home",
|
||||||
|
FullName: "Jane Doe",
|
||||||
|
AddressLine1: "Storgatan 1",
|
||||||
|
City: "Stockholm",
|
||||||
|
State: "Stockholm",
|
||||||
|
Zip: "111 22",
|
||||||
|
Country: "SE",
|
||||||
|
Phone: strPtr("+46 70 123 45 67"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddAddress(t *testing.T) {
|
||||||
|
reg := newRegistry()
|
||||||
|
g := NewProfileGrain(10, time.Now())
|
||||||
|
|
||||||
|
mustApply(t, reg, g, &messages.AddAddress{Address: addrProto(nil)})
|
||||||
|
|
||||||
|
if len(g.Addresses) != 1 {
|
||||||
|
t.Fatalf("expected 1 address, got %d", len(g.Addresses))
|
||||||
|
}
|
||||||
|
a := g.Addresses[0]
|
||||||
|
if a.Id != 1 {
|
||||||
|
t.Fatalf("expected address id 1, got %d", a.Id)
|
||||||
|
}
|
||||||
|
if a.Label != "Home" {
|
||||||
|
t.Fatalf("Label = %q, want %q", a.Label, "Home")
|
||||||
|
}
|
||||||
|
if a.City != "Stockholm" {
|
||||||
|
t.Fatalf("City = %q, want %q", a.City, "Stockholm")
|
||||||
|
}
|
||||||
|
if a.Phone != "+46 70 123 45 67" {
|
||||||
|
t.Fatalf("Phone = %q, want %q", a.Phone, "+46 70 123 45 67")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddAddressIncrementsId(t *testing.T) {
|
||||||
|
reg := newRegistry()
|
||||||
|
g := NewProfileGrain(11, time.Now())
|
||||||
|
|
||||||
|
mustApply(t, reg, g, &messages.AddAddress{Address: addrProto(nil)})
|
||||||
|
mustApply(t, reg, g, &messages.AddAddress{Address: addrProto(nil)})
|
||||||
|
mustApply(t, reg, g, &messages.AddAddress{Address: addrProto(nil)})
|
||||||
|
|
||||||
|
if len(g.Addresses) != 3 {
|
||||||
|
t.Fatalf("expected 3 addresses, got %d", len(g.Addresses))
|
||||||
|
}
|
||||||
|
for i, a := range g.Addresses {
|
||||||
|
if want := uint32(i + 1); a.Id != want {
|
||||||
|
t.Fatalf("address %d id = %d, want %d", i, a.Id, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddAddressDefaultShippingClearsOthers(t *testing.T) {
|
||||||
|
reg := newRegistry()
|
||||||
|
g := NewProfileGrain(12, time.Now())
|
||||||
|
|
||||||
|
// Add two non-default addresses
|
||||||
|
addr1 := addrProto(nil)
|
||||||
|
addr1.IsDefaultShipping = false
|
||||||
|
addr1.IsDefaultBilling = false
|
||||||
|
mustApply(t, reg, g, &messages.AddAddress{Address: addr1})
|
||||||
|
|
||||||
|
addr2 := addrProto(nil)
|
||||||
|
addr2.IsDefaultShipping = false
|
||||||
|
addr2.IsDefaultBilling = false
|
||||||
|
mustApply(t, reg, g, &messages.AddAddress{Address: addr2})
|
||||||
|
|
||||||
|
// Add a default-shipping address — should clear the previous ones
|
||||||
|
addr3 := addrProto(nil)
|
||||||
|
addr3.IsDefaultShipping = true
|
||||||
|
mustApply(t, reg, g, &messages.AddAddress{Address: addr3})
|
||||||
|
|
||||||
|
if len(g.Addresses) != 3 {
|
||||||
|
t.Fatalf("expected 3 addresses, got %d", len(g.Addresses))
|
||||||
|
}
|
||||||
|
if !g.Addresses[2].IsDefaultShipping {
|
||||||
|
t.Fatal("expected address 3 to be default shipping")
|
||||||
|
}
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
if g.Addresses[i].IsDefaultShipping {
|
||||||
|
t.Fatalf("address %d should not be default shipping", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddAddressNil(t *testing.T) {
|
||||||
|
reg := newRegistry()
|
||||||
|
g := NewProfileGrain(13, time.Now())
|
||||||
|
|
||||||
|
if err := apply(t, reg, g, &messages.AddAddress{Address: nil}); err == nil {
|
||||||
|
t.Fatal("expected error for nil address, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- UpdateAddress ---
|
||||||
|
|
||||||
|
func TestUpdateAddressAllFields(t *testing.T) {
|
||||||
|
reg := newRegistry()
|
||||||
|
g := NewProfileGrain(20, time.Now())
|
||||||
|
|
||||||
|
mustApply(t, reg, g, &messages.AddAddress{Address: addrProto(nil)})
|
||||||
|
|
||||||
|
mustApply(t, reg, g, &messages.UpdateAddress{
|
||||||
|
Id: 1,
|
||||||
|
Label: strPtr("Work"),
|
||||||
|
FullName: strPtr("Jane Doe Updated"),
|
||||||
|
AddressLine1: strPtr("Drottninggatan 10"),
|
||||||
|
City: strPtr("Göteborg"),
|
||||||
|
State: strPtr("Västra Götaland"),
|
||||||
|
Zip: strPtr("411 03"),
|
||||||
|
Country: strPtr("SE"),
|
||||||
|
Phone: strPtr("+46 70 987 65 43"),
|
||||||
|
})
|
||||||
|
|
||||||
|
a := g.Addresses[0]
|
||||||
|
if a.Label != "Work" {
|
||||||
|
t.Fatalf("Label = %q, want %q", a.Label, "Work")
|
||||||
|
}
|
||||||
|
if a.City != "Göteborg" {
|
||||||
|
t.Fatalf("City = %q, want %q", a.City, "Göteborg")
|
||||||
|
}
|
||||||
|
if a.Phone != "+46 70 987 65 43" {
|
||||||
|
t.Fatalf("Phone = %q, want %q", a.Phone, "+46 70 987 65 43")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateAddressPartialFields(t *testing.T) {
|
||||||
|
reg := newRegistry()
|
||||||
|
g := NewProfileGrain(21, time.Now())
|
||||||
|
|
||||||
|
mustApply(t, reg, g, &messages.AddAddress{Address: addrProto(nil)})
|
||||||
|
|
||||||
|
// Update only the label
|
||||||
|
mustApply(t, reg, g, &messages.UpdateAddress{
|
||||||
|
Id: 1,
|
||||||
|
Label: strPtr("Summer Home"),
|
||||||
|
})
|
||||||
|
|
||||||
|
a := g.Addresses[0]
|
||||||
|
if a.Label != "Summer Home" {
|
||||||
|
t.Fatalf("Label = %q, want %q", a.Label, "Summer Home")
|
||||||
|
}
|
||||||
|
if a.City != "Stockholm" {
|
||||||
|
t.Fatalf("City should be unchanged, got %q", a.City)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateAddressNotFound(t *testing.T) {
|
||||||
|
reg := newRegistry()
|
||||||
|
g := NewProfileGrain(22, time.Now())
|
||||||
|
|
||||||
|
if err := apply(t, reg, g, &messages.UpdateAddress{Id: 999, Label: strPtr("Nowhere")}); err == nil {
|
||||||
|
t.Fatal("expected error for non-existent address, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateAddressDefaultShippingClearsOthers(t *testing.T) {
|
||||||
|
reg := newRegistry()
|
||||||
|
g := NewProfileGrain(23, time.Now())
|
||||||
|
|
||||||
|
addr1 := addrProto(nil)
|
||||||
|
addr1.IsDefaultShipping = true
|
||||||
|
mustApply(t, reg, g, &messages.AddAddress{Address: addr1})
|
||||||
|
mustApply(t, reg, g, &messages.AddAddress{Address: addrProto(nil)})
|
||||||
|
|
||||||
|
// Update address 2 to be default shipping — should clear address 1
|
||||||
|
mustApply(t, reg, g, &messages.UpdateAddress{
|
||||||
|
Id: 2,
|
||||||
|
IsDefaultShipping: boolPtr(true),
|
||||||
|
})
|
||||||
|
|
||||||
|
if !g.Addresses[1].IsDefaultShipping {
|
||||||
|
t.Fatal("expected address 2 to be default shipping")
|
||||||
|
}
|
||||||
|
if g.Addresses[0].IsDefaultShipping {
|
||||||
|
t.Fatal("address 1 should no longer be default shipping")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- RemoveAddress ---
|
||||||
|
|
||||||
|
func TestRemoveAddress(t *testing.T) {
|
||||||
|
reg := newRegistry()
|
||||||
|
g := NewProfileGrain(30, time.Now())
|
||||||
|
|
||||||
|
mustApply(t, reg, g, &messages.AddAddress{Address: addrProto(nil)})
|
||||||
|
mustApply(t, reg, g, &messages.AddAddress{Address: addrProto(nil)})
|
||||||
|
mustApply(t, reg, g, &messages.AddAddress{Address: addrProto(nil)})
|
||||||
|
|
||||||
|
mustApply(t, reg, g, &messages.RemoveAddress{Id: 2})
|
||||||
|
|
||||||
|
if len(g.Addresses) != 2 {
|
||||||
|
t.Fatalf("expected 2 addresses after removal, got %d", len(g.Addresses))
|
||||||
|
}
|
||||||
|
if g.Addresses[0].Id != 1 {
|
||||||
|
t.Fatalf("expected address id 1 at index 0, got %d", g.Addresses[0].Id)
|
||||||
|
}
|
||||||
|
if g.Addresses[1].Id != 3 {
|
||||||
|
t.Fatalf("expected address id 3 at index 1, got %d", g.Addresses[1].Id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveAddressNotFound(t *testing.T) {
|
||||||
|
reg := newRegistry()
|
||||||
|
g := NewProfileGrain(31, time.Now())
|
||||||
|
|
||||||
|
mustApply(t, reg, g, &messages.AddAddress{Address: addrProto(nil)})
|
||||||
|
|
||||||
|
if err := apply(t, reg, g, &messages.RemoveAddress{Id: 99}); err == nil {
|
||||||
|
t.Fatal("expected error for removing non-existent address, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveAddressLastElement(t *testing.T) {
|
||||||
|
reg := newRegistry()
|
||||||
|
g := NewProfileGrain(32, time.Now())
|
||||||
|
|
||||||
|
mustApply(t, reg, g, &messages.AddAddress{Address: addrProto(nil)})
|
||||||
|
mustApply(t, reg, g, &messages.RemoveAddress{Id: 1})
|
||||||
|
|
||||||
|
if len(g.Addresses) != 0 {
|
||||||
|
t.Fatalf("expected 0 addresses, got %d", len(g.Addresses))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- LinkCart ---
|
||||||
|
|
||||||
|
func TestLinkCart(t *testing.T) {
|
||||||
|
reg := newRegistry()
|
||||||
|
g := NewProfileGrain(40, time.Now())
|
||||||
|
|
||||||
|
mustApply(t, reg, g, &messages.LinkCart{
|
||||||
|
CartId: 1001,
|
||||||
|
Label: "current cart",
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(g.Carts) != 1 {
|
||||||
|
t.Fatalf("expected 1 linked cart, got %d", len(g.Carts))
|
||||||
|
}
|
||||||
|
if g.Carts[0].CartId != 1001 {
|
||||||
|
t.Fatalf("CartId = %d, want %d", g.Carts[0].CartId, 1001)
|
||||||
|
}
|
||||||
|
if g.Carts[0].Label != "current cart" {
|
||||||
|
t.Fatalf("Label = %q, want %q", g.Carts[0].Label, "current cart")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinkCartMultiple(t *testing.T) {
|
||||||
|
reg := newRegistry()
|
||||||
|
g := NewProfileGrain(41, time.Now())
|
||||||
|
|
||||||
|
mustApply(t, reg, g, &messages.LinkCart{CartId: 1, Label: "first"})
|
||||||
|
mustApply(t, reg, g, &messages.LinkCart{CartId: 2, Label: "second"})
|
||||||
|
|
||||||
|
if len(g.Carts) != 2 {
|
||||||
|
t.Fatalf("expected 2 linked carts, got %d", len(g.Carts))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinkCartDuplicateIdempotent(t *testing.T) {
|
||||||
|
reg := newRegistry()
|
||||||
|
g := NewProfileGrain(42, time.Now())
|
||||||
|
|
||||||
|
mustApply(t, reg, g, &messages.LinkCart{CartId: 1001})
|
||||||
|
mustApply(t, reg, g, &messages.LinkCart{CartId: 1001}) // same cart again
|
||||||
|
|
||||||
|
if len(g.Carts) != 1 {
|
||||||
|
t.Fatalf("expected 1 linked cart (idempotent), got %d", len(g.Carts))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinkCartMissingId(t *testing.T) {
|
||||||
|
reg := newRegistry()
|
||||||
|
g := NewProfileGrain(43, time.Now())
|
||||||
|
|
||||||
|
if err := apply(t, reg, g, &messages.LinkCart{CartId: 0}); err == nil {
|
||||||
|
t.Fatal("expected error for zero cartId, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- LinkCheckout ---
|
||||||
|
|
||||||
|
func TestLinkCheckout(t *testing.T) {
|
||||||
|
reg := newRegistry()
|
||||||
|
g := NewProfileGrain(50, time.Now())
|
||||||
|
|
||||||
|
mustApply(t, reg, g, &messages.LinkCheckout{
|
||||||
|
CheckoutId: 2001,
|
||||||
|
CartId: 1001,
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(g.Checkouts) != 1 {
|
||||||
|
t.Fatalf("expected 1 linked checkout, got %d", len(g.Checkouts))
|
||||||
|
}
|
||||||
|
if g.Checkouts[0].CheckoutId != 2001 {
|
||||||
|
t.Fatalf("CheckoutId = %d, want %d", g.Checkouts[0].CheckoutId, 2001)
|
||||||
|
}
|
||||||
|
if g.Checkouts[0].CartId != 1001 {
|
||||||
|
t.Fatalf("CartId = %d, want %d", g.Checkouts[0].CartId, 1001)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinkCheckoutMultiple(t *testing.T) {
|
||||||
|
reg := newRegistry()
|
||||||
|
g := NewProfileGrain(51, time.Now())
|
||||||
|
|
||||||
|
mustApply(t, reg, g, &messages.LinkCheckout{CheckoutId: 1, CartId: 10})
|
||||||
|
mustApply(t, reg, g, &messages.LinkCheckout{CheckoutId: 2, CartId: 20})
|
||||||
|
|
||||||
|
if len(g.Checkouts) != 2 {
|
||||||
|
t.Fatalf("expected 2 linked checkouts, got %d", len(g.Checkouts))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinkCheckoutDuplicateIdempotent(t *testing.T) {
|
||||||
|
reg := newRegistry()
|
||||||
|
g := NewProfileGrain(52, time.Now())
|
||||||
|
|
||||||
|
mustApply(t, reg, g, &messages.LinkCheckout{CheckoutId: 2001, CartId: 1001})
|
||||||
|
mustApply(t, reg, g, &messages.LinkCheckout{CheckoutId: 2001, CartId: 1001})
|
||||||
|
|
||||||
|
if len(g.Checkouts) != 1 {
|
||||||
|
t.Fatalf("expected 1 linked checkout (idempotent), got %d", len(g.Checkouts))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinkCheckoutMissingId(t *testing.T) {
|
||||||
|
reg := newRegistry()
|
||||||
|
g := NewProfileGrain(53, time.Now())
|
||||||
|
|
||||||
|
if err := apply(t, reg, g, &messages.LinkCheckout{CheckoutId: 0}); err == nil {
|
||||||
|
t.Fatal("expected error for zero checkoutId, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- LinkOrder ---
|
||||||
|
|
||||||
|
func TestLinkOrder(t *testing.T) {
|
||||||
|
reg := newRegistry()
|
||||||
|
g := NewProfileGrain(60, time.Now())
|
||||||
|
|
||||||
|
mustApply(t, reg, g, &messages.LinkOrder{
|
||||||
|
OrderReference: "ORD-001",
|
||||||
|
CartId: 1001,
|
||||||
|
Status: "pending",
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(g.Orders) != 1 {
|
||||||
|
t.Fatalf("expected 1 linked order, got %d", len(g.Orders))
|
||||||
|
}
|
||||||
|
if g.Orders[0].OrderReference != "ORD-001" {
|
||||||
|
t.Fatalf("OrderReference = %q, want %q", g.Orders[0].OrderReference, "ORD-001")
|
||||||
|
}
|
||||||
|
if g.Orders[0].CartId != 1001 {
|
||||||
|
t.Fatalf("CartId = %d, want %d", g.Orders[0].CartId, 1001)
|
||||||
|
}
|
||||||
|
if g.Orders[0].Status != "pending" {
|
||||||
|
t.Fatalf("Status = %q, want %q", g.Orders[0].Status, "pending")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinkOrderUpdatesStatusOnDuplicate(t *testing.T) {
|
||||||
|
reg := newRegistry()
|
||||||
|
g := NewProfileGrain(61, time.Now())
|
||||||
|
|
||||||
|
mustApply(t, reg, g, &messages.LinkOrder{
|
||||||
|
OrderReference: "ORD-001",
|
||||||
|
CartId: 1001,
|
||||||
|
Status: "pending",
|
||||||
|
})
|
||||||
|
mustApply(t, reg, g, &messages.LinkOrder{
|
||||||
|
OrderReference: "ORD-001",
|
||||||
|
CartId: 1001,
|
||||||
|
Status: "captured",
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(g.Orders) != 1 {
|
||||||
|
t.Fatalf("expected 1 linked order (idempotent), got %d", len(g.Orders))
|
||||||
|
}
|
||||||
|
if g.Orders[0].Status != "captured" {
|
||||||
|
t.Fatalf("Status = %q, want %q", g.Orders[0].Status, "captured")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinkOrderMissingReference(t *testing.T) {
|
||||||
|
reg := newRegistry()
|
||||||
|
g := NewProfileGrain(62, time.Now())
|
||||||
|
|
||||||
|
if err := apply(t, reg, g, &messages.LinkOrder{OrderReference: "", CartId: 1001}); err == nil {
|
||||||
|
t.Fatal("expected error for empty orderReference, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Nil message edge cases (call handlers directly with typed nil pointers;
|
||||||
|
// the registry silently skips typed-nil messages, so Apply does not reach
|
||||||
|
// the handler and we must test the handler in isolation) ---
|
||||||
|
|
||||||
|
func TestNilMessages(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
}{
|
||||||
|
{"nil SetProfile", HandleSetProfile(NewProfileGrain(70, time.Now()), (*messages.SetProfile)(nil))},
|
||||||
|
{"nil AddAddress", HandleAddAddress(NewProfileGrain(70, time.Now()), (*messages.AddAddress)(nil))},
|
||||||
|
{"nil UpdateAddress", HandleUpdateAddress(NewProfileGrain(70, time.Now()), (*messages.UpdateAddress)(nil))},
|
||||||
|
{"nil RemoveAddress", HandleRemoveAddress(NewProfileGrain(70, time.Now()), (*messages.RemoveAddress)(nil))},
|
||||||
|
{"nil LinkCart", HandleLinkCart(NewProfileGrain(70, time.Now()), (*messages.LinkCart)(nil))},
|
||||||
|
{"nil LinkCheckout", HandleLinkCheckout(NewProfileGrain(70, time.Now()), (*messages.LinkCheckout)(nil))},
|
||||||
|
{"nil LinkOrder", HandleLinkOrder(NewProfileGrain(70, time.Now()), (*messages.LinkOrder)(nil))},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.err == nil {
|
||||||
|
t.Fatal("expected error for nil message, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Event-sourced replay test (mirroring order/order_test.go) ---
|
||||||
|
|
||||||
|
func TestEventSourcedReplay(t *testing.T) {
|
||||||
|
reg := newRegistry()
|
||||||
|
storage := actor.NewDiskStorage[ProfileGrain](t.TempDir(), reg)
|
||||||
|
|
||||||
|
const id uint64 = 100
|
||||||
|
g := NewProfileGrain(id, time.Now())
|
||||||
|
|
||||||
|
events := []proto.Message{
|
||||||
|
&messages.SetProfile{
|
||||||
|
Name: strPtr("Jane Doe"),
|
||||||
|
Email: strPtr("jane@example.com"),
|
||||||
|
},
|
||||||
|
&messages.AddAddress{Address: addrProto(nil)},
|
||||||
|
&messages.LinkCart{CartId: 1001, Label: "current"},
|
||||||
|
&messages.LinkCheckout{CheckoutId: 2001, CartId: 1001},
|
||||||
|
&messages.LinkOrder{OrderReference: "ORD-001", CartId: 1001, Status: "pending"},
|
||||||
|
&messages.AddAddress{
|
||||||
|
Address: &messages.Address{
|
||||||
|
Label: "Work",
|
||||||
|
FullName: "Jane Doe",
|
||||||
|
AddressLine1: "Kungsgatan 5",
|
||||||
|
City: "Malmö",
|
||||||
|
State: "Skåne",
|
||||||
|
Zip: "211 11",
|
||||||
|
Country: "SE",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
&messages.UpdateAddress{
|
||||||
|
Id: 1,
|
||||||
|
Label: strPtr("Home (Updated)"),
|
||||||
|
},
|
||||||
|
&messages.LinkOrder{OrderReference: "ORD-002", CartId: 1002, Status: "captured"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, e := range events {
|
||||||
|
mustApply(t, reg, g, e)
|
||||||
|
if err := storage.AppendMutations(id, e); err != nil {
|
||||||
|
t.Fatalf("append: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify initial grain state before replay.
|
||||||
|
if g.Name != "Jane Doe" {
|
||||||
|
t.Fatalf("Name = %q, want %q", g.Name, "Jane Doe")
|
||||||
|
}
|
||||||
|
if len(g.Addresses) != 2 {
|
||||||
|
t.Fatalf("expected 2 addresses, got %d", len(g.Addresses))
|
||||||
|
}
|
||||||
|
if g.Addresses[0].Label != "Home (Updated)" {
|
||||||
|
t.Fatalf("first address label = %q, want %q", g.Addresses[0].Label, "Home (Updated)")
|
||||||
|
}
|
||||||
|
if len(g.Carts) != 1 {
|
||||||
|
t.Fatalf("expected 1 cart, got %d", len(g.Carts))
|
||||||
|
}
|
||||||
|
if len(g.Checkouts) != 1 {
|
||||||
|
t.Fatalf("expected 1 checkout, got %d", len(g.Checkouts))
|
||||||
|
}
|
||||||
|
if len(g.Orders) != 2 {
|
||||||
|
t.Fatalf("expected 2 orders, got %d", len(g.Orders))
|
||||||
|
}
|
||||||
|
if g.Orders[1].Status != "captured" {
|
||||||
|
t.Fatalf("second order status = %q, want %q", g.Orders[1].Status, "captured")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replay into a brand-new grain and compare the projection.
|
||||||
|
replayed := NewProfileGrain(id, time.Now())
|
||||||
|
if err := storage.LoadEvents(context.Background(), id, replayed); err != nil {
|
||||||
|
t.Fatalf("replay: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if replayed.Name != g.Name {
|
||||||
|
t.Fatalf("replayed Name = %q, want %q", replayed.Name, g.Name)
|
||||||
|
}
|
||||||
|
if replayed.Email != g.Email {
|
||||||
|
t.Fatalf("replayed Email = %q, want %q", replayed.Email, g.Email)
|
||||||
|
}
|
||||||
|
if len(replayed.Addresses) != len(g.Addresses) {
|
||||||
|
t.Fatalf("replayed addresses = %d, want %d", len(replayed.Addresses), len(g.Addresses))
|
||||||
|
}
|
||||||
|
if replayed.Addresses[0].Label != g.Addresses[0].Label {
|
||||||
|
t.Fatalf("replayed first address label = %q, want %q", replayed.Addresses[0].Label, g.Addresses[0].Label)
|
||||||
|
}
|
||||||
|
if replayed.Addresses[0].Id != g.Addresses[0].Id {
|
||||||
|
t.Fatalf("replayed first address id = %d, want %d", replayed.Addresses[0].Id, g.Addresses[0].Id)
|
||||||
|
}
|
||||||
|
// Address book: second address should have been added AFTER the update to
|
||||||
|
// address 1, so its id is 2 and it should match the original.
|
||||||
|
if replayed.Addresses[1].City != g.Addresses[1].City {
|
||||||
|
t.Fatalf("replayed second address city = %q, want %q", replayed.Addresses[1].City, g.Addresses[1].City)
|
||||||
|
}
|
||||||
|
if len(replayed.Carts) != len(g.Carts) {
|
||||||
|
t.Fatalf("replayed carts = %d, want %d", len(replayed.Carts), len(g.Carts))
|
||||||
|
}
|
||||||
|
if len(replayed.Checkouts) != len(g.Checkouts) {
|
||||||
|
t.Fatalf("replayed checkouts = %d, want %d", len(replayed.Checkouts), len(g.Checkouts))
|
||||||
|
}
|
||||||
|
if len(replayed.Orders) != len(g.Orders) {
|
||||||
|
t.Fatalf("replayed orders = %d, want %d", len(replayed.Orders), len(g.Orders))
|
||||||
|
}
|
||||||
|
if replayed.Orders[0].Status != g.Orders[0].Status {
|
||||||
|
t.Fatalf("replayed first order status = %q, want %q", replayed.Orders[0].Status, g.Orders[0].Status)
|
||||||
|
}
|
||||||
|
if replayed.Orders[1].Status != g.Orders[1].Status {
|
||||||
|
t.Fatalf("replayed second order status = %q, want %q", replayed.Orders[1].Status, g.Orders[1].Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
package profile_messages;
|
||||||
|
option go_package = "git.k6n.net/mats/go-cart-actor/proto/profile;profile_messages";
|
||||||
|
|
||||||
|
// Profile mutations — each message is an immutable event in a user's profile
|
||||||
|
// event log (actor.DiskStorage). The current ProfileGrain is a projection
|
||||||
|
// rebuilt by replaying them in order.
|
||||||
|
|
||||||
|
// Address represents a stored address in the user's profile.
|
||||||
|
message Address {
|
||||||
|
uint32 id = 1;
|
||||||
|
string label = 2; // e.g. "Home", "Work"
|
||||||
|
string fullName = 3;
|
||||||
|
string addressLine1 = 4;
|
||||||
|
optional string addressLine2 = 5;
|
||||||
|
string city = 6;
|
||||||
|
string state = 7;
|
||||||
|
string zip = 8;
|
||||||
|
string country = 9;
|
||||||
|
optional string phone = 10;
|
||||||
|
bool isDefaultShipping = 11;
|
||||||
|
bool isDefaultBilling = 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetProfile sets the user's core profile information.
|
||||||
|
message SetProfile {
|
||||||
|
optional string name = 1;
|
||||||
|
optional string email = 2;
|
||||||
|
optional string phone = 3;
|
||||||
|
optional string language = 4;
|
||||||
|
optional string currency = 5;
|
||||||
|
optional string avatarUrl = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddAddress adds a new address to the user's address book.
|
||||||
|
message AddAddress {
|
||||||
|
Address address = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAddress updates an existing address (matched by id).
|
||||||
|
message UpdateAddress {
|
||||||
|
uint32 id = 1;
|
||||||
|
optional string label = 2;
|
||||||
|
optional string fullName = 3;
|
||||||
|
optional string addressLine1 = 4;
|
||||||
|
optional string addressLine2 = 5;
|
||||||
|
optional string city = 6;
|
||||||
|
optional string state = 7;
|
||||||
|
optional string zip = 8;
|
||||||
|
optional string country = 9;
|
||||||
|
optional string phone = 10;
|
||||||
|
optional bool isDefaultShipping = 11;
|
||||||
|
optional bool isDefaultBilling = 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveAddress removes an address from the address book.
|
||||||
|
message RemoveAddress {
|
||||||
|
uint32 id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinkCart links a cart grain to this user profile.
|
||||||
|
message LinkCart {
|
||||||
|
uint64 cartId = 1;
|
||||||
|
string label = 2; // optional label, e.g. "current cart"
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinkCheckout links a checkout grain to this user profile.
|
||||||
|
message LinkCheckout {
|
||||||
|
uint64 checkoutId = 1;
|
||||||
|
uint64 cartId = 2; // the cart this checkout belongs to
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinkOrder links an order to this user profile.
|
||||||
|
message LinkOrder {
|
||||||
|
string orderReference = 1; // order reference / id
|
||||||
|
uint64 cartId = 2; // originating cart
|
||||||
|
string status = 3; // order status snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
message Mutation {
|
||||||
|
oneof type {
|
||||||
|
SetProfile set_profile = 1;
|
||||||
|
AddAddress add_address = 2;
|
||||||
|
UpdateAddress update_address = 3;
|
||||||
|
RemoveAddress remove_address = 4;
|
||||||
|
LinkCart link_cart = 5;
|
||||||
|
LinkCheckout link_checkout = 6;
|
||||||
|
LinkOrder link_order = 7;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user