@jgardner04/ghost-mcp-server 1.1.9 → 1.1.11
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
|
+
});
|
|
@@ -0,0 +1,504 @@
|
|
|
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 ghostService functions - must use factory pattern to avoid hoisting issues
|
|
14
|
+
vi.mock('../ghostService.js', () => ({
|
|
15
|
+
createPost: vi.fn(),
|
|
16
|
+
getTags: vi.fn(),
|
|
17
|
+
createTag: vi.fn(),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
// Import after mocks are set up
|
|
21
|
+
import { createPostService } from '../postService.js';
|
|
22
|
+
import { createPost, getTags, createTag } from '../ghostService.js';
|
|
23
|
+
|
|
24
|
+
describe('postService', () => {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
vi.clearAllMocks();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('createPostService - validation', () => {
|
|
30
|
+
it('should accept valid input and create a post', async () => {
|
|
31
|
+
const validInput = {
|
|
32
|
+
title: 'Test Post',
|
|
33
|
+
html: '<p>Test content</p>',
|
|
34
|
+
};
|
|
35
|
+
const expectedPost = { id: '1', title: 'Test Post', status: 'draft' };
|
|
36
|
+
createPost.mockResolvedValue(expectedPost);
|
|
37
|
+
|
|
38
|
+
const result = await createPostService(validInput);
|
|
39
|
+
|
|
40
|
+
expect(result).toEqual(expectedPost);
|
|
41
|
+
expect(createPost).toHaveBeenCalledWith(
|
|
42
|
+
expect.objectContaining({
|
|
43
|
+
title: 'Test Post',
|
|
44
|
+
html: '<p>Test content</p>',
|
|
45
|
+
status: 'draft',
|
|
46
|
+
})
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should reject input with missing title', async () => {
|
|
51
|
+
const invalidInput = {
|
|
52
|
+
html: '<p>Test content</p>',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
await expect(createPostService(invalidInput)).rejects.toThrow(
|
|
56
|
+
'Invalid post input: "title" is required'
|
|
57
|
+
);
|
|
58
|
+
expect(createPost).not.toHaveBeenCalled();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should reject input with missing html', async () => {
|
|
62
|
+
const invalidInput = {
|
|
63
|
+
title: 'Test Post',
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
await expect(createPostService(invalidInput)).rejects.toThrow(
|
|
67
|
+
'Invalid post input: "html" is required'
|
|
68
|
+
);
|
|
69
|
+
expect(createPost).not.toHaveBeenCalled();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should reject input with invalid status', async () => {
|
|
73
|
+
const invalidInput = {
|
|
74
|
+
title: 'Test Post',
|
|
75
|
+
html: '<p>Content</p>',
|
|
76
|
+
status: 'invalid-status',
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
await expect(createPostService(invalidInput)).rejects.toThrow(
|
|
80
|
+
'Invalid post input: "status" must be one of [draft, published, scheduled]'
|
|
81
|
+
);
|
|
82
|
+
expect(createPost).not.toHaveBeenCalled();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should accept valid status values', async () => {
|
|
86
|
+
const statuses = ['draft', 'published', 'scheduled'];
|
|
87
|
+
createPost.mockResolvedValue({ id: '1', title: 'Test' });
|
|
88
|
+
|
|
89
|
+
for (const status of statuses) {
|
|
90
|
+
const input = {
|
|
91
|
+
title: 'Test Post',
|
|
92
|
+
html: '<p>Content</p>',
|
|
93
|
+
status,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
await createPostService(input);
|
|
97
|
+
|
|
98
|
+
expect(createPost).toHaveBeenCalledWith(expect.objectContaining({ status }));
|
|
99
|
+
vi.clearAllMocks();
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should validate tags array with maximum length', async () => {
|
|
104
|
+
const invalidInput = {
|
|
105
|
+
title: 'Test Post',
|
|
106
|
+
html: '<p>Content</p>',
|
|
107
|
+
tags: Array(11).fill('tag'), // 11 tags exceeds max of 10
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
await expect(createPostService(invalidInput)).rejects.toThrow('Invalid post input:');
|
|
111
|
+
expect(createPost).not.toHaveBeenCalled();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should validate tag string max length', async () => {
|
|
115
|
+
const invalidInput = {
|
|
116
|
+
title: 'Test Post',
|
|
117
|
+
html: '<p>Content</p>',
|
|
118
|
+
tags: ['a'.repeat(51)], // 51 chars exceeds max of 50
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
await expect(createPostService(invalidInput)).rejects.toThrow('Invalid post input:');
|
|
122
|
+
expect(createPost).not.toHaveBeenCalled();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('should validate feature_image is a valid URI', async () => {
|
|
126
|
+
const invalidInput = {
|
|
127
|
+
title: 'Test Post',
|
|
128
|
+
html: '<p>Content</p>',
|
|
129
|
+
feature_image: 'not-a-valid-url',
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
await expect(createPostService(invalidInput)).rejects.toThrow('Invalid post input:');
|
|
133
|
+
expect(createPost).not.toHaveBeenCalled();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should accept valid feature_image URI', async () => {
|
|
137
|
+
const validInput = {
|
|
138
|
+
title: 'Test Post',
|
|
139
|
+
html: '<p>Content</p>',
|
|
140
|
+
feature_image: 'https://example.com/image.jpg',
|
|
141
|
+
};
|
|
142
|
+
createPost.mockResolvedValue({ id: '1', title: 'Test' });
|
|
143
|
+
|
|
144
|
+
await createPostService(validInput);
|
|
145
|
+
|
|
146
|
+
expect(createPost).toHaveBeenCalledWith(
|
|
147
|
+
expect.objectContaining({
|
|
148
|
+
feature_image: 'https://example.com/image.jpg',
|
|
149
|
+
})
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('createPostService - tag resolution', () => {
|
|
155
|
+
it('should find and reuse existing tag', async () => {
|
|
156
|
+
const input = {
|
|
157
|
+
title: 'Test Post',
|
|
158
|
+
html: '<p>Content</p>',
|
|
159
|
+
tags: ['existing-tag'],
|
|
160
|
+
};
|
|
161
|
+
const existingTag = { id: '1', name: 'existing-tag', slug: 'existing-tag' };
|
|
162
|
+
getTags.mockResolvedValue([existingTag]);
|
|
163
|
+
createPost.mockResolvedValue({ id: '1', title: 'Test' });
|
|
164
|
+
|
|
165
|
+
await createPostService(input);
|
|
166
|
+
|
|
167
|
+
expect(getTags).toHaveBeenCalledWith('existing-tag');
|
|
168
|
+
expect(createTag).not.toHaveBeenCalled();
|
|
169
|
+
expect(createPost).toHaveBeenCalledWith(
|
|
170
|
+
expect.objectContaining({
|
|
171
|
+
tags: [{ name: 'existing-tag' }],
|
|
172
|
+
})
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('should create new tag when not found', async () => {
|
|
177
|
+
const input = {
|
|
178
|
+
title: 'Test Post',
|
|
179
|
+
html: '<p>Content</p>',
|
|
180
|
+
tags: ['new-tag'],
|
|
181
|
+
};
|
|
182
|
+
getTags.mockResolvedValue([]); // Tag not found
|
|
183
|
+
const newTag = { id: '2', name: 'new-tag', slug: 'new-tag' };
|
|
184
|
+
createTag.mockResolvedValue(newTag);
|
|
185
|
+
createPost.mockResolvedValue({ id: '1', title: 'Test' });
|
|
186
|
+
|
|
187
|
+
await createPostService(input);
|
|
188
|
+
|
|
189
|
+
expect(getTags).toHaveBeenCalledWith('new-tag');
|
|
190
|
+
expect(createTag).toHaveBeenCalledWith({ name: 'new-tag' });
|
|
191
|
+
expect(createPost).toHaveBeenCalledWith(
|
|
192
|
+
expect.objectContaining({
|
|
193
|
+
tags: [{ name: 'new-tag' }],
|
|
194
|
+
})
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('should handle errors during tag lookup gracefully', async () => {
|
|
199
|
+
const input = {
|
|
200
|
+
title: 'Test Post',
|
|
201
|
+
html: '<p>Content</p>',
|
|
202
|
+
tags: ['error-tag', 'good-tag'],
|
|
203
|
+
};
|
|
204
|
+
// First tag causes error, second tag exists
|
|
205
|
+
getTags
|
|
206
|
+
.mockRejectedValueOnce(new Error('Tag lookup failed'))
|
|
207
|
+
.mockResolvedValueOnce([{ id: '1', name: 'good-tag' }]);
|
|
208
|
+
createPost.mockResolvedValue({ id: '1', title: 'Test' });
|
|
209
|
+
|
|
210
|
+
await createPostService(input);
|
|
211
|
+
|
|
212
|
+
// Should skip error-tag and only include good-tag
|
|
213
|
+
expect(createPost).toHaveBeenCalledWith(
|
|
214
|
+
expect.objectContaining({
|
|
215
|
+
tags: [{ name: 'good-tag' }],
|
|
216
|
+
})
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('should reject tags array with non-string values', async () => {
|
|
221
|
+
const input = {
|
|
222
|
+
title: 'Test Post',
|
|
223
|
+
html: '<p>Content</p>',
|
|
224
|
+
tags: [null, 'valid-tag'],
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
await expect(createPostService(input)).rejects.toThrow('Invalid post input:');
|
|
228
|
+
expect(createPost).not.toHaveBeenCalled();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('should reject tags array with empty strings', async () => {
|
|
232
|
+
const input = {
|
|
233
|
+
title: 'Test Post',
|
|
234
|
+
html: '<p>Content</p>',
|
|
235
|
+
tags: ['', 'valid-tag'],
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
await expect(createPostService(input)).rejects.toThrow('Invalid post input:');
|
|
239
|
+
expect(createPost).not.toHaveBeenCalled();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('should trim whitespace from tag names', async () => {
|
|
243
|
+
const input = {
|
|
244
|
+
title: 'Test Post',
|
|
245
|
+
html: '<p>Content</p>',
|
|
246
|
+
tags: [' trimmed-tag '],
|
|
247
|
+
};
|
|
248
|
+
getTags.mockResolvedValue([{ id: '1', name: 'trimmed-tag' }]);
|
|
249
|
+
createPost.mockResolvedValue({ id: '1', title: 'Test' });
|
|
250
|
+
|
|
251
|
+
await createPostService(input);
|
|
252
|
+
|
|
253
|
+
expect(getTags).toHaveBeenCalledWith('trimmed-tag');
|
|
254
|
+
expect(createPost).toHaveBeenCalledWith(
|
|
255
|
+
expect.objectContaining({
|
|
256
|
+
tags: [{ name: 'trimmed-tag' }],
|
|
257
|
+
})
|
|
258
|
+
);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('should handle mixed existing and new tags', async () => {
|
|
262
|
+
const input = {
|
|
263
|
+
title: 'Test Post',
|
|
264
|
+
html: '<p>Content</p>',
|
|
265
|
+
tags: ['existing-tag', 'new-tag'],
|
|
266
|
+
};
|
|
267
|
+
getTags
|
|
268
|
+
.mockResolvedValueOnce([{ id: '1', name: 'existing-tag' }]) // First tag exists
|
|
269
|
+
.mockResolvedValueOnce([]); // Second tag doesn't exist
|
|
270
|
+
createTag.mockResolvedValue({ id: '2', name: 'new-tag' });
|
|
271
|
+
createPost.mockResolvedValue({ id: '1', title: 'Test' });
|
|
272
|
+
|
|
273
|
+
await createPostService(input);
|
|
274
|
+
|
|
275
|
+
expect(getTags).toHaveBeenCalledTimes(2);
|
|
276
|
+
expect(createTag).toHaveBeenCalledTimes(1);
|
|
277
|
+
expect(createPost).toHaveBeenCalledWith(
|
|
278
|
+
expect.objectContaining({
|
|
279
|
+
tags: [{ name: 'existing-tag' }, { name: 'new-tag' }],
|
|
280
|
+
})
|
|
281
|
+
);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
describe('createPostService - metadata defaults', () => {
|
|
286
|
+
it('should default meta_title to title when not provided', async () => {
|
|
287
|
+
const input = {
|
|
288
|
+
title: 'Test Post Title',
|
|
289
|
+
html: '<p>Content</p>',
|
|
290
|
+
};
|
|
291
|
+
createPost.mockResolvedValue({ id: '1', title: 'Test' });
|
|
292
|
+
|
|
293
|
+
await createPostService(input);
|
|
294
|
+
|
|
295
|
+
expect(createPost).toHaveBeenCalledWith(
|
|
296
|
+
expect.objectContaining({
|
|
297
|
+
meta_title: 'Test Post Title',
|
|
298
|
+
})
|
|
299
|
+
);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('should use provided meta_title instead of defaulting to title', async () => {
|
|
303
|
+
const input = {
|
|
304
|
+
title: 'Test Post Title',
|
|
305
|
+
html: '<p>Content</p>',
|
|
306
|
+
meta_title: 'Custom Meta Title',
|
|
307
|
+
};
|
|
308
|
+
createPost.mockResolvedValue({ id: '1', title: 'Test' });
|
|
309
|
+
|
|
310
|
+
await createPostService(input);
|
|
311
|
+
|
|
312
|
+
expect(createPost).toHaveBeenCalledWith(
|
|
313
|
+
expect.objectContaining({
|
|
314
|
+
meta_title: 'Custom Meta Title',
|
|
315
|
+
})
|
|
316
|
+
);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('should default meta_description to custom_excerpt when provided', async () => {
|
|
320
|
+
const input = {
|
|
321
|
+
title: 'Test Post',
|
|
322
|
+
html: '<p>Long HTML content that would be stripped</p>',
|
|
323
|
+
custom_excerpt: 'This is the custom excerpt',
|
|
324
|
+
};
|
|
325
|
+
createPost.mockResolvedValue({ id: '1', title: 'Test' });
|
|
326
|
+
|
|
327
|
+
await createPostService(input);
|
|
328
|
+
|
|
329
|
+
expect(createPost).toHaveBeenCalledWith(
|
|
330
|
+
expect.objectContaining({
|
|
331
|
+
meta_description: 'This is the custom excerpt',
|
|
332
|
+
})
|
|
333
|
+
);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('should generate meta_description from HTML when no excerpt provided', async () => {
|
|
337
|
+
const input = {
|
|
338
|
+
title: 'Test Post',
|
|
339
|
+
html: '<p>This is HTML content with tags stripped</p>',
|
|
340
|
+
};
|
|
341
|
+
createPost.mockResolvedValue({ id: '1', title: 'Test' });
|
|
342
|
+
|
|
343
|
+
await createPostService(input);
|
|
344
|
+
|
|
345
|
+
expect(createPost).toHaveBeenCalledWith(
|
|
346
|
+
expect.objectContaining({
|
|
347
|
+
meta_description: 'This is HTML content with tags stripped',
|
|
348
|
+
})
|
|
349
|
+
);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('should use provided meta_description over custom_excerpt and HTML', async () => {
|
|
353
|
+
const input = {
|
|
354
|
+
title: 'Test Post',
|
|
355
|
+
html: '<p>HTML content</p>',
|
|
356
|
+
custom_excerpt: 'Custom excerpt',
|
|
357
|
+
meta_description: 'Explicit meta description',
|
|
358
|
+
};
|
|
359
|
+
createPost.mockResolvedValue({ id: '1', title: 'Test' });
|
|
360
|
+
|
|
361
|
+
await createPostService(input);
|
|
362
|
+
|
|
363
|
+
expect(createPost).toHaveBeenCalledWith(
|
|
364
|
+
expect.objectContaining({
|
|
365
|
+
meta_description: 'Explicit meta description',
|
|
366
|
+
})
|
|
367
|
+
);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('should truncate meta_description to 500 characters with ellipsis when generated from long HTML', async () => {
|
|
371
|
+
const longHtml = '<p>' + 'a'.repeat(600) + '</p>';
|
|
372
|
+
const input = {
|
|
373
|
+
title: 'Test Post',
|
|
374
|
+
html: longHtml,
|
|
375
|
+
};
|
|
376
|
+
createPost.mockResolvedValue({ id: '1', title: 'Test' });
|
|
377
|
+
|
|
378
|
+
await createPostService(input);
|
|
379
|
+
|
|
380
|
+
const calledDescription = createPost.mock.calls[0][0].meta_description;
|
|
381
|
+
expect(calledDescription).toHaveLength(500);
|
|
382
|
+
expect(calledDescription.endsWith('...')).toBe(true);
|
|
383
|
+
expect(calledDescription).toBe('a'.repeat(497) + '...');
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('should reject empty HTML content', async () => {
|
|
387
|
+
const input = {
|
|
388
|
+
title: 'Test Post',
|
|
389
|
+
html: '',
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
await expect(createPostService(input)).rejects.toThrow('Invalid post input:');
|
|
393
|
+
expect(createPost).not.toHaveBeenCalled();
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('should strip HTML tags and truncate when generating meta_description', async () => {
|
|
397
|
+
const longHtml = '<p>' + 'word '.repeat(200) + '</p>';
|
|
398
|
+
const input = {
|
|
399
|
+
title: 'Test Post',
|
|
400
|
+
html: longHtml,
|
|
401
|
+
};
|
|
402
|
+
createPost.mockResolvedValue({ id: '1', title: 'Test' });
|
|
403
|
+
|
|
404
|
+
await createPostService(input);
|
|
405
|
+
|
|
406
|
+
const calledDescription = createPost.mock.calls[0][0].meta_description;
|
|
407
|
+
expect(calledDescription).not.toContain('<p>');
|
|
408
|
+
expect(calledDescription).not.toContain('</p>');
|
|
409
|
+
expect(calledDescription.length).toBeLessThanOrEqual(500);
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
describe('createPostService - complete post creation', () => {
|
|
414
|
+
it('should create post with all optional fields', async () => {
|
|
415
|
+
const input = {
|
|
416
|
+
title: 'Complete Post',
|
|
417
|
+
html: '<p>Full content</p>',
|
|
418
|
+
custom_excerpt: 'Excerpt',
|
|
419
|
+
status: 'published',
|
|
420
|
+
published_at: '2025-12-10T12:00:00.000Z',
|
|
421
|
+
tags: ['tag1', 'tag2'],
|
|
422
|
+
feature_image: 'https://example.com/image.jpg',
|
|
423
|
+
feature_image_alt: 'Image alt text',
|
|
424
|
+
feature_image_caption: 'Image caption',
|
|
425
|
+
meta_title: 'Custom Meta Title',
|
|
426
|
+
meta_description: 'Custom meta description',
|
|
427
|
+
};
|
|
428
|
+
getTags.mockResolvedValue([]);
|
|
429
|
+
createTag
|
|
430
|
+
.mockResolvedValueOnce({ id: '1', name: 'tag1' })
|
|
431
|
+
.mockResolvedValueOnce({ id: '2', name: 'tag2' });
|
|
432
|
+
createPost.mockResolvedValue({ id: '1', title: 'Complete Post' });
|
|
433
|
+
|
|
434
|
+
await createPostService(input);
|
|
435
|
+
|
|
436
|
+
expect(createPost).toHaveBeenCalledWith({
|
|
437
|
+
title: 'Complete Post',
|
|
438
|
+
html: '<p>Full content</p>',
|
|
439
|
+
custom_excerpt: 'Excerpt',
|
|
440
|
+
status: 'published',
|
|
441
|
+
published_at: '2025-12-10T12:00:00.000Z',
|
|
442
|
+
tags: [{ name: 'tag1' }, { name: 'tag2' }],
|
|
443
|
+
feature_image: 'https://example.com/image.jpg',
|
|
444
|
+
feature_image_alt: 'Image alt text',
|
|
445
|
+
feature_image_caption: 'Image caption',
|
|
446
|
+
meta_title: 'Custom Meta Title',
|
|
447
|
+
meta_description: 'Custom meta description',
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('should default status to draft when not provided', async () => {
|
|
452
|
+
const input = {
|
|
453
|
+
title: 'Test Post',
|
|
454
|
+
html: '<p>Content</p>',
|
|
455
|
+
};
|
|
456
|
+
createPost.mockResolvedValue({ id: '1', title: 'Test' });
|
|
457
|
+
|
|
458
|
+
await createPostService(input);
|
|
459
|
+
|
|
460
|
+
expect(createPost).toHaveBeenCalledWith(
|
|
461
|
+
expect.objectContaining({
|
|
462
|
+
status: 'draft',
|
|
463
|
+
})
|
|
464
|
+
);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('should handle post creation with no tags', async () => {
|
|
468
|
+
const input = {
|
|
469
|
+
title: 'Test Post',
|
|
470
|
+
html: '<p>Content</p>',
|
|
471
|
+
};
|
|
472
|
+
createPost.mockResolvedValue({ id: '1', title: 'Test' });
|
|
473
|
+
|
|
474
|
+
await createPostService(input);
|
|
475
|
+
|
|
476
|
+
expect(createPost).toHaveBeenCalledWith(
|
|
477
|
+
expect.objectContaining({
|
|
478
|
+
tags: [],
|
|
479
|
+
})
|
|
480
|
+
);
|
|
481
|
+
expect(getTags).not.toHaveBeenCalled();
|
|
482
|
+
expect(createTag).not.toHaveBeenCalled();
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it('should handle post creation with empty tags array', async () => {
|
|
486
|
+
const input = {
|
|
487
|
+
title: 'Test Post',
|
|
488
|
+
html: '<p>Content</p>',
|
|
489
|
+
tags: [],
|
|
490
|
+
};
|
|
491
|
+
createPost.mockResolvedValue({ id: '1', title: 'Test' });
|
|
492
|
+
|
|
493
|
+
await createPostService(input);
|
|
494
|
+
|
|
495
|
+
expect(createPost).toHaveBeenCalledWith(
|
|
496
|
+
expect.objectContaining({
|
|
497
|
+
tags: [],
|
|
498
|
+
})
|
|
499
|
+
);
|
|
500
|
+
expect(getTags).not.toHaveBeenCalled();
|
|
501
|
+
expect(createTag).not.toHaveBeenCalled();
|
|
502
|
+
});
|
|
503
|
+
});
|
|
504
|
+
});
|
|
@@ -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
|
}
|