422 lines
12 KiB
Go
422 lines
12 KiB
Go
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)
|
|
}
|
|
}
|