add profile actors
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user