@jgardner04/ghost-mcp-server 1.1.10 → 1.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { mockDotenv } from '../../__tests__/helpers/testUtils.js';
|
|
3
|
+
import { createMockContextLogger } from '../../__tests__/helpers/mockLogger.js';
|
|
4
|
+
|
|
5
|
+
// Mock dotenv
|
|
6
|
+
vi.mock('dotenv', () => mockDotenv());
|
|
7
|
+
|
|
8
|
+
// Mock logger
|
|
9
|
+
vi.mock('../../utils/logger.js', () => ({
|
|
10
|
+
createContextLogger: createMockContextLogger(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
// Mock sharp with chainable API
|
|
14
|
+
const mockMetadata = vi.fn();
|
|
15
|
+
const mockResize = vi.fn();
|
|
16
|
+
const mockJpeg = vi.fn();
|
|
17
|
+
const mockToFile = vi.fn();
|
|
18
|
+
|
|
19
|
+
const createMockSharp = () => {
|
|
20
|
+
const instance = {
|
|
21
|
+
metadata: mockMetadata,
|
|
22
|
+
resize: mockResize,
|
|
23
|
+
jpeg: mockJpeg,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Make methods chainable
|
|
27
|
+
mockResize.mockReturnValue(instance);
|
|
28
|
+
mockJpeg.mockReturnValue(instance);
|
|
29
|
+
instance.toFile = mockToFile;
|
|
30
|
+
|
|
31
|
+
return instance;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
vi.mock('sharp', () => ({
|
|
35
|
+
default: vi.fn(() => createMockSharp()),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
// Mock fs
|
|
39
|
+
vi.mock('fs', () => ({
|
|
40
|
+
default: {
|
|
41
|
+
existsSync: vi.fn(),
|
|
42
|
+
},
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
// Mock path - use actual implementation but allow spying
|
|
46
|
+
vi.mock('path', async () => {
|
|
47
|
+
const actual = await vi.importActual('path');
|
|
48
|
+
return {
|
|
49
|
+
default: actual.default,
|
|
50
|
+
...actual,
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Import after mocks are set up
|
|
55
|
+
import { processImage } from '../imageProcessingService.js';
|
|
56
|
+
import sharp from 'sharp';
|
|
57
|
+
import fs from 'fs';
|
|
58
|
+
|
|
59
|
+
describe('imageProcessingService', () => {
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
vi.clearAllMocks();
|
|
62
|
+
// Reset mock implementations
|
|
63
|
+
mockMetadata.mockResolvedValue({ width: 800, size: 100000 });
|
|
64
|
+
mockToFile.mockResolvedValue();
|
|
65
|
+
fs.existsSync.mockReturnValue(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('Input Validation', () => {
|
|
69
|
+
it('should accept valid inputPath and outputDir', async () => {
|
|
70
|
+
const inputPath = '/tmp/test-image.jpg';
|
|
71
|
+
const outputDir = '/tmp/output';
|
|
72
|
+
|
|
73
|
+
await processImage(inputPath, outputDir);
|
|
74
|
+
|
|
75
|
+
expect(sharp).toHaveBeenCalledWith(inputPath);
|
|
76
|
+
expect(mockToFile).toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should reject missing inputPath', async () => {
|
|
80
|
+
await expect(processImage(undefined, '/tmp/output')).rejects.toThrow(
|
|
81
|
+
'Invalid processing parameters'
|
|
82
|
+
);
|
|
83
|
+
expect(sharp).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should reject missing outputDir', async () => {
|
|
87
|
+
await expect(processImage('/tmp/test.jpg', undefined)).rejects.toThrow(
|
|
88
|
+
'Invalid processing parameters'
|
|
89
|
+
);
|
|
90
|
+
expect(sharp).not.toHaveBeenCalled();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('Path Security', () => {
|
|
95
|
+
it('should resolve paths correctly', async () => {
|
|
96
|
+
const inputPath = './relative/path/image.jpg';
|
|
97
|
+
const outputDir = './output';
|
|
98
|
+
|
|
99
|
+
await processImage(inputPath, outputDir);
|
|
100
|
+
|
|
101
|
+
expect(fs.existsSync).toHaveBeenCalledWith(expect.stringContaining('image.jpg'));
|
|
102
|
+
expect(mockToFile).toHaveBeenCalledWith(expect.stringContaining('output'));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should throw error when input file does not exist', async () => {
|
|
106
|
+
fs.existsSync.mockReturnValue(false);
|
|
107
|
+
|
|
108
|
+
await expect(processImage('/tmp/nonexistent.jpg', '/tmp/output')).rejects.toThrow(
|
|
109
|
+
'Input file does not exist'
|
|
110
|
+
);
|
|
111
|
+
expect(sharp).not.toHaveBeenCalled();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('Image Processing', () => {
|
|
116
|
+
it('should not resize image when width <= MAX_WIDTH (1200)', async () => {
|
|
117
|
+
mockMetadata.mockResolvedValue({ width: 1000, size: 100000 });
|
|
118
|
+
|
|
119
|
+
await processImage('/tmp/small-image.jpg', '/tmp/output');
|
|
120
|
+
|
|
121
|
+
expect(mockResize).not.toHaveBeenCalled();
|
|
122
|
+
expect(mockJpeg).toHaveBeenCalledWith({ quality: 80 });
|
|
123
|
+
expect(mockToFile).toHaveBeenCalled();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should resize image when width > MAX_WIDTH (1200)', async () => {
|
|
127
|
+
mockMetadata.mockResolvedValue({ width: 2000, size: 200000 });
|
|
128
|
+
|
|
129
|
+
await processImage('/tmp/large-image.jpg', '/tmp/output');
|
|
130
|
+
|
|
131
|
+
expect(mockResize).toHaveBeenCalledWith({ width: 1200 });
|
|
132
|
+
expect(mockJpeg).toHaveBeenCalledWith({ quality: 80 });
|
|
133
|
+
expect(mockToFile).toHaveBeenCalled();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should generate output filename with timestamp and processed prefix', async () => {
|
|
137
|
+
const inputPath = '/tmp/test-photo.jpg';
|
|
138
|
+
const outputDir = '/tmp/output';
|
|
139
|
+
|
|
140
|
+
// Mock Date.now to get predictable filename
|
|
141
|
+
const mockTimestamp = 1234567890;
|
|
142
|
+
vi.spyOn(Date, 'now').mockReturnValue(mockTimestamp);
|
|
143
|
+
|
|
144
|
+
await processImage(inputPath, outputDir);
|
|
145
|
+
|
|
146
|
+
expect(mockToFile).toHaveBeenCalledWith(
|
|
147
|
+
expect.stringMatching(/processed-1234567890-test-photo\.jpg$/)
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
vi.restoreAllMocks();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should convert image to JPEG with quality setting of 80', async () => {
|
|
154
|
+
await processImage('/tmp/image.png', '/tmp/output');
|
|
155
|
+
|
|
156
|
+
expect(mockJpeg).toHaveBeenCalledWith({ quality: 80 });
|
|
157
|
+
expect(mockToFile).toHaveBeenCalled();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('should handle images with multiple dots in filename', async () => {
|
|
161
|
+
const inputPath = '/tmp/my.test.image.png';
|
|
162
|
+
const outputDir = '/tmp/output';
|
|
163
|
+
const mockTimestamp = 9999999999;
|
|
164
|
+
vi.spyOn(Date, 'now').mockReturnValue(mockTimestamp);
|
|
165
|
+
|
|
166
|
+
await processImage(inputPath, outputDir);
|
|
167
|
+
|
|
168
|
+
expect(mockToFile).toHaveBeenCalledWith(
|
|
169
|
+
expect.stringMatching(/processed-9999999999-my\.test\.image\.jpg$/)
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
vi.restoreAllMocks();
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('Error Handling', () => {
|
|
177
|
+
it('should catch and re-throw sharp processing failures', async () => {
|
|
178
|
+
const processingError = new Error('Sharp processing failed');
|
|
179
|
+
mockMetadata.mockRejectedValue(processingError);
|
|
180
|
+
|
|
181
|
+
await expect(processImage('/tmp/corrupt.jpg', '/tmp/output')).rejects.toThrow(
|
|
182
|
+
'Image processing failed: Sharp processing failed'
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('should include original error message in re-thrown error', async () => {
|
|
187
|
+
const originalMessage = 'Input buffer contains unsupported image format';
|
|
188
|
+
mockToFile.mockRejectedValue(new Error(originalMessage));
|
|
189
|
+
|
|
190
|
+
await expect(processImage('/tmp/bad-format.dat', '/tmp/output')).rejects.toThrow(
|
|
191
|
+
`Image processing failed: ${originalMessage}`
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('should handle errors during JPEG conversion', async () => {
|
|
196
|
+
const conversionError = new Error('JPEG conversion failed');
|
|
197
|
+
mockToFile.mockRejectedValue(conversionError);
|
|
198
|
+
|
|
199
|
+
await expect(processImage('/tmp/image.jpg', '/tmp/output')).rejects.toThrow(
|
|
200
|
+
'Image processing failed: JPEG conversion failed'
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
});
|
|
@@ -28,8 +28,8 @@ const processImage = async (inputPath, outputDir) => {
|
|
|
28
28
|
if (error) {
|
|
29
29
|
logger.error('Invalid processing parameters', {
|
|
30
30
|
error: error.details[0].message,
|
|
31
|
-
inputPath: path.basename(inputPath),
|
|
32
|
-
outputDir: path.basename(outputDir),
|
|
31
|
+
inputPath: inputPath ? path.basename(inputPath) : 'undefined',
|
|
32
|
+
outputDir: outputDir ? path.basename(outputDir) : 'undefined',
|
|
33
33
|
});
|
|
34
34
|
throw new Error('Invalid processing parameters');
|
|
35
35
|
}
|
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { createContextLogger } from '../logger.js';
|
|
3
|
+
import logger from '../logger.js';
|
|
4
|
+
|
|
5
|
+
describe('logger', () => {
|
|
6
|
+
describe('default logger export', () => {
|
|
7
|
+
it('should export a winston logger instance', () => {
|
|
8
|
+
expect(logger).toBeDefined();
|
|
9
|
+
expect(typeof logger.info).toBe('function');
|
|
10
|
+
expect(typeof logger.error).toBe('function');
|
|
11
|
+
expect(typeof logger.warn).toBe('function');
|
|
12
|
+
expect(typeof logger.debug).toBe('function');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('should have core logging methods', () => {
|
|
16
|
+
expect(logger).toHaveProperty('info');
|
|
17
|
+
expect(logger).toHaveProperty('error');
|
|
18
|
+
expect(logger).toHaveProperty('warn');
|
|
19
|
+
expect(logger).toHaveProperty('debug');
|
|
20
|
+
expect(logger).toHaveProperty('log');
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('createContextLogger', () => {
|
|
25
|
+
let contextLogger;
|
|
26
|
+
let logSpy;
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
// Spy on the underlying logger methods
|
|
30
|
+
logSpy = {
|
|
31
|
+
debug: vi.spyOn(logger, 'debug').mockImplementation(() => {}),
|
|
32
|
+
info: vi.spyOn(logger, 'info').mockImplementation(() => {}),
|
|
33
|
+
warn: vi.spyOn(logger, 'warn').mockImplementation(() => {}),
|
|
34
|
+
error: vi.spyOn(logger, 'error').mockImplementation(() => {}),
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
vi.restoreAllMocks();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('return value structure', () => {
|
|
43
|
+
it('should return an object with expected methods', () => {
|
|
44
|
+
contextLogger = createContextLogger('test-context');
|
|
45
|
+
|
|
46
|
+
expect(contextLogger).toBeDefined();
|
|
47
|
+
expect(typeof contextLogger).toBe('object');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should have basic logging methods', () => {
|
|
51
|
+
contextLogger = createContextLogger('test-context');
|
|
52
|
+
|
|
53
|
+
expect(typeof contextLogger.info).toBe('function');
|
|
54
|
+
expect(typeof contextLogger.warn).toBe('function');
|
|
55
|
+
expect(typeof contextLogger.error).toBe('function');
|
|
56
|
+
expect(typeof contextLogger.debug).toBe('function');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should have API-specific methods', () => {
|
|
60
|
+
contextLogger = createContextLogger('test-context');
|
|
61
|
+
|
|
62
|
+
expect(typeof contextLogger.apiRequest).toBe('function');
|
|
63
|
+
expect(typeof contextLogger.apiResponse).toBe('function');
|
|
64
|
+
expect(typeof contextLogger.apiError).toBe('function');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should have tool-specific methods', () => {
|
|
68
|
+
contextLogger = createContextLogger('test-context');
|
|
69
|
+
|
|
70
|
+
expect(typeof contextLogger.toolExecution).toBe('function');
|
|
71
|
+
expect(typeof contextLogger.toolSuccess).toBe('function');
|
|
72
|
+
expect(typeof contextLogger.toolError).toBe('function');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should have file operation method', () => {
|
|
76
|
+
contextLogger = createContextLogger('test-context');
|
|
77
|
+
|
|
78
|
+
expect(typeof contextLogger.fileOperation).toBe('function');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('basic logging methods', () => {
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
contextLogger = createContextLogger('test-service');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should call logger.debug with context', () => {
|
|
88
|
+
contextLogger.debug('debug message');
|
|
89
|
+
|
|
90
|
+
expect(logSpy.debug).toHaveBeenCalledWith('debug message', {
|
|
91
|
+
context: 'test-service',
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should call logger.info with context', () => {
|
|
96
|
+
contextLogger.info('info message');
|
|
97
|
+
|
|
98
|
+
expect(logSpy.info).toHaveBeenCalledWith('info message', {
|
|
99
|
+
context: 'test-service',
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should call logger.warn with context', () => {
|
|
104
|
+
contextLogger.warn('warning message');
|
|
105
|
+
|
|
106
|
+
expect(logSpy.warn).toHaveBeenCalledWith('warning message', {
|
|
107
|
+
context: 'test-service',
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should call logger.error with context', () => {
|
|
112
|
+
contextLogger.error('error message');
|
|
113
|
+
|
|
114
|
+
expect(logSpy.error).toHaveBeenCalledWith('error message', {
|
|
115
|
+
context: 'test-service',
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should merge additional metadata with context', () => {
|
|
120
|
+
contextLogger.info('info message', { userId: '123', action: 'login' });
|
|
121
|
+
|
|
122
|
+
expect(logSpy.info).toHaveBeenCalledWith('info message', {
|
|
123
|
+
context: 'test-service',
|
|
124
|
+
userId: '123',
|
|
125
|
+
action: 'login',
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should handle empty metadata object', () => {
|
|
130
|
+
contextLogger.info('info message', {});
|
|
131
|
+
|
|
132
|
+
expect(logSpy.info).toHaveBeenCalledWith('info message', {
|
|
133
|
+
context: 'test-service',
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('should work with different context values', () => {
|
|
138
|
+
const logger1 = createContextLogger('service-a');
|
|
139
|
+
const logger2 = createContextLogger('service-b');
|
|
140
|
+
|
|
141
|
+
logger1.info('message from A');
|
|
142
|
+
logger2.info('message from B');
|
|
143
|
+
|
|
144
|
+
expect(logSpy.info).toHaveBeenCalledWith('message from A', {
|
|
145
|
+
context: 'service-a',
|
|
146
|
+
});
|
|
147
|
+
expect(logSpy.info).toHaveBeenCalledWith('message from B', {
|
|
148
|
+
context: 'service-b',
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('apiRequest method', () => {
|
|
154
|
+
beforeEach(() => {
|
|
155
|
+
contextLogger = createContextLogger('api-handler');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should log API request with method and URL', () => {
|
|
159
|
+
contextLogger.apiRequest('GET', '/api/posts');
|
|
160
|
+
|
|
161
|
+
expect(logSpy.info).toHaveBeenCalledWith('GET /api/posts', {
|
|
162
|
+
context: 'api-handler',
|
|
163
|
+
type: 'api_request',
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('should include additional metadata', () => {
|
|
168
|
+
contextLogger.apiRequest('POST', '/api/posts', { requestId: 'req-123' });
|
|
169
|
+
|
|
170
|
+
expect(logSpy.info).toHaveBeenCalledWith('POST /api/posts', {
|
|
171
|
+
context: 'api-handler',
|
|
172
|
+
type: 'api_request',
|
|
173
|
+
requestId: 'req-123',
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('should handle different HTTP methods', () => {
|
|
178
|
+
contextLogger.apiRequest('PUT', '/api/posts/1');
|
|
179
|
+
contextLogger.apiRequest('DELETE', '/api/posts/1');
|
|
180
|
+
contextLogger.apiRequest('PATCH', '/api/posts/1');
|
|
181
|
+
|
|
182
|
+
expect(logSpy.info).toHaveBeenCalledWith('PUT /api/posts/1', expect.any(Object));
|
|
183
|
+
expect(logSpy.info).toHaveBeenCalledWith('DELETE /api/posts/1', expect.any(Object));
|
|
184
|
+
expect(logSpy.info).toHaveBeenCalledWith('PATCH /api/posts/1', expect.any(Object));
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('apiResponse method', () => {
|
|
189
|
+
beforeEach(() => {
|
|
190
|
+
contextLogger = createContextLogger('api-handler');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('should log API response with method, URL, and status', () => {
|
|
194
|
+
contextLogger.apiResponse('GET', '/api/posts', 200);
|
|
195
|
+
|
|
196
|
+
expect(logSpy.info).toHaveBeenCalledWith('GET /api/posts -> 200', {
|
|
197
|
+
context: 'api-handler',
|
|
198
|
+
type: 'api_response',
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('should include additional metadata', () => {
|
|
203
|
+
contextLogger.apiResponse('POST', '/api/posts', 201, { duration: 150 });
|
|
204
|
+
|
|
205
|
+
expect(logSpy.info).toHaveBeenCalledWith('POST /api/posts -> 201', {
|
|
206
|
+
context: 'api-handler',
|
|
207
|
+
type: 'api_response',
|
|
208
|
+
duration: 150,
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('should handle different status codes', () => {
|
|
213
|
+
contextLogger.apiResponse('GET', '/api/posts', 404);
|
|
214
|
+
contextLogger.apiResponse('POST', '/api/posts', 500);
|
|
215
|
+
|
|
216
|
+
expect(logSpy.info).toHaveBeenCalledWith('GET /api/posts -> 404', expect.any(Object));
|
|
217
|
+
expect(logSpy.info).toHaveBeenCalledWith('POST /api/posts -> 500', expect.any(Object));
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe('apiError method', () => {
|
|
222
|
+
beforeEach(() => {
|
|
223
|
+
contextLogger = createContextLogger('api-handler');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('should log API error with method, URL, and error details', () => {
|
|
227
|
+
const error = new Error('Connection timeout');
|
|
228
|
+
error.stack = 'Error: Connection timeout\n at test.js:1:1';
|
|
229
|
+
|
|
230
|
+
contextLogger.apiError('GET', '/api/posts', error);
|
|
231
|
+
|
|
232
|
+
expect(logSpy.error).toHaveBeenCalledWith('GET /api/posts failed', {
|
|
233
|
+
context: 'api-handler',
|
|
234
|
+
type: 'api_error',
|
|
235
|
+
error: 'Connection timeout',
|
|
236
|
+
stack: error.stack,
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('should include additional metadata', () => {
|
|
241
|
+
const error = new Error('Database error');
|
|
242
|
+
error.stack = 'Error: Database error\n at test.js:1:1';
|
|
243
|
+
|
|
244
|
+
contextLogger.apiError('POST', '/api/posts', error, { retryCount: 3 });
|
|
245
|
+
|
|
246
|
+
expect(logSpy.error).toHaveBeenCalledWith('POST /api/posts failed', {
|
|
247
|
+
context: 'api-handler',
|
|
248
|
+
type: 'api_error',
|
|
249
|
+
error: 'Database error',
|
|
250
|
+
stack: error.stack,
|
|
251
|
+
retryCount: 3,
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('should extract error message and stack from error object', () => {
|
|
256
|
+
const error = new Error('Simple error');
|
|
257
|
+
|
|
258
|
+
contextLogger.apiError('GET', '/api/posts', error);
|
|
259
|
+
|
|
260
|
+
expect(logSpy.error).toHaveBeenCalledWith('GET /api/posts failed', {
|
|
261
|
+
context: 'api-handler',
|
|
262
|
+
type: 'api_error',
|
|
263
|
+
error: 'Simple error',
|
|
264
|
+
stack: expect.any(String),
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe('toolExecution method', () => {
|
|
270
|
+
beforeEach(() => {
|
|
271
|
+
contextLogger = createContextLogger('mcp-server');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('should log tool execution with tool name', () => {
|
|
275
|
+
contextLogger.toolExecution('ghost_create_post', { title: 'Test' });
|
|
276
|
+
|
|
277
|
+
expect(logSpy.info).toHaveBeenCalledWith('Executing tool: ghost_create_post', {
|
|
278
|
+
context: 'mcp-server',
|
|
279
|
+
type: 'tool_execution',
|
|
280
|
+
tool: 'ghost_create_post',
|
|
281
|
+
inputKeys: ['title'],
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('should handle empty input object', () => {
|
|
286
|
+
contextLogger.toolExecution('ghost_get_tags', {});
|
|
287
|
+
|
|
288
|
+
expect(logSpy.info).toHaveBeenCalledWith('Executing tool: ghost_get_tags', {
|
|
289
|
+
context: 'mcp-server',
|
|
290
|
+
type: 'tool_execution',
|
|
291
|
+
tool: 'ghost_get_tags',
|
|
292
|
+
inputKeys: [],
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('should handle null input', () => {
|
|
297
|
+
contextLogger.toolExecution('ghost_get_tags', null);
|
|
298
|
+
|
|
299
|
+
expect(logSpy.info).toHaveBeenCalledWith('Executing tool: ghost_get_tags', {
|
|
300
|
+
context: 'mcp-server',
|
|
301
|
+
type: 'tool_execution',
|
|
302
|
+
tool: 'ghost_get_tags',
|
|
303
|
+
inputKeys: [],
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('should include additional metadata', () => {
|
|
308
|
+
contextLogger.toolExecution(
|
|
309
|
+
'ghost_upload_image',
|
|
310
|
+
{ url: 'https://example.com' },
|
|
311
|
+
{ requestId: 'req-456' }
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
expect(logSpy.info).toHaveBeenCalledWith('Executing tool: ghost_upload_image', {
|
|
315
|
+
context: 'mcp-server',
|
|
316
|
+
type: 'tool_execution',
|
|
317
|
+
tool: 'ghost_upload_image',
|
|
318
|
+
inputKeys: ['url'],
|
|
319
|
+
requestId: 'req-456',
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
describe('toolSuccess method', () => {
|
|
325
|
+
beforeEach(() => {
|
|
326
|
+
contextLogger = createContextLogger('mcp-server');
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('should log tool success with tool name and result type', () => {
|
|
330
|
+
contextLogger.toolSuccess('ghost_create_post', { id: '123' });
|
|
331
|
+
|
|
332
|
+
expect(logSpy.info).toHaveBeenCalledWith('Tool ghost_create_post completed successfully', {
|
|
333
|
+
context: 'mcp-server',
|
|
334
|
+
type: 'tool_success',
|
|
335
|
+
tool: 'ghost_create_post',
|
|
336
|
+
resultType: 'object',
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('should handle different result types', () => {
|
|
341
|
+
contextLogger.toolSuccess('tool1', 'string result');
|
|
342
|
+
contextLogger.toolSuccess('tool2', 123);
|
|
343
|
+
contextLogger.toolSuccess('tool3', true);
|
|
344
|
+
contextLogger.toolSuccess('tool4', ['array']);
|
|
345
|
+
|
|
346
|
+
expect(logSpy.info).toHaveBeenCalledWith(
|
|
347
|
+
expect.any(String),
|
|
348
|
+
expect.objectContaining({ resultType: 'string' })
|
|
349
|
+
);
|
|
350
|
+
expect(logSpy.info).toHaveBeenCalledWith(
|
|
351
|
+
expect.any(String),
|
|
352
|
+
expect.objectContaining({ resultType: 'number' })
|
|
353
|
+
);
|
|
354
|
+
expect(logSpy.info).toHaveBeenCalledWith(
|
|
355
|
+
expect.any(String),
|
|
356
|
+
expect.objectContaining({ resultType: 'object' })
|
|
357
|
+
);
|
|
358
|
+
expect(logSpy.info).toHaveBeenCalledWith(
|
|
359
|
+
expect.any(String),
|
|
360
|
+
expect.objectContaining({ resultType: 'object' })
|
|
361
|
+
);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('should include additional metadata', () => {
|
|
365
|
+
contextLogger.toolSuccess('ghost_upload_image', { url: 'https://...' }, { duration: 250 });
|
|
366
|
+
|
|
367
|
+
expect(logSpy.info).toHaveBeenCalledWith('Tool ghost_upload_image completed successfully', {
|
|
368
|
+
context: 'mcp-server',
|
|
369
|
+
type: 'tool_success',
|
|
370
|
+
tool: 'ghost_upload_image',
|
|
371
|
+
resultType: 'object',
|
|
372
|
+
duration: 250,
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
describe('toolError method', () => {
|
|
378
|
+
beforeEach(() => {
|
|
379
|
+
contextLogger = createContextLogger('mcp-server');
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('should log tool error with tool name and error details', () => {
|
|
383
|
+
const error = new Error('Upload failed');
|
|
384
|
+
error.stack = 'Error: Upload failed\n at test.js:1:1';
|
|
385
|
+
|
|
386
|
+
contextLogger.toolError('ghost_upload_image', error);
|
|
387
|
+
|
|
388
|
+
expect(logSpy.error).toHaveBeenCalledWith('Tool ghost_upload_image failed', {
|
|
389
|
+
context: 'mcp-server',
|
|
390
|
+
type: 'tool_error',
|
|
391
|
+
tool: 'ghost_upload_image',
|
|
392
|
+
error: 'Upload failed',
|
|
393
|
+
stack: error.stack,
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('should include additional metadata', () => {
|
|
398
|
+
const error = new Error('Validation failed');
|
|
399
|
+
error.stack = 'Error: Validation failed\n at test.js:1:1';
|
|
400
|
+
|
|
401
|
+
contextLogger.toolError('ghost_create_post', error, {
|
|
402
|
+
validationErrors: ['title required'],
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
expect(logSpy.error).toHaveBeenCalledWith('Tool ghost_create_post failed', {
|
|
406
|
+
context: 'mcp-server',
|
|
407
|
+
type: 'tool_error',
|
|
408
|
+
tool: 'ghost_create_post',
|
|
409
|
+
error: 'Validation failed',
|
|
410
|
+
stack: error.stack,
|
|
411
|
+
validationErrors: ['title required'],
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('should extract error message and stack from error object', () => {
|
|
416
|
+
const error = new Error('Simple error');
|
|
417
|
+
|
|
418
|
+
contextLogger.toolError('ghost_get_tags', error);
|
|
419
|
+
|
|
420
|
+
expect(logSpy.error).toHaveBeenCalledWith('Tool ghost_get_tags failed', {
|
|
421
|
+
context: 'mcp-server',
|
|
422
|
+
type: 'tool_error',
|
|
423
|
+
tool: 'ghost_get_tags',
|
|
424
|
+
error: 'Simple error',
|
|
425
|
+
stack: expect.any(String),
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
describe('fileOperation method', () => {
|
|
431
|
+
beforeEach(() => {
|
|
432
|
+
contextLogger = createContextLogger('image-processor');
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('should log file operation with operation type and filename', () => {
|
|
436
|
+
contextLogger.fileOperation('write', '/tmp/images/test.jpg');
|
|
437
|
+
|
|
438
|
+
expect(logSpy.debug).toHaveBeenCalledWith('File operation: write', {
|
|
439
|
+
context: 'image-processor',
|
|
440
|
+
type: 'file_operation',
|
|
441
|
+
operation: 'write',
|
|
442
|
+
file: 'test.jpg',
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it('should extract basename from full path', () => {
|
|
447
|
+
contextLogger.fileOperation('read', '/very/long/path/to/image.png');
|
|
448
|
+
|
|
449
|
+
expect(logSpy.debug).toHaveBeenCalledWith('File operation: read', {
|
|
450
|
+
context: 'image-processor',
|
|
451
|
+
type: 'file_operation',
|
|
452
|
+
operation: 'read',
|
|
453
|
+
file: 'image.png',
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('should handle different operation types', () => {
|
|
458
|
+
contextLogger.fileOperation('delete', '/tmp/old-file.jpg');
|
|
459
|
+
contextLogger.fileOperation('move', '/tmp/new-location.jpg');
|
|
460
|
+
|
|
461
|
+
expect(logSpy.debug).toHaveBeenCalledWith('File operation: delete', expect.any(Object));
|
|
462
|
+
expect(logSpy.debug).toHaveBeenCalledWith('File operation: move', expect.any(Object));
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it('should include additional metadata', () => {
|
|
466
|
+
contextLogger.fileOperation('optimize', '/tmp/image.jpg', { size: 1024, quality: 80 });
|
|
467
|
+
|
|
468
|
+
expect(logSpy.debug).toHaveBeenCalledWith('File operation: optimize', {
|
|
469
|
+
context: 'image-processor',
|
|
470
|
+
type: 'file_operation',
|
|
471
|
+
operation: 'optimize',
|
|
472
|
+
file: 'image.jpg',
|
|
473
|
+
size: 1024,
|
|
474
|
+
quality: 80,
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
describe('context isolation', () => {
|
|
480
|
+
it('should maintain separate contexts for different loggers', () => {
|
|
481
|
+
const logger1 = createContextLogger('service-1');
|
|
482
|
+
const logger2 = createContextLogger('service-2');
|
|
483
|
+
|
|
484
|
+
logger1.info('message 1');
|
|
485
|
+
logger2.info('message 2');
|
|
486
|
+
|
|
487
|
+
expect(logSpy.info).toHaveBeenCalledWith('message 1', {
|
|
488
|
+
context: 'service-1',
|
|
489
|
+
});
|
|
490
|
+
expect(logSpy.info).toHaveBeenCalledWith('message 2', {
|
|
491
|
+
context: 'service-2',
|
|
492
|
+
});
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it('should not leak metadata between calls', () => {
|
|
496
|
+
contextLogger = createContextLogger('test');
|
|
497
|
+
|
|
498
|
+
contextLogger.info('first', { value: 1 });
|
|
499
|
+
contextLogger.info('second', { value: 2 });
|
|
500
|
+
|
|
501
|
+
expect(logSpy.info).toHaveBeenNthCalledWith(1, 'first', {
|
|
502
|
+
context: 'test',
|
|
503
|
+
value: 1,
|
|
504
|
+
});
|
|
505
|
+
expect(logSpy.info).toHaveBeenNthCalledWith(2, 'second', {
|
|
506
|
+
context: 'test',
|
|
507
|
+
value: 2,
|
|
508
|
+
});
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
});
|