add profile actors
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 3s

This commit is contained in:
2026-06-25 17:19:59 +02:00
parent faa5330b81
commit 86797e520e
18 changed files with 3436 additions and 10 deletions
+33
View File
@@ -18,6 +18,7 @@ import (
"git.k6n.net/mats/go-cart-actor/pkg/promotions"
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/pkg/profile"
"git.k6n.net/mats/go-cart-actor/pkg/proxy"
"git.k6n.net/mats/go-cart-actor/pkg/voucher"
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
@@ -296,6 +297,38 @@ func main() {
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
// 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).
+18 -1
View File
@@ -3,10 +3,12 @@ package ucp
import (
"context"
"encoding/json"
"log"
"net/http"
"sync"
"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"
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
"google.golang.org/protobuf/proto"
@@ -15,6 +17,7 @@ import (
// CartServer is the UCP REST adapter over the cart grain pool.
type CartServer struct {
applier CartApplier
profileApplier ProfileApplier // optional; when set, links carts to customer profiles
newCartId func() (cart.CartId, error)
}
@@ -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
// ---------------------------------------------------------------------------
@@ -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 _, err := s.applier.Apply(r.Context(), id, &messages.SetUserId{UserId: *req.UserId}); err != nil {
writeError(w, http.StatusInternalServerError, "failed to set user: "+err.Error())
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)
+26
View File
@@ -6,12 +6,14 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
"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"
"google.golang.org/protobuf/types/known/anypb"
"google.golang.org/protobuf/types/known/timestamppb"
@@ -20,6 +22,7 @@ import (
// CheckoutServer is the UCP REST adapter over the checkout grain pool.
type CheckoutServer struct {
applier CheckoutApplier
profileApplier ProfileApplier // optional; when set links checkouts/orders to profiles
orderSvc OrderApplier // optional; when set creates real orders on complete
}
@@ -32,6 +35,11 @@ func NewCheckoutServer(applier CheckoutApplier, orderSvc ...OrderApplier) *Check
return s
}
// SetProfileApplier enables identity linking on this server.
func (s *CheckoutServer) SetProfileApplier(pa ProfileApplier) {
s.profileApplier = pa
}
// ---------------------------------------------------------------------------
// UCP Checkout endpoints
// ---------------------------------------------------------------------------
@@ -180,6 +188,15 @@ func (s *CheckoutServer) handleCreateCheckout(w http.ResponseWriter, r *http.Req
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))
}
@@ -336,6 +353,15 @@ func (s *CheckoutServer) handleCompleteCheckout(w http.ResponseWriter, r *http.R
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))
}
+351
View File
@@ -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
}
+498
View File
@@ -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)
}
}
+42
View File
@@ -73,6 +73,30 @@ func CheckoutHandler(applier CheckoutApplier, orderSvc ...OrderApplier) http.Han
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
// ---------------------------------------------------------------------------
@@ -81,6 +105,7 @@ func CheckoutHandler(applier CheckoutApplier, orderSvc ...OrderApplier) http.Han
type Options struct {
CheckoutApplier CheckoutApplier // when set, mounts /checkout-sessions
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
@@ -88,12 +113,16 @@ type Options struct {
//
// mux.Handle("/ucp/v1/", ucp.Handler(pool, ucp.Options{
// CheckoutApplier: checkoutPool,
// ProfileApplier: profilePool,
// }))
func Handler(cartApplier CartApplier, opts Options) http.Handler {
mux := http.NewServeMux()
// Cart endpoints
cartSrv := NewCartServer(cartApplier)
if opts.ProfileApplier != nil {
cartSrv.SetProfileApplier(opts.ProfileApplier)
}
mux.HandleFunc("POST /carts", cartSrv.handleCreateCart)
mux.HandleFunc("GET /carts/{id}", cartSrv.handleGetCart)
mux.HandleFunc("PUT /carts/{id}", cartSrv.handleUpdateCart)
@@ -106,6 +135,9 @@ func Handler(cartApplier CartApplier, opts Options) http.Handler {
orderOpts = []OrderApplier{opts.OrderApplier}
}
checkoutSrv := NewCheckoutServer(opts.CheckoutApplier, orderOpts...)
if opts.ProfileApplier != nil {
checkoutSrv.SetProfileApplier(opts.ProfileApplier)
}
mux.HandleFunc("POST /checkout-sessions", checkoutSrv.handleCreateCheckout)
mux.HandleFunc("GET /checkout-sessions/{id}", checkoutSrv.handleGetCheckout)
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)
}
// 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))
}
+81
View File
@@ -22,6 +22,7 @@ import (
"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/order"
"git.k6n.net/mats/go-cart-actor/pkg/profile"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"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)
}
// 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
// order from a completed checkout session. It abstracts over the transport
// (direct grain pool, HTTP, etc.) so the adapter is portable.
@@ -148,6 +155,7 @@ type CreateCheckoutRequest struct {
LineItems []CartItemInput `json:"line_items,omitempty"`
Country string `json:"country,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}.
@@ -187,6 +195,7 @@ type CompleteCheckoutRequest struct {
Provider string `json:"provider,omitempty"`
Currency string `json:"currency,omitempty"`
Country string `json:"country,omitempty"`
CustomerId string `json:"customerId,omitempty"` // profile ID for identity linking
}
// CheckoutResponse is the UCP checkout session response.
@@ -316,6 +325,78 @@ type OrderLineEntry struct {
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)
// ---------------------------------------------------------------------------
+18
View File
@@ -21,6 +21,7 @@ import (
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/checkout"
"git.k6n.net/mats/go-cart-actor/pkg/profile"
"git.k6n.net/mats/go-redis-inventory/pkg/inventory"
"git.k6n.net/mats/slask-finder/pkg/messaging"
amqp "github.com/rabbitmq/amqp091-go"
@@ -49,6 +50,8 @@ type Config struct {
DataDir string
// CheckoutDataDir holds the checkout event logs (default "checkout-data").
CheckoutDataDir string
// ProfileDataDir holds the profile event logs (optional, default "" — customer endpoints disabled).
ProfileDataDir string
// RedisAddress / RedisPassword reach the inventory store.
RedisAddress string
RedisPassword string
@@ -61,6 +64,7 @@ type App struct {
hub *Hub
rdb *redis.Client
inv *inventory.RedisInventoryService
cs *CustomerServer
}
// 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())
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{
fs: NewFileServer(cfg.DataDir, cfg.CheckoutDataDir, diskStorage, diskStorageCheckout),
hub: NewHub(),
rdb: rdb,
inv: inventoryService,
cs: customerSrv,
}, nil
}
@@ -114,6 +128,10 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/vouchers", a.fs.VoucherHandler)
mux.HandleFunc("/promotion/{id}", a.fs.PromotionPartHandler)
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) {
+195
View File
@@ -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
}
+70
View File
@@ -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:])
}
+21
View File
@@ -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
}
+130
View File
@@ -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,
}
}
+73
View File
@@ -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
}
+33
View File
@@ -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
}
+108
View File
@@ -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
}
+633
View File
@@ -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)
}
}
+90
View File
@@ -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