Skip to main content
Glama
search-issues.test.ts12.7 kB
import { describe, it, expect, vi, beforeEach } from "vitest"; import { http, HttpResponse } from "msw"; import { mswServer } from "@sentry/mcp-server-mocks"; import searchIssues from "./search-issues"; import { generateText } from "ai"; import type { ServerContext } from "../types"; // Mock the AI SDK vi.mock("@ai-sdk/openai", () => { const mockModel = vi.fn(() => "mocked-model"); return { openai: mockModel, createOpenAI: vi.fn(() => mockModel), }; }); vi.mock("ai", () => ({ generateText: vi.fn(), tool: vi.fn(() => ({ execute: vi.fn() })), Output: { object: vi.fn(() => ({})) }, })); describe("search_issues", () => { const mockGenerateText = vi.mocked(generateText); const mockContext: ServerContext = { accessToken: "test-token", userId: "user-123", clientId: "client-123", grantedSkills: new Set(), constraints: {}, sentryHost: "sentry.io", }; // Helper to create AI agent response const mockAIResponse = ( query = "", sort: "date" | "freq" | "new" | "user" | null = "date", errorMessage?: string, ) => { const output = errorMessage ? { error: errorMessage } : { query, sort, explanation: "Test query translation", }; return { text: JSON.stringify(output), experimental_output: output, finishReason: "stop" as const, usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 }, warnings: [] as const, experimental_providerMetadata: { openai: { reasoningTokens: 0, cachedPromptTokens: 0, }, }, } as any; }; beforeEach(() => { vi.clearAllMocks(); process.env.OPENAI_API_KEY = "test-key"; mockGenerateText.mockResolvedValue(mockAIResponse()); }); it("should search issues with natural language query", async () => { mockGenerateText.mockResolvedValue(mockAIResponse("is:unresolved", "date")); mswServer.use( http.get("*/api/0/organizations/*/issues/", ({ request }) => { const url = new URL(request.url); const query = url.searchParams.get("query"); expect(query).toBe("is:unresolved"); return HttpResponse.json([ { id: "123", shortId: "PROJ-123", title: "Test Error", status: "unresolved", count: "100", userCount: 50, firstSeen: "2025-01-15T10:00:00Z", lastSeen: "2025-01-15T12:00:00Z", permalink: "https://sentry.io/issues/123/", project: { id: "456", slug: "test-project", name: "Test Project", }, culprit: "test.function", }, ]); }), ); const result = await searchIssues.handler( { organizationSlug: "test-org", naturalLanguageQuery: "unresolved issues", projectSlugOrId: null, regionUrl: null, limit: 10, includeExplanation: false, }, mockContext, ); expect(result).toContain("PROJ-123"); expect(result).toContain("Test Error"); }); it("should handle project slug parameter", async () => { mockGenerateText.mockResolvedValue(mockAIResponse("", "date")); // Mock getProject call - handler needs to fetch project to get ID mswServer.use( http.get("*/api/0/projects/*/my-project/", () => { return HttpResponse.json({ id: "789", slug: "my-project", name: "My Project", }); }), http.get("*/api/0/projects/*/my-project/issues/*", () => { return HttpResponse.json([]); }), ); const result = await searchIssues.handler( { organizationSlug: "test-org", naturalLanguageQuery: "all issues", projectSlugOrId: "my-project", regionUrl: null, limit: 10, includeExplanation: false, }, mockContext, ); expect(result).toContain("No issues found"); }); it("should handle numeric project ID", async () => { mockGenerateText.mockResolvedValue(mockAIResponse("", "date")); // Numeric ID doesn't need getProject call - used directly mswServer.use( http.get("*/api/0/projects/*/123456/issues/*", () => { return HttpResponse.json([]); }), ); await searchIssues.handler( { organizationSlug: "test-org", naturalLanguageQuery: "all issues", projectSlugOrId: "123456", regionUrl: null, limit: 10, includeExplanation: false, }, mockContext, ); }); it("should pass sort parameter to API", async () => { mockGenerateText.mockResolvedValue(mockAIResponse("", "freq")); mswServer.use( http.get("*/api/0/organizations/*/issues/", ({ request }) => { const url = new URL(request.url); const sort = url.searchParams.get("sort"); expect(sort).toBe("freq"); return HttpResponse.json([]); }), ); await searchIssues.handler( { organizationSlug: "test-org", naturalLanguageQuery: "most frequent errors", projectSlugOrId: null, regionUrl: null, limit: 10, includeExplanation: false, }, mockContext, ); }); it("should default to date sort when agent returns null", async () => { mockGenerateText.mockResolvedValue(mockAIResponse("", null)); mswServer.use( http.get("*/api/0/organizations/*/issues/", ({ request }) => { const url = new URL(request.url); const sort = url.searchParams.get("sort"); expect(sort).toBe("date"); return HttpResponse.json([]); }), ); await searchIssues.handler( { organizationSlug: "test-org", naturalLanguageQuery: "all issues", projectSlugOrId: null, regionUrl: null, limit: 10, includeExplanation: false, }, mockContext, ); }); it("should respect custom limit parameter", async () => { mockGenerateText.mockResolvedValue(mockAIResponse()); mswServer.use( http.get("*/api/0/organizations/*/issues/", ({ request }) => { const url = new URL(request.url); const perPage = url.searchParams.get("per_page"); expect(perPage).toBe("25"); return HttpResponse.json([]); }), ); await searchIssues.handler( { organizationSlug: "test-org", naturalLanguageQuery: "test", projectSlugOrId: null, regionUrl: null, limit: 25, includeExplanation: false, }, mockContext, ); }); it("should include explanation when requested", async () => { mockGenerateText.mockResolvedValue(mockAIResponse("is:unresolved", "date")); mswServer.use( http.get("*/api/0/organizations/*/issues/", () => { return HttpResponse.json([]); }), ); const result = await searchIssues.handler( { organizationSlug: "test-org", naturalLanguageQuery: "unresolved issues", projectSlugOrId: null, regionUrl: null, limit: 10, includeExplanation: true, }, mockContext, ); expect(result).toContain("Query Translation"); expect(result).toContain("Test query translation"); }); it("should handle empty results", async () => { mockGenerateText.mockResolvedValue(mockAIResponse()); mswServer.use( http.get("*/api/0/organizations/*/issues/", () => { return HttpResponse.json([]); }), ); const result = await searchIssues.handler( { organizationSlug: "test-org", naturalLanguageQuery: "nonexistent issues", projectSlugOrId: null, regionUrl: null, limit: 10, includeExplanation: false, }, mockContext, ); expect(result).toContain("No issues found"); }); it("should handle whitespace-only query gracefully", async () => { mockGenerateText.mockResolvedValue(mockAIResponse("", "date")); mswServer.use( http.get("*/api/0/organizations/*/issues/", () => { return HttpResponse.json([]); }), ); // Whitespace gets trimmed but doesn't fail - the AI agent processes it const result = await searchIssues.handler( { organizationSlug: "test-org", naturalLanguageQuery: " ", projectSlugOrId: null, regionUrl: null, limit: 10, includeExplanation: false, }, mockContext, ); expect(result).toContain("No issues found"); }); it("should pass agent query directly to API", async () => { mockGenerateText.mockResolvedValue( mockAIResponse("is:unresolved level:error", "date"), ); mswServer.use( http.get("*/api/0/organizations/*/issues/", ({ request }) => { const url = new URL(request.url); const query = url.searchParams.get("query"); expect(query).toBe("is:unresolved level:error"); return HttpResponse.json([]); }), ); await searchIssues.handler( { organizationSlug: "test-org", naturalLanguageQuery: "unresolved errors", projectSlugOrId: null, regionUrl: null, limit: 10, includeExplanation: false, }, mockContext, ); }); it("should handle all sort options", async () => { const sortOptions: Array<"date" | "freq" | "new" | "user"> = [ "date", "freq", "new", "user", ]; for (const sortOption of sortOptions) { mockGenerateText.mockResolvedValue(mockAIResponse("", sortOption)); mswServer.use( http.get("*/api/0/organizations/*/issues/", ({ request }) => { const url = new URL(request.url); const sort = url.searchParams.get("sort"); expect(sort).toBe(sortOption); return HttpResponse.json([]); }), ); await searchIssues.handler( { organizationSlug: "test-org", naturalLanguageQuery: "test", projectSlugOrId: null, regionUrl: null, limit: 10, includeExplanation: false, }, mockContext, ); } }); it("should format issues with proper markdown", async () => { mockGenerateText.mockResolvedValue(mockAIResponse("", "date")); mswServer.use( http.get("*/api/0/organizations/*/issues/", () => { return HttpResponse.json([ { id: "123", shortId: "PROJ-123", title: "Test Error", status: "unresolved", count: "100", userCount: 50, level: "error", firstSeen: "2025-01-15T10:00:00Z", lastSeen: "2025-01-15T12:00:00Z", permalink: "https://sentry.io/issues/123/", project: { id: "456", slug: "test-project", name: "Test Project", }, culprit: "test.function", }, ]); }), ); const result = await searchIssues.handler( { organizationSlug: "test-org", naturalLanguageQuery: "all issues", projectSlugOrId: null, regionUrl: null, limit: 10, includeExplanation: false, }, mockContext, ); expect(result).toContain("# Search Results"); expect(result).toContain("PROJ-123"); expect(result).toContain("Test Error"); expect(result).toContain("unresolved"); }); it("should validate project slug format", async () => { // Invalid characters in slug should fail validation await expect( searchIssues.handler( { organizationSlug: "test-org", naturalLanguageQuery: "test", projectSlugOrId: "invalid@slug", regionUrl: null, limit: 10, includeExplanation: false, }, mockContext, ), ).rejects.toThrow(); }); it("should handle API errors gracefully", async () => { mockGenerateText.mockResolvedValue(mockAIResponse()); mswServer.use( http.get("*/api/0/organizations/*/issues/", () => { return HttpResponse.json( { detail: "Organization not found" }, { status: 404 }, ); }), ); await expect( searchIssues.handler( { organizationSlug: "nonexistent-org", naturalLanguageQuery: "test", projectSlugOrId: null, regionUrl: null, limit: 10, includeExplanation: false, }, mockContext, ), ).rejects.toThrow(); }); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/getsentry/sentry-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server