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/order" messages "git.k6n.net/mats/go-cart-actor/proto/order" "google.golang.org/protobuf/proto" ) // testApplier implements OrderApplier with an in-memory map of orders, using // the real order mutation registry so write tools exercise the actual handlers. type testApplier struct { grains map[uint64]*order.OrderGrain reg actor.MutationRegistry } func newTestApplier() *testApplier { reg := actor.NewMutationRegistry() order.RegisterMutations(reg) return &testApplier{ grains: make(map[uint64]*order.OrderGrain), reg: reg, } } // addPlacedOrder creates a fully placed order with n line items, returning its // base62 id string. func (a *testApplier) addPlacedOrder(id uint64, items int) string { g := order.NewOrderGrain(id, time.Now()) lines := make([]*messages.OrderLine, items) for i := 0; i < items; i++ { lines[i] = &messages.OrderLine{ Reference: fmt.Sprintf("line-%d", i+1), Sku: fmt.Sprintf("SKU-%03d", i+1), Name: fmt.Sprintf("Item %d", i+1), Quantity: int32(i + 1), UnitPrice: int64((i + 1) * 10000), TaxRate: 2500, } } // Place the order through the real handler. po := &messages.PlaceOrder{ OrderReference: "ord-" + order.OrderId(id).String(), CartId: "cart-abc", Currency: "SEK", Locale: "sv-se", Country: "se", CustomerEmail: "test@example.com", TotalAmount: 60000, TotalTax: 12000, PlacedAtMs: time.Now().UnixMilli(), Lines: lines, } results, err := a.reg.Apply(context.Background(), g, po) if err != nil || len(results) == 0 || results[0].Error != nil { panic(fmt.Sprintf("place order: %v %v", err, results)) } a.grains[id] = g return order.OrderId(id).String() } func (a *testApplier) Get(_ context.Context, id uint64) (*order.OrderGrain, error) { g, ok := a.grains[id] if !ok { return nil, fmt.Errorf("order %d not found", id) } return g, nil } func (a *testApplier) Apply(ctx context.Context, id uint64, msgs ...proto.Message) (*actor.MutationResult[order.OrderGrain], error) { g, ok := a.grains[id] if !ok { return nil, fmt.Errorf("order %d not found", id) } results, err := a.reg.Apply(ctx, g, msgs...) if err != nil { return nil, err } state, err := g.GetCurrentState() if err != nil { return nil, err } return &actor.MutationResult[order.OrderGrain]{ 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 } // 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 != "orders" { 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_order": false, "get_order_status": false, "cancel_order": false, "complete_order": false, "create_fulfillment": false, "request_return": false, "issue_refund": 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 TestGetOrder(t *testing.T) { a := newTestApplier() oid := a.addPlacedOrder(42, 2) h := New(a).Handler() got := call(t, h, "get_order", map[string]any{"orderId": oid}) if got == nil { t.Fatal("get_order returned nil") } if ref, _ := got["orderReference"].(string); ref != "ord-"+oid { t.Errorf("orderReference = %q, want ord-%s", ref, oid) } if curr, _ := got["currency"].(string); curr != "SEK" { t.Errorf("currency = %q, want SEK", curr) } if email, _ := got["customerEmail"].(string); email != "test@example.com" { t.Errorf("customerEmail = %q, want test@example.com", email) } } func TestGetOrderStatus(t *testing.T) { a := newTestApplier() oid := a.addPlacedOrder(1, 1) h := New(a).Handler() got := call(t, h, "get_order_status", map[string]any{"orderId": oid}) if s, _ := got["status"].(string); s != "pending" { t.Errorf("status = %q, want pending", s) } transitions, _ := got["nextTransitions"].([]any) if len(transitions) == 0 { t.Fatal("expected non-empty nextTransitions") } } func TestGetOrderLines(t *testing.T) { a := newTestApplier() oid := a.addPlacedOrder(1, 3) h := New(a).Handler() got := call(t, h, "get_order_lines", map[string]any{"orderId": oid}) if c, _ := got["count"].(float64); c != 3 { t.Errorf("lines count = %v, want 3", got["count"]) } } func TestGetOrderPayments(t *testing.T) { a := newTestApplier() oid := a.addPlacedOrder(1, 1) // Simulate an authorized + captured payment. g, _ := a.Get(context.Background(), 1) g.Status = order.StatusAuthorized g.Payments = append(g.Payments, &order.Payment{ Provider: "mock", Authorized: 60000, AuthRef: "auth-123", }) h := New(a).Handler() got := call(t, h, "get_order_payments", map[string]any{"orderId": oid}) if got == nil { t.Fatal("get_order_payments returned nil") } payments, _ := got["payments"].([]any) if len(payments) != 1 { t.Fatalf("want 1 payment, got %d", len(payments)) } p := payments[0].(map[string]any) if prov, _ := p["provider"].(string); prov != "mock" { t.Errorf("payment provider = %q, want mock", prov) } } func TestCancelOrder(t *testing.T) { a := newTestApplier() oid := a.addPlacedOrder(1, 1) h := New(a).Handler() // Cancel pending order. got := call(t, h, "cancel_order", map[string]any{"orderId": oid, "reason": "test cancellation"}) if got == nil { t.Fatal("cancel_order returned nil") } // Verify via get_order. after := call(t, h, "get_order", map[string]any{"orderId": oid}) if s, _ := after["status"].(string); s != "cancelled" { t.Errorf("after cancel: status = %q, want cancelled", s) } } func TestCreateFulfillment(t *testing.T) { a := newTestApplier() oid := a.addPlacedOrder(1, 1) // Advance to captured state. h := New(a).Handler() g, _ := a.Get(context.Background(), 1) g.Status = order.StatusCaptured g.CapturedAmount = 60000 // Fulfill the only line. got := call(t, h, "create_fulfillment", map[string]any{ "orderId": oid, "carrier": "PostNord", "trackingNumber": "TN123456", "lines": []any{ map[string]any{"reference": "line-1", "quantity": 1}, }, }) if got == nil { t.Fatal("create_fulfillment returned nil") } after := call(t, h, "get_order", map[string]any{"orderId": oid}) if s, _ := after["status"].(string); s != "fulfilled" { t.Errorf("after fulfill: status = %q, want fulfilled", s) } } func TestCompleteOrder(t *testing.T) { a := newTestApplier() oid := a.addPlacedOrder(1, 1) // Advance to fulfilled state. g, _ := a.Get(context.Background(), 1) g.Status = order.StatusFulfilled h := New(a).Handler() got := call(t, h, "complete_order", map[string]any{"orderId": oid}) if got == nil { t.Fatal("complete_order returned nil") } after := call(t, h, "get_order", map[string]any{"orderId": oid}) if s, _ := after["status"].(string); s != "completed" { t.Errorf("after complete: status = %q, want completed", s) } } func TestRequestReturn(t *testing.T) { a := newTestApplier() oid := a.addPlacedOrder(1, 2) // Advance to fulfilled state. g, _ := a.Get(context.Background(), 1) g.Status = order.StatusFulfilled h := New(a).Handler() got := call(t, h, "request_return", map[string]any{ "orderId": oid, "reason": "defective item", "lines": []any{ map[string]any{"reference": "line-1", "quantity": 1}, }, }) if got == nil { t.Fatal("request_return returned nil") } after := call(t, h, "get_order", map[string]any{"orderId": oid}) returns, _ := after["returns"].([]any) if len(returns) != 1 { t.Errorf("after return: want 1 return, got %d", len(returns)) } } func TestIssueRefund(t *testing.T) { a := newTestApplier() oid := a.addPlacedOrder(1, 1) // Advance to captured state. g, _ := a.Get(context.Background(), 1) g.Status = order.StatusCaptured g.CapturedAmount = 60000 h := New(a).Handler() got := call(t, h, "issue_refund", map[string]any{"orderId": oid, "amount": 30000}) if got == nil { t.Fatal("issue_refund returned nil") } after := call(t, h, "get_order", map[string]any{"orderId": oid}) if r, _ := after["refundedAmount"].(float64); r != 30000 { t.Errorf("refundedAmount = %v, want 30000", r) } } func TestOrderMCPErrors(t *testing.T) { a := newTestApplier() oid := a.addPlacedOrder(1, 1) h := New(a).Handler() // Invalid order id -> isError. result := callWithRaw(t, h, "get_order", map[string]any{"orderId": "!!!"}) if !result.IsError { t.Fatal("expected isError for invalid order id") } // Non-existent order -> isError. result2 := callWithRaw(t, h, "get_order", map[string]any{"orderId": "zzzzzz"}) if !result2.IsError { t.Fatal("expected isError for non-existent order") } // Cancel wrong state (already cancelled) -> isError. call(t, h, "cancel_order", map[string]any{"orderId": oid, "reason": "cancel"}) result3 := callWithRaw(t, h, "cancel_order", map[string]any{"orderId": oid, "reason": "double cancel"}) if !result3.IsError { t.Fatal("expected isError for cancelling already-cancelled order") } // Unknown tool -> protocol error. var resp struct { Error *struct{ Message string } `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") } }