@jgardner04/ghost-mcp-server 1.13.5 → 1.14.1

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.
@@ -20,7 +20,6 @@ import {
20
20
  createTag,
21
21
  getTags,
22
22
  getSiteInfo,
23
- uploadImage,
24
23
  handleApiRequest,
25
24
  api,
26
25
  } from '../ghostService.js';
@@ -209,24 +208,6 @@ describe('ghostService', () => {
209
208
  });
210
209
  });
211
210
 
212
- describe('uploadImage', () => {
213
- it('should throw error when image path is missing', async () => {
214
- await expect(uploadImage()).rejects.toThrow('Image path is required for upload');
215
- await expect(uploadImage('')).rejects.toThrow('Image path is required for upload');
216
- });
217
-
218
- it('should successfully upload image with valid path', async () => {
219
- const imagePath = '/path/to/image.jpg';
220
- const expectedResult = { url: 'https://example.com/uploaded-image.jpg' };
221
- api.images.upload.mockResolvedValue(expectedResult);
222
-
223
- const result = await uploadImage(imagePath);
224
-
225
- expect(result).toEqual(expectedResult);
226
- expect(api.images.upload).toHaveBeenCalledWith({ file: imagePath });
227
- });
228
- });
229
-
230
211
  describe('createPost', () => {
231
212
  it('should throw error when title is missing', async () => {
232
213
  await expect(createPost({})).rejects.toThrow('Post title is required');
@@ -1,204 +1,175 @@
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());
1
+ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
2
+ import sharp from 'sharp';
3
+ import fs from 'fs/promises';
4
+ import path from 'path';
5
+ import os from 'os';
7
6
 
8
- // Mock logger
7
+ import { createMockContextLogger } from '../../__tests__/helpers/mockLogger.js';
9
8
  vi.mock('../../utils/logger.js', () => ({
10
9
  createContextLogger: createMockContextLogger(),
11
10
  }));
12
11
 
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
- }));
12
+ // Import under test (real sharp, real filesystem)
13
+ const { processImage } = await import('../imageProcessingService.js');
14
+
15
+ const tmpRoot = path.join(os.tmpdir(), `ghost-mcp-img-test-${Date.now()}`);
16
+ const fixtures = {};
17
+
18
+ async function makeRasterFixture(format, width, height = width) {
19
+ const file = path.join(tmpRoot, `in-${format}-${width}.${format === 'jpeg' ? 'jpg' : format}`);
20
+ await sharp({
21
+ create: {
22
+ width,
23
+ height,
24
+ channels: format === 'png' ? 4 : 3,
25
+ background: { r: 10, g: 50, b: 150, alpha: 1 },
26
+ },
27
+ })
28
+ [format]()
29
+ .toFile(file);
30
+ return file;
31
+ }
32
+
33
+ beforeAll(async () => {
34
+ await fs.mkdir(tmpRoot, { recursive: true });
35
+
36
+ fixtures.pngSmall = await makeRasterFixture('png', 800);
37
+ fixtures.pngLarge = await makeRasterFixture('png', 2000);
38
+ fixtures.jpegSmall = await makeRasterFixture('jpeg', 800);
39
+ fixtures.jpegLarge = await makeRasterFixture('jpeg', 2000);
40
+ fixtures.webpSmall = await makeRasterFixture('webp', 800);
41
+ fixtures.webpLarge = await makeRasterFixture('webp', 2000);
42
+
43
+ // SVG: write minimal vector source
44
+ fixtures.svg = path.join(tmpRoot, 'in.svg');
45
+ await fs.writeFile(
46
+ fixtures.svg,
47
+ '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#39f"/></svg>'
48
+ );
49
+
50
+ // GIF: sharp cannot reliably create animated GIFs across versions; use a
51
+ // known-valid 1x1 transparent GIF89a byte sequence for a deterministic fixture.
52
+ fixtures.gif = path.join(tmpRoot, 'in.gif');
53
+ await fs.writeFile(
54
+ fixtures.gif,
55
+ Buffer.from(
56
+ '47494638396101000100800000ffffff00000021f90401000001002c00000000010001000002024401003b',
57
+ 'hex'
58
+ )
59
+ );
60
+
61
+ // ICO: minimal 1x1 ICO header (used only for passthrough byte comparison).
62
+ fixtures.ico = path.join(tmpRoot, 'in.ico');
63
+ await fs.writeFile(
64
+ fixtures.ico,
65
+ Buffer.from(
66
+ '00000100010001010000010020003000000016000000280000000100000002000000010020000000000004000000000000000000000000000000000000000000000000000000000000000000000000',
67
+ 'hex'
68
+ )
69
+ );
70
+ });
44
71
 
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
- };
72
+ afterAll(async () => {
73
+ await fs.rm(tmpRoot, { recursive: true, force: true });
52
74
  });
53
75
 
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);
76
+ async function metaOf(file) {
77
+ return sharp(file).metadata();
78
+ }
79
+
80
+ describe('processImage — format passthrough', () => {
81
+ it('PNG <= 1200: preserves format and passes through unmodified', async () => {
82
+ const out = await processImage(fixtures.pngSmall, tmpRoot);
83
+ expect(path.extname(out).toLowerCase()).toBe('.png');
84
+ const m = await metaOf(out);
85
+ expect(m.format).toBe('png');
86
+ expect(m.width).toBe(800);
66
87
  });
67
88
 
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
- });
89
+ it('PNG > 1200: resized but still PNG (no JPEG conversion)', async () => {
90
+ const out = await processImage(fixtures.pngLarge, tmpRoot);
91
+ expect(path.extname(out).toLowerCase()).toBe('.png');
92
+ const m = await metaOf(out);
93
+ expect(m.format).toBe('png');
94
+ expect(m.width).toBe(1200);
92
95
  });
93
96
 
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
- });
97
+ it('JPEG <= 1200: passes through as JPEG', async () => {
98
+ const out = await processImage(fixtures.jpegSmall, tmpRoot);
99
+ expect(path.extname(out).toLowerCase()).toMatch(/\.jpe?g$/);
100
+ const m = await metaOf(out);
101
+ expect(m.format).toBe('jpeg');
102
+ expect(m.width).toBe(800);
113
103
  });
114
104
 
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);
105
+ it('JPEG > 1200: resized as JPEG', async () => {
106
+ const out = await processImage(fixtures.jpegLarge, tmpRoot);
107
+ const m = await metaOf(out);
108
+ expect(m.format).toBe('jpeg');
109
+ expect(m.width).toBe(1200);
110
+ });
143
111
 
144
- await processImage(inputPath, outputDir);
112
+ it('WEBP <= 1200: passes through as WEBP', async () => {
113
+ const out = await processImage(fixtures.webpSmall, tmpRoot);
114
+ const m = await metaOf(out);
115
+ expect(m.format).toBe('webp');
116
+ expect(m.width).toBe(800);
117
+ });
145
118
 
146
- expect(mockToFile).toHaveBeenCalledWith(
147
- expect.stringMatching(/processed-1234567890-test-photo\.jpg$/)
148
- );
119
+ it('WEBP > 1200: resized as WEBP', async () => {
120
+ const out = await processImage(fixtures.webpLarge, tmpRoot);
121
+ const m = await metaOf(out);
122
+ expect(m.format).toBe('webp');
123
+ expect(m.width).toBe(1200);
124
+ });
149
125
 
150
- vi.restoreAllMocks();
151
- });
126
+ it('SVG: passthrough — output bytes identical to input (never touched by sharp)', async () => {
127
+ const out = await processImage(fixtures.svg, tmpRoot);
128
+ expect(path.extname(out).toLowerCase()).toBe('.svg');
129
+ const [a, b] = await Promise.all([fs.readFile(fixtures.svg), fs.readFile(out)]);
130
+ expect(a.equals(b)).toBe(true);
131
+ });
152
132
 
153
- it('should convert image to JPEG with quality setting of 80', async () => {
154
- await processImage('/tmp/image.png', '/tmp/output');
133
+ it('GIF: passthrough output bytes identical to input (animation-safe)', async () => {
134
+ const out = await processImage(fixtures.gif, tmpRoot);
135
+ expect(path.extname(out).toLowerCase()).toBe('.gif');
136
+ const [a, b] = await Promise.all([fs.readFile(fixtures.gif), fs.readFile(out)]);
137
+ expect(a.equals(b)).toBe(true);
138
+ });
155
139
 
156
- expect(mockJpeg).toHaveBeenCalledWith({ quality: 80 });
157
- expect(mockToFile).toHaveBeenCalled();
158
- });
140
+ it('ICO: passthrough — output bytes identical to input', async () => {
141
+ const out = await processImage(fixtures.ico, tmpRoot, { purpose: 'icon' });
142
+ expect(path.extname(out).toLowerCase()).toBe('.ico');
143
+ const [a, b] = await Promise.all([fs.readFile(fixtures.ico), fs.readFile(out)]);
144
+ expect(a.equals(b)).toBe(true);
145
+ });
159
146
 
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);
147
+ it('output path is distinct from input path', async () => {
148
+ const out = await processImage(fixtures.pngSmall, tmpRoot);
149
+ expect(out).not.toBe(fixtures.pngSmall);
150
+ });
151
+ });
165
152
 
166
- await processImage(inputPath, outputDir);
153
+ describe('processImage — validation & errors', () => {
154
+ it('rejects missing inputPath', async () => {
155
+ await expect(processImage(undefined, tmpRoot)).rejects.toThrow(/Invalid processing parameters/);
156
+ });
167
157
 
168
- expect(mockToFile).toHaveBeenCalledWith(
169
- expect.stringMatching(/processed-9999999999-my\.test\.image\.jpg$/)
170
- );
158
+ it('rejects missing outputDir', async () => {
159
+ await expect(processImage(fixtures.pngSmall, undefined)).rejects.toThrow(
160
+ /Invalid processing parameters/
161
+ );
162
+ });
171
163
 
172
- vi.restoreAllMocks();
173
- });
164
+ it('throws when input file does not exist', async () => {
165
+ await expect(processImage('/nonexistent/xyz.png', tmpRoot)).rejects.toThrow(
166
+ /Input file does not exist/
167
+ );
174
168
  });
175
169
 
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
- });
170
+ it('wraps sharp failures with context', async () => {
171
+ const bogus = path.join(tmpRoot, 'bogus.png');
172
+ await fs.writeFile(bogus, 'not an image');
173
+ await expect(processImage(bogus, tmpRoot)).rejects.toThrow(/Image processing failed/);
203
174
  });
204
175
  });
@@ -0,0 +1,78 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { mockGhostApiModule } from '../../__tests__/helpers/mockGhostApi.js';
3
+ import { createMockContextLogger } from '../../__tests__/helpers/mockLogger.js';
4
+ import { mockDotenv } from '../../__tests__/helpers/testUtils.js';
5
+
6
+ vi.mock('@tryghost/admin-api', () => mockGhostApiModule());
7
+ vi.mock('dotenv', () => mockDotenv());
8
+ vi.mock('../../utils/logger.js', () => ({
9
+ createContextLogger: createMockContextLogger(),
10
+ }));
11
+
12
+ // Stub path validation so these tests focus on argument shaping, not FS.
13
+ vi.mock('../validators.js', () => ({
14
+ validators: { validateImagePath: vi.fn().mockResolvedValue(undefined) },
15
+ }));
16
+
17
+ import { uploadImage } from '../images.js';
18
+ import { api } from '../ghostApiClient.js';
19
+ import { ValidationError } from '../../errors/index.js';
20
+
21
+ describe('images.uploadImage (domain module used by MCP server)', () => {
22
+ beforeEach(() => {
23
+ vi.clearAllMocks();
24
+ });
25
+
26
+ it('forwards purpose and ref to the SDK', async () => {
27
+ const expected = { url: 'https://cdn/x.png', ref: 'original.png' };
28
+ api.images.upload.mockResolvedValue(expected);
29
+
30
+ const result = await uploadImage('/tmp/x.png', {
31
+ purpose: 'icon',
32
+ ref: 'original.png',
33
+ });
34
+
35
+ expect(result).toEqual(expected);
36
+ expect(api.images.upload).toHaveBeenCalledWith({
37
+ file: '/tmp/x.png',
38
+ purpose: 'icon',
39
+ ref: 'original.png',
40
+ });
41
+ });
42
+
43
+ it('omits purpose/ref when not provided', async () => {
44
+ api.images.upload.mockResolvedValue({ url: 'https://cdn/x.png' });
45
+ await uploadImage('/tmp/x.png');
46
+ expect(api.images.upload).toHaveBeenCalledWith({ file: '/tmp/x.png' });
47
+ });
48
+
49
+ it('accepts each allowed purpose value', async () => {
50
+ api.images.upload.mockResolvedValue({ url: 'https://cdn/x' });
51
+ for (const purpose of ['image', 'profile_image', 'icon']) {
52
+ await uploadImage('/tmp/x.png', { purpose });
53
+ }
54
+ expect(api.images.upload).toHaveBeenCalledTimes(3);
55
+ });
56
+
57
+ it('rejects unknown purpose', async () => {
58
+ await expect(uploadImage('/tmp/x.png', { purpose: 'banner' })).rejects.toBeInstanceOf(
59
+ ValidationError
60
+ );
61
+ expect(api.images.upload).not.toHaveBeenCalled();
62
+ });
63
+
64
+ it('rejects non-string ref', async () => {
65
+ await expect(uploadImage('/tmp/x.png', { ref: 42 })).rejects.toThrow(/must be a string/);
66
+ });
67
+
68
+ it('rejects ref longer than 200 chars', async () => {
69
+ await expect(uploadImage('/tmp/x.png', { ref: 'x'.repeat(201) })).rejects.toThrow(
70
+ /cannot exceed 200/
71
+ );
72
+ });
73
+
74
+ it('accepts ref of exactly 200 chars', async () => {
75
+ api.images.upload.mockResolvedValue({ url: 'https://cdn/x' });
76
+ await expect(uploadImage('/tmp/x.png', { ref: 'x'.repeat(200) })).resolves.toBeDefined();
77
+ });
78
+ });
@@ -148,24 +148,6 @@ const createPost = async (postData, options = { source: 'html' }) => {
148
148
  return handleApiRequest('posts', 'add', dataWithDefaults, options);
149
149
  };
150
150
 
151
- /**
152
- * Uploads an image to Ghost.
153
- * Requires the image file path.
154
- * @param {string} imagePath - The local path to the image file.
155
- * @returns {Promise<object>} The result from the image upload API call, typically includes the URL of the uploaded image.
156
- */
157
- const uploadImage = async (imagePath) => {
158
- if (!imagePath) {
159
- throw new Error('Image path is required for upload.');
160
- }
161
-
162
- // The Ghost Admin API expects an object with a 'file' property containing the path
163
- const imageData = { file: imagePath };
164
-
165
- // Use the handleApiRequest function for consistency
166
- return handleApiRequest('images', 'upload', imageData);
167
- };
168
-
169
151
  /**
170
152
  * Creates a new tag in Ghost.
171
153
  * @param {object} tagData - Data for the new tag (e.g., { name: 'New Tag', slug: 'new-tag' }).
@@ -209,4 +191,4 @@ const getTags = async (options = {}) => {
209
191
  // Add other content management functions here (createTag, etc.)
210
192
 
211
193
  // Export the API client instance and any service functions
212
- export { api, getSiteInfo, handleApiRequest, createPost, uploadImage, createTag, getTags };
194
+ export { api, getSiteInfo, handleApiRequest, createPost, createTag, getTags };