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
+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)
}
}