add profile actors
This commit is contained in:
@@ -0,0 +1,498 @@
|
||||
package ucp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/actor"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/cart"
|
||||
"git.k6n.net/mats/go-cart-actor/pkg/profile"
|
||||
messages "git.k6n.net/mats/go-cart-actor/proto/profile"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// testProfileApplier implements ProfileApplier with in-memory grains.
|
||||
type testProfileApplier struct {
|
||||
grains map[uint64]*profile.ProfileGrain
|
||||
}
|
||||
|
||||
func newTestProfileApplier() *testProfileApplier {
|
||||
return &testProfileApplier{
|
||||
grains: make(map[uint64]*profile.ProfileGrain),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *testProfileApplier) Get(_ context.Context, id uint64) (*profile.ProfileGrain, error) {
|
||||
g, ok := a.grains[id]
|
||||
if !ok {
|
||||
g = profile.NewProfileGrain(id, time.UnixMilli(1000000))
|
||||
a.grains[id] = g
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func (a *testProfileApplier) Apply(ctx context.Context, id uint64, msgs ...proto.Message) (*actor.MutationResult[profile.ProfileGrain], error) {
|
||||
g, err := a.Get(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, msg := range msgs {
|
||||
switch m := msg.(type) {
|
||||
case *messages.SetProfile:
|
||||
if m.Name != nil {
|
||||
g.Name = *m.Name
|
||||
}
|
||||
if m.Email != nil {
|
||||
g.Email = *m.Email
|
||||
}
|
||||
if m.Phone != nil {
|
||||
g.Phone = *m.Phone
|
||||
}
|
||||
if m.Language != nil {
|
||||
g.Language = *m.Language
|
||||
}
|
||||
if m.Currency != nil {
|
||||
g.Currency = *m.Currency
|
||||
}
|
||||
if m.AvatarUrl != nil {
|
||||
g.AvatarUrl = *m.AvatarUrl
|
||||
}
|
||||
case *messages.AddAddress:
|
||||
if m.Address == nil {
|
||||
continue
|
||||
}
|
||||
g.Addresses = append(g.Addresses, profile.StoredAddress{
|
||||
Id: g.NextAddrId(),
|
||||
Label: m.Address.Label,
|
||||
FullName: m.Address.FullName,
|
||||
AddressLine1: m.Address.AddressLine1,
|
||||
AddressLine2: m.Address.GetAddressLine2(),
|
||||
City: m.Address.City,
|
||||
State: m.Address.State,
|
||||
Zip: m.Address.Zip,
|
||||
Country: m.Address.Country,
|
||||
Phone: m.Address.GetPhone(),
|
||||
IsDefaultShipping: m.Address.IsDefaultShipping,
|
||||
IsDefaultBilling: m.Address.IsDefaultBilling,
|
||||
})
|
||||
case *messages.UpdateAddress:
|
||||
for i := range g.Addresses {
|
||||
if g.Addresses[i].Id == m.Id {
|
||||
if m.Label != nil {
|
||||
g.Addresses[i].Label = *m.Label
|
||||
}
|
||||
if m.AddressLine1 != nil {
|
||||
g.Addresses[i].AddressLine1 = *m.AddressLine1
|
||||
}
|
||||
}
|
||||
}
|
||||
case *messages.RemoveAddress:
|
||||
for i := range g.Addresses {
|
||||
if g.Addresses[i].Id == m.Id {
|
||||
g.Addresses = append(g.Addresses[:i], g.Addresses[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
case *messages.LinkCart:
|
||||
g.Carts = append(g.Carts, profile.LinkedCart{CartId: m.CartId, Label: m.Label})
|
||||
case *messages.LinkCheckout:
|
||||
g.Checkouts = append(g.Checkouts, profile.LinkedCheckout{CheckoutId: m.CheckoutId, CartId: m.CartId})
|
||||
case *messages.LinkOrder:
|
||||
g.Orders = append(g.Orders, profile.LinkedOrder{OrderReference: m.OrderReference, CartId: m.CartId, Status: m.Status})
|
||||
}
|
||||
}
|
||||
return &actor.MutationResult[profile.ProfileGrain]{
|
||||
Result: *g,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// testCartApplier is a minimal CartApplier mock for combined handler tests.
|
||||
type testCartApplier struct{}
|
||||
|
||||
func (a *testCartApplier) Get(ctx context.Context, id uint64) (*cart.CartGrain, error) {
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
func (a *testCartApplier) Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[cart.CartGrain], error) {
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
// Helper to get a test profile ID as a base62 string.
|
||||
func testProfileID(t *testing.T, applier *testProfileApplier) string {
|
||||
t.Helper()
|
||||
pid, err := profile.NewProfileId()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate profile id: %v", err)
|
||||
}
|
||||
// Seed the grain.
|
||||
applier.Get(context.Background(), uint64(pid))
|
||||
return pid.String()
|
||||
}
|
||||
|
||||
func TestGetCustomer(t *testing.T) {
|
||||
applier := newTestProfileApplier()
|
||||
id := testProfileID(t, applier)
|
||||
handler := CustomerHandler(applier)
|
||||
|
||||
// Set up some profile data
|
||||
pid, _ := profile.ParseProfileId(id)
|
||||
applier.Apply(context.Background(), uint64(pid), &messages.SetProfile{
|
||||
Name: proto.String("Alice"),
|
||||
Email: proto.String("alice@example.com"),
|
||||
})
|
||||
|
||||
// GET /customers/{id}
|
||||
req := httptest.NewRequest("GET", fmt.Sprintf("/%s", id), nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK, got %d. Body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp CustomerResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp.Id != id {
|
||||
t.Fatalf("expected id %q, got %q", id, resp.Id)
|
||||
}
|
||||
if resp.Name != "Alice" {
|
||||
t.Fatalf("expected Name 'Alice', got %q", resp.Name)
|
||||
}
|
||||
if resp.Email != "alice@example.com" {
|
||||
t.Fatalf("expected Email 'alice@example.com', got %q", resp.Email)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCustomerNotFound(t *testing.T) {
|
||||
applier := newTestProfileApplier()
|
||||
handler := CustomerHandler(applier)
|
||||
|
||||
// "nonexistent" is actually valid base62, so use an ID with characters outside the alphabet.
|
||||
req := httptest.NewRequest("GET", "/!invalid!", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 Bad Request for invalid id, got %d. Body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCustomer(t *testing.T) {
|
||||
applier := newTestProfileApplier()
|
||||
id := testProfileID(t, applier)
|
||||
handler := CustomerHandler(applier)
|
||||
|
||||
body := `{"name": "Bob", "email": "bob@example.com", "phone": "+46701234567", "language": "sv", "currency": "SEK"}`
|
||||
req := httptest.NewRequest("PUT", fmt.Sprintf("/%s", id), strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK, got %d. Body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp CustomerResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp.Name != "Bob" {
|
||||
t.Fatalf("expected Name 'Bob', got %q", resp.Name)
|
||||
}
|
||||
if resp.Email != "bob@example.com" {
|
||||
t.Fatalf("expected Email 'bob@example.com', got %q", resp.Email)
|
||||
}
|
||||
if resp.Phone != "+46701234567" {
|
||||
t.Fatalf("expected Phone '+46701234567', got %q", resp.Phone)
|
||||
}
|
||||
if resp.Language != "sv" {
|
||||
t.Fatalf("expected Language 'sv', got %q", resp.Language)
|
||||
}
|
||||
if resp.Currency != "SEK" {
|
||||
t.Fatalf("expected Currency 'SEK', got %q", resp.Currency)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddAddress(t *testing.T) {
|
||||
applier := newTestProfileApplier()
|
||||
id := testProfileID(t, applier)
|
||||
handler := CustomerHandler(applier)
|
||||
|
||||
body := `{
|
||||
"label": "Home",
|
||||
"fullName": "Alice",
|
||||
"addressLine1": "Storgatan 1",
|
||||
"addressLine2": "Lgh 101",
|
||||
"city": "Stockholm",
|
||||
"zip": "111 22",
|
||||
"country": "SE",
|
||||
"phone": "+46701234567"
|
||||
}`
|
||||
req := httptest.NewRequest("POST", fmt.Sprintf("/%s/addresses", id), strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201 Created, got %d. Body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp CustomerResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if len(resp.Addresses) != 1 {
|
||||
t.Fatalf("expected 1 address, got %d", len(resp.Addresses))
|
||||
}
|
||||
if resp.Addresses[0].Label != "Home" {
|
||||
t.Fatalf("expected address label 'Home', got %q", resp.Addresses[0].Label)
|
||||
}
|
||||
if resp.Addresses[0].AddressLine1 != "Storgatan 1" {
|
||||
t.Fatalf("expected addressLine1 'Storgatan 1', got %q", resp.Addresses[0].AddressLine1)
|
||||
}
|
||||
if resp.Addresses[0].AddressLine2 != "Lgh 101" {
|
||||
t.Fatalf("expected addressLine2 'Lgh 101', got %q", resp.Addresses[0].AddressLine2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddAddressMissingRequired(t *testing.T) {
|
||||
applier := newTestProfileApplier()
|
||||
id := testProfileID(t, applier)
|
||||
handler := CustomerHandler(applier)
|
||||
|
||||
// Missing addressLine1
|
||||
body := `{"city": "Stockholm", "zip": "111 22", "country": "SE"}`
|
||||
req := httptest.NewRequest("POST", fmt.Sprintf("/%s/addresses", id), strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 Bad Request for missing required fields, got %d. Body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAddress(t *testing.T) {
|
||||
applier := newTestProfileApplier()
|
||||
id := testProfileID(t, applier)
|
||||
handler := CustomerHandler(applier)
|
||||
|
||||
// First add an address
|
||||
pid, _ := profile.ParseProfileId(id)
|
||||
applier.Apply(context.Background(), uint64(pid), &messages.AddAddress{
|
||||
Address: &messages.Address{
|
||||
Label: "Old Home",
|
||||
FullName: "Alice",
|
||||
AddressLine1: "Old Street 1",
|
||||
City: "Old City",
|
||||
Zip: "000 00",
|
||||
Country: "SE",
|
||||
},
|
||||
})
|
||||
|
||||
// Get the address id from the grain
|
||||
grain, _ := applier.Get(context.Background(), uint64(pid))
|
||||
if len(grain.Addresses) == 0 {
|
||||
t.Fatal("expected at least one address")
|
||||
}
|
||||
addrID := grain.Addresses[0].Id
|
||||
|
||||
body := fmt.Sprintf(`{"label": "New Home", "addressLine1": "New Street 2"}`)
|
||||
req := httptest.NewRequest("PUT", fmt.Sprintf("/%s/addresses/%d", id, addrID), strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK, got %d. Body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp CustomerResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if len(resp.Addresses) != 1 {
|
||||
t.Fatalf("expected 1 address, got %d", len(resp.Addresses))
|
||||
}
|
||||
if resp.Addresses[0].Label != "New Home" {
|
||||
t.Fatalf("expected label 'New Home', got %q", resp.Addresses[0].Label)
|
||||
}
|
||||
if resp.Addresses[0].AddressLine1 != "New Street 2" {
|
||||
t.Fatalf("expected addressLine1 'New Street 2', got %q", resp.Addresses[0].AddressLine1)
|
||||
}
|
||||
// Unchanged fields should still be there
|
||||
if resp.Addresses[0].City != "Old City" {
|
||||
t.Fatalf("expected City 'Old City', got %q", resp.Addresses[0].City)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAddressInvalidID(t *testing.T) {
|
||||
applier := newTestProfileApplier()
|
||||
id := testProfileID(t, applier)
|
||||
handler := CustomerHandler(applier)
|
||||
|
||||
body := `{"label": "New Home"}`
|
||||
req := httptest.NewRequest("PUT", fmt.Sprintf("/%s/addresses/abc", id), strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 Bad Request for invalid address id, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveAddress(t *testing.T) {
|
||||
applier := newTestProfileApplier()
|
||||
id := testProfileID(t, applier)
|
||||
handler := CustomerHandler(applier)
|
||||
|
||||
// First add an address
|
||||
pid, _ := profile.ParseProfileId(id)
|
||||
applier.Apply(context.Background(), uint64(pid), &messages.AddAddress{
|
||||
Address: &messages.Address{
|
||||
Label: "Remove Me",
|
||||
FullName: "Alice",
|
||||
AddressLine1: "Storgatan 1",
|
||||
City: "Stockholm",
|
||||
Zip: "111 22",
|
||||
Country: "SE",
|
||||
},
|
||||
})
|
||||
|
||||
grain, _ := applier.Get(context.Background(), uint64(pid))
|
||||
if len(grain.Addresses) == 0 {
|
||||
t.Fatal("expected at least one address")
|
||||
}
|
||||
addrID := grain.Addresses[0].Id
|
||||
|
||||
// DELETE /customers/{id}/addresses/{addressId}
|
||||
req := httptest.NewRequest("DELETE", fmt.Sprintf("/%s/addresses/%d", id, addrID), nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK, got %d. Body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
grain, _ = applier.Get(context.Background(), uint64(pid))
|
||||
if len(grain.Addresses) != 0 {
|
||||
t.Fatalf("expected 0 addresses after removal, got %d", len(grain.Addresses))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveAddressNotFound(t *testing.T) {
|
||||
applier := newTestProfileApplier()
|
||||
pid, err := profile.NewProfileId()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate profile id: %v", err)
|
||||
}
|
||||
applier.Get(context.Background(), uint64(pid))
|
||||
|
||||
id := pid.String()
|
||||
handler := CustomerHandler(applier)
|
||||
req := httptest.NewRequest("DELETE", fmt.Sprintf("/%s/addresses/999", id), nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
// Test applier silently ignores not-found removals (returns no error).
|
||||
// In production, HandleRemoveAddress returns an error which maps to 404.
|
||||
// Accept 200 here since the test applier is a simplified mock.
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 (test applier limitation), got %d. Body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoveAddressNotFoundRealistic tests the real mutation handler path via the registry.
|
||||
// It uses the real HandleRemoveAddress mutation to verify 404 behavior.
|
||||
func TestRemoveAddressNotFoundMutation(t *testing.T) {
|
||||
// Test the real HandleRemoveAddress mutation directly.
|
||||
g := profile.NewProfileGrain(1, time.Now())
|
||||
|
||||
// Add an address via the real handler
|
||||
if err := profile.HandleAddAddress(g, &messages.AddAddress{
|
||||
Address: &messages.Address{
|
||||
Label: "Test",
|
||||
FullName: "Alice",
|
||||
AddressLine1: "Storgatan 1",
|
||||
City: "Stockholm",
|
||||
Zip: "111 22",
|
||||
Country: "SE",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("HandleAddAddress failed: %v", err)
|
||||
}
|
||||
|
||||
// Try to remove address 999 (doesn't exist)
|
||||
err := profile.HandleRemoveAddress(g, &messages.RemoveAddress{Id: 999})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-existent address, got nil")
|
||||
}
|
||||
expected := "RemoveAddress: address 999 not found"
|
||||
if err.Error() != expected {
|
||||
t.Fatalf("expected error %q, got %q", expected, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerHandlerInvalidID(t *testing.T) {
|
||||
applier := newTestProfileApplier()
|
||||
handler := CustomerHandler(applier)
|
||||
|
||||
// Path "/" doesn't match the /{id} pattern — falls through to 404.
|
||||
// Paths with invalid base62 characters should get 400.
|
||||
tests := []struct {
|
||||
path string
|
||||
expectedStatus int
|
||||
}{
|
||||
{"/", http.StatusNotFound}, // doesn't match /{id}
|
||||
{"/!invalid!", http.StatusBadRequest}, // has non-base62 chars
|
||||
}
|
||||
for _, tt := range tests {
|
||||
req := httptest.NewRequest("GET", tt.path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != tt.expectedStatus {
|
||||
t.Fatalf("for path %q: expected %d, got %d. Body: %s", tt.path, tt.expectedStatus, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerHandlerThroughCombinedMount(t *testing.T) {
|
||||
// Test that the combined Handler correctly mounts customer endpoints.
|
||||
// Use a mock CartApplier (bare minimum — never called in this test).
|
||||
mockCart := &testCartApplier{}
|
||||
profileApplier := newTestProfileApplier()
|
||||
|
||||
pid, _ := profile.NewProfileId()
|
||||
profileApplier.Get(context.Background(), uint64(pid))
|
||||
|
||||
handler := Handler(mockCart, Options{
|
||||
ProfileApplier: profileApplier,
|
||||
})
|
||||
|
||||
// GET /customers/{id} should work
|
||||
id := pid.String()
|
||||
req := httptest.NewRequest("GET", fmt.Sprintf("/customers/%s", id), nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK, got %d. Body: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp CustomerResponse
|
||||
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp.Id != id {
|
||||
t.Fatalf("expected id %q, got %q", id, resp.Id)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user