Skip to main content
Glama
history-manager.test.ts12.4 kB
/** * Tests for HistoryManager * Ported from Python Phase 1C history_manager_test.py */ import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; import { HistoryManager } from "./history-manager.js"; import { createUserMessage, createAssistantMessage } from "../types/message.js"; // Mock the Redis and D1 clients vi.mock("./redis-client.js"); vi.mock("./d1-client.js"); import { RedisClient } from "./redis-client.js"; import { D1Client } from "./d1-client.js"; describe("HistoryManager", () => { let redisClient: RedisClient; let d1Client: D1Client; let historyManager: HistoryManager; beforeEach(() => { // Create mock instances redisClient = new RedisClient(); d1Client = new D1Client({} as any); // Mock D1Database // Mock Redis methods vi.spyOn(redisClient, "rpush").mockResolvedValue(true); vi.spyOn(redisClient, "lrange").mockResolvedValue([]); vi.spyOn(redisClient, "ltrim").mockResolvedValue(true); vi.spyOn(redisClient, "expire").mockResolvedValue(true); vi.spyOn(redisClient, "delete").mockResolvedValue(true); vi.spyOn(redisClient, "exists").mockResolvedValue(false); // Mock D1 methods vi.spyOn(d1Client, "query").mockResolvedValue([]); vi.spyOn(d1Client, "execute").mockResolvedValue(1); historyManager = new HistoryManager(redisClient, d1Client); }); afterEach(() => { vi.clearAllMocks(); }); describe("addMessage", () => { it("should add message to Redis cache", async () => { const sessionId = "session123"; const message = createUserMessage("Hello world"); const result = await historyManager.addMessage(sessionId, message); expect(result).toBeDefined(); expect(typeof result).toBe("string"); expect(redisClient.rpush).toHaveBeenCalledWith(`history:${sessionId}`, expect.any(String)); }); it("should add message to D1 database", async () => { const sessionId = "session123"; const message = createUserMessage("Hello world"); const result = await historyManager.addMessage(sessionId, message); expect(result).toBeDefined(); expect(typeof result).toBe("string"); expect(d1Client.execute).toHaveBeenCalled(); }); it("should handle Redis failure gracefully", async () => { vi.spyOn(redisClient, "rpush").mockResolvedValue(false); const result = await historyManager.addMessage("session123", createUserMessage("test")); expect(result).toBeDefined(); // Should still succeed with D1, returns messageId expect(typeof result).toBe("string"); }); it("should handle D1 failure", async () => { vi.spyOn(d1Client, "execute").mockRejectedValue(new Error("DB error")); // D1 write is non-blocking, so addMessage should still succeed // The error is logged but doesn't throw const result = await historyManager.addMessage("session123", createUserMessage("test")); expect(result).toBeDefined(); // Returns messageId even if D1 fails (non-blocking) expect(typeof result).toBe("string"); }); it("should compress large messages", async () => { const sessionId = "session123"; const largeMessage = createUserMessage("x".repeat(10000)); // Large message await historyManager.addMessage(sessionId, largeMessage); // Should still work, compression is placeholder for now expect(redisClient.rpush).toHaveBeenCalled(); }); it("compresses payloads greater than 1KB", async () => { const sessionId = "session-large"; const largePayload = createUserMessage("a".repeat(2000)); await historyManager.addMessage(sessionId, largePayload); expect(redisClient.rpush).toHaveBeenCalledWith( `history:${sessionId}`, expect.any(String) ); }); }); describe("getHistory", () => { it("should return history from Redis cache if available", async () => { const sessionId = "session123"; const messages = [createUserMessage("Hello"), createAssistantMessage("Hi there")]; vi.spyOn(redisClient, "lrange").mockResolvedValue(messages.map((m) => JSON.stringify(m))); const result = await historyManager.getHistory(sessionId); expect(result).toHaveLength(2); expect(result[0].content).toBe("Hello"); expect(result[1].content).toBe("Hi there"); expect(d1Client.query).not.toHaveBeenCalled(); }); it("should fetch from D1 if not in Redis cache", async () => { const sessionId = "session123"; const messages = [createUserMessage("Hello"), createAssistantMessage("Hi there")]; vi.spyOn(redisClient, "lrange").mockResolvedValue([]); vi.spyOn(d1Client, "query").mockResolvedValue( messages.map((m, i) => ({ message_id: `msg${i}`, session_id: sessionId, role: m.role, content: m.content, tool_calls: m.toolCalls ? JSON.stringify(m.toolCalls) : null, tool_call_id: m.toolCallId || null, timestamp: m.timestamp, metadata: m.metadata ? JSON.stringify(m.metadata) : null, })) ); const result = await historyManager.getHistory(sessionId); expect(result).toHaveLength(2); expect(d1Client.query).toHaveBeenCalled(); }); it("should cache D1 results in Redis", async () => { const sessionId = "session123"; vi.spyOn(redisClient, "lrange").mockResolvedValue([]); vi.spyOn(d1Client, "query").mockResolvedValue([ { message_id: "msg1", session_id: sessionId, role: "user", content: "Hello", tool_calls: null, tool_call_id: null, timestamp: new Date().toISOString(), metadata: null, }, ]); await historyManager.getHistory(sessionId); expect(redisClient.rpush).toHaveBeenCalled(); }); it("should return empty array for non-existent session", async () => { vi.spyOn(redisClient, "lrange").mockResolvedValue([]); vi.spyOn(d1Client, "query").mockResolvedValue([]); const result = await historyManager.getHistory("nonexistent"); expect(result).toEqual([]); }); it("should handle Redis failure and fall back to D1", async () => { const sessionId = "session123"; vi.spyOn(redisClient, "lrange").mockRejectedValue(new Error("Redis down")); vi.spyOn(d1Client, "query").mockResolvedValue([ { message_id: "msg1", session_id: sessionId, role: "user", content: "Hello", tool_calls: null, tool_call_id: null, timestamp: new Date().toISOString(), metadata: null, }, ]); const result = await historyManager.getHistory(sessionId); expect(result).toHaveLength(1); }); it("should limit results when specified", async () => { const sessionId = "session123"; const messages = Array.from({ length: 10 }, (_, i) => createUserMessage(`Message ${i}`)); vi.spyOn(redisClient, "lrange").mockResolvedValue(messages.map((m) => JSON.stringify(m))); const result = await historyManager.getHistory(sessionId, 5); expect(result).toHaveLength(5); expect(redisClient.lrange).toHaveBeenCalledWith(`history:${sessionId}`, -20, -1); }); }); describe("getHistoryPage", () => { it("should return paginated results", async () => { const sessionId = "session123"; const messages = Array.from({ length: 10 }, (_, i) => ({ message_id: `msg${i}`, session_id: sessionId, role: "user", content: `Message ${i}`, tool_calls: null, tool_call_id: null, timestamp: new Date(Date.now() + i * 1000).toISOString(), // Different timestamps metadata: null, })); // Return limit + 1 to indicate there are more messages vi.spyOn(d1Client, "query").mockResolvedValue(messages.slice(0, 6)); const result = await historyManager.getHistoryPage(sessionId, "cursor123", 5); expect(result.messages).toHaveLength(5); // Should return only limit, not limit+1 expect(result.nextCursor).toBeDefined(); // Should have cursor since we returned 6 (limit+1) }); it("should handle last page", async () => { const sessionId = "session123"; const messages = Array.from({ length: 3 }, (_, i) => ({ message_id: `msg${i}`, session_id: sessionId, role: "user", content: `Message ${i}`, tool_calls: null, tool_call_id: null, timestamp: new Date().toISOString(), metadata: null, })); vi.spyOn(d1Client, "query").mockResolvedValue(messages); const result = await historyManager.getHistoryPage(sessionId, "cursor123", 5); expect(result.messages).toHaveLength(3); expect(result.nextCursor).toBeUndefined(); }); it("should handle empty results", async () => { vi.spyOn(d1Client, "query").mockResolvedValue([]); const result = await historyManager.getHistoryPage("session123", "cursor123", 5); expect(result.messages).toEqual([]); expect(result.nextCursor).toBeUndefined(); }); }); describe("clearHistory", () => { it("should clear history from Redis cache", async () => { const sessionId = "session123"; const result = await historyManager.clearHistory(sessionId); expect(result).toBe(true); expect(redisClient.delete).toHaveBeenCalledWith(`history:${sessionId}`); }); it("should clear history from D1 database", async () => { const sessionId = "session123"; const result = await historyManager.clearHistory(sessionId); expect(result).toBe(true); expect(d1Client.execute).toHaveBeenCalled(); }); it("should handle Redis failure gracefully", async () => { vi.spyOn(redisClient, "delete").mockResolvedValue(false); const result = await historyManager.clearHistory("session123"); expect(result).toBe(true); // Should still succeed with D1 }); it("should handle D1 failure", async () => { vi.spyOn(d1Client, "execute").mockRejectedValue(new Error("DB error")); const result = await historyManager.clearHistory("session123"); expect(result).toBe(false); }); it("continues when Redis delete fails", async () => { vi.spyOn(redisClient, "delete").mockResolvedValueOnce(false); const result = await historyManager.clearHistory("session123"); expect(result).toBe(true); expect(d1Client.execute).toHaveBeenCalled(); }); }); // Note: trimHistory and getHistorySize methods don't exist in current implementation // Use getMessageCount() instead of getHistorySize() describe("compressMessage/decompressMessage", () => { it("should compress and decompress messages (placeholder implementation)", async () => { const message = createUserMessage("Test message"); // These are placeholder implementations, so they should work const compressed = await (historyManager as any).compressMessage(message); expect(typeof compressed).toBe("string"); // decompressMessage was removed as unused, but if it existed: // const decompressed = await (historyManager as any).decompressMessage(compressed); // expect(decompressed).toEqual(message); }); }); describe("error handling", () => { it("should handle malformed JSON in Redis cache", async () => { vi.spyOn(redisClient, "lrange").mockResolvedValue(["invalid json"]); vi.spyOn(d1Client, "query").mockResolvedValue([]); const result = await historyManager.getHistory("session123"); expect(result).toEqual([]); }); it("should handle database connection errors", async () => { vi.spyOn(d1Client, "query").mockRejectedValue(new Error("DB connection failed")); await expect(historyManager.getHistory("session123")).rejects.toThrow("DB connection failed"); }); it("should handle Redis connection errors", async () => { vi.spyOn(redisClient, "rpush").mockRejectedValue(new Error("Redis connection failed")); // Redis failure is now non-fatal, should still return messageId const result = await historyManager.addMessage("session123", createUserMessage("test")); expect(result).toBeDefined(); expect(typeof result).toBe("string"); }); }); });

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/hummbl-dev/mcp-server'

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