package ucp import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "git.k6n.net/mats/go-cart-actor/pkg/cart" "git.k6n.net/mats/go-cart-actor/pkg/actor" messages "git.k6n.net/mats/go-cart-actor/proto/cart" "google.golang.org/protobuf/proto" ) // testApplier implements CartApplier with an in-memory map of grains. type testApplier struct { grains map[uint64]*cart.CartGrain } func newTestApplier() *testApplier { return &testApplier{ grains: make(map[uint64]*cart.CartGrain), } } func (a *testApplier) Get(_ context.Context, id uint64) (*cart.CartGrain, error) { g, ok := a.grains[id] if !ok { g = cart.NewCartGrain(id, time.UnixMilli(1000000)) a.grains[id] = g } return g, nil } func (a *testApplier) Apply(_ context.Context, id uint64, msgs ...proto.Message) (*actor.MutationResult[cart.CartGrain], error) { g, err := a.Get(nil, id) if err != nil { return nil, err } results := make([]actor.ApplyResult, len(msgs)) for i, msg := range msgs { results[i] = actor.ApplyResult{Type: string(msg.ProtoReflect().Descriptor().FullName())} switch m := msg.(type) { case *messages.ClearCartRequest: g.Items = nil g.Vouchers = nil g.TotalDiscount = cart.NewPrice() g.TotalPrice = cart.NewPrice() case *messages.SetUserId: // userId is unexported; rely on serialization to verify. _ = m } } return &actor.MutationResult[cart.CartGrain]{ Result: *g, Mutations: results, }, nil } // mustParseID returns the uint64 representation of a base62 cart id string. func mustParseID(s string) uint64 { id, _ := cart.ParseCartId(s) return uint64(id) } func TestCartHandler_CreateCart(t *testing.T) { applier := newTestApplier() handler := CartHandler(applier) req := httptest.NewRequest("POST", "/", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("expected 201 Created, got %d", rec.Code) } var resp CartResponse if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("failed to decode response: %v", err) } if resp.Id == "" { t.Fatal("expected non-empty cart id") } if resp.Status != "active" { t.Fatalf("expected status 'active', got %q", resp.Status) } } func TestCartHandler_GetCartNotFound(t *testing.T) { applier := newTestApplier() handler := CartHandler(applier) req := httptest.NewRequest("GET", "/nonexistent", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) // /nonexistent is a valid base62 string, so it parses, but the cart // auto-creates on first Get, so we get a 200 with an empty cart. // This is expected behavior (UCP auto-creates carts on read). if rec.Code != http.StatusOK { t.Fatalf("expected 200 (auto-create), got %d", rec.Code) } } func TestCartHandler_CreateAndGet(t *testing.T) { applier := newTestApplier() handler := CartHandler(applier) // Create a cart. req := httptest.NewRequest("POST", "/", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("create failed: %d", rec.Code) } var created CartResponse json.NewDecoder(rec.Body).Decode(&created) // GET it back. req = httptest.NewRequest("GET", "/"+created.Id, nil) rec = httptest.NewRecorder() handler.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } var got CartResponse json.NewDecoder(rec.Body).Decode(&got) if got.Id != created.Id { t.Fatalf("expected id %q, got %q", created.Id, got.Id) } } func TestCartHandler_CancelCart(t *testing.T) { applier := newTestApplier() handler := CartHandler(applier) // Create a cart. req := httptest.NewRequest("POST", "/", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) var created CartResponse json.NewDecoder(rec.Body).Decode(&created) // Cancel it. req = httptest.NewRequest("POST", "/"+created.Id+"/cancel", nil) rec = httptest.NewRecorder() handler.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rec.Code) } var cancelled CartResponse json.NewDecoder(rec.Body).Decode(&cancelled) if cancelled.Id != created.Id { t.Fatalf("expected same id %q, got %q", created.Id, cancelled.Id) } } func TestCartHandler_UpdateCart(t *testing.T) { applier := newTestApplier() handler := CartHandler(applier) // Create a cart. req := httptest.NewRequest("POST", "/", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) var created CartResponse json.NewDecoder(rec.Body).Decode(&created) // Update with items (test applier doesn't process AddItem, but the // handler still returns 200 — items are populated by the real grain pool). updateBody := `{"items": [{"sku": "test-sku", "quantity": 2, "name": "Test Item"}]}` req = httptest.NewRequest("PUT", "/"+created.Id, strings.NewReader(updateBody)) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() handler.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) } var updated CartResponse json.NewDecoder(rec.Body).Decode(&updated) if updated.Id != created.Id { t.Fatalf("expected same id %q, got %q", created.Id, updated.Id) } } func TestCartResponse_Conversion(t *testing.T) { id := mustParseID("ABCD") g := cart.NewCartGrain(id, time.UnixMilli(1000000)) g.Currency = "SEK" g.Items = append(g.Items, &cart.CartItem{ Sku: "test-1", Quantity: 2, Price: *mustPrice(10000, 2500), TotalPrice: *mustPrice(20000, 5000), Tax: 2500, Meta: &cart.ItemMeta{Name: "Test Item"}, }) g.TotalPrice = mustPrice(20000, 5000) resp := cartGrainToResponse(g.Id.String(), g) if len(resp.Items) != 1 { t.Fatalf("expected 1 item, got %d", len(resp.Items)) } if resp.Items[0].Sku != "test-1" { t.Fatalf("expected sku 'test-1', got %q", resp.Items[0].Sku) } if resp.Items[0].Name != "Test Item" { t.Fatalf("expected name 'Test Item', got %q", resp.Items[0].Name) } if resp.Totals.TotalIncVat != 20000 { t.Fatalf("expected total 20000, got %d", resp.Totals.TotalIncVat) } if resp.Totals.Currency != "SEK" { t.Fatalf("expected currency SEK, got %q", resp.Totals.Currency) } } func mustPrice(incVat int64, totalVat int64) *cart.Price { return &cart.Price{ IncVat: incVat, VatRates: map[float32]int64{25: totalVat}, } }