Files
2026-07-01 10:40:28 +02:00

576 lines
18 KiB
Go

package ucp
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"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})
case *messages.AnonymizeProfile:
g.Name = ""
g.Email = ""
g.Phone = ""
g.AvatarUrl = ""
g.Addresses = nil
}
}
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)
}
}
type testCredentialDeleter struct {
deletedEmail string
}
func (d *testCredentialDeleter) Delete(_ context.Context, email string) (bool, error) {
d.deletedEmail = email
return true, nil
}
func TestDeleteCustomer(t *testing.T) {
applier := newTestProfileApplier()
id := testProfileID(t, applier)
deleter := &testCredentialDeleter{}
auditPath := filepath.Join(t.TempDir(), "audit.log")
handler := CustomerHandler(applier, auditPath, WithCredentialDeleter(deleter))
// 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"),
})
applier.Apply(context.Background(), uint64(pid), &messages.AddAddress{
Address: &messages.Address{
Label: "Home",
AddressLine1: "Storgatan 1",
City: "Stockholm",
Zip: "111 22",
Country: "SE",
},
})
// DELETE /customers/{id}
req := httptest.NewRequest("DELETE", fmt.Sprintf("/%s", id), nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("expected 204 No Content, got %d. Body: %s", rec.Code, rec.Body.String())
}
// Verify credentials deleted
if deleter.deletedEmail != "alice@example.com" {
t.Fatalf("expected deleted email 'alice@example.com', got %q", deleter.deletedEmail)
}
// Verify profile is anonymized
g, _ := applier.Get(context.Background(), uint64(pid))
if g.Name != "" || g.Email != "" || g.Addresses != nil {
t.Fatalf("expected profile to be anonymized, got Name=%q, Email=%q, Addresses=%v", g.Name, g.Email, g.Addresses)
}
// Verify audit log has the correct entry and no PII
logBytes, err := os.ReadFile(auditPath)
if err != nil {
t.Fatalf("failed to read audit log: %v", err)
}
logContent := string(logBytes)
if !strings.Contains(logContent, "ACTION=GDPR_ERASURE") {
t.Fatal("expected audit log to record erasure action")
}
if !strings.Contains(logContent, "PROFILE_ID="+id) {
t.Fatal("expected audit log to record profile ID")
}
if strings.Contains(logContent, "Alice") || strings.Contains(logContent, "alice@example.com") {
t.Fatal("expected audit log to exclude PII")
}
}