@jgardner04/ghost-mcp-server 1.13.4 → 1.14.0
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/README.md +68 -0
- package/package.json +7 -3
- package/src/__tests__/helpers/testUtils.js +15 -1
- package/src/__tests__/mcp_server.test.js +152 -1
- package/src/__tests__/mcp_server_pages.test.js +23 -6
- package/src/controllers/__tests__/imageController.test.js +2 -2
- package/src/controllers/imageController.js +11 -10
- package/src/mcp_server.js +647 -1203
- package/src/routes/__tests__/imageRoutes.test.js +2 -2
- package/src/schemas/__tests__/common.test.js +3 -3
- package/src/schemas/__tests__/pageSchemas.test.js +11 -2
- package/src/schemas/common.js +3 -2
- package/src/schemas/pageSchemas.js +1 -1
- package/src/schemas/postSchemas.js +1 -1
- package/src/services/__tests__/createResourceService.test.js +468 -0
- package/src/services/__tests__/ghostService.test.js +0 -19
- package/src/services/__tests__/ghostServiceImproved.members.test.js +0 -3
- package/src/services/__tests__/ghostServiceImproved.newsletters.test.js +2 -2
- package/src/services/__tests__/ghostServiceImproved.pages.test.js +0 -3
- package/src/services/__tests__/ghostServiceImproved.posts.test.js +0 -1
- package/src/services/__tests__/ghostServiceImproved.tags.test.js +0 -3
- package/src/services/__tests__/ghostServiceImproved.tiers.test.js +3 -5
- package/src/services/__tests__/imageProcessingService.test.js +148 -177
- package/src/services/__tests__/images.test.js +78 -0
- package/src/services/createResourceService.js +138 -0
- package/src/services/ghostApiClient.js +240 -0
- package/src/services/ghostService.js +1 -19
- package/src/services/ghostServiceImproved.js +76 -915
- package/src/services/imageProcessingService.js +100 -56
- package/src/services/images.js +54 -0
- package/src/services/members.js +127 -0
- package/src/services/newsletters.js +63 -0
- package/src/services/pageService.js +2 -2
- package/src/services/pages.js +116 -0
- package/src/services/posts.js +116 -0
- package/src/services/tags.js +118 -0
- package/src/services/tiers.js +72 -0
- package/src/services/validators.js +218 -0
- package/src/utils/__tests__/imageInputResolver.test.js +134 -0
- package/src/utils/imageInputResolver.js +127 -0
|
@@ -252,7 +252,6 @@ describe('ghostServiceImproved - Tags', () => {
|
|
|
252
252
|
|
|
253
253
|
it('should throw not found error when tag does not exist', async () => {
|
|
254
254
|
const error404 = new GhostAPIError('tags.read', 'Tag not found', 404);
|
|
255
|
-
error404.response = { status: 404 };
|
|
256
255
|
api.tags.read.mockRejectedValue(error404);
|
|
257
256
|
|
|
258
257
|
const rejection = getTag('non-existent');
|
|
@@ -411,7 +410,6 @@ describe('ghostServiceImproved - Tags', () => {
|
|
|
411
410
|
|
|
412
411
|
it('should throw not found error if tag does not exist', async () => {
|
|
413
412
|
const error404 = new GhostAPIError('tags.read', 'Tag not found', 404);
|
|
414
|
-
error404.response = { status: 404 };
|
|
415
413
|
api.tags.read.mockRejectedValue(error404);
|
|
416
414
|
|
|
417
415
|
const rejection = updateTag('non-existent', { name: 'Test' });
|
|
@@ -438,7 +436,6 @@ describe('ghostServiceImproved - Tags', () => {
|
|
|
438
436
|
|
|
439
437
|
it('should throw not found error if tag does not exist', async () => {
|
|
440
438
|
const error404 = new GhostAPIError('tags.delete', 'Tag not found', 404);
|
|
441
|
-
error404.response = { status: 404 };
|
|
442
439
|
api.tags.delete.mockRejectedValue(error404);
|
|
443
440
|
|
|
444
441
|
const rejection = deleteTag('non-existent');
|
|
@@ -295,9 +295,7 @@ describe('ghostServiceImproved - Tiers', () => {
|
|
|
295
295
|
});
|
|
296
296
|
|
|
297
297
|
it('should throw ValidationError when ID is missing', async () => {
|
|
298
|
-
await expect(updateTier('', { name: 'Updated' })).rejects.toThrow(
|
|
299
|
-
'Tier ID is required for update'
|
|
300
|
-
);
|
|
298
|
+
await expect(updateTier('', { name: 'Updated' })).rejects.toThrow('Tier ID is required');
|
|
301
299
|
});
|
|
302
300
|
|
|
303
301
|
it('should throw ValidationError for invalid update data', async () => {
|
|
@@ -327,11 +325,11 @@ describe('ghostServiceImproved - Tiers', () => {
|
|
|
327
325
|
});
|
|
328
326
|
|
|
329
327
|
it('should throw ValidationError when ID is missing', async () => {
|
|
330
|
-
await expect(deleteTier()).rejects.toThrow('Tier ID is required
|
|
328
|
+
await expect(deleteTier()).rejects.toThrow('Tier ID is required');
|
|
331
329
|
});
|
|
332
330
|
|
|
333
331
|
it('should throw ValidationError when ID is empty string', async () => {
|
|
334
|
-
await expect(deleteTier('')).rejects.toThrow('Tier ID is required
|
|
332
|
+
await expect(deleteTier('')).rejects.toThrow('Tier ID is required');
|
|
335
333
|
});
|
|
336
334
|
|
|
337
335
|
it('should throw NotFoundError when tier does not exist', async () => {
|
|
@@ -1,204 +1,175 @@
|
|
|
1
|
-
import { describe, it, expect,
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
7
|
+
import { createMockContextLogger } from '../../__tests__/helpers/mockLogger.js';
|
|
9
8
|
vi.mock('../../utils/logger.js', () => ({
|
|
10
9
|
createContextLogger: createMockContextLogger(),
|
|
11
10
|
}));
|
|
12
11
|
|
|
13
|
-
//
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
return
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { GhostAPIError, ValidationError } from '../errors/index.js';
|
|
2
|
+
import { handleApiRequest, readResource, updateWithOCC, deleteResource } from './ghostApiClient.js';
|
|
3
|
+
import { validators } from './validators.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Factory that generates standard CRUD service methods for a Ghost CMS resource.
|
|
7
|
+
*
|
|
8
|
+
* Each domain (posts, pages, tags, etc.) shares the same structural patterns:
|
|
9
|
+
* create → validate → API add → 422 mapping
|
|
10
|
+
* update → requireId → optional validation → OCC edit → optional 422 mapping
|
|
11
|
+
* remove → deleteResource
|
|
12
|
+
* getOne → readResource
|
|
13
|
+
* getList → browse with defaults → empty-array fallback
|
|
14
|
+
*
|
|
15
|
+
* Domain-specific behavior is injected via config hooks.
|
|
16
|
+
*
|
|
17
|
+
* @param {Object} config - Resource configuration
|
|
18
|
+
* @param {string} config.resource - Ghost API resource name (e.g., 'posts')
|
|
19
|
+
* @param {string} config.label - Human-readable label (e.g., 'Post')
|
|
20
|
+
* @param {Object} [config.listDefaults] - Default options for getList (e.g., { limit: 15, include: 'tags,authors' })
|
|
21
|
+
* @param {Object} [config.createDefaults] - Default data merged into create payload (e.g., { status: 'draft' })
|
|
22
|
+
* @param {Object} [config.createOptions] - Default options for create API call (e.g., { source: 'html' })
|
|
23
|
+
* @param {Function} [config.validateCreate] - Validation function called before create: (data) => void | Promise<void>
|
|
24
|
+
* @param {Function} [config.validateUpdate] - Validation function called before update: (id, data) => void | Promise<void>
|
|
25
|
+
* @param {boolean} [config.catch422OnUpdate=false] - Whether to catch 422 errors on update and wrap as ValidationError
|
|
26
|
+
* @returns {Object} Object with { create, update, remove, getOne, getList } methods
|
|
27
|
+
*
|
|
28
|
+
* SECURITY: HTML content must be sanitized before reaching this function.
|
|
29
|
+
* See htmlContentSchema in schemas/common.js for the validation gate.
|
|
30
|
+
*/
|
|
31
|
+
export function createResourceService(config) {
|
|
32
|
+
const {
|
|
33
|
+
resource,
|
|
34
|
+
label,
|
|
35
|
+
listDefaults = { limit: 15 },
|
|
36
|
+
createDefaults = {},
|
|
37
|
+
createOptions = {},
|
|
38
|
+
validateCreate,
|
|
39
|
+
validateUpdate,
|
|
40
|
+
catch422OnUpdate = false,
|
|
41
|
+
} = config;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Creates a new resource.
|
|
45
|
+
* @param {Object} data - Resource data
|
|
46
|
+
* @param {Object} [options] - API request options (merged with createOptions)
|
|
47
|
+
* @returns {Promise<Object>} Created resource
|
|
48
|
+
*/
|
|
49
|
+
async function create(data, options = {}) {
|
|
50
|
+
if (validateCreate) {
|
|
51
|
+
await validateCreate(data);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const dataWithDefaults = {
|
|
55
|
+
...createDefaults,
|
|
56
|
+
...data,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const mergedOptions = { ...createOptions, ...options };
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
return await handleApiRequest(resource, 'add', dataWithDefaults, mergedOptions);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
|
|
65
|
+
throw new ValidationError(`${label} creation failed due to validation errors`, [
|
|
66
|
+
{ field: label.toLowerCase(), message: error.originalError },
|
|
67
|
+
]);
|
|
68
|
+
}
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Updates an existing resource with optimistic concurrency control.
|
|
75
|
+
* @param {string} id - Resource ID
|
|
76
|
+
* @param {Object} updateData - Fields to update
|
|
77
|
+
* @param {Object} [options={}] - API request options
|
|
78
|
+
* @returns {Promise<Object>} Updated resource
|
|
79
|
+
*/
|
|
80
|
+
async function update(id, updateData, options = {}) {
|
|
81
|
+
validators.requireId(id, label);
|
|
82
|
+
|
|
83
|
+
if (validateUpdate) {
|
|
84
|
+
await validateUpdate(id, updateData);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (catch422OnUpdate) {
|
|
88
|
+
try {
|
|
89
|
+
return await updateWithOCC(resource, id, updateData, options, label);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
|
|
92
|
+
throw new ValidationError(`${label} update failed`, [
|
|
93
|
+
{ field: label.toLowerCase(), message: error.originalError },
|
|
94
|
+
]);
|
|
95
|
+
}
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return updateWithOCC(resource, id, updateData, options, label);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Deletes a resource by ID.
|
|
105
|
+
* @param {string} id - Resource ID
|
|
106
|
+
* @returns {Promise<Object>} Deletion confirmation
|
|
107
|
+
*/
|
|
108
|
+
async function remove(id) {
|
|
109
|
+
return deleteResource(resource, id, label);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Retrieves a single resource by ID.
|
|
114
|
+
* @param {string} id - Resource ID
|
|
115
|
+
* @param {Object} [options={}] - API request options
|
|
116
|
+
* @returns {Promise<Object>} Resource object
|
|
117
|
+
*/
|
|
118
|
+
async function getOne(id, options = {}) {
|
|
119
|
+
return readResource(resource, id, label, options);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Lists resources with optional filtering and pagination.
|
|
124
|
+
* @param {Object} [options={}] - Query options
|
|
125
|
+
* @returns {Promise<Array>} Array of resources (empty array if none found)
|
|
126
|
+
*/
|
|
127
|
+
async function getList(options = {}) {
|
|
128
|
+
const mergedOptions = {
|
|
129
|
+
...listDefaults,
|
|
130
|
+
...options,
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const result = await handleApiRequest(resource, 'browse', {}, mergedOptions);
|
|
134
|
+
return result || [];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { create, update, remove, getOne, getList };
|
|
138
|
+
}
|