mcp and ucp
This commit is contained in:
@@ -0,0 +1,457 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user