package mcpserver import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/modelcontextprotocol/go-sdk/mcp" "gogs.netdome.biz/paul/cheapo/internal/game" ) // fakeProvider returns a fixed (mutable) game list, so tests can simulate a // store whose offers change between polls. Optional capabilities (upcoming, // deals) are enabled via the flags. type fakeProvider struct { name string games []game.Game err error upcoming []game.Game deals []game.Game } func (f *fakeProvider) Name() string { return f.name } func (f *fakeProvider) GetFreeGames(context.Context) ([]game.Game, error) { if f.err != nil { return nil, f.err } return f.games, nil } func (f *fakeProvider) GetUpcomingGames(context.Context) ([]game.Game, error) { if f.err != nil { return nil, f.err } return f.upcoming, nil } func (f *fakeProvider) GetDeals(context.Context) ([]game.Game, error) { if f.err != nil { return nil, f.err } return f.deals, nil } // newTestServer builds a server backed by prov instead of the real stores. func newTestServer(prov game.Provider) *Server { return New(Options{Version: "test", newProviders: func(_, _, _ string) ([]game.Provider, error) { return []game.Provider{prov}, nil }}) } // newTestSession starts an in-memory server/client pair for prov. func newTestSession(t *testing.T, prov game.Provider) *mcp.ClientSession { t.Helper() ctx := context.Background() srv := newTestServer(prov) st, ct := mcp.NewInMemoryTransports() ss, err := srv.srv.Connect(ctx, st, nil) if err != nil { t.Fatalf("server connect: %v", err) } t.Cleanup(func() { _ = ss.Close() }) client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "v0"}, nil) cs, err := client.Connect(ctx, ct, nil) if err != nil { t.Fatalf("client connect: %v", err) } t.Cleanup(func() { _ = cs.Close() }) return cs } // callTool invokes name with args and decodes the structured result into out. func callTool(t *testing.T, cs *mcp.ClientSession, name string, args map[string]any, out any) { t.Helper() res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args}) if err != nil { t.Fatalf("%s: %v", name, err) } if res.IsError { t.Fatalf("%s returned an error result: %+v", name, res.Content) } if res.StructuredContent == nil { t.Fatalf("%s returned no structured content", name) } raw, err := json.Marshal(res.StructuredContent) if err != nil { t.Fatalf("%s: marshal structured content: %v", name, err) } if err := json.Unmarshal(raw, out); err != nil { t.Fatalf("%s: decode %s: %v", name, raw, err) } } // TestToolsAreRegistered pins the tool names an MCP client will discover. func TestToolsAreRegistered(t *testing.T) { cs := newTestSession(t, &fakeProvider{name: "fake"}) var names []string for tool, err := range cs.Tools(context.Background(), nil) { if err != nil { t.Fatalf("tools: %v", err) } names = append(names, tool.Name) } got := strings.Join(names, ",") if got != "list_offers" { t.Fatalf("tools = %q, want only list_offers", got) } } // TestListFreeGamesReturnsAllGames checks the plain listing path, including // that time.Time fields survive the JSON schema round-trip. func TestListFreeGamesReturnsAllGames(t *testing.T) { end := time.Date(2026, 11, 2, 17, 0, 0, 0, time.UTC) cs := newTestSession(t, &fakeProvider{name: "epic", games: []game.Game{ {Title: "A", ID: "a", Store: "epic", URL: "https://example.test/a", EndDate: end}, {Title: "B", ID: "b", Store: "epic", URL: "https://example.test/b"}, }}) var out gamesOut callTool(t, cs, "list_offers", map[string]any{}, &out) if len(out.Games) != 2 { t.Fatalf("games = %d, want 2", len(out.Games)) } if out.Games[0].Title != "A" || out.Games[0].Ends != "2026-11-02T17:00:00Z" { t.Fatalf("first game = %+v, want A ending 2026-11-02T17:00:00Z", out.Games[0]) } if out.Games[1].Ends != "" { t.Fatalf("second game = %+v, want no end date rather than a zero timestamp", out.Games[1]) } if len(out.Warnings) != 0 { t.Fatalf("warnings = %v, want none", out.Warnings) } } // TestListFreeGamesEmptyIsEmptyArray pins that "no games" serializes as [] // rather than null, which clients handle differently. func TestListFreeGamesEmptyIsEmptyArray(t *testing.T) { cs := newTestSession(t, &fakeProvider{name: "steam"}) res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: "list_offers"}) if err != nil { t.Fatalf("list_offers: %v", err) } raw, err := json.Marshal(res.StructuredContent) if err != nil { t.Fatalf("marshal: %v", err) } if !strings.Contains(string(raw), `"games":[]`) { t.Fatalf("structured content = %s, want an empty games array", raw) } } // TestProviderErrorBecomesWarning pins that a broken store degrades the result // instead of failing the tool call. func TestProviderErrorBecomesWarning(t *testing.T) { prov := &fakeProvider{name: "gog", err: errors.New("boom")} cs := newTestSession(t, prov) var out gamesOut callTool(t, cs, "list_offers", map[string]any{}, &out) if len(out.Games) != 0 { t.Fatalf("games = %v, want none", out.Games) } if len(out.Warnings) != 1 || !strings.Contains(out.Warnings[0], "gog: boom") { t.Fatalf("warnings = %v, want one gog warning", out.Warnings) } } // TestUnknownStoreIsAnError checks that bad input reaches the client as a tool // error rather than an empty result. func TestUnknownStoreIsAnError(t *testing.T) { ctx := context.Background() srv := New(Options{}) // real factory: only the store name is validated st, ct := mcp.NewInMemoryTransports() if _, err := srv.srv.Connect(ctx, st, nil); err != nil { t.Fatalf("server connect: %v", err) } client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "v0"}, nil) cs, err := client.Connect(ctx, ct, nil) if err != nil { t.Fatalf("client connect: %v", err) } defer cs.Close() res, err := cs.CallTool(ctx, &mcp.CallToolParams{ Name: "list_offers", Arguments: map[string]any{"store": "nope"}, }) if err == nil && !res.IsError { t.Fatalf("unknown store: got success %+v, want an error", res) } } // TestHTTPTransport pins that a client can drive the tools over streamable // HTTP — the transport that matters for remote agents. func TestHTTPTransport(t *testing.T) { ctx := context.Background() prov := &fakeProvider{name: "epic", games: []game.Game{ {Title: "A", ID: "a", Store: "epic", URL: "https://example.test/a"}, }} hs := httptest.NewServer(newTestServer(prov).HTTPHandler(HTTPOptions{})) defer hs.Close() client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "v0"}, nil) cs, err := client.Connect(ctx, &mcp.StreamableClientTransport{Endpoint: hs.URL + "/mcp"}, nil) if err != nil { t.Fatalf("connect: %v", err) } defer cs.Close() var out gamesOut callTool(t, cs, "list_offers", map[string]any{}, &out) if len(out.Games) != 1 || out.Games[0].Title != "A" { t.Fatalf("games = %+v, want one game A", out.Games) } } // TestHTTPStateless pins that stateless mode works: no session header, every // request self-contained. The tools hold no state, so nothing is lost. func TestHTTPStateless(t *testing.T) { ctx := context.Background() prov := &fakeProvider{name: "epic", games: []game.Game{{Title: "A", ID: "a"}}} hs := httptest.NewServer(newTestServer(prov).HTTPHandler(HTTPOptions{Stateless: true})) defer hs.Close() client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "v0"}, nil) cs, err := client.Connect(ctx, &mcp.StreamableClientTransport{Endpoint: hs.URL + "/mcp"}, nil) if err != nil { t.Fatalf("connect: %v", err) } defer cs.Close() var out gamesOut callTool(t, cs, "list_offers", map[string]any{}, &out) if len(out.Games) != 1 || out.Games[0].Title != "A" { t.Fatalf("games = %+v, want one game A", out.Games) } } // TestHTTPTokenRequired pins that a configured token actually guards the // endpoint while /healthz stays reachable for monitoring. func TestHTTPTokenRequired(t *testing.T) { hs := httptest.NewServer(newTestServer(&fakeProvider{name: "epic"}).HTTPHandler( HTTPOptions{Token: "s3cret"})) defer hs.Close() resp, err := http.Post(hs.URL+"/mcp", "application/json", strings.NewReader(`{}`)) if err != nil { t.Fatalf("post: %v", err) } resp.Body.Close() if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("status = %d, want 401 without token", resp.StatusCode) } if got := resp.Header.Get("WWW-Authenticate"); !strings.Contains(got, "Bearer") { t.Fatalf("WWW-Authenticate = %q, want a Bearer challenge", got) } hresp, err := http.Get(hs.URL + "/healthz") if err != nil { t.Fatalf("healthz: %v", err) } hresp.Body.Close() if hresp.StatusCode != http.StatusOK { t.Fatalf("healthz status = %d, want 200 without token", hresp.StatusCode) } } // TestListModesPinsCapabilityMatrix pins the mode semantics that mirror // `cheapo list`: free from everyone, upcoming and deals only from stores with // that capability, unknown modes rejected. func TestListModesPinsCapabilityMatrix(t *testing.T) { prov := &fakeProvider{ name: "epic", games: []game.Game{{Title: "Free", ID: "f"}}, upcoming: []game.Game{{Title: "Soon", ID: "s"}}, deals: []game.Game{{Title: "Cheap", ID: "c", DiscountPct: 50}}, } cs := newTestSession(t, prov) for _, tc := range []struct { mode string title string }{ {"", "Free"}, {"free", "Free"}, {"upcoming", "Soon"}, {"deals", "Cheap"}, } { var out gamesOut args := map[string]any{} if tc.mode != "" { args["mode"] = tc.mode } callTool(t, cs, "list_offers", args, &out) if len(out.Games) != 1 || out.Games[0].Title != tc.title { t.Fatalf("mode %q: games = %+v, want %q", tc.mode, out.Games, tc.title) } } // Unknown mode must surface as a tool error, not an empty list. res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{ Name: "list_offers", Arguments: map[string]any{"mode": "nonsense"}, }) if err != nil { t.Fatalf("call: %v", err) } if !res.IsError { t.Fatalf("unknown mode: got success %+v, want an error", res.StructuredContent) } var msg string for _, c := range res.Content { if tc, ok := c.(*mcp.TextContent); ok { msg += tc.Text } } if !strings.Contains(msg, "unknown mode") { t.Fatalf("error content %q, want the ParseMode message", msg) } }