Skip to content

Commit 35c099c

Browse files
committed
test: add comprehensive unit tests for all tools
1 parent 93b2c20 commit 35c099c

File tree

9 files changed

+811
-10
lines changed

9 files changed

+811
-10
lines changed

.github/workflows/ci.yml

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,17 @@ jobs:
4444
- name: Type check
4545
run: npx tsc --noEmit
4646

47-
- name: Run tests
48-
run: yarn test
47+
- name: Run tests with coverage
48+
run: yarn test:ci
49+
50+
- name: Upload coverage reports
51+
if: matrix.node-version == '20'
52+
uses: codecov/codecov-action@v4
53+
with:
54+
token: ${{ secrets.CODECOV_TOKEN }}
55+
files: ./coverage/coverage-final.json
56+
flags: unittests
57+
name: codecov-umbrella
4958

5059
- name: Build
5160
run: yarn build

README.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ Add to your Claude Desktop settings:
131131
}
132132
```
133133

134-
#### Using local installation
134+
#### Using local installation
135135
```json
136136
{
137137
"mcpServers": {
@@ -143,6 +143,18 @@ Add to your Claude Desktop settings:
143143
}
144144
```
145145

146+
#### For development (without building)
147+
```json
148+
{
149+
"mcpServers": {
150+
"wayback-machine": {
151+
"command": "npx",
152+
"args": ["tsx", "/absolute/path/to/mcp-wayback-machine/src/index.ts"]
153+
}
154+
}
155+
}
156+
```
157+
146158
## Development
147159

148160
```bash

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@
1010
"scripts": {
1111
"build": "tsc",
1212
"dev": "tsx watch src/index.ts",
13-
"test": "vitest",
13+
"test": "vitest run --coverage",
1414
"test:watch": "vitest --watch",
15+
"test:ci": "vitest run --coverage --reporter=json --reporter=default",
1516
"prepare": "npm run build",
1617
"prepublishOnly": "npm test && npm run build",
1718
"start": "node dist/index.js"
@@ -60,6 +61,7 @@
6061
"@semantic-release/npm": "^12.0.1",
6162
"@semantic-release/release-notes-generator": "^14.0.3",
6263
"@types/node": "^22.13.2",
64+
"@vitest/coverage-v8": "^3.2.1",
6365
"semantic-release": "^24.2.5",
6466
"tsx": "^4.19.2",
6567
"typescript": "^5.7.3",

src/tools/retrieve.test.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import { getArchivedUrl } from './retrieve.js';
3+
import * as httpModule from '../utils/http.js';
4+
import * as rateLimitModule from '../utils/rate-limit.js';
5+
6+
vi.mock('../utils/http.js');
7+
vi.mock('../utils/rate-limit.js');
8+
9+
describe('getArchivedUrl', () => {
10+
beforeEach(() => {
11+
vi.clearAllMocks();
12+
vi.spyOn(rateLimitModule.waybackRateLimiter, 'waitForSlot').mockResolvedValue();
13+
vi.spyOn(rateLimitModule.waybackRateLimiter, 'recordRequest').mockImplementation();
14+
});
15+
16+
afterEach(() => {
17+
vi.restoreAllMocks();
18+
});
19+
20+
it('should retrieve archived URL successfully', async () => {
21+
const mockResponse = new Response(JSON.stringify({
22+
url: 'https://example.com',
23+
archived_snapshots: {
24+
closest: {
25+
status: '200',
26+
available: true,
27+
url: 'https://web.archive.org/web/20231225120000/https://example.com',
28+
timestamp: '20231225120000'
29+
}
30+
}
31+
}));
32+
33+
vi.spyOn(httpModule, 'fetchWithTimeout').mockResolvedValueOnce(mockResponse);
34+
vi.spyOn(httpModule, 'parseJsonResponse').mockResolvedValueOnce({
35+
url: 'https://example.com',
36+
archived_snapshots: {
37+
closest: {
38+
status: '200',
39+
available: true,
40+
url: 'https://web.archive.org/web/20231225120000/https://example.com',
41+
timestamp: '20231225120000'
42+
}
43+
}
44+
});
45+
46+
const result = await getArchivedUrl({ url: 'https://example.com' });
47+
48+
expect(result.success).toBe(true);
49+
expect(result.available).toBe(true);
50+
expect(result.archivedUrl).toBe('https://web.archive.org/web/20231225120000/https://example.com');
51+
expect(result.timestamp).toBe('20231225120000');
52+
});
53+
54+
it('should handle no snapshots found', async () => {
55+
const mockResponse = new Response(JSON.stringify({
56+
url: 'https://example.com',
57+
archived_snapshots: {}
58+
}));
59+
60+
vi.spyOn(httpModule, 'fetchWithTimeout').mockResolvedValueOnce(mockResponse);
61+
vi.spyOn(httpModule, 'parseJsonResponse').mockResolvedValueOnce({
62+
url: 'https://example.com',
63+
archived_snapshots: {}
64+
});
65+
66+
const result = await getArchivedUrl({ url: 'https://example.com' });
67+
68+
expect(result.success).toBe(false);
69+
expect(result.available).toBe(false);
70+
expect(result.message).toContain('No archived versions found');
71+
});
72+
73+
it('should provide direct URL when timestamp is specified', async () => {
74+
const mockResponse = new Response(JSON.stringify({
75+
url: 'https://example.com',
76+
archived_snapshots: {}
77+
}));
78+
79+
vi.spyOn(httpModule, 'fetchWithTimeout').mockResolvedValueOnce(mockResponse);
80+
vi.spyOn(httpModule, 'parseJsonResponse').mockResolvedValueOnce({
81+
url: 'https://example.com',
82+
archived_snapshots: {}
83+
});
84+
85+
const result = await getArchivedUrl({
86+
url: 'https://example.com',
87+
timestamp: '20231225120000'
88+
});
89+
90+
expect(result.success).toBe(true);
91+
expect(result.available).toBe(false);
92+
expect(result.archivedUrl).toBe('https://web.archive.org/web/20231225120000/https://example.com');
93+
});
94+
95+
it('should handle HTTP errors', async () => {
96+
vi.spyOn(httpModule, 'fetchWithTimeout').mockRejectedValueOnce(
97+
new httpModule.HttpError('Not found', 404)
98+
);
99+
100+
const result = await getArchivedUrl({ url: 'https://example.com' });
101+
102+
expect(result.success).toBe(false);
103+
expect(result.message).toContain('Failed to retrieve archived URL');
104+
});
105+
106+
it('should handle invalid URLs', async () => {
107+
const result = await getArchivedUrl({ url: 'not-a-url' });
108+
109+
expect(result.success).toBe(false);
110+
expect(result.message).toContain('Failed to retrieve archived URL');
111+
});
112+
113+
it('should handle latest timestamp', async () => {
114+
const mockResponse = new Response(JSON.stringify({
115+
url: 'https://example.com',
116+
archived_snapshots: {
117+
closest: {
118+
status: '200',
119+
available: true,
120+
url: 'https://web.archive.org/web/20231225120000/https://example.com',
121+
timestamp: '20231225120000'
122+
}
123+
}
124+
}));
125+
126+
vi.spyOn(httpModule, 'fetchWithTimeout').mockResolvedValueOnce(mockResponse);
127+
vi.spyOn(httpModule, 'parseJsonResponse').mockResolvedValueOnce({
128+
url: 'https://example.com',
129+
archived_snapshots: {
130+
closest: {
131+
status: '200',
132+
available: true,
133+
url: 'https://web.archive.org/web/20231225120000/https://example.com',
134+
timestamp: '20231225120000'
135+
}
136+
}
137+
});
138+
139+
const result = await getArchivedUrl({
140+
url: 'https://example.com',
141+
timestamp: 'latest'
142+
});
143+
144+
expect(result.success).toBe(true);
145+
expect(result.available).toBe(true);
146+
});
147+
});

src/tools/save.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import { saveUrl } from './save.js';
3+
import * as httpModule from '../utils/http.js';
4+
import * as rateLimitModule from '../utils/rate-limit.js';
5+
6+
vi.mock('../utils/http.js');
7+
vi.mock('../utils/rate-limit.js');
8+
9+
describe('saveUrl', () => {
10+
beforeEach(() => {
11+
vi.clearAllMocks();
12+
vi.spyOn(rateLimitModule.waybackRateLimiter, 'waitForSlot').mockResolvedValue();
13+
vi.spyOn(rateLimitModule.waybackRateLimiter, 'recordRequest').mockImplementation();
14+
});
15+
16+
afterEach(() => {
17+
vi.restoreAllMocks();
18+
});
19+
20+
it('should successfully save a URL with location header', async () => {
21+
const mockResponse = new Response('', {
22+
headers: {
23+
Location: '/web/20231225120000/https://example.com',
24+
},
25+
});
26+
27+
vi.spyOn(httpModule, 'fetchWithTimeout').mockResolvedValueOnce(mockResponse);
28+
29+
const result = await saveUrl({ url: 'https://example.com' });
30+
31+
expect(result.success).toBe(true);
32+
expect(result.message).toContain('Successfully submitted');
33+
expect(result.archivedUrl).toBe('https://web.archive.org/web/20231225120000/https://example.com');
34+
expect(result.timestamp).toBe('20231225120000');
35+
});
36+
37+
it('should handle rate limit errors', async () => {
38+
vi.spyOn(httpModule, 'fetchWithTimeout').mockRejectedValueOnce(
39+
new httpModule.HttpError('Rate limited', 429)
40+
);
41+
42+
const result = await saveUrl({ url: 'https://example.com' });
43+
44+
expect(result.success).toBe(false);
45+
expect(result.message).toContain('Rate limit exceeded');
46+
});
47+
48+
it('should handle invalid URLs', async () => {
49+
const result = await saveUrl({ url: 'not-a-url' });
50+
51+
expect(result.success).toBe(false);
52+
expect(result.message).toContain('Failed to save URL');
53+
});
54+
55+
it('should try alternative save endpoint', async () => {
56+
const mockResponse1 = new Response('', { status: 200 });
57+
const mockResponse2 = new Response(JSON.stringify({
58+
job_id: '12345',
59+
url: 'https://web.archive.org/web/20231225120000/https://example.com',
60+
timestamp: '20231225120000'
61+
}));
62+
63+
vi.spyOn(httpModule, 'fetchWithTimeout')
64+
.mockResolvedValueOnce(mockResponse1)
65+
.mockResolvedValueOnce(mockResponse2);
66+
67+
const result = await saveUrl({ url: 'https://example.com' });
68+
69+
expect(result.success).toBe(true);
70+
expect(result.jobId).toBe('12345');
71+
expect(httpModule.fetchWithTimeout).toHaveBeenCalledTimes(2);
72+
});
73+
74+
it('should handle content-location header', async () => {
75+
const mockResponse = new Response('', {
76+
headers: {
77+
'Content-Location': '/web/20231225120000/https://example.com',
78+
},
79+
});
80+
81+
vi.spyOn(httpModule, 'fetchWithTimeout').mockResolvedValueOnce(mockResponse);
82+
83+
const result = await saveUrl({ url: 'https://example.com' });
84+
85+
expect(result.success).toBe(true);
86+
expect(result.archivedUrl).toBe('https://web.archive.org/web/20231225120000/https://example.com');
87+
});
88+
89+
it('should handle generic errors', async () => {
90+
vi.spyOn(httpModule, 'fetchWithTimeout').mockRejectedValueOnce(
91+
new Error('Network error')
92+
);
93+
94+
const result = await saveUrl({ url: 'https://example.com' });
95+
96+
expect(result.success).toBe(false);
97+
expect(result.message).toContain('Network error');
98+
});
99+
});

0 commit comments

Comments
 (0)