package mcp import ( "bytes" "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "testing" "time" "git.k6n.net/mats/go-cart-actor/pkg/actor" "git.k6n.net/mats/go-cart-actor/pkg/cart" "google.golang.org/protobuf/proto" ) // testApplier implements Applier with an in-memory map of carts, using the real // cart mutation registry so write tools exercise the actual mutation handlers. type testApplier struct { grains map[uint64]*cart.CartGrain reg actor.MutationRegistry } func newTestApplier() *testApplier { reg := cart.NewCartMultationRegistry(cart.NewCartMutationContext(nil)) return &testApplier{ grains: make(map[uint64]*cart.CartGrain), reg: reg, } } func (a *testApplier) addCart(id uint64, items int) *cart.CartGrain { g := cart.NewCartGrain(id, time.Now()) g.Currency = "SEK" g.Language = "sv-se" for i := 1; i <= items; i++ { g.Items = append(g.Items, &cart.CartItem{ Id: uint32(i), ItemId: uint32(1000 + i), Sku: fmt.Sprintf("SKU-%03d", i), Quantity: uint16(i), Price: *cart.NewPriceFromIncVat(int64(i*10000), 25), Tax: 2500, Meta: &cart.ItemMeta{Name: fmt.Sprintf("Item %d", i)}, }) } g.UpdateTotals() a.grains[id] = g return g } func (a *testApplier) Get(_ context.Context, id uint64) (*cart.CartGrain, error) { g, ok := a.grains[id] if !ok { return nil, fmt.Errorf("cart %d not found", id) } return g, nil } func (a *testApplier) Apply(ctx context.Context, id uint64, msgs ...proto.Message) (*actor.MutationResult[cart.CartGrain], error) { g, ok := a.grains[id] if !ok { return nil, fmt.Errorf("cart %d not found", id) } results, err := a.reg.Apply(ctx, g, msgs...) if err != nil { return nil, err } // Re-read state after mutations. state, err := g.GetCurrentState() if err != nil { return nil, err } return &actor.MutationResult[cart.CartGrain]{ Result: *state, //nolint:govet // test helper snapshot, same pattern as SimpleGrainPool Mutations: results, }, nil } // call issues a tools/call against the handler and returns the decoded text // payload (the JSON the tool returned), failing on a tool/transport error. func call(t *testing.T, h http.Handler, name string, args map[string]any) map[string]any { t.Helper() body, _ := json.Marshal(map[string]any{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": map[string]any{"name": name, "arguments": args}, }) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body)) h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("%s: status %d", name, rec.Code) } var resp struct { Result struct { IsError bool `json:"isError"` Content []struct { Text string `json:"text"` } `json:"content"` } `json:"result"` Error *struct { Message string `json:"message"` } `json:"error"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("%s: decode response: %v (%s)", name, err, rec.Body.String()) } if resp.Error != nil { t.Fatalf("%s: rpc error: %s", name, resp.Error.Message) } if resp.Result.IsError { t.Fatalf("%s: tool error: %s", name, resp.Result.Content[0].Text) } var out map[string]any if len(resp.Result.Content) > 0 { _ = json.Unmarshal([]byte(resp.Result.Content[0].Text), &out) } return out } func TestCartMCPRoundTrip(t *testing.T) { a := newTestApplier() a.addCart(42, 3) // cart 42 with 3 items h := New(a).Handler() // get_cart got := call(t, h, "get_cart", map[string]any{"cartId": "g"}) if got == nil { t.Fatal("get_cart returned nil") } if id, _ := got["id"].(string); id != "g" { t.Errorf("get_cart id = %v, want g", got["id"]) } if curr, _ := got["currency"].(string); curr != "SEK" { t.Errorf("get_cart currency = %q, want SEK", curr) } // get_cart_items items := call(t, h, "get_cart_items", map[string]any{"cartId": "g"}) if c, _ := items["count"].(float64); c != 3 { t.Errorf("get_cart_items count = %v, want 3", items["count"]) } // get_cart_totals totals := call(t, h, "get_cart_totals", map[string]any{"cartId": "g"}) if _, ok := totals["totalPrice"]; !ok { t.Errorf("get_cart_totals missing totalPrice: %v", totals) } if _, ok := totals["totalDiscount"]; !ok { t.Errorf("get_cart_totals missing totalDiscount: %v", totals) } // get_cart_vouchers (empty cart) vouchers := call(t, h, "get_cart_vouchers", map[string]any{"cartId": "g"}) if c, _ := vouchers["count"].(float64); c != 0 { t.Errorf("get_cart_vouchers count = %v, want 0", vouchers["count"]) } // update_item_quantity: change item id=1 from qty 1 to qty 5 updated := call(t, h, "update_item_quantity", map[string]any{ "cartId": "g", "itemId": 1, "quantity": 5, }) if updated == nil { t.Fatal("update_item_quantity returned nil") } // Verify via get_cart_items items2 := call(t, h, "get_cart_items", map[string]any{"cartId": "g"}) itemsRaw, _ := items2["items"].([]any) if len(itemsRaw) != 3 { t.Fatalf("after qty change: want 3 items, got %d", len(itemsRaw)) } firstItem := itemsRaw[0].(map[string]any) if q, _ := firstItem["qty"].(float64); q != 5 { t.Errorf("item 1 qty = %v, want 5", q) } // remove_cart_item: remove item id=2 removed := call(t, h, "remove_cart_item", map[string]any{ "cartId": "g", "itemId": 2, }) if removed == nil { t.Fatal("remove_cart_item returned nil") } items3 := call(t, h, "get_cart_items", map[string]any{"cartId": "g"}) itemsRaw3, _ := items3["items"].([]any) if len(itemsRaw3) != 2 { t.Fatalf("after remove: want 2 items, got %d", len(itemsRaw3)) } // apply_voucher withVoucher := call(t, h, "apply_voucher", map[string]any{ "cartId": "g", "code": "SAVE10", "value": 10000, }) if withVoucher == nil { t.Fatal("apply_voucher returned nil") } vouchers2 := call(t, h, "get_cart_vouchers", map[string]any{"cartId": "g"}) if c, _ := vouchers2["count"].(float64); c != 1 { t.Errorf("after voucher apply: count = %v, want 1", vouchers2["count"]) } // remove_voucher: find the voucher id voucherList, _ := vouchers2["vouchers"].([]any) if len(voucherList) > 0 { v := voucherList[0].(map[string]any) vID := v["id"].(float64) call(t, h, "remove_voucher", map[string]any{ "cartId": "g", "voucherId": int(vID), }) vouchers3 := call(t, h, "get_cart_vouchers", map[string]any{"cartId": "g"}) if c, _ := vouchers3["count"].(float64); c != 0 { t.Errorf("after remove voucher: count = %v, want 0", vouchers3["count"]) } } // set_cart_user withUser := call(t, h, "set_cart_user", map[string]any{ "cartId": "g", "userId": "user-abc-123", }) if withUser == nil { t.Fatal("set_cart_user returned nil") } // clear_cart cleared := call(t, h, "clear_cart", map[string]any{"cartId": "g"}) if cleared == nil { t.Fatal("clear_cart returned nil") } items4 := call(t, h, "get_cart_items", map[string]any{"cartId": "g"}) if c, _ := items4["count"].(float64); c != 0 { t.Errorf("after clear: count = %v, want 0", items4["count"]) } } func TestCartMCPErrors(t *testing.T) { a := newTestApplier() a.addCart(1, 1) h := New(a).Handler() // Invalid cart id -> isError result. result := callWithRaw(t, h, "get_cart", map[string]any{"cartId": "!!!"}) if !result.IsError { t.Fatal("expected isError for invalid cart id") } // Missing required cartId -> still hits decode (cartId absent is empty string) result2 := callWithRaw(t, h, "get_cart", map[string]any{}) if !result2.IsError { t.Fatal("expected isError for missing cartId") } // Non-existent cart -> isError result. result3 := callWithRaw(t, h, "get_cart", map[string]any{"cartId": "zzzzzz"}) if !result3.IsError { t.Fatal("expected isError for non-existent cart") } // Remove non-existent item -> isError result4 := callWithRaw(t, h, "remove_cart_item", map[string]any{ "cartId": "1", "itemId": 999, }) if !result4.IsError { t.Fatal("expected isError for non-existent item") } // Unknown tool -> protocol error. var resp struct { Error *struct { Message string `json:"message"` } `json:"error"` } body, _ := json.Marshal(map[string]any{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": map[string]any{"name": "nope", "arguments": map[string]any{}}, }) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body)) h.ServeHTTP(rec, req) json.Unmarshal(rec.Body.Bytes(), &resp) if resp.Error == nil { t.Fatal("expected protocol error for unknown tool") } } // callWithRaw returns the raw result (including isError) without failing. type rawResult struct { IsError bool Content []struct { Text string `json:"text"` } } func callWithRaw(t *testing.T, h http.Handler, name string, args map[string]any) rawResult { t.Helper() body, _ := json.Marshal(map[string]any{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": map[string]any{"name": name, "arguments": args}, }) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body)) h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("%s: status %d", name, rec.Code) } var resp struct { Result rawResult `json:"result"` Error *struct{ Message string } `json:"error"` } if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("%s: decode: %v", name, err) } if resp.Error != nil { t.Fatalf("%s: rpc error: %s", name, resp.Error.Message) } return resp.Result } func TestInitializeAndToolsList(t *testing.T) { a := newTestApplier() h := New(a).Handler() // Initialize. body, _ := json.Marshal(map[string]any{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": map[string]any{"protocolVersion": "2025-06-18"}, }) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body)) h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("initialize: status %d", rec.Code) } var initResp struct { Result struct { ProtocolVersion string `json:"protocolVersion"` ServerInfo struct { Name string `json:"name"` } `json:"serverInfo"` } `json:"result"` } json.Unmarshal(rec.Body.Bytes(), &initResp) if initResp.Result.ProtocolVersion != "2025-06-18" || initResp.Result.ServerInfo.Name != "cart" { t.Fatalf("unexpected initialize: %+v", initResp) } // tools/list. body2, _ := json.Marshal(map[string]any{ "jsonrpc": "2.0", "id": 1, "method": "tools/list", }) rec2 := httptest.NewRecorder() req2 := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body2)) h.ServeHTTP(rec2, req2) var listResp struct { Result struct { Tools []struct { Name string `json:"name"` InputSchema json.RawMessage `json:"inputSchema"` } `json:"tools"` } `json:"result"` } json.Unmarshal(rec2.Body.Bytes(), &listResp) want := map[string]bool{ "get_cart": false, "update_item_quantity": false, "remove_cart_item": false, "clear_cart": false, } for _, tl := range listResp.Result.Tools { if _, ok := want[tl.Name]; ok { want[tl.Name] = true } if len(tl.InputSchema) == 0 { t.Fatalf("tool %s has empty inputSchema", tl.Name) } } for name, found := range want { if !found { t.Fatalf("expected tool %q in tools/list", name) } } } func TestCartMCPCartIdRoundTrip(t *testing.T) { // Verify that we can round-trip a base62 cart id through tools. a := newTestApplier() // Use a larger cart id to test base62 encoding. g := cart.NewCartGrain(12345, time.Now()) g.Currency = "EUR" a.grains[12345] = g h := New(a).Handler() got := call(t, h, "get_cart", map[string]any{"cartId": g.Id.String()}) if curr, _ := got["currency"].(string); curr != "EUR" { t.Errorf("currency = %q, want EUR", curr) } } func TestGetCartPromotions(t *testing.T) { a := newTestApplier() g := a.addCart(99, 2) // Add a synthetic applied promotion. g.AppliedPromotions = []cart.AppliedPromotion{ {PromotionId: "volymrabatt", Name: "Volymrabatt", Type: "tiered_discount", Discount: cart.NewPriceFromIncVat(10000, 25)}, } h := New(a).Handler() result := call(t, h, "get_cart_promotions", map[string]any{"cartId": g.Id.String()}) if result == nil { t.Fatal("get_cart_promotions returned nil") } promos, _ := result["appliedPromotions"].([]any) if len(promos) != 1 { t.Fatalf("want 1 promotion, got %d", len(promos)) } p := promos[0].(map[string]any) if name, _ := p["name"].(string); name != "Volymrabatt" { t.Errorf("promotion name = %q, want Volymrabatt", name) } }