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