package mcp import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "path/filepath" "testing" "git.k6n.net/mats/go-cart-actor/pkg/promotions" ) // 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 TestPromotionMCPRoundTrip(t *testing.T) { path := filepath.Join(t.TempDir(), "promotions.json") store, err := promotions.NewStore(path) if err != nil { t.Fatal(err) } h := New(store, nil).Handler() rule := map[string]any{ "id": "volymrabatt", "name": "Volymrabatt", "status": "active", "priority": 100, "conditions": []any{ map[string]any{"id": "min", "type": "cart_total", "operator": ">=", "value": 2000000}, }, "actions": []any{ map[string]any{ "id": "tiers", "type": "tiered_discount", "value": 0, "config": map[string]any{ "tiers": []any{ map[string]any{"minTotal": 2000000, "maxTotal": 4000000, "percent": 5}, map[string]any{"minTotal": 4000000, "maxTotal": 6000000, "percent": 9}, map[string]any{"minTotal": 6000000, "maxTotal": 9000000, "percent": 14}, }, }, }, }, } // Upsert (create). got := call(t, h, "upsert_promotion", map[string]any{"promotion": rule}) if got["replaced"] != false { t.Errorf("first upsert should create, got replaced=%v", got["replaced"]) } // It persisted to disk and a fresh store sees it. reloaded, err := promotions.NewStore(path) if err != nil { t.Fatal(err) } if len(reloaded.List()) != 1 { t.Fatalf("expected 1 persisted promotion, got %d", len(reloaded.List())) } // List. listed := call(t, h, "list_promotions", map[string]any{"status": "active"}) if c, _ := listed["count"].(float64); c != 1 { t.Errorf("list count = %v, want 1", listed["count"]) } // Preview the 9% mid tier at 50 000 kr. preview := call(t, h, "preview_cart_discount", map[string]any{"cartTotalIncVat": 5000000}) if d, _ := preview["discountIncVat"].(float64); int64(d) != 450000 { t.Errorf("discount = %v, want 450000", preview["discountIncVat"]) } if n, _ := preview["netIncVat"].(float64); int64(n) != 4550000 { t.Errorf("net = %v, want 4550000", preview["netIncVat"]) } // Toggle off → preview yields no discount. call(t, h, "set_promotion_status", map[string]any{"id": "volymrabatt", "status": "inactive"}) off := call(t, h, "preview_cart_discount", map[string]any{"cartTotalIncVat": 5000000}) if d, _ := off["discountIncVat"].(float64); d != 0 { t.Errorf("inactive promo should give 0 discount, got %v", off["discountIncVat"]) } // Delete. call(t, h, "delete_promotion", map[string]any{"id": "volymrabatt"}) final := call(t, h, "list_promotions", nil) if c, _ := final["count"].(float64); c != 0 { t.Errorf("after delete count = %v, want 0", final["count"]) } }