import { describe, it, expect, vi, beforeEach } from 'vitest'; import { LinkResolver } from '../src/services/link-resolver.js'; import type { McpdClient } from '../src/http/mcpd-client.js'; function mockClient(): McpdClient { return { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), forward: vi.fn(), withHeaders: vi.fn(), } as unknown as McpdClient; } describe('LinkResolver', () => { let client: McpdClient; let resolver: LinkResolver; beforeEach(() => { client = mockClient(); resolver = new LinkResolver(client, 1000); // 1s TTL for tests }); // ── parseLink ── describe('parseLink', () => { it('parses valid link target', () => { const result = resolver.parseLink('my-project/docmost-mcp:docmost://pages/abc'); expect(result).toEqual({ project: 'my-project', server: 'docmost-mcp', uri: 'docmost://pages/abc', }); }); it('parses link with complex URI', () => { const result = resolver.parseLink('proj/srv:file:///path/to/resource'); expect(result).toEqual({ project: 'proj', server: 'srv', uri: 'file:///path/to/resource', }); }); it('throws on missing project separator', () => { expect(() => resolver.parseLink('noslash')).toThrow('missing project'); }); it('throws on missing server:uri separator', () => { expect(() => resolver.parseLink('proj/nocolon')).toThrow('missing server:uri'); }); it('throws on empty uri', () => { expect(() => resolver.parseLink('proj/srv:')).toThrow('empty uri'); }); it('throws when project is empty', () => { expect(() => resolver.parseLink('/srv:uri')).toThrow('missing project'); }); it('throws when server is empty', () => { expect(() => resolver.parseLink('proj/:uri')).toThrow('missing server:uri'); }); }); // ── resolve ── describe('resolve', () => { it('fetches resource content successfully', async () => { vi.mocked(client.get).mockResolvedValue([ { id: 'srv-id-1', name: 'docmost-mcp' }, ]); vi.mocked(client.post).mockResolvedValue({ result: { contents: [{ text: 'Hello from docmost', uri: 'docmost://pages/abc' }] }, }); const result = await resolver.resolve('my-project/docmost-mcp:docmost://pages/abc'); expect(result).toEqual({ content: 'Hello from docmost', status: 'alive' }); expect(client.get).toHaveBeenCalledWith('/api/v1/projects/my-project/servers'); expect(client.post).toHaveBeenCalledWith('/api/v1/mcp/proxy', { serverId: 'srv-id-1', method: 'resources/read', params: { uri: 'docmost://pages/abc' }, }); }); it('returns dead status when server not found in project', async () => { vi.mocked(client.get).mockResolvedValue([ { id: 'srv-other', name: 'other-server' }, ]); const result = await resolver.resolve('proj/missing-srv:some://uri'); expect(result.status).toBe('dead'); expect(result.content).toBeNull(); expect(result.error).toContain("Server 'missing-srv' not found"); }); it('returns dead status when MCP proxy returns error', async () => { vi.mocked(client.get).mockResolvedValue([{ id: 'srv-1', name: 'srv' }]); vi.mocked(client.post).mockResolvedValue({ error: { code: -32601, message: 'Method not found' }, }); const result = await resolver.resolve('proj/srv:some://uri'); expect(result.status).toBe('dead'); expect(result.error).toContain('Method not found'); }); it('returns dead status when no content returned', async () => { vi.mocked(client.get).mockResolvedValue([{ id: 'srv-1', name: 'srv' }]); vi.mocked(client.post).mockResolvedValue({ result: { contents: [] }, }); const result = await resolver.resolve('proj/srv:some://uri'); expect(result.status).toBe('dead'); expect(result.error).toContain('No content returned'); }); it('returns dead status on network error', async () => { vi.mocked(client.get).mockRejectedValue(new Error('Connection refused')); const result = await resolver.resolve('proj/srv:some://uri'); expect(result.status).toBe('dead'); expect(result.error).toContain('Connection refused'); }); it('concatenates multiple content entries', async () => { vi.mocked(client.get).mockResolvedValue([{ id: 'srv-1', name: 'srv' }]); vi.mocked(client.post).mockResolvedValue({ result: { contents: [ { text: 'Part 1', uri: 'uri1' }, { text: 'Part 2', uri: 'uri2' }, ], }, }); const result = await resolver.resolve('proj/srv:some://uri'); expect(result.content).toBe('Part 1\nPart 2'); expect(result.status).toBe('alive'); }); it('logs dead link to console.error', async () => { vi.mocked(client.get).mockRejectedValue(new Error('fail')); const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); await resolver.resolve('proj/srv:some://uri'); expect(spy).toHaveBeenCalledWith(expect.stringContaining('[link-resolver] Dead link')); spy.mockRestore(); }); }); // ── caching ── describe('caching', () => { it('returns cached result on second call', async () => { vi.mocked(client.get).mockResolvedValue([{ id: 'srv-1', name: 'srv' }]); vi.mocked(client.post).mockResolvedValue({ result: { contents: [{ text: 'cached content' }] }, }); const first = await resolver.resolve('proj/srv:some://uri'); const second = await resolver.resolve('proj/srv:some://uri'); expect(first).toEqual(second); // Only one HTTP call — second was cached expect(client.get).toHaveBeenCalledTimes(1); }); it('refetches after cache expires', async () => { vi.mocked(client.get).mockResolvedValue([{ id: 'srv-1', name: 'srv' }]); vi.mocked(client.post).mockResolvedValue({ result: { contents: [{ text: 'content' }] }, }); await resolver.resolve('proj/srv:some://uri'); // Advance time past TTL vi.useFakeTimers(); vi.advanceTimersByTime(1500); await resolver.resolve('proj/srv:some://uri'); expect(client.get).toHaveBeenCalledTimes(2); vi.useRealTimers(); }); it('clearCache removes all entries', async () => { vi.mocked(client.get).mockResolvedValue([{ id: 'srv-1', name: 'srv' }]); vi.mocked(client.post).mockResolvedValue({ result: { contents: [{ text: 'content' }] }, }); await resolver.resolve('proj/srv:some://uri'); resolver.clearCache(); await resolver.resolve('proj/srv:some://uri'); expect(client.get).toHaveBeenCalledTimes(2); }); }); // ── checkHealth ── describe('checkHealth', () => { it('returns cached status if available', async () => { vi.mocked(client.get).mockResolvedValue([{ id: 'srv-1', name: 'srv' }]); vi.mocked(client.post).mockResolvedValue({ result: { contents: [{ text: 'content' }] }, }); await resolver.resolve('proj/srv:some://uri'); const health = await resolver.checkHealth('proj/srv:some://uri'); expect(health).toBe('alive'); }); it('returns unknown if not cached', async () => { const health = await resolver.checkHealth('proj/srv:some://uri'); expect(health).toBe('unknown'); }); it('returns dead from cached dead link', async () => { vi.mocked(client.get).mockRejectedValue(new Error('fail')); vi.spyOn(console, 'error').mockImplementation(() => {}); await resolver.resolve('proj/srv:some://uri'); const health = await resolver.checkHealth('proj/srv:some://uri'); expect(health).toBe('dead'); }); }); });