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