Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/__tests__/__snapshots__/options.defaults.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,9 @@ exports[`options defaults should return specific properties: defaults 1`] = `
],
},
"xhrFetch": {
"allowBinary": false,
"maxSizeBytes": 5242880,
"preflightHead": false,
"timeoutMs": 15000,
},
}
Expand Down
25 changes: 23 additions & 2 deletions src/__tests__/resource.patternFlyDocsTemplate.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { readFile } from 'node:fs/promises';
import { ReadableStream } from 'node:stream/web';
import { McpError } from '@modelcontextprotocol/sdk/types.js';
import {
patternFlyDocsTemplateResource,
Expand Down Expand Up @@ -76,7 +77,17 @@ describe('resourceCallback', () => {
mockReadFile.mockResolvedValue(mockContent);
mockFetch.mockResolvedValue({
ok: true,
text: () => mockContent
status: 200,
statusText: 'OK',
headers: {
get: (name: string) => (name === 'content-type' ? 'text/plain' : null)
},
body: new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(mockContent));
controller.close();
}
})
} as any);

const result = await resourceCallback(
Expand Down Expand Up @@ -151,7 +162,17 @@ describe('resourceCallback', () => {
mockReadFile.mockResolvedValue(mockContent);
mockFetch.mockResolvedValue({
ok: true,
text: () => mockContent
status: 200,
statusText: 'OK',
headers: {
get: (name: string) => (name === 'content-type' ? 'text/plain' : null)
},
body: new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(mockContent));
controller.close();
}
})
} as any);

const uri = new URL('patternfly://docs/test');
Expand Down
222 changes: 222 additions & 0 deletions src/__tests__/server.fetch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import { ReadableStream } from 'node:stream/web';
import { setFetch, FetchError, type FetchState } from '../server.fetch';
import { getOptions } from '../options.context';

describe('setFetch', () => {
beforeEach(() => {
jest.clearAllMocks();
global.fetch = jest.fn();
});

it('should initialize in idle phase', () => {
const { status } = setFetch();

expect(status() as FetchState).toEqual({
phase: 'idle',
progress: 0,
bytesReceived: 0
});
});

it('should fetch and parse text correctly', async () => {
const mockResponse = {
ok: true,
status: 200,
statusText: 'OK',
headers: {
get: (name: string) => {
if (name === 'content-length') {
return '11';
}

if (name === 'content-type') {
return 'text/plain';
}

return null;
}
},
body: new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('hello '));
controller.enqueue(new TextEncoder().encode('world'));
controller.close();
}
})
};

(global.fetch as jest.Mock).mockResolvedValue(mockResponse);

const { get, status } = setFetch();
const result = await get('https://example.com');

expect(result).toEqual({
type: 'text',
status: 200,
statusText: 'OK',
data: 'hello world'
});

expect(status() as FetchState).toEqual({
phase: 'success',
progress: 100,
bytesReceived: 11,
type: 'text',
data: 'hello world'
});
});

it('should fetch and parse JSON correctly', async () => {
const mockResponse = {
ok: true,
status: 200,
statusText: 'OK',
headers: {
get: (name: string) => {
if (name === 'content-type') {
return 'application/json';
}

return null;
}
},
body: new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('{"foo": "bar"}'));
controller.close();
}
})
};

(global.fetch as jest.Mock).mockResolvedValue(mockResponse);

const { get } = setFetch();
const result = await get('https://example.com/data.json');

expect(result.type).toBe('json');
expect(result.data).toEqual({ foo: 'bar' });
});

it('should reject with FetchError on non-ok response', async () => {
const mockResponse = {
ok: false,
status: 404,
statusText: 'Not Found',
headers: {
get: () => null
}
};

(global.fetch as jest.Mock).mockResolvedValue(mockResponse);

const { get, status } = setFetch();

await expect(get('https://example.com/404')).rejects.toThrow(FetchError);

try {
await get('https://example.com/404');
} catch (error) {
expect(error).toBeInstanceOf(FetchError);
const fe = error as FetchError;

expect(fe.status).toBe(404);
expect(fe.statusText).toBe('Not Found');
}

expect((status() as FetchState).phase).toBe('error');
expect((status() as FetchState).error).toBeInstanceOf(FetchError);
});

it('should check content-length against maxSizeBytes', async () => {
const mockResponse = {
ok: true,
status: 200,
statusText: 'OK',
headers: {
get: (name: string) => (name === 'content-length' ? '1000' : null)
}
};

(global.fetch as jest.Mock).mockResolvedValue(mockResponse);

const options = {
...getOptions(),
xhrFetch: { allowBinary: false, maxSizeBytes: 500, timeoutMs: 1000, preflightHead: false }
};

const { get, status } = setFetch(options as any);

await expect(get('https://example.com')).rejects.toThrow('File blocked: exceeds 500 bytes.');
expect((status() as FetchState).phase).toBe('error');
});

it('should handle cancel properly', async () => {
let rejectPull: (reason: any) => void = () => {};
const mockCancel = jest.fn();

const stream = new ReadableStream({
pull() {
return new Promise((_, reject) => {
rejectPull = reject;
});
},
cancel(reason) {
mockCancel(reason);
rejectPull(reason);
}
});

const mockResponse = {
ok: true,
status: 200,
statusText: 'OK',
headers: {
get: () => null
},
body: stream
};

(global.fetch as jest.Mock).mockResolvedValue(mockResponse);

const { get, cancel, status } = setFetch();
const promise = get('https://example.com');

// Wait for the fetch to start and hit the reader
await new Promise(resolve => setTimeout(resolve, 10));

expect((status() as FetchState).phase).toBe('loading');

cancel();

await expect(promise).rejects.toMatchObject({ cancelled: true });
expect((status() as FetchState).phase).toBe('cancelled');
expect(mockCancel).toHaveBeenCalled();
});

it('should handle timeout', async () => {
jest.useFakeTimers();

(global.fetch as jest.Mock).mockImplementation((_url, init) => new Promise((_, reject) => {
if (init?.signal) {
init.signal.addEventListener('abort', () => {
reject(init.signal.reason);
});
}
}));

const options = {
...getOptions(),
xhrFetch: { allowBinary: false, maxSizeBytes: 0, timeoutMs: 100, preflightHead: false }
};

const { get, status } = setFetch(options as any);
const promise = get('https://example.com');

jest.advanceTimersByTime(150);

await expect(promise).rejects.toThrow(/Timeout/);
expect((status() as FetchState).phase).toBe('error');

jest.useRealTimers();
});
});
44 changes: 32 additions & 12 deletions src/__tests__/server.getResources.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { ReadableStream } from 'node:stream/web';
import {
matchPackageVersion,
findNearestPackageJson,
Expand Down Expand Up @@ -190,10 +191,26 @@ describe('fetchUrlFunction', () => {
global.fetch = jest.fn();
});

it('should attempt to fetch a URL with correct headers', async () => {
it('should attempt to fetch a URL', async () => {
const mockResponse = {
ok: true,
text: jest.fn().mockResolvedValue('fetched content')
status: 200,
statusText: 'OK',
headers: {
get: (name: string) => {
if (name === 'content-type') {
return 'text/plain';
}

return null;
}
},
body: new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('fetched content'));
controller.close();
}
})
};

(global.fetch as jest.Mock).mockResolvedValue(mockResponse);
Expand All @@ -203,7 +220,7 @@ describe('fetchUrlFunction', () => {
expect(global.fetch).toHaveBeenCalledWith(
'https://example.com/doc.md',
expect.objectContaining({
headers: { Accept: 'text/plain, text/markdown, */*' }
method: 'GET'
})
);
expect(result).toBe('fetched content');
Expand All @@ -213,7 +230,10 @@ describe('fetchUrlFunction', () => {
const mockResponse = {
ok: false,
status: 404,
statusText: 'Not Found'
statusText: 'Not Found',
headers: {
get: () => null
}
};

(global.fetch as jest.Mock).mockResolvedValue(mockResponse);
Expand Down Expand Up @@ -308,8 +328,8 @@ describe('loadFileFetch', () => {
expectedIsFetch: false
}
])('should attempt to load a file or fetch, $description', async ({ pathUrl, expectedIsFetch }) => {
const mockFetchCall = jest.fn().mockResolvedValue('content');
const mockReadCall = jest.fn().mockResolvedValue('content');
const mockFetchCall = jest.fn().mockResolvedValue('content') as any;
const mockReadCall = jest.fn().mockResolvedValue('content') as any;

readLocalFileFunction.memo = mockReadCall;
fetchUrlFunction.memo = mockFetchCall;
Expand All @@ -332,12 +352,12 @@ describe('promiseQueue', () => {
});

it('should execute promises in order', async () => {
readLocalFileFunction.memo = jest.fn().mockImplementation(path => Promise.resolve(path));
fetchUrlFunction.memo = jest.fn().mockImplementation(url => Promise.reject(url));
readLocalFileFunction.memo = jest.fn().mockImplementation(path => Promise.resolve(path)) as any;
fetchUrlFunction.memo = jest.fn().mockImplementation(url => Promise.reject(url)) as any;

const pathUrlQueue = ['dolor-sit.md', 'https://example.com/remote.md', 'lorem-ipsum.md'];

await expect(promiseQueue(pathUrlQueue, 1)).resolves.toMatchSnapshot('allSettled');
await expect(promiseQueue(pathUrlQueue, { limit: 1 })).resolves.toMatchSnapshot('allSettled');
});
});

Expand All @@ -346,8 +366,8 @@ describe('processDocsFunction', () => {
jest.clearAllMocks();

// Mock the memo functions
readLocalFileFunction.memo = jest.fn().mockResolvedValue('local file content');
fetchUrlFunction.memo = jest.fn().mockResolvedValue('fetched content');
readLocalFileFunction.memo = jest.fn().mockResolvedValue('local file content') as any;
fetchUrlFunction.memo = jest.fn().mockResolvedValue('fetched content') as any;
});

it.each([
Expand Down Expand Up @@ -438,7 +458,7 @@ describe('processDocsFunction', () => {
// Mock one success and one failure
readLocalFileFunction.memo = jest.fn()
.mockResolvedValueOnce('success content')
.mockRejectedValueOnce(new Error('File not found'));
.mockRejectedValueOnce(new Error('File not found')) as any;

const inputs = [
'good-file.md',
Expand Down
Loading
Loading