// ABOUTME: Unit tests for resource handlers and registration
// ABOUTME: Tests all resource types including posts, agents, feed, and notifications
import { jest } from '@jest/globals';
import { URL } from 'node:url';
// Mock logger
jest.mock('../src/logger.js', () => ({
logger: {
info: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
}));
// Mock config
jest.mock('../src/config.js', () => ({
config: {
teamName: 'test-team',
},
}));
// Mock ResourceTemplate
const mockResourceTemplate = jest.fn().mockImplementation((pattern, options) => ({
pattern,
options,
}));
jest.mock('@modelcontextprotocol/sdk/server/mcp.js', () => ({
ResourceTemplate: mockResourceTemplate,
}));
import { registerResources, listResources, type ResourceContext } from '../src/resources/index.js';
import { readPostResource, readThreadResource } from '../src/resources/posts.js';
import { readAgentProfileResource, readAgentPostsResource } from '../src/resources/agents.js';
import { readFeedResource, readNotificationsResource } from '../src/resources/feed.js';
import type { IApiClient } from '../src/api-client.js';
import type { SessionManager } from '../src/session-manager.js';
import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';
import type { ServerRequest, ServerNotification } from '@modelcontextprotocol/sdk/types.js';
describe('Resource Handlers', () => {
let mockApiClient: jest.Mocked<IApiClient>;
let mockSessionManager: jest.Mocked<SessionManager>;
let mockContext: ResourceContext;
let mockExtra: RequestHandlerExtra<ServerRequest, ServerNotification>;
let mockServer: any;
const mockPosts = [
{
id: 'post-123',
author_name: 'agent1',
content: 'This is a test post about AI development',
tags: ['ai', 'development'],
timestamp: '2025-01-01T10:00:00Z',
created_at: '2025-01-01T10:00:00Z',
updated_at: '2025-01-01T10:00:00Z',
thread_id: 'thread-456',
parent_post_id: null,
},
{
id: 'post-456',
author_name: 'agent2',
content: 'Great insights on machine learning trends',
tags: ['ml', 'trends'],
timestamp: '2025-01-01T11:00:00Z',
created_at: '2025-01-01T11:00:00Z',
updated_at: '2025-01-01T11:00:00Z',
thread_id: 'thread-789',
parent_post_id: null,
},
{
id: 'post-789',
author_name: 'agent1',
content: 'Reply to the AI development discussion',
tags: ['ai', 'discussion'],
timestamp: '2025-01-01T12:00:00Z',
created_at: '2025-01-01T12:00:00Z',
updated_at: '2025-01-01T12:00:00Z',
thread_id: 'thread-456',
parent_post_id: 'post-123',
},
];
beforeEach(() => {
jest.clearAllMocks();
mockApiClient = {
fetchPosts: jest.fn(),
createPost: jest.fn(),
deletePost: jest.fn(),
} as any;
mockSessionManager = {
getSession: jest.fn(),
createSession: jest.fn(),
destroySession: jest.fn(),
} as any;
mockContext = {
apiClient: mockApiClient,
sessionManager: mockSessionManager,
};
mockExtra = {
signal: new AbortController().signal,
} as any;
mockServer = {
resource: jest.fn(),
};
// Default posts mock - setup to match different filter scenarios
mockApiClient.fetchPosts.mockImplementation((teamName, options = {}) => {
let filteredPosts = [...mockPosts];
// Only apply filters when they are explicitly provided
if (options.agent_filter) {
filteredPosts = filteredPosts.filter(p => p.author_name === options.agent_filter);
}
if (options.thread_id) {
filteredPosts = filteredPosts.filter(p => p.thread_id === options.thread_id);
}
if (options.tag_filter) {
filteredPosts = filteredPosts.filter(p => p.tags?.includes(options.tag_filter));
}
// For posts without specific filters, return all posts (like readPostResource does)
const limit = options.limit || 10;
const offset = options.offset || 0;
const paginatedPosts = filteredPosts.slice(offset, offset + limit);
return Promise.resolve({
posts: paginatedPosts,
total: filteredPosts.length,
});
});
// Default session mock
mockSessionManager.getSession.mockReturnValue({
id: 'test-session',
agentName: 'test-agent',
createdAt: new Date(),
lastActivity: new Date(),
});
});
describe('registerResources', () => {
it('should register all resources with the server', () => {
registerResources(mockServer, mockContext);
expect(mockServer.resource).toHaveBeenCalledTimes(6);
const calls = mockServer.resource.mock.calls;
const resourceNames = calls.map(call => call[0]);
expect(resourceNames).toContain('social-feed');
expect(resourceNames).toContain('notifications');
expect(resourceNames).toContain('post');
expect(resourceNames).toContain('thread');
expect(resourceNames).toContain('agent-profile');
expect(resourceNames).toContain('agent-posts');
});
it('should register fixed URI resources with correct URIs', () => {
registerResources(mockServer, mockContext);
const feedCall = mockServer.resource.mock.calls.find(call => call[0] === 'social-feed');
expect(feedCall[1]).toBe('social://feed');
const notificationsCall = mockServer.resource.mock.calls.find(call => call[0] === 'notifications');
expect(notificationsCall[1]).toBe('social://notifications');
});
it('should register template resources with ResourceTemplate instances', () => {
registerResources(mockServer, mockContext);
const postCall = mockServer.resource.mock.calls.find(call => call[0] === 'post');
expect(postCall[1]).toBeInstanceOf(Object);
expect(postCall[1]).toHaveProperty('_uriTemplate');
});
it('should register resources with correct metadata', () => {
registerResources(mockServer, mockContext);
const feedCall = mockServer.resource.mock.calls.find(call => call[0] === 'social-feed');
expect(feedCall[2].description).toContain('Real-time social media feed');
expect(feedCall[2].mimeType).toBe('application/json');
});
});
describe('listResources', () => {
it('should return fixed resources', async () => {
const result = await listResources(mockExtra, mockContext);
expect(result.resources).toEqual(
expect.arrayContaining([
expect.objectContaining({
uri: 'social://feed',
name: 'Social Media Feed',
mimeType: 'application/json',
}),
expect.objectContaining({
uri: 'social://notifications',
name: 'Notifications',
mimeType: 'application/json',
}),
])
);
});
it('should include dynamic post resources', async () => {
const result = await listResources(mockExtra, mockContext);
const postResources = result.resources.filter(r => r.uri.startsWith('social://posts/'));
expect(postResources.length).toBeGreaterThan(0);
expect(postResources[0]).toEqual(
expect.objectContaining({
uri: 'social://posts/post-123',
name: 'Post by agent1',
mimeType: 'application/json',
})
);
});
it('should include dynamic agent profile resources', async () => {
const result = await listResources(mockExtra, mockContext);
const agentResources = result.resources.filter(r => r.uri.includes('/agents/'));
expect(agentResources.length).toBeGreaterThan(0);
expect(agentResources[0]).toEqual(
expect.objectContaining({
uri: expect.stringMatching(/^social:\/\/agents\/agent[12]\/profile$/),
name: expect.stringMatching(/^agent[12]'s Profile$/),
mimeType: 'application/json',
})
);
});
it('should handle API errors gracefully', async () => {
mockApiClient.fetchPosts.mockRejectedValue(new Error('API Error'));
const result = await listResources(mockExtra, mockContext);
// Should still return fixed resources
expect(result.resources).toEqual(
expect.arrayContaining([
expect.objectContaining({ uri: 'social://feed' }),
expect.objectContaining({ uri: 'social://notifications' }),
])
);
});
it('should limit dynamic resources to avoid overwhelming list', async () => {
const largeMockPosts = Array(20).fill(0).map((_, i) => ({
...mockPosts[0],
id: `post-${i}`,
author_name: `agent-${i}`,
}));
mockApiClient.fetchPosts.mockResolvedValue({
posts: largeMockPosts,
total: largeMockPosts.length,
});
const result = await listResources(mockExtra, mockContext);
const postResources = result.resources.filter(r => r.uri.startsWith('social://posts/'));
const agentResources = result.resources.filter(r => r.uri.includes('/agents/'));
// Should limit to 3 posts and 3 agents
expect(postResources.length).toBe(3);
expect(agentResources.length).toBe(3);
});
});
describe('Post Resources', () => {
describe('readPostResource', () => {
it('should read a valid post by ID', async () => {
const uri = new URL('social://posts/post-123');
const result = await readPostResource(uri, mockContext);
expect(result.contents).toHaveLength(1);
expect(result.contents[0].mimeType).toBe('application/json');
const content = JSON.parse(result.contents[0].text);
expect(content.post).toEqual(
expect.objectContaining({
id: 'post-123',
author_name: 'agent1',
content: 'This is a test post about AI development',
})
);
});
it('should handle non-existent post ID', async () => {
const uri = new URL('social://posts/non-existent');
const result = await readPostResource(uri, mockContext);
const content = JSON.parse(result.contents[0].text);
expect(content.error).toContain('Post not found');
});
it('should handle invalid URI format', async () => {
const uri = new URL('social://posts/');
const result = await readPostResource(uri, mockContext);
const content = JSON.parse(result.contents[0].text);
expect(content.error).toContain('Invalid post URI');
});
it('should handle API errors gracefully', async () => {
mockApiClient.fetchPosts.mockRejectedValue(new Error('API Error'));
const uri = new URL('social://posts/post-123');
const result = await readPostResource(uri, mockContext);
const content = JSON.parse(result.contents[0].text);
expect(content.error).toContain('Error fetching post');
});
it('should include post metadata and statistics', async () => {
const uri = new URL('social://posts/post-123');
const result = await readPostResource(uri, mockContext);
const content = JSON.parse(result.contents[0].text);
// Simple post resource structure without metadata
expect(content.post.id).toBe('post-123');
});
});
describe('readThreadResource', () => {
it('should read a complete thread', async () => {
const uri = new URL('social://threads/thread-456');
const result = await readThreadResource(uri, mockContext);
expect(result.contents).toHaveLength(1);
const content = JSON.parse(result.contents[0].text);
expect(content.thread).toEqual(
expect.objectContaining({
threadId: 'thread-456',
posts: expect.arrayContaining([
expect.objectContaining({ id: 'post-123' }),
expect.objectContaining({ id: 'post-789' }),
]),
participantCount: 1,
postCount: 2,
})
);
});
it('should handle empty thread', async () => {
mockApiClient.fetchPosts.mockResolvedValue({ posts: [], total: 0 });
const uri = new URL('social://threads/empty-thread');
const result = await readThreadResource(uri, mockContext);
const content = JSON.parse(result.contents[0].text);
expect(content.error).toContain('Thread not found');
});
it('should organize posts in chronological order', async () => {
const uri = new URL('social://threads/thread-456');
const result = await readThreadResource(uri, mockContext);
const content = JSON.parse(result.contents[0].text);
const posts = content.thread.posts;
expect(posts[0].created_at).toBeLessThanOrEqual(posts[1].created_at);
});
it('should include thread statistics', async () => {
const uri = new URL('social://threads/thread-456');
const result = await readThreadResource(uri, mockContext);
const content = JSON.parse(result.contents[0].text);
expect(content.thread.postCount).toBe(2);
expect(content.thread.participantCount).toBe(1);
});
});
});
describe('Agent Resources', () => {
describe('readAgentProfileResource', () => {
it('should read agent profile with statistics', async () => {
const uri = new URL('social://agents/agent1/profile');
const result = await readAgentProfileResource(uri, mockContext);
expect(result.contents).toHaveLength(1);
const content = JSON.parse(result.contents[0].text);
expect(content.profile).toEqual(
expect.objectContaining({
agentName: 'agent1',
postCount: 2,
firstSeenAt: expect.any(String),
lastSeenAt: expect.any(String),
})
);
});
it('should handle non-existent agent', async () => {
const uri = new URL('social://agents/unknown-agent/profile');
const result = await readAgentProfileResource(uri, mockContext);
const content = JSON.parse(result.contents[0].text);
expect(content.error).toContain('Failed to read agent profile resource');
});
it('should include popular tags and activity metrics', async () => {
const uri = new URL('social://agents/agent1/profile');
const result = await readAgentProfileResource(uri, mockContext);
const content = JSON.parse(result.contents[0].text);
expect(content.profile.agentName).toBe('agent1');
expect(content.profile.postCount).toBe(2);
});
});
describe('readAgentPostsResource', () => {
it('should read all posts by specific agent', async () => {
const uri = new URL('social://agents/agent1/posts');
const result = await readAgentPostsResource(uri, mockContext);
expect(result.contents).toHaveLength(1);
const content = JSON.parse(result.contents[0].text);
expect(content.posts).toHaveLength(2);
expect(content.agentName).toBe('agent1');
expect(content.total).toBe(2);
});
it('should support pagination parameters', async () => {
const uri = new URL('social://agents/agent1/posts?limit=1&offset=0');
const result = await readAgentPostsResource(uri, mockContext);
const content = JSON.parse(result.contents[0].text);
expect(content.posts).toHaveLength(1);
expect(content.agentName).toBe('agent1');
expect(content.total).toBeDefined();
});
it('should handle agent with no posts', async () => {
mockApiClient.fetchPosts.mockResolvedValue({ posts: [], total: 0 });
const uri = new URL('social://agents/silent-agent/posts');
const result = await readAgentPostsResource(uri, mockContext);
const content = JSON.parse(result.contents[0].text);
expect(content.posts).toHaveLength(0);
expect(content.agentName).toBe('silent-agent');
expect(content.total).toBe(0);
});
});
});
describe('Feed Resources', () => {
describe('readFeedResource', () => {
it('should read social media feed', async () => {
const uri = new URL('social://feed');
const result = await readFeedResource(uri, mockContext);
expect(result.contents).toHaveLength(1);
const content = JSON.parse(result.contents[0].text);
expect(content.feed).toEqual(
expect.objectContaining({
posts: expect.arrayContaining([
expect.objectContaining({ id: 'post-123' }),
expect.objectContaining({ id: 'post-456' }),
expect.objectContaining({ id: 'post-789' }),
]),
metadata: expect.objectContaining({
totalPosts: 3,
agents: expect.arrayContaining(['agent1', 'agent2']),
}),
})
);
});
it('should support feed filtering via query parameters', async () => {
const uri = new URL('social://feed?agent_filter=agent1');
const result = await readFeedResource(uri, mockContext);
const content = JSON.parse(result.contents[0].text);
expect(content.feed.posts.every(p => p.author_name === 'agent1')).toBe(true);
});
it('should support tag filtering', async () => {
const uri = new URL('social://feed?tag_filter=ai');
const result = await readFeedResource(uri, mockContext);
const content = JSON.parse(result.contents[0].text);
expect(content.feed.posts.every(p => p.tags.includes('ai'))).toBe(true);
});
it('should handle empty feed gracefully', async () => {
mockApiClient.fetchPosts.mockResolvedValue({ posts: [], total: 0 });
const uri = new URL('social://feed');
const result = await readFeedResource(uri, mockContext);
const content = JSON.parse(result.contents[0].text);
expect(content.feed.posts).toHaveLength(0);
expect(content.feed.metadata.totalPosts).toBe(0);
});
});
describe('readNotificationsResource', () => {
it('should read notifications for authenticated user', async () => {
const uri = new URL('social://notifications');
const result = await readNotificationsResource(uri, mockContext);
expect(result.contents).toHaveLength(1);
const content = JSON.parse(result.contents[0].text);
expect(content.notifications).toEqual(expect.any(Array));
expect(content.unreadCount).toEqual(expect.any(Number));
});
it('should handle unauthenticated user', async () => {
mockSessionManager.getSession.mockReturnValue(null);
const uri = new URL('social://notifications');
const result = await readNotificationsResource(uri, mockContext);
const content = JSON.parse(result.contents[0].text);
expect(content.error).toContain('Not authenticated');
});
it('should filter notifications by user', async () => {
const uri = new URL('social://notifications');
const result = await readNotificationsResource(uri, mockContext);
const content = JSON.parse(result.contents[0].text);
// Notifications should be for the authenticated user
expect(content.unreadCount).toEqual(expect.any(Number));
});
});
});
describe('Error Handling', () => {
it('should handle malformed URIs gracefully', async () => {
const badUri = new URL('social://invalid/format');
// Should not throw for any resource handler
await expect(readPostResource(badUri, mockContext)).resolves.toBeDefined();
await expect(readThreadResource(badUri, mockContext)).resolves.toBeDefined();
await expect(readAgentProfileResource(badUri, mockContext)).resolves.toBeDefined();
});
it('should handle API timeouts', async () => {
mockApiClient.fetchPosts.mockImplementation(() =>
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 100)
)
);
const uri = new URL('social://feed');
const result = await readFeedResource(uri, mockContext);
const content = JSON.parse(result.contents[0].text);
expect(content.error).toContain('Failed to read feed resource');
});
it('should validate resource URIs properly', async () => {
const testCases = [
{ uri: 'social://posts/', expectError: true },
{ uri: 'social://posts/valid-id', expectError: false },
{ uri: 'social://agents//profile', expectError: true },
{ uri: 'social://agents/valid-agent/profile', expectError: false },
];
for (const testCase of testCases) {
const uri = new URL(testCase.uri);
const result = await readPostResource(uri, mockContext);
const content = JSON.parse(result.contents[0].text);
if (testCase.expectError) {
expect(content.error).toBeDefined();
} else {
// Should either have data or a "not found" error, not a validation error
expect(content.error?.includes('Invalid')).toBeFalsy();
}
}
});
});
describe('Integration Tests', () => {
it('should work with complete resource workflow', async () => {
// Register resources
registerResources(mockServer, mockContext);
// List resources
const list = await listResources(mockExtra, mockContext);
expect(list.resources.length).toBeGreaterThan(0);
// Read a specific resource
const uri = new URL('social://posts/post-123');
const result = await readPostResource(uri, mockContext);
expect(result.contents).toHaveLength(1);
expect(mockApiClient.fetchPosts).toHaveBeenCalled();
});
it('should handle concurrent resource reads', async () => {
const uris = [
new URL('social://posts/post-123'),
new URL('social://agents/agent1/profile'),
new URL('social://feed'),
];
const promises = [
readPostResource(uris[0], mockContext),
readAgentProfileResource(uris[1], mockContext),
readFeedResource(uris[2], mockContext),
];
const results = await Promise.all(promises);
expect(results).toHaveLength(3);
results.forEach(result => {
expect(result.contents).toHaveLength(1);
expect(result.contents[0].mimeType).toBe('application/json');
});
});
it('should maintain consistent data across related resources', async () => {
// Read post and its thread
const postResult = await readPostResource(new URL('social://posts/post-123'), mockContext);
const threadResult = await readThreadResource(new URL('social://threads/thread-456'), mockContext);
const postContent = JSON.parse(postResult.contents[0].text);
const threadContent = JSON.parse(threadResult.contents[0].text);
// Post should be included in its thread
const postInThread = threadContent.thread.posts.find(p => p.id === 'post-123');
expect(postInThread).toBeDefined();
expect(postInThread.content).toBe(postContent.post.content);
});
it('should handle resource caching appropriately', async () => {
// Make multiple calls to same resource
const uri = new URL('social://feed');
await readFeedResource(uri, mockContext);
await readFeedResource(uri, mockContext);
// Should call API each time (no implicit caching)
expect(mockApiClient.fetchPosts).toHaveBeenCalledTimes(2);
});
});
});