UNPKG

@access-mcp/system-status

Version:

MCP server for ACCESS-CI System Status and Outages API

469 lines (468 loc) 21.6 kB
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; import { SystemStatusServer } from "../server.js"; import { createRequire } from "module"; const require = createRequire(import.meta.url); const { version } = require("../../package.json"); // Mock axios vi.mock("axios"); describe("SystemStatusServer", () => { let server; let mockHttpClient; const mockCurrentOutagesData = [ { id: "1", Subject: "Emergency maintenance on Anvil", Content: "Critical issue requiring immediate attention", OutageStart: "2024-08-27T10:00:00Z", OutageEnd: "2024-08-27T11:00:00Z", AffectedResources: [{ ResourceName: "Anvil", ResourceID: "anvil-1.purdue.access-ci.org" }], }, { id: "2", Subject: "Scheduled maintenance on Bridges-2", Content: "Regular maintenance window", OutageStart: "2024-08-27T08:00:00Z", OutageEnd: "2024-08-27T08:30:00Z", AffectedResources: [{ ResourceName: "Bridges-2", ResourceID: "bridges2.psc.access-ci.org" }], }, ]; const mockFutureOutagesData = [ { id: "3", Subject: "Scheduled Jetstream maintenance", Content: "Planned maintenance", OutageStart: "2024-08-30T10:00:00Z", OutageEnd: "2024-08-30T14:00:00Z", AffectedResources: [{ ResourceName: "Jetstream", ResourceID: "jetstream-1" }], }, ]; const mockPastOutagesData = [ { id: "4", Subject: "Past maintenance on Stampede3", Content: "Completed maintenance", OutageStart: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(), // 3 days ago OutageEnd: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000 + 6 * 60 * 60 * 1000).toISOString(), // 3 days ago + 6 hours OutageType: "Full", AffectedResources: [{ ResourceName: "Stampede3", ResourceID: "stampede3-1" }], }, ]; beforeEach(() => { server = new SystemStatusServer(); // Set up mock HTTP client mockHttpClient = { get: vi.fn(), }; // Override the httpClient getter Object.defineProperty(server, "httpClient", { get: () => mockHttpClient, configurable: true, }); }); afterEach(() => { vi.clearAllMocks(); }); describe("Server Initialization", () => { it("should initialize with correct server info", () => { expect(server).toBeDefined(); expect(server["serverName"]).toBe("access-mcp-system-status"); expect(server["version"]).toBe(version); expect(server["baseURL"]).toBe("https://operations-api.access-ci.org"); }); it("should provide correct tools", () => { const tools = server["getTools"](); expect(tools).toHaveLength(1); expect(tools[0].name).toBe("get_infrastructure_news"); }); it("should provide correct resources", () => { const resources = server["getResources"](); expect(resources).toHaveLength(4); expect(resources.map((r) => r.uri)).toEqual([ "accessci://system-status", "accessci://outages/current", "accessci://outages/scheduled", "accessci://outages/past", ]); }); }); describe("getCurrentOutages", () => { it("should fetch and enhance current outages", async () => { mockHttpClient.get.mockResolvedValue({ status: 200, data: { results: mockCurrentOutagesData }, }); const result = await server["handleToolCall"]({ method: "tools/call", params: { name: "get_infrastructure_news", arguments: { time: "current" } }, }); expect(mockHttpClient.get).toHaveBeenCalledWith("/wh2/news/v1/affiliation/access-ci.org/current_outages/"); const content = result.content[0]; const response = JSON.parse(content.text); expect(response.total_outages).toBe(2); expect(response.affected_resources).toEqual(["Anvil", "Bridges-2"]); expect(response.severity_counts).toHaveProperty("high", 1); // Emergency expect(response.severity_counts).toHaveProperty("low", 1); // Scheduled maintenance expect(response.outages[0]).toHaveProperty("severity"); }); it("should filter outages by resource", async () => { mockHttpClient.get.mockResolvedValue({ status: 200, data: { results: mockCurrentOutagesData }, }); const result = await server["handleToolCall"]({ method: "tools/call", params: { name: "get_infrastructure_news", arguments: { query: "Anvil", time: "current" }, }, }); const content = result.content[0]; const response = JSON.parse(content.text); expect(response.total_outages).toBe(1); expect(response.outages[0].Subject).toContain("Anvil"); }); it("should categorize severity correctly", async () => { mockHttpClient.get.mockResolvedValue({ status: 200, data: { results: mockCurrentOutagesData }, }); const result = await server["handleToolCall"]({ method: "tools/call", params: { name: "get_infrastructure_news", arguments: { time: "current" } }, }); const content = result.content[0]; const response = JSON.parse(content.text); const emergencyOutage = response.outages.find((o) => o.Subject.includes("Emergency")); const maintenanceOutage = response.outages.find((o) => o.Subject.includes("Scheduled")); expect(emergencyOutage.severity).toBe("high"); expect(maintenanceOutage.severity).toBe("low"); }); }); describe("getScheduledMaintenance", () => { it("should fetch and enhance scheduled maintenance", async () => { mockHttpClient.get.mockResolvedValue({ status: 200, data: { results: mockFutureOutagesData }, }); const result = await server["handleToolCall"]({ method: "tools/call", params: { name: "get_infrastructure_news", arguments: { time: "scheduled" } }, }); const content = result.content[0]; const response = JSON.parse(content.text); expect(response.total_scheduled).toBe(1); expect(response.affected_resources).toEqual(["Jetstream"]); expect(response.maintenance[0]).toHaveProperty("hours_until_start"); expect(response.maintenance[0]).toHaveProperty("duration_hours", 4); // 10am to 2pm = 4 hours expect(response.maintenance[0]).toHaveProperty("has_scheduled_time", true); }); it("should handle missing scheduled times", async () => { const dataWithoutSchedule = [ { ...mockFutureOutagesData[0], OutageStart: null, OutageEnd: null, }, ]; mockHttpClient.get.mockResolvedValue({ status: 200, data: { results: dataWithoutSchedule }, }); const result = await server["handleToolCall"]({ method: "tools/call", params: { name: "get_infrastructure_news", arguments: { time: "scheduled" } }, }); const content = result.content[0]; const response = JSON.parse(content.text); expect(response.maintenance[0].has_scheduled_time).toBe(false); expect(response.maintenance[0].duration_hours).toBe(null); }); it("should sort by scheduled start time", async () => { const multipleMaintenanceData = [ { ...mockFutureOutagesData[0], OutageStart: "2024-08-31T10:00:00Z", // Later Subject: "Later maintenance", }, { ...mockFutureOutagesData[0], OutageStart: "2024-08-30T10:00:00Z", // Earlier Subject: "Earlier maintenance", }, ]; mockHttpClient.get.mockResolvedValue({ status: 200, data: { results: multipleMaintenanceData }, }); const result = await server["handleToolCall"]({ method: "tools/call", params: { name: "get_infrastructure_news", arguments: { time: "scheduled" } }, }); const content = result.content[0]; const response = JSON.parse(content.text); expect(response.maintenance[0].Subject).toBe("Earlier maintenance"); expect(response.maintenance[1].Subject).toBe("Later maintenance"); }); }); describe("getPastOutages", () => { it("should fetch and enhance past outages", async () => { mockHttpClient.get.mockResolvedValue({ status: 200, data: { results: mockPastOutagesData }, }); const result = await server["handleToolCall"]({ method: "tools/call", params: { name: "get_infrastructure_news", arguments: { time: "past" } }, }); const content = result.content[0]; const response = JSON.parse(content.text); expect(response.total_past_outages).toBe(1); expect(response.outage_types).toEqual(["Full"]); expect(response.average_duration_hours).toBe(6); // 6 hour duration expect(response.outages[0]).toHaveProperty("duration_hours", 6); expect(response.outages[0]).toHaveProperty("days_ago"); }); it("should apply limit correctly", async () => { const manyOutages = Array(50) .fill(0) .map((_, i) => ({ ...mockPastOutagesData[0], id: `past-${i}`, Subject: `Past outage ${i}`, })); mockHttpClient.get.mockResolvedValue({ status: 200, data: { results: manyOutages }, }); const result = await server["handleToolCall"]({ method: "tools/call", params: { name: "get_infrastructure_news", arguments: { time: "past", limit: 10 }, }, }); const content = result.content[0]; const response = JSON.parse(content.text); expect(response.outages).toHaveLength(10); }); }); describe("getSystemAnnouncements", () => { it("should combine current, future, and recent past outages", async () => { mockHttpClient.get .mockResolvedValueOnce({ status: 200, data: { results: mockCurrentOutagesData } }) .mockResolvedValueOnce({ status: 200, data: { results: mockFutureOutagesData } }) .mockResolvedValueOnce({ status: 200, data: { results: mockPastOutagesData } }); const result = await server["handleToolCall"]({ method: "tools/call", params: { name: "get_infrastructure_news", arguments: { time: "all" } }, }); expect(mockHttpClient.get).toHaveBeenCalledTimes(3); const content = result.content[0]; const response = JSON.parse(content.text); expect(response.current_outages).toBe(2); expect(response.scheduled_maintenance).toBe(1); expect(response.recent_past_outages).toBe(1); // Within 30 days expect(response.categories).toHaveProperty("current"); expect(response.categories).toHaveProperty("scheduled"); expect(response.categories).toHaveProperty("recent_past"); }); it("should prioritize current outages in sorting", async () => { mockHttpClient.get .mockResolvedValueOnce({ status: 200, data: { results: mockCurrentOutagesData } }) .mockResolvedValueOnce({ status: 200, data: { results: mockFutureOutagesData } }) .mockResolvedValueOnce({ status: 200, data: { results: mockPastOutagesData } }); const result = await server["handleToolCall"]({ method: "tools/call", params: { name: "get_infrastructure_news", arguments: { time: "all" } }, }); const content = result.content[0]; const response = JSON.parse(content.text); const firstAnnouncement = response.announcements[0]; expect(firstAnnouncement.category).toBe("current"); }); }); describe("checkResourceStatus", () => { it("should check resource status efficiently (direct method)", async () => { mockHttpClient.get.mockResolvedValue({ status: 200, data: { results: mockCurrentOutagesData }, }); // Use full IDs with dots to skip resolution lookup const result = await server["handleToolCall"]({ method: "tools/call", params: { name: "get_infrastructure_news", arguments: { ids: ["anvil-1.purdue.access-ci.org", "unknown.resource.org"] }, }, }); const content = result.content[0]; const response = JSON.parse(content.text); expect(response.api_method).toBe("direct_outages_check"); expect(response.resources_checked).toBe(2); expect(response.operational).toBe(1); // unknown.resource.org expect(response.affected).toBe(1); // anvil-1.purdue.access-ci.org const anvilStatus = response.resource_status.find((r) => r.resource_id === "anvil-1.purdue.access-ci.org"); expect(anvilStatus.status).toBe("affected"); expect(anvilStatus.severity).toBe("high"); // Emergency maintenance }); it("should use group API when requested", async () => { mockHttpClient.get.mockResolvedValue({ status: 200, data: { results: [] }, // No outages for this group }); // Use full ID with dots to skip resolution lookup const result = await server["handleToolCall"]({ method: "tools/call", params: { name: "get_infrastructure_news", arguments: { ids: ["anvil.purdue.access-ci.org"], use_group_api: true, }, }, }); expect(mockHttpClient.get).toHaveBeenCalledWith("/wh2/news/v1/info_groupid/anvil.purdue.access-ci.org/"); const content = result.content[0]; const response = JSON.parse(content.text); expect(response.api_method).toBe("resource_group_api"); expect(response.resource_status[0].status).toBe("operational"); expect(response.resource_status[0].api_method).toBe("group_specific"); }); it("should handle group API failures gracefully", async () => { mockHttpClient.get.mockRejectedValue(new Error("API Error")); // Use full ID with dots to skip resolution lookup const result = await server["handleToolCall"]({ method: "tools/call", params: { name: "get_infrastructure_news", arguments: { ids: ["invalid.resource.org"], use_group_api: true, }, }, }); const content = result.content[0]; const response = JSON.parse(content.text); expect(response).toHaveProperty("unknown", 1); expect(response.resource_status[0].status).toBe("unknown"); expect(response.resource_status[0].api_method).toBe("group_specific_failed"); expect(response.resource_status[0]).toHaveProperty("error"); }); it("should resolve human-readable name to resource ID", async () => { // First call: resource search for name resolution // Second call: current outages mockHttpClient.get .mockResolvedValueOnce({ status: 200, data: { results: { active_groups: [ { info_groupid: "anvil.purdue.access-ci.org", group_descriptive_name: "Anvil" }, ], }, }, }) .mockResolvedValueOnce({ status: 200, data: { results: [] }, // No outages }); const result = await server["handleToolCall"]({ method: "tools/call", params: { name: "get_infrastructure_news", arguments: { ids: ["Anvil"] }, }, }); const content = result.content[0]; const response = JSON.parse(content.text); expect(response.resources_checked).toBe(1); expect(response.resource_status[0].resource_id).toBe("anvil.purdue.access-ci.org"); }); it("should return error when resource name is ambiguous", async () => { mockHttpClient.get.mockResolvedValueOnce({ status: 200, data: { results: { active_groups: [ { info_groupid: "stampede2.tacc.access-ci.org", group_descriptive_name: "Stampede 2", }, { info_groupid: "stampede3.tacc.access-ci.org", group_descriptive_name: "Stampede 3", }, ], }, }, }); const result = await server["handleToolCall"]({ method: "tools/call", params: { name: "get_infrastructure_news", arguments: { ids: ["Stampede"] }, }, }); const content = result.content[0]; const response = JSON.parse(content.text); expect(response.error).toContain("Could not resolve"); expect(response.resolution_errors[0].error).toContain("Multiple resources match"); }); }); describe("Error Handling", () => { it("should handle API errors gracefully", async () => { mockHttpClient.get.mockResolvedValue({ status: 500, statusText: "Internal Server Error", data: null, }); const result = await server["handleToolCall"]({ method: "tools/call", params: { name: "get_infrastructure_news", arguments: { time: "current" } }, }); const content = result.content[0]; const response = JSON.parse(content.text); expect(response.error).toBeDefined(); }); it("should handle network errors", async () => { mockHttpClient.get.mockRejectedValue(new Error("Network error")); const result = await server["handleToolCall"]({ method: "tools/call", params: { name: "get_infrastructure_news", arguments: { time: "current" } }, }); const content = result.content[0]; const response = JSON.parse(content.text); expect(response.error).toBe("Network error"); }); it("should handle unknown tools", async () => { const result = await server["handleToolCall"]({ method: "tools/call", params: { name: "unknown_tool", arguments: {} }, }); const content = result.content[0]; expect(content.text).toContain("Unknown tool"); }); }); describe("Resource Handling", () => { it("should handle resource reads correctly", async () => { mockHttpClient.get.mockResolvedValue({ status: 200, data: { results: mockCurrentOutagesData }, }); const result = await server["handleResourceRead"]({ method: "resources/read", params: { uri: "accessci://outages/current" }, }); expect(result.contents[0].mimeType).toBe("application/json"); expect("text" in result.contents[0] && result.contents[0].text).toBeDefined(); }); it("should handle unknown resources", async () => { await expect(async () => { await server["handleResourceRead"]({ method: "resources/read", params: { uri: "accessci://unknown" }, }); }).rejects.toThrow("Unknown resource"); }); }); });