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