Skip to content

Commit 7bb04b5

Browse files
stefanskiasanclaude
andcommitted
test: Add comprehensive tests for SessionStore feature
- Add unit tests for RedisSessionStore and InMemorySessionStore - Store/retrieve session data - TTL handling and expiry - Activity updates - Custom key prefix - Logging callbacks - Add integration tests for StreamableHTTPServerTransport with SessionStore - SessionStorageMode configuration (memory/external) - External session storage integration - Session lifecycle (store, validate, update, delete) - Cross-pod session recovery simulation - Error handling for invalid sessions - InMemorySessionStore integration for development All 1458 SDK tests pass with no regressions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 1dafa92 commit 7bb04b5

File tree

2 files changed

+879
-0
lines changed

2 files changed

+879
-0
lines changed
Lines changed: 376 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,376 @@
1+
import { describe, it, expect, beforeEach, vi } from 'vitest';
2+
import { RedisSessionStore, InMemorySessionStore, RedisClient } from './redis.js';
3+
import { SessionData } from '../streamableHttp.js';
4+
5+
/**
6+
* Mock Redis Client for testing
7+
*/
8+
function createMockRedisClient(): RedisClient & {
9+
_store: Map<string, { value: string; expiry: number }>;
10+
_getKey: (key: string) => { value: string; expiry: number } | undefined;
11+
} {
12+
const store = new Map<string, { value: string; expiry: number }>();
13+
14+
return {
15+
_store: store,
16+
_getKey: (key: string) => store.get(key),
17+
18+
async get(key: string): Promise<string | null> {
19+
const entry = store.get(key);
20+
if (!entry) return null;
21+
// Check expiry
22+
if (entry.expiry > 0 && Date.now() > entry.expiry) {
23+
store.delete(key);
24+
return null;
25+
}
26+
return entry.value;
27+
},
28+
29+
async setex(key: string, seconds: number, value: string): Promise<'OK'> {
30+
store.set(key, {
31+
value,
32+
expiry: Date.now() + seconds * 1000
33+
});
34+
return 'OK';
35+
},
36+
37+
async del(key: string | string[]): Promise<number> {
38+
const keys = Array.isArray(key) ? key : [key];
39+
let deleted = 0;
40+
for (const k of keys) {
41+
if (store.delete(k)) deleted++;
42+
}
43+
return deleted;
44+
},
45+
46+
async exists(key: string | string[]): Promise<number> {
47+
const keys = Array.isArray(key) ? key : [key];
48+
let count = 0;
49+
for (const k of keys) {
50+
const entry = store.get(k);
51+
if (entry && (entry.expiry === 0 || Date.now() <= entry.expiry)) {
52+
count++;
53+
}
54+
}
55+
return count;
56+
},
57+
58+
async expire(key: string, seconds: number): Promise<number> {
59+
const entry = store.get(key);
60+
if (!entry) return 0;
61+
entry.expiry = Date.now() + seconds * 1000;
62+
return 1;
63+
}
64+
};
65+
}
66+
67+
describe('RedisSessionStore', () => {
68+
let mockRedis: ReturnType<typeof createMockRedisClient>;
69+
let sessionStore: RedisSessionStore;
70+
const testSessionId = 'test-session-123';
71+
72+
beforeEach(() => {
73+
mockRedis = createMockRedisClient();
74+
sessionStore = new RedisSessionStore({
75+
redis: mockRedis,
76+
keyPrefix: 'mcp:test:session:',
77+
ttlSeconds: 3600
78+
});
79+
});
80+
81+
describe('storeSession', () => {
82+
it('should store session data in Redis', async () => {
83+
const sessionData: SessionData = {
84+
sessionId: testSessionId,
85+
initialized: true,
86+
createdAt: Date.now(),
87+
lastActivity: Date.now()
88+
};
89+
90+
await sessionStore.storeSession(testSessionId, sessionData);
91+
92+
const stored = mockRedis._getKey(`mcp:test:session:${testSessionId}`);
93+
expect(stored).toBeDefined();
94+
expect(JSON.parse(stored!.value)).toEqual(sessionData);
95+
});
96+
97+
it('should set TTL when storing session', async () => {
98+
const sessionData: SessionData = {
99+
sessionId: testSessionId,
100+
initialized: true,
101+
createdAt: Date.now(),
102+
lastActivity: Date.now()
103+
};
104+
105+
await sessionStore.storeSession(testSessionId, sessionData);
106+
107+
const stored = mockRedis._getKey(`mcp:test:session:${testSessionId}`);
108+
expect(stored).toBeDefined();
109+
// TTL should be approximately 3600 seconds from now
110+
const expectedExpiry = Date.now() + 3600 * 1000;
111+
expect(stored!.expiry).toBeGreaterThan(expectedExpiry - 1000);
112+
expect(stored!.expiry).toBeLessThan(expectedExpiry + 1000);
113+
});
114+
115+
it('should store session with metadata', async () => {
116+
const sessionData: SessionData = {
117+
sessionId: testSessionId,
118+
initialized: true,
119+
createdAt: Date.now(),
120+
lastActivity: Date.now(),
121+
metadata: { serverId: 'server-1', userId: 'user-123' }
122+
};
123+
124+
await sessionStore.storeSession(testSessionId, sessionData);
125+
126+
const retrieved = await sessionStore.getSession(testSessionId);
127+
expect(retrieved?.metadata).toEqual({ serverId: 'server-1', userId: 'user-123' });
128+
});
129+
});
130+
131+
describe('getSession', () => {
132+
it('should retrieve stored session', async () => {
133+
const sessionData: SessionData = {
134+
sessionId: testSessionId,
135+
initialized: true,
136+
createdAt: 1000,
137+
lastActivity: 2000
138+
};
139+
140+
await sessionStore.storeSession(testSessionId, sessionData);
141+
const retrieved = await sessionStore.getSession(testSessionId);
142+
143+
expect(retrieved).toEqual(sessionData);
144+
});
145+
146+
it('should return null for non-existent session', async () => {
147+
const retrieved = await sessionStore.getSession('non-existent');
148+
expect(retrieved).toBeNull();
149+
});
150+
151+
it('should return null for expired session', async () => {
152+
// Create a store with 1 second TTL
153+
const shortTtlStore = new RedisSessionStore({
154+
redis: mockRedis,
155+
ttlSeconds: 0 // Immediate expiry for test
156+
});
157+
158+
const sessionData: SessionData = {
159+
sessionId: testSessionId,
160+
initialized: true,
161+
createdAt: Date.now(),
162+
lastActivity: Date.now()
163+
};
164+
165+
// Manually set expired data
166+
mockRedis._store.set(`mcp:session:${testSessionId}`, {
167+
value: JSON.stringify(sessionData),
168+
expiry: Date.now() - 1000 // Already expired
169+
});
170+
171+
const retrieved = await shortTtlStore.getSession(testSessionId);
172+
expect(retrieved).toBeNull();
173+
});
174+
});
175+
176+
describe('updateSessionActivity', () => {
177+
it('should update lastActivity timestamp', async () => {
178+
const originalTime = Date.now() - 10000;
179+
const sessionData: SessionData = {
180+
sessionId: testSessionId,
181+
initialized: true,
182+
createdAt: originalTime,
183+
lastActivity: originalTime
184+
};
185+
186+
await sessionStore.storeSession(testSessionId, sessionData);
187+
188+
// Wait a bit to ensure time difference
189+
await new Promise(resolve => setTimeout(resolve, 10));
190+
191+
await sessionStore.updateSessionActivity(testSessionId);
192+
193+
const retrieved = await sessionStore.getSession(testSessionId);
194+
expect(retrieved?.lastActivity).toBeGreaterThan(originalTime);
195+
expect(retrieved?.createdAt).toBe(originalTime); // Should not change
196+
});
197+
198+
it('should not throw for non-existent session', async () => {
199+
// Should not throw
200+
await expect(sessionStore.updateSessionActivity('non-existent')).resolves.not.toThrow();
201+
});
202+
});
203+
204+
describe('deleteSession', () => {
205+
it('should delete session from Redis', async () => {
206+
const sessionData: SessionData = {
207+
sessionId: testSessionId,
208+
initialized: true,
209+
createdAt: Date.now(),
210+
lastActivity: Date.now()
211+
};
212+
213+
await sessionStore.storeSession(testSessionId, sessionData);
214+
expect(await sessionStore.sessionExists(testSessionId)).toBe(true);
215+
216+
await sessionStore.deleteSession(testSessionId);
217+
218+
expect(await sessionStore.sessionExists(testSessionId)).toBe(false);
219+
expect(await sessionStore.getSession(testSessionId)).toBeNull();
220+
});
221+
222+
it('should not throw when deleting non-existent session', async () => {
223+
await expect(sessionStore.deleteSession('non-existent')).resolves.not.toThrow();
224+
});
225+
});
226+
227+
describe('sessionExists', () => {
228+
it('should return true for existing session', async () => {
229+
const sessionData: SessionData = {
230+
sessionId: testSessionId,
231+
initialized: true,
232+
createdAt: Date.now(),
233+
lastActivity: Date.now()
234+
};
235+
236+
await sessionStore.storeSession(testSessionId, sessionData);
237+
expect(await sessionStore.sessionExists(testSessionId)).toBe(true);
238+
});
239+
240+
it('should return false for non-existent session', async () => {
241+
expect(await sessionStore.sessionExists('non-existent')).toBe(false);
242+
});
243+
});
244+
245+
describe('custom key prefix', () => {
246+
it('should use custom key prefix', async () => {
247+
const customStore = new RedisSessionStore({
248+
redis: mockRedis,
249+
keyPrefix: 'custom:prefix:',
250+
ttlSeconds: 3600
251+
});
252+
253+
const sessionData: SessionData = {
254+
sessionId: testSessionId,
255+
initialized: true,
256+
createdAt: Date.now(),
257+
lastActivity: Date.now()
258+
};
259+
260+
await customStore.storeSession(testSessionId, sessionData);
261+
262+
expect(mockRedis._getKey(`custom:prefix:${testSessionId}`)).toBeDefined();
263+
expect(mockRedis._getKey(`mcp:session:${testSessionId}`)).toBeUndefined();
264+
});
265+
});
266+
267+
describe('logging callback', () => {
268+
it('should call onLog callback', async () => {
269+
const logSpy = vi.fn();
270+
const loggingStore = new RedisSessionStore({
271+
redis: mockRedis,
272+
onLog: logSpy
273+
});
274+
275+
const sessionData: SessionData = {
276+
sessionId: testSessionId,
277+
initialized: true,
278+
createdAt: Date.now(),
279+
lastActivity: Date.now()
280+
};
281+
282+
await loggingStore.storeSession(testSessionId, sessionData);
283+
284+
expect(logSpy).toHaveBeenCalledWith('debug', expect.stringContaining('Session stored'));
285+
});
286+
});
287+
});
288+
289+
describe('InMemorySessionStore', () => {
290+
let sessionStore: InMemorySessionStore;
291+
const testSessionId = 'test-session-456';
292+
293+
beforeEach(() => {
294+
sessionStore = new InMemorySessionStore(3600);
295+
});
296+
297+
describe('basic operations', () => {
298+
it('should store and retrieve session', async () => {
299+
const sessionData: SessionData = {
300+
sessionId: testSessionId,
301+
initialized: true,
302+
createdAt: Date.now(),
303+
lastActivity: Date.now()
304+
};
305+
306+
await sessionStore.storeSession(testSessionId, sessionData);
307+
const retrieved = await sessionStore.getSession(testSessionId);
308+
309+
expect(retrieved).toEqual(sessionData);
310+
});
311+
312+
it('should delete session', async () => {
313+
const sessionData: SessionData = {
314+
sessionId: testSessionId,
315+
initialized: true,
316+
createdAt: Date.now(),
317+
lastActivity: Date.now()
318+
};
319+
320+
await sessionStore.storeSession(testSessionId, sessionData);
321+
await sessionStore.deleteSession(testSessionId);
322+
323+
expect(await sessionStore.getSession(testSessionId)).toBeNull();
324+
});
325+
326+
it('should check session existence', async () => {
327+
expect(await sessionStore.sessionExists(testSessionId)).toBe(false);
328+
329+
const sessionData: SessionData = {
330+
sessionId: testSessionId,
331+
initialized: true,
332+
createdAt: Date.now(),
333+
lastActivity: Date.now()
334+
};
335+
336+
await sessionStore.storeSession(testSessionId, sessionData);
337+
expect(await sessionStore.sessionExists(testSessionId)).toBe(true);
338+
});
339+
340+
it('should update session activity', async () => {
341+
const originalTime = Date.now() - 10000;
342+
const sessionData: SessionData = {
343+
sessionId: testSessionId,
344+
initialized: true,
345+
createdAt: originalTime,
346+
lastActivity: originalTime
347+
};
348+
349+
await sessionStore.storeSession(testSessionId, sessionData);
350+
await sessionStore.updateSessionActivity(testSessionId);
351+
352+
const retrieved = await sessionStore.getSession(testSessionId);
353+
expect(retrieved?.lastActivity).toBeGreaterThan(originalTime);
354+
});
355+
});
356+
357+
describe('TTL behavior', () => {
358+
it('should expire sessions after TTL', async () => {
359+
// Create store with very short TTL (100ms)
360+
const shortTtlStore = new InMemorySessionStore(0.1); // 0.1 seconds = 100ms
361+
362+
const sessionData: SessionData = {
363+
sessionId: testSessionId,
364+
initialized: true,
365+
createdAt: Date.now(),
366+
lastActivity: Date.now() - 200 // Already past TTL
367+
};
368+
369+
await shortTtlStore.storeSession(testSessionId, sessionData);
370+
371+
// Session should be considered expired
372+
const retrieved = await shortTtlStore.getSession(testSessionId);
373+
expect(retrieved).toBeNull();
374+
});
375+
});
376+
});

0 commit comments

Comments
 (0)