499 lines
15 KiB
Go
499 lines
15 KiB
Go
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 "/" now has POST / registered (create customer), so GET / is 405.
|
|
// Paths with invalid base62 characters should get 400.
|
|
tests := []struct {
|
|
path string
|
|
expectedStatus int
|
|
}{
|
|
{"/", http.StatusMethodNotAllowed}, // POST / exists, GET doesn't
|
|
{"/!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)
|
|
}
|
|
}
|