Skip to content

Commit 5897c7f

Browse files
committed
Add test files and Docker configuration
- Jest configuration and test suites - Docker deployment files - Updated package.json with test scripts
1 parent 505dfdb commit 5897c7f

File tree

7 files changed

+391
-0
lines changed

7 files changed

+391
-0
lines changed

.dockerignore

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
node_modules
2+
npm-debug.log
3+
.env
4+
.env.local
5+
.env.*.local
6+
.git
7+
.gitignore
8+
README.md
9+
CHANGELOG.md
10+
LICENSE
11+
CLAUDE.md
12+
*.md
13+
test-*.js
14+
debug-*.js
15+
cleanup.js
16+
full-test.js
17+
.vscode
18+
.idea
19+
*.swp
20+
*.swo
21+
.DS_Store
22+
Thumbs.db

Dockerfile

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
FROM node:18-alpine
2+
3+
WORKDIR /app
4+
5+
# Copy package files
6+
COPY package*.json ./
7+
8+
# Install dependencies
9+
RUN npm ci --only=production
10+
11+
# Copy source code
12+
COPY src ./src
13+
14+
# Create non-root user
15+
RUN addgroup -g 1001 -S nodejs && \
16+
adduser -S nodejs -u 1001 && \
17+
chown -R nodejs:nodejs /app
18+
19+
USER nodejs
20+
21+
# Expose port
22+
EXPOSE 3000
23+
24+
# Health check
25+
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
26+
CMD node -e "require('http').get('http://localhost:3000/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1));"
27+
28+
# Start server
29+
CMD ["node", "src/index.js"]

__tests__/error-handler.test.js

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { describe, test, expect } from '@jest/globals';
2+
import {
3+
MCP_ERROR_CODES,
4+
formatMCPError,
5+
createToolErrorResponse,
6+
createHTTPErrorResponse,
7+
ValidationError,
8+
AuthenticationError,
9+
CalDAVError,
10+
CardDAVError
11+
} from '../src/error-handler.js';
12+
13+
describe('Error Handler Module', () => {
14+
describe('MCP_ERROR_CODES', () => {
15+
test('should have standard JSON-RPC error codes', () => {
16+
expect(MCP_ERROR_CODES.PARSE_ERROR).toBe(-32700);
17+
expect(MCP_ERROR_CODES.INVALID_REQUEST).toBe(-32600);
18+
expect(MCP_ERROR_CODES.METHOD_NOT_FOUND).toBe(-32601);
19+
expect(MCP_ERROR_CODES.INVALID_PARAMS).toBe(-32602);
20+
expect(MCP_ERROR_CODES.INTERNAL_ERROR).toBe(-32603);
21+
});
22+
23+
test('should have custom application error codes', () => {
24+
expect(MCP_ERROR_CODES.CALDAV_ERROR).toBe(-32000);
25+
expect(MCP_ERROR_CODES.CARDDAV_ERROR).toBe(-32001);
26+
expect(MCP_ERROR_CODES.VALIDATION_ERROR).toBe(-32002);
27+
expect(MCP_ERROR_CODES.AUTH_ERROR).toBe(-32003);
28+
});
29+
});
30+
31+
describe('Custom Error Classes', () => {
32+
test('ValidationError should have correct properties', () => {
33+
const error = new ValidationError('Test validation error', { field: 'test' });
34+
expect(error.name).toBe('ValidationError');
35+
expect(error.code).toBe(MCP_ERROR_CODES.VALIDATION_ERROR);
36+
expect(error.message).toBe('Test validation error');
37+
expect(error.details).toEqual({ field: 'test' });
38+
});
39+
40+
test('AuthenticationError should have correct properties', () => {
41+
const error = new AuthenticationError('Unauthorized');
42+
expect(error.name).toBe('AuthenticationError');
43+
expect(error.code).toBe(MCP_ERROR_CODES.AUTH_ERROR);
44+
});
45+
46+
test('CalDAVError should have correct properties', () => {
47+
const error = new CalDAVError('CalDAV connection failed');
48+
expect(error.name).toBe('CalDAVError');
49+
expect(error.code).toBe(MCP_ERROR_CODES.CALDAV_ERROR);
50+
});
51+
52+
test('CardDAVError should have correct properties', () => {
53+
const error = new CardDAVError('CardDAV connection failed');
54+
expect(error.name).toBe('CardDAVError');
55+
expect(error.code).toBe(MCP_ERROR_CODES.CARDDAV_ERROR);
56+
});
57+
});
58+
59+
describe('formatMCPError', () => {
60+
test('should format error with explicit code', () => {
61+
const error = new Error('Test error');
62+
error.code = MCP_ERROR_CODES.INVALID_REQUEST;
63+
64+
const formatted = formatMCPError(error);
65+
66+
expect(formatted.code).toBe(MCP_ERROR_CODES.INVALID_REQUEST);
67+
expect(formatted.message).toBe('Test error');
68+
expect(formatted.data.type).toBe('Error');
69+
});
70+
71+
test('should include stack trace when requested', () => {
72+
const error = new Error('Test error');
73+
74+
const formatted = formatMCPError(error, true);
75+
76+
expect(formatted.data.stack).toBeDefined();
77+
expect(typeof formatted.data.stack).toBe('string');
78+
});
79+
80+
test('should not include stack trace by default', () => {
81+
const error = new Error('Test error');
82+
83+
const formatted = formatMCPError(error, false);
84+
85+
expect(formatted.data.stack).toBeUndefined();
86+
});
87+
88+
test('should detect error type from message', () => {
89+
const error = new Error('caldav connection failed');
90+
91+
const formatted = formatMCPError(error);
92+
93+
expect(formatted.code).toBe(MCP_ERROR_CODES.CALDAV_ERROR);
94+
});
95+
});
96+
97+
describe('createToolErrorResponse', () => {
98+
test('should create MCP-compliant error response', () => {
99+
const error = new ValidationError('Invalid input');
100+
101+
const response = createToolErrorResponse(error);
102+
103+
expect(response.isError).toBe(true);
104+
expect(response.content).toHaveLength(1);
105+
expect(response.content[0].type).toBe('text');
106+
107+
const parsed = JSON.parse(response.content[0].text);
108+
expect(parsed.code).toBe(MCP_ERROR_CODES.VALIDATION_ERROR);
109+
expect(parsed.message).toBe('Invalid input');
110+
});
111+
});
112+
113+
describe('createHTTPErrorResponse', () => {
114+
test('should map validation error to 400', () => {
115+
const error = new ValidationError('Invalid input');
116+
117+
const response = createHTTPErrorResponse(error);
118+
119+
expect(response.statusCode).toBe(400);
120+
expect(response.body.error).toBe('Invalid input');
121+
expect(response.body.code).toBe(MCP_ERROR_CODES.VALIDATION_ERROR);
122+
});
123+
124+
test('should map auth error to 401', () => {
125+
const error = new AuthenticationError('Unauthorized');
126+
127+
const response = createHTTPErrorResponse(error);
128+
129+
expect(response.statusCode).toBe(401);
130+
});
131+
132+
test('should map method not found to 404', () => {
133+
const error = new Error('Tool not found');
134+
error.code = MCP_ERROR_CODES.METHOD_NOT_FOUND;
135+
136+
const response = createHTTPErrorResponse(error);
137+
138+
expect(response.statusCode).toBe(404);
139+
});
140+
141+
test('should use custom status code if provided', () => {
142+
const error = new Error('Custom error');
143+
144+
const response = createHTTPErrorResponse(error, 418);
145+
146+
expect(response.statusCode).toBe(418);
147+
});
148+
});
149+
});

__tests__/logger.test.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { describe, test, expect } from '@jest/globals';
2+
import { logger, createContextLogger, createRequestLogger, createSessionLogger } from '../src/logger.js';
3+
4+
describe('Logger Module', () => {
5+
test('logger should be defined', () => {
6+
expect(logger).toBeDefined();
7+
expect(typeof logger.info).toBe('function');
8+
expect(typeof logger.error).toBe('function');
9+
expect(typeof logger.debug).toBe('function');
10+
expect(typeof logger.warn).toBe('function');
11+
});
12+
13+
test('createContextLogger should create child logger with context', () => {
14+
const childLogger = createContextLogger({ module: 'test' });
15+
expect(childLogger).toBeDefined();
16+
expect(typeof childLogger.info).toBe('function');
17+
});
18+
19+
test('createRequestLogger should create child logger with requestId', () => {
20+
const requestLogger = createRequestLogger('req-123');
21+
expect(requestLogger).toBeDefined();
22+
expect(typeof requestLogger.info).toBe('function');
23+
});
24+
25+
test('createRequestLogger should accept additional context', () => {
26+
const requestLogger = createRequestLogger('req-123', { userId: 'user-456' });
27+
expect(requestLogger).toBeDefined();
28+
});
29+
30+
test('createSessionLogger should create child logger with sessionId', () => {
31+
const sessionLogger = createSessionLogger('session-789');
32+
expect(sessionLogger).toBeDefined();
33+
expect(typeof sessionLogger.info).toBe('function');
34+
});
35+
36+
test('createSessionLogger should accept additional context', () => {
37+
const sessionLogger = createSessionLogger('session-789', { ip: '127.0.0.1' });
38+
expect(sessionLogger).toBeDefined();
39+
});
40+
});

__tests__/validation.test.js

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { describe, test, expect } from '@jest/globals';
2+
import {
3+
validateInput,
4+
sanitizeICalString,
5+
sanitizeVCardString,
6+
createEventSchema,
7+
listEventsSchema
8+
} from '../src/validation.js';
9+
10+
describe('Validation Module', () => {
11+
describe('sanitizeICalString', () => {
12+
test('should escape backslashes', () => {
13+
const result = sanitizeICalString('test\\string');
14+
expect(result).toBe('test\\\\string');
15+
});
16+
17+
test('should escape semicolons', () => {
18+
const result = sanitizeICalString('test;string');
19+
expect(result).toBe('test\\;string');
20+
});
21+
22+
test('should escape commas', () => {
23+
const result = sanitizeICalString('test,string');
24+
expect(result).toBe('test\\,string');
25+
});
26+
27+
test('should escape newlines', () => {
28+
const result = sanitizeICalString('test\nstring');
29+
expect(result).toBe('test\\nstring');
30+
});
31+
32+
test('should handle empty string', () => {
33+
const result = sanitizeICalString('');
34+
expect(result).toBe('');
35+
});
36+
37+
test('should handle null/undefined', () => {
38+
expect(sanitizeICalString(null)).toBe('');
39+
expect(sanitizeICalString(undefined)).toBe('');
40+
});
41+
});
42+
43+
describe('sanitizeVCardString', () => {
44+
test('should work same as sanitizeICalString', () => {
45+
const input = 'test;with,special\\chars\n';
46+
expect(sanitizeVCardString(input)).toBe(sanitizeICalString(input));
47+
});
48+
});
49+
50+
describe('validateInput', () => {
51+
test('should validate correct event data', () => {
52+
const validData = {
53+
calendar_url: 'https://example.com/calendar/',
54+
time_range_start: '2025-01-01T00:00:00.000Z',
55+
time_range_end: '2025-12-31T23:59:59.999Z',
56+
};
57+
58+
const result = validateInput(listEventsSchema, validData);
59+
expect(result).toEqual(validData);
60+
});
61+
62+
test('should throw on invalid URL', () => {
63+
const invalidData = {
64+
calendar_url: 'not-a-url',
65+
};
66+
67+
expect(() => validateInput(listEventsSchema, invalidData)).toThrow('Validation failed');
68+
});
69+
70+
test('should validate event creation with all fields', () => {
71+
const validEvent = {
72+
calendar_url: 'https://example.com/calendar/',
73+
summary: 'Test Event',
74+
start_date: '2025-10-15T10:00:00.000Z',
75+
end_date: '2025-10-15T11:00:00.000Z',
76+
description: 'Test description',
77+
location: 'Test location',
78+
};
79+
80+
const result = validateInput(createEventSchema, validEvent);
81+
expect(result).toEqual(validEvent);
82+
});
83+
84+
test('should reject event with end before start', () => {
85+
const invalidEvent = {
86+
calendar_url: 'https://example.com/calendar/',
87+
summary: 'Test Event',
88+
start_date: '2025-10-15T11:00:00.000Z',
89+
end_date: '2025-10-15T10:00:00.000Z', // Before start!
90+
};
91+
92+
expect(() => validateInput(createEventSchema, invalidEvent)).toThrow('End date must be after start date');
93+
});
94+
95+
test('should reject event with too long summary', () => {
96+
const invalidEvent = {
97+
calendar_url: 'https://example.com/calendar/',
98+
summary: 'x'.repeat(501), // Max is 500
99+
start_date: '2025-10-15T10:00:00.000Z',
100+
end_date: '2025-10-15T11:00:00.000Z',
101+
};
102+
103+
expect(() => validateInput(createEventSchema, invalidEvent)).toThrow('Validation failed');
104+
});
105+
});
106+
});

docker-compose.yml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
version: '3.8'
2+
3+
services:
4+
tsdav-mcp:
5+
build: .
6+
container_name: tsdav-mcp-server
7+
restart: unless-stopped
8+
ports:
9+
- "3000:3000"
10+
environment:
11+
- NODE_ENV=production
12+
env_file:
13+
- .env
14+
healthcheck:
15+
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1));"]
16+
interval: 30s
17+
timeout: 3s
18+
retries: 3
19+
start_period: 5s
20+
networks:
21+
- mcp-network
22+
logging:
23+
driver: "json-file"
24+
options:
25+
max-size: "10m"
26+
max-file: "3"
27+
28+
networks:
29+
mcp-network:
30+
driver: bridge

jest.config.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export default {
2+
testEnvironment: 'node',
3+
transform: {},
4+
moduleNameMapper: {
5+
'^(\\.{1,2}/.*)\\.js$': '$1',
6+
},
7+
testMatch: ['**/__tests__/**/*.test.js', '**/*.test.js'],
8+
collectCoverageFrom: [
9+
'src/**/*.js',
10+
'!src/index.js', // Skip server startup
11+
'!src/server-stdio.js', // Skip server startup
12+
],
13+
coverageDirectory: 'coverage',
14+
verbose: true,
15+
};

0 commit comments

Comments
 (0)