@jgardner04/ghost-mcp-server 1.1.11 → 1.1.13
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 +2 -1
- package/src/__tests__/helpers/mockExpress.js +38 -0
- package/src/__tests__/index.test.js +312 -0
- package/src/__tests__/mcp_server.test.js +381 -0
- package/src/config/__tests__/mcp-config.test.js +311 -0
- package/src/controllers/__tests__/imageController.test.js +572 -0
- package/src/controllers/__tests__/postController.test.js +236 -0
- package/src/controllers/__tests__/tagController.test.js +222 -0
- package/src/middleware/__tests__/errorMiddleware.test.js +1113 -0
- package/src/resources/__tests__/ResourceManager.test.js +977 -0
- package/src/routes/__tests__/imageRoutes.test.js +117 -0
- package/src/routes/__tests__/postRoutes.test.js +262 -0
- package/src/routes/__tests__/tagRoutes.test.js +175 -0
- package/src/utils/__tests__/logger.test.js +512 -0
|
@@ -0,0 +1,977 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Mock MCP SDK
|
|
4
|
+
vi.mock('@modelcontextprotocol/sdk/server/index.js', () => ({
|
|
5
|
+
Resource: class MockResource {
|
|
6
|
+
constructor(config) {
|
|
7
|
+
this.name = config.name;
|
|
8
|
+
this.description = config.description;
|
|
9
|
+
this.schema = config.schema;
|
|
10
|
+
this.fetch = config.fetch;
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
// Mock errors
|
|
16
|
+
vi.mock('../../errors/index.js', () => ({
|
|
17
|
+
NotFoundError: class NotFoundError extends Error {
|
|
18
|
+
constructor(resource, identifier) {
|
|
19
|
+
super(`${resource} not found: ${identifier}`);
|
|
20
|
+
this.name = 'NotFoundError';
|
|
21
|
+
this.resource = resource;
|
|
22
|
+
this.identifier = identifier;
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
ValidationError: class ValidationError extends Error {
|
|
26
|
+
constructor(message) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = 'ValidationError';
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
import { ResourceManager } from '../ResourceManager.js';
|
|
34
|
+
|
|
35
|
+
// Helper to create mock ghost service
|
|
36
|
+
function createMockGhostService() {
|
|
37
|
+
return {
|
|
38
|
+
getPost: vi.fn(),
|
|
39
|
+
getPosts: vi.fn(),
|
|
40
|
+
getTag: vi.fn(),
|
|
41
|
+
getTags: vi.fn(),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('ResourceManager', () => {
|
|
46
|
+
let mockGhostService;
|
|
47
|
+
let resourceManager;
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
vi.clearAllMocks();
|
|
51
|
+
vi.useFakeTimers();
|
|
52
|
+
mockGhostService = createMockGhostService();
|
|
53
|
+
resourceManager = new ResourceManager(mockGhostService);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
vi.useRealTimers();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('LRUCache', () => {
|
|
61
|
+
describe('get', () => {
|
|
62
|
+
it('should return null for non-existent key', () => {
|
|
63
|
+
const stats = resourceManager.getCacheStats();
|
|
64
|
+
expect(stats.size).toBe(0);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should return cached value for existing key', async () => {
|
|
68
|
+
const post = { id: '1', title: 'Test Post' };
|
|
69
|
+
mockGhostService.getPost.mockResolvedValue(post);
|
|
70
|
+
|
|
71
|
+
// First fetch - should call service
|
|
72
|
+
await resourceManager.fetchResource('ghost/post/1');
|
|
73
|
+
expect(mockGhostService.getPost).toHaveBeenCalledTimes(1);
|
|
74
|
+
|
|
75
|
+
// Second fetch - should use cache
|
|
76
|
+
await resourceManager.fetchResource('ghost/post/1');
|
|
77
|
+
expect(mockGhostService.getPost).toHaveBeenCalledTimes(1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should return null for expired items', async () => {
|
|
81
|
+
const post = { id: '1', title: 'Test Post' };
|
|
82
|
+
mockGhostService.getPost.mockResolvedValue(post);
|
|
83
|
+
|
|
84
|
+
await resourceManager.fetchResource('ghost/post/1');
|
|
85
|
+
expect(mockGhostService.getPost).toHaveBeenCalledTimes(1);
|
|
86
|
+
|
|
87
|
+
// Advance time beyond TTL (5 minutes)
|
|
88
|
+
vi.advanceTimersByTime(301000);
|
|
89
|
+
|
|
90
|
+
// Should fetch again because cache expired
|
|
91
|
+
await resourceManager.fetchResource('ghost/post/1');
|
|
92
|
+
expect(mockGhostService.getPost).toHaveBeenCalledTimes(2);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should update access order on get', async () => {
|
|
96
|
+
const post1 = { id: '1', title: 'Post 1' };
|
|
97
|
+
const post2 = { id: '2', title: 'Post 2' };
|
|
98
|
+
mockGhostService.getPost.mockImplementation((id) =>
|
|
99
|
+
Promise.resolve(id === '1' ? post1 : post2)
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
await resourceManager.fetchResource('ghost/post/1');
|
|
103
|
+
await resourceManager.fetchResource('ghost/post/2');
|
|
104
|
+
|
|
105
|
+
// Access post 1 again to update its access order
|
|
106
|
+
await resourceManager.fetchResource('ghost/post/1');
|
|
107
|
+
|
|
108
|
+
const stats = resourceManager.getCacheStats();
|
|
109
|
+
expect(stats.size).toBe(2);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('set', () => {
|
|
114
|
+
it('should evict oldest items when at capacity', async () => {
|
|
115
|
+
// Create a resource manager with small cache
|
|
116
|
+
const smallCacheManager = new ResourceManager(mockGhostService);
|
|
117
|
+
|
|
118
|
+
// We can't directly test cache eviction without modifying the class,
|
|
119
|
+
// but we can verify the cache stores items
|
|
120
|
+
const post = { id: '1', title: 'Test Post' };
|
|
121
|
+
mockGhostService.getPost.mockResolvedValue(post);
|
|
122
|
+
|
|
123
|
+
await smallCacheManager.fetchResource('ghost/post/1');
|
|
124
|
+
const stats = smallCacheManager.getCacheStats();
|
|
125
|
+
expect(stats.size).toBe(1);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should allow custom TTL', async () => {
|
|
129
|
+
// Collections use shorter TTL (1 minute)
|
|
130
|
+
const posts = [{ id: '1', title: 'Post 1' }];
|
|
131
|
+
mockGhostService.getPosts.mockResolvedValue(posts);
|
|
132
|
+
|
|
133
|
+
await resourceManager.fetchResource('ghost/posts?limit=10');
|
|
134
|
+
expect(mockGhostService.getPosts).toHaveBeenCalledTimes(1);
|
|
135
|
+
|
|
136
|
+
// Advance less than collection TTL
|
|
137
|
+
vi.advanceTimersByTime(30000);
|
|
138
|
+
|
|
139
|
+
// Should still use cache
|
|
140
|
+
await resourceManager.fetchResource('ghost/posts?limit=10');
|
|
141
|
+
expect(mockGhostService.getPosts).toHaveBeenCalledTimes(1);
|
|
142
|
+
|
|
143
|
+
// Advance past collection TTL (1 minute)
|
|
144
|
+
vi.advanceTimersByTime(35000);
|
|
145
|
+
|
|
146
|
+
// Should fetch again
|
|
147
|
+
await resourceManager.fetchResource('ghost/posts?limit=10');
|
|
148
|
+
expect(mockGhostService.getPosts).toHaveBeenCalledTimes(2);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('invalidate', () => {
|
|
153
|
+
it('should clear all cache when no pattern provided', async () => {
|
|
154
|
+
const post = { id: '1', title: 'Test Post' };
|
|
155
|
+
mockGhostService.getPost.mockResolvedValue(post);
|
|
156
|
+
|
|
157
|
+
await resourceManager.fetchResource('ghost/post/1');
|
|
158
|
+
expect(resourceManager.getCacheStats().size).toBe(1);
|
|
159
|
+
|
|
160
|
+
resourceManager.invalidateCache();
|
|
161
|
+
expect(resourceManager.getCacheStats().size).toBe(0);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('should invalidate entries matching pattern', async () => {
|
|
165
|
+
const post = { id: '1', title: 'Test Post' };
|
|
166
|
+
const tag = { id: '1', name: 'Test Tag' };
|
|
167
|
+
mockGhostService.getPost.mockResolvedValue(post);
|
|
168
|
+
mockGhostService.getTag.mockResolvedValue(tag);
|
|
169
|
+
|
|
170
|
+
await resourceManager.fetchResource('ghost/post/1');
|
|
171
|
+
await resourceManager.fetchResource('ghost/tag/1');
|
|
172
|
+
expect(resourceManager.getCacheStats().size).toBe(2);
|
|
173
|
+
|
|
174
|
+
resourceManager.invalidateCache('post');
|
|
175
|
+
// Only tag should remain
|
|
176
|
+
expect(resourceManager.getCacheStats().size).toBe(1);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe('getStats', () => {
|
|
181
|
+
it('should return cache statistics', async () => {
|
|
182
|
+
const post = { id: '1', title: 'Test Post' };
|
|
183
|
+
mockGhostService.getPost.mockResolvedValue(post);
|
|
184
|
+
|
|
185
|
+
await resourceManager.fetchResource('ghost/post/1');
|
|
186
|
+
|
|
187
|
+
const stats = resourceManager.getCacheStats();
|
|
188
|
+
expect(stats).toHaveProperty('size');
|
|
189
|
+
expect(stats).toHaveProperty('maxSize');
|
|
190
|
+
expect(stats).toHaveProperty('ttl');
|
|
191
|
+
expect(stats).toHaveProperty('keys');
|
|
192
|
+
expect(stats.size).toBe(1);
|
|
193
|
+
expect(stats.maxSize).toBe(100);
|
|
194
|
+
expect(stats.ttl).toBe(300000);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe('ResourceURIParser', () => {
|
|
200
|
+
describe('parse', () => {
|
|
201
|
+
it('should parse simple resource URI', async () => {
|
|
202
|
+
const post = { id: '123', title: 'Test Post' };
|
|
203
|
+
mockGhostService.getPost.mockResolvedValue(post);
|
|
204
|
+
|
|
205
|
+
await resourceManager.fetchResource('ghost/post/123');
|
|
206
|
+
|
|
207
|
+
expect(mockGhostService.getPost).toHaveBeenCalledWith('123', {
|
|
208
|
+
include: 'tags,authors',
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('should parse URI with slug identifier', async () => {
|
|
213
|
+
const posts = [{ id: '1', slug: 'my-post-slug', title: 'Test Post' }];
|
|
214
|
+
mockGhostService.getPosts.mockResolvedValue(posts);
|
|
215
|
+
|
|
216
|
+
await resourceManager.fetchResource('ghost/post/slug:my-post-slug');
|
|
217
|
+
|
|
218
|
+
expect(mockGhostService.getPosts).toHaveBeenCalledWith({
|
|
219
|
+
filter: 'slug:my-post-slug',
|
|
220
|
+
include: 'tags,authors',
|
|
221
|
+
limit: 1,
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('should parse URI with uuid identifier', async () => {
|
|
226
|
+
const posts = [{ id: '1', uuid: '550e8400-e29b-41d4-a716-446655440000' }];
|
|
227
|
+
mockGhostService.getPosts.mockResolvedValue(posts);
|
|
228
|
+
|
|
229
|
+
await resourceManager.fetchResource('ghost/post/uuid:550e8400-e29b-41d4-a716-446655440000');
|
|
230
|
+
|
|
231
|
+
expect(mockGhostService.getPosts).toHaveBeenCalledWith({
|
|
232
|
+
filter: 'uuid:550e8400-e29b-41d4-a716-446655440000',
|
|
233
|
+
include: 'tags,authors',
|
|
234
|
+
limit: 1,
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('should parse collection URI with query parameters', async () => {
|
|
239
|
+
const posts = [{ id: '1', title: 'Post 1' }];
|
|
240
|
+
mockGhostService.getPosts.mockResolvedValue(posts);
|
|
241
|
+
|
|
242
|
+
await resourceManager.fetchResource('ghost/posts?status=published&limit=10&page=2');
|
|
243
|
+
|
|
244
|
+
expect(mockGhostService.getPosts).toHaveBeenCalledWith(
|
|
245
|
+
expect.objectContaining({
|
|
246
|
+
limit: 10,
|
|
247
|
+
page: 2,
|
|
248
|
+
})
|
|
249
|
+
);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('should throw ValidationError for invalid URI format', async () => {
|
|
253
|
+
await expect(resourceManager.fetchResource('invalid')).rejects.toThrow(
|
|
254
|
+
'Invalid resource URI format'
|
|
255
|
+
);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('should throw ValidationError for unknown resource type', async () => {
|
|
259
|
+
await expect(resourceManager.fetchResource('ghost/unknown/123')).rejects.toThrow(
|
|
260
|
+
'Unknown resource type: unknown'
|
|
261
|
+
);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe('build', () => {
|
|
266
|
+
it('should build simple URI', async () => {
|
|
267
|
+
// Test indirectly through fetcher behavior
|
|
268
|
+
const post = { id: '1', title: 'Test' };
|
|
269
|
+
mockGhostService.getPost.mockResolvedValue(post);
|
|
270
|
+
|
|
271
|
+
await resourceManager.fetchResource('ghost/post/1');
|
|
272
|
+
expect(mockGhostService.getPost).toHaveBeenCalled();
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe('ResourceFetcher', () => {
|
|
278
|
+
describe('fetchPost', () => {
|
|
279
|
+
it('should fetch single post by id', async () => {
|
|
280
|
+
const post = { id: '1', title: 'Test Post', tags: [], authors: [] };
|
|
281
|
+
mockGhostService.getPost.mockResolvedValue(post);
|
|
282
|
+
|
|
283
|
+
const result = await resourceManager.fetchResource('ghost/post/1');
|
|
284
|
+
|
|
285
|
+
expect(result).toEqual(post);
|
|
286
|
+
expect(mockGhostService.getPost).toHaveBeenCalledWith('1', {
|
|
287
|
+
include: 'tags,authors',
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('should fetch single post by slug', async () => {
|
|
292
|
+
const posts = [{ id: '1', slug: 'test-slug', title: 'Test Post' }];
|
|
293
|
+
mockGhostService.getPosts.mockResolvedValue(posts);
|
|
294
|
+
|
|
295
|
+
const result = await resourceManager.fetchResource('ghost/post/slug:test-slug');
|
|
296
|
+
|
|
297
|
+
expect(result).toEqual(posts[0]);
|
|
298
|
+
expect(mockGhostService.getPosts).toHaveBeenCalledWith({
|
|
299
|
+
filter: 'slug:test-slug',
|
|
300
|
+
include: 'tags,authors',
|
|
301
|
+
limit: 1,
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('should fetch single post by uuid', async () => {
|
|
306
|
+
const posts = [{ id: '1', uuid: 'test-uuid', title: 'Test Post' }];
|
|
307
|
+
mockGhostService.getPosts.mockResolvedValue(posts);
|
|
308
|
+
|
|
309
|
+
const result = await resourceManager.fetchResource('ghost/post/uuid:test-uuid');
|
|
310
|
+
|
|
311
|
+
expect(result).toEqual(posts[0]);
|
|
312
|
+
expect(mockGhostService.getPosts).toHaveBeenCalledWith({
|
|
313
|
+
filter: 'uuid:test-uuid',
|
|
314
|
+
include: 'tags,authors',
|
|
315
|
+
limit: 1,
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('should throw NotFoundError when post not found', async () => {
|
|
320
|
+
mockGhostService.getPost.mockResolvedValue(null);
|
|
321
|
+
|
|
322
|
+
await expect(resourceManager.fetchResource('ghost/post/nonexistent')).rejects.toThrow(
|
|
323
|
+
'Post not found'
|
|
324
|
+
);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('should throw ValidationError for unknown identifier type', async () => {
|
|
328
|
+
await expect(resourceManager.fetchResource('ghost/post/unknown:value')).rejects.toThrow(
|
|
329
|
+
'Unknown identifier type: unknown'
|
|
330
|
+
);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('should use cache for repeated fetches', async () => {
|
|
334
|
+
const post = { id: '1', title: 'Test Post' };
|
|
335
|
+
mockGhostService.getPost.mockResolvedValue(post);
|
|
336
|
+
|
|
337
|
+
await resourceManager.fetchResource('ghost/post/1');
|
|
338
|
+
await resourceManager.fetchResource('ghost/post/1');
|
|
339
|
+
|
|
340
|
+
expect(mockGhostService.getPost).toHaveBeenCalledTimes(1);
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
describe('fetchPosts', () => {
|
|
345
|
+
it('should fetch posts collection with default options', async () => {
|
|
346
|
+
const posts = [
|
|
347
|
+
{ id: '1', title: 'Post 1' },
|
|
348
|
+
{ id: '2', title: 'Post 2' },
|
|
349
|
+
];
|
|
350
|
+
mockGhostService.getPosts.mockResolvedValue(posts);
|
|
351
|
+
|
|
352
|
+
const result = await resourceManager.fetchResource('ghost/posts');
|
|
353
|
+
|
|
354
|
+
expect(result).toHaveProperty('data');
|
|
355
|
+
expect(result).toHaveProperty('meta');
|
|
356
|
+
expect(result.meta.pagination).toBeDefined();
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('should fetch posts with status filter', async () => {
|
|
360
|
+
const posts = [{ id: '1', title: 'Post 1', status: 'published' }];
|
|
361
|
+
mockGhostService.getPosts.mockResolvedValue(posts);
|
|
362
|
+
|
|
363
|
+
await resourceManager.fetchResource('ghost/posts?status=published');
|
|
364
|
+
|
|
365
|
+
expect(mockGhostService.getPosts).toHaveBeenCalledWith(
|
|
366
|
+
expect.objectContaining({
|
|
367
|
+
filter: 'status:published',
|
|
368
|
+
})
|
|
369
|
+
);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('should append status to existing filter', async () => {
|
|
373
|
+
const posts = [{ id: '1', title: 'Post 1' }];
|
|
374
|
+
mockGhostService.getPosts.mockResolvedValue(posts);
|
|
375
|
+
|
|
376
|
+
await resourceManager.fetchResource('ghost/posts?filter=featured:true&status=published');
|
|
377
|
+
|
|
378
|
+
expect(mockGhostService.getPosts).toHaveBeenCalledWith(
|
|
379
|
+
expect.objectContaining({
|
|
380
|
+
filter: 'featured:true+status:published',
|
|
381
|
+
})
|
|
382
|
+
);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('should handle pagination parameters', async () => {
|
|
386
|
+
const posts = [{ id: '1', title: 'Post 1' }];
|
|
387
|
+
mockGhostService.getPosts.mockResolvedValue(posts);
|
|
388
|
+
|
|
389
|
+
await resourceManager.fetchResource('ghost/posts?limit=5&page=3');
|
|
390
|
+
|
|
391
|
+
expect(mockGhostService.getPosts).toHaveBeenCalledWith(
|
|
392
|
+
expect.objectContaining({
|
|
393
|
+
limit: 5,
|
|
394
|
+
page: 3,
|
|
395
|
+
})
|
|
396
|
+
);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('should include pagination metadata in response', async () => {
|
|
400
|
+
const posts = [{ id: '1' }];
|
|
401
|
+
posts.meta = { pagination: { total: 100, next: 2, prev: null } };
|
|
402
|
+
mockGhostService.getPosts.mockResolvedValue(posts);
|
|
403
|
+
|
|
404
|
+
const result = await resourceManager.fetchResource('ghost/posts?limit=10&page=1');
|
|
405
|
+
|
|
406
|
+
expect(result.meta.pagination).toBeDefined();
|
|
407
|
+
expect(result.meta.pagination.page).toBe(1);
|
|
408
|
+
expect(result.meta.pagination.limit).toBe(10);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('should cache posts with shorter TTL', async () => {
|
|
412
|
+
const posts = [{ id: '1', title: 'Post 1' }];
|
|
413
|
+
mockGhostService.getPosts.mockResolvedValue(posts);
|
|
414
|
+
|
|
415
|
+
await resourceManager.fetchResource('ghost/posts');
|
|
416
|
+
expect(mockGhostService.getPosts).toHaveBeenCalledTimes(1);
|
|
417
|
+
|
|
418
|
+
// Still cached at 30 seconds
|
|
419
|
+
vi.advanceTimersByTime(30000);
|
|
420
|
+
await resourceManager.fetchResource('ghost/posts');
|
|
421
|
+
expect(mockGhostService.getPosts).toHaveBeenCalledTimes(1);
|
|
422
|
+
|
|
423
|
+
// Expired at 65 seconds
|
|
424
|
+
vi.advanceTimersByTime(35000);
|
|
425
|
+
await resourceManager.fetchResource('ghost/posts');
|
|
426
|
+
expect(mockGhostService.getPosts).toHaveBeenCalledTimes(2);
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
describe('fetchTag', () => {
|
|
431
|
+
it('should fetch single tag by id', async () => {
|
|
432
|
+
const tag = { id: '1', name: 'Test Tag', slug: 'test-tag' };
|
|
433
|
+
mockGhostService.getTag.mockResolvedValue(tag);
|
|
434
|
+
|
|
435
|
+
const result = await resourceManager.fetchResource('ghost/tag/1');
|
|
436
|
+
|
|
437
|
+
expect(result).toEqual(tag);
|
|
438
|
+
expect(mockGhostService.getTag).toHaveBeenCalledWith('1');
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it('should fetch single tag by slug', async () => {
|
|
442
|
+
const tags = [
|
|
443
|
+
{ id: '1', slug: 'test-slug', name: 'Test Tag' },
|
|
444
|
+
{ id: '2', slug: 'other', name: 'Other' },
|
|
445
|
+
];
|
|
446
|
+
mockGhostService.getTags.mockResolvedValue(tags);
|
|
447
|
+
|
|
448
|
+
const result = await resourceManager.fetchResource('ghost/tag/slug:test-slug');
|
|
449
|
+
|
|
450
|
+
expect(result).toEqual(tags[0]);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('should fetch single tag by name', async () => {
|
|
454
|
+
// Reset mock to clear any previous calls
|
|
455
|
+
mockGhostService.getTags.mockReset();
|
|
456
|
+
const tags = [{ id: '1', name: 'Technology', slug: 'technology' }];
|
|
457
|
+
mockGhostService.getTags.mockResolvedValue(tags);
|
|
458
|
+
|
|
459
|
+
const result = await resourceManager.fetchResource('ghost/tag/name:Technology');
|
|
460
|
+
|
|
461
|
+
expect(result).toEqual(tags[0]);
|
|
462
|
+
expect(mockGhostService.getTags).toHaveBeenCalledWith('Technology');
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it('should use id by default when no identifier type specified', async () => {
|
|
466
|
+
// When no type is specified (e.g., ghost/tag/tech), identifierType defaults to 'id'
|
|
467
|
+
const tag = { id: 'tech', slug: 'tech', name: 'Technology' };
|
|
468
|
+
mockGhostService.getTag.mockResolvedValue(tag);
|
|
469
|
+
|
|
470
|
+
const result = await resourceManager.fetchResource('ghost/tag/tech');
|
|
471
|
+
|
|
472
|
+
expect(result).toEqual(tag);
|
|
473
|
+
expect(mockGhostService.getTag).toHaveBeenCalledWith('tech');
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it('should throw NotFoundError when tag not found by id', async () => {
|
|
477
|
+
mockGhostService.getTag.mockResolvedValue(null);
|
|
478
|
+
|
|
479
|
+
await expect(resourceManager.fetchResource('ghost/tag/nonexistent')).rejects.toThrow(
|
|
480
|
+
'Tag not found'
|
|
481
|
+
);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it('should throw NotFoundError when tag not found by slug', async () => {
|
|
485
|
+
mockGhostService.getTags.mockResolvedValue([]);
|
|
486
|
+
|
|
487
|
+
await expect(resourceManager.fetchResource('ghost/tag/slug:nonexistent')).rejects.toThrow(
|
|
488
|
+
'Tag not found'
|
|
489
|
+
);
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
describe('fetchTags', () => {
|
|
494
|
+
it('should fetch tags collection', async () => {
|
|
495
|
+
const tags = [
|
|
496
|
+
{ id: '1', name: 'Tag 1' },
|
|
497
|
+
{ id: '2', name: 'Tag 2' },
|
|
498
|
+
];
|
|
499
|
+
mockGhostService.getTags.mockResolvedValue(tags);
|
|
500
|
+
|
|
501
|
+
const result = await resourceManager.fetchResource('ghost/tags');
|
|
502
|
+
|
|
503
|
+
expect(result).toHaveProperty('data');
|
|
504
|
+
expect(result).toHaveProperty('meta');
|
|
505
|
+
expect(result.data).toEqual(tags);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it('should filter tags by name', async () => {
|
|
509
|
+
const tags = [{ id: '1', name: 'Tech' }];
|
|
510
|
+
mockGhostService.getTags.mockResolvedValue(tags);
|
|
511
|
+
|
|
512
|
+
await resourceManager.fetchResource('ghost/tags?name=Tech');
|
|
513
|
+
|
|
514
|
+
expect(mockGhostService.getTags).toHaveBeenCalledWith('Tech');
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it('should apply client-side filtering', async () => {
|
|
518
|
+
const tags = [
|
|
519
|
+
{ id: '1', name: 'Tech', slug: 'tech' },
|
|
520
|
+
{ id: '2', name: 'News', slug: 'news' },
|
|
521
|
+
];
|
|
522
|
+
mockGhostService.getTags.mockResolvedValue(tags);
|
|
523
|
+
|
|
524
|
+
const result = await resourceManager.fetchResource('ghost/tags?filter=name:Tech');
|
|
525
|
+
|
|
526
|
+
expect(result.data).toHaveLength(1);
|
|
527
|
+
expect(result.data[0].name).toBe('Tech');
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it('should apply pagination to tags', async () => {
|
|
531
|
+
const tags = Array.from({ length: 100 }, (_, i) => ({
|
|
532
|
+
id: String(i + 1),
|
|
533
|
+
name: `Tag ${i + 1}`,
|
|
534
|
+
}));
|
|
535
|
+
mockGhostService.getTags.mockResolvedValue(tags);
|
|
536
|
+
|
|
537
|
+
const result = await resourceManager.fetchResource('ghost/tags?limit=10&page=2');
|
|
538
|
+
|
|
539
|
+
expect(result.data).toHaveLength(10);
|
|
540
|
+
expect(result.data[0].name).toBe('Tag 11');
|
|
541
|
+
expect(result.meta.pagination.page).toBe(2);
|
|
542
|
+
expect(result.meta.pagination.pages).toBe(10);
|
|
543
|
+
expect(result.meta.pagination.total).toBe(100);
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
describe('ResourceSubscriptionManager', () => {
|
|
549
|
+
describe('subscribe', () => {
|
|
550
|
+
it('should create subscription and return id', () => {
|
|
551
|
+
const callback = vi.fn();
|
|
552
|
+
const subscriptionId = resourceManager.subscribe('ghost/post/1', callback);
|
|
553
|
+
|
|
554
|
+
expect(subscriptionId).toMatch(/^sub_\d+_[a-z0-9]+$/);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it('should start polling when enablePolling is true', async () => {
|
|
558
|
+
const post = { id: '1', title: 'Test Post' };
|
|
559
|
+
mockGhostService.getPost.mockResolvedValue(post);
|
|
560
|
+
|
|
561
|
+
const callback = vi.fn();
|
|
562
|
+
resourceManager.subscribe('ghost/post/1', callback, {
|
|
563
|
+
enablePolling: true,
|
|
564
|
+
pollingInterval: 1000,
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
// Wait for initial poll
|
|
568
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
569
|
+
|
|
570
|
+
expect(callback).toHaveBeenCalledWith(
|
|
571
|
+
expect.objectContaining({
|
|
572
|
+
type: 'update',
|
|
573
|
+
uri: 'ghost/post/1',
|
|
574
|
+
data: post,
|
|
575
|
+
})
|
|
576
|
+
);
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
describe('unsubscribe', () => {
|
|
581
|
+
it('should remove subscription', () => {
|
|
582
|
+
const callback = vi.fn();
|
|
583
|
+
const subscriptionId = resourceManager.subscribe('ghost/post/1', callback);
|
|
584
|
+
|
|
585
|
+
resourceManager.unsubscribe(subscriptionId);
|
|
586
|
+
|
|
587
|
+
// Should not throw when unsubscribing
|
|
588
|
+
expect(() => resourceManager.unsubscribe(subscriptionId)).toThrow('Subscription not found');
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it('should throw NotFoundError for non-existent subscription', () => {
|
|
592
|
+
expect(() => resourceManager.unsubscribe('invalid_id')).toThrow('Subscription not found');
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it('should stop polling when unsubscribed', async () => {
|
|
596
|
+
const post = { id: '1', title: 'Test Post' };
|
|
597
|
+
mockGhostService.getPost.mockResolvedValue(post);
|
|
598
|
+
|
|
599
|
+
const callback = vi.fn();
|
|
600
|
+
const subscriptionId = resourceManager.subscribe('ghost/post/1', callback, {
|
|
601
|
+
enablePolling: true,
|
|
602
|
+
pollingInterval: 1000,
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
606
|
+
const initialCallCount = callback.mock.calls.length;
|
|
607
|
+
|
|
608
|
+
resourceManager.unsubscribe(subscriptionId);
|
|
609
|
+
|
|
610
|
+
// Advance time - callback should not be called again
|
|
611
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
612
|
+
expect(callback.mock.calls.length).toBe(initialCallCount);
|
|
613
|
+
});
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
describe('polling', () => {
|
|
617
|
+
it('should call callback when value changes', async () => {
|
|
618
|
+
const post1 = { id: '1', title: 'Original Title' };
|
|
619
|
+
const post2 = { id: '1', title: 'Updated Title' };
|
|
620
|
+
|
|
621
|
+
// Reset mock for clean state
|
|
622
|
+
mockGhostService.getPost.mockReset();
|
|
623
|
+
mockGhostService.getPost.mockResolvedValueOnce(post1).mockResolvedValueOnce(post2);
|
|
624
|
+
|
|
625
|
+
const callback = vi.fn();
|
|
626
|
+
resourceManager.subscribe('ghost/post/1', callback, {
|
|
627
|
+
enablePolling: true,
|
|
628
|
+
pollingInterval: 1000,
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
// Initial poll - need to let async operations complete
|
|
632
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
633
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
634
|
+
|
|
635
|
+
// Invalidate cache so the next fetch goes to service
|
|
636
|
+
resourceManager.invalidateCache();
|
|
637
|
+
|
|
638
|
+
// Next poll with changed value
|
|
639
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
640
|
+
expect(callback).toHaveBeenCalledTimes(2);
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
it('should not call callback when value unchanged', async () => {
|
|
644
|
+
const post = { id: '1', title: 'Test Post' };
|
|
645
|
+
mockGhostService.getPost.mockResolvedValue(post);
|
|
646
|
+
|
|
647
|
+
const callback = vi.fn();
|
|
648
|
+
resourceManager.subscribe('ghost/post/1', callback, {
|
|
649
|
+
enablePolling: true,
|
|
650
|
+
pollingInterval: 1000,
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
// Initial poll
|
|
654
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
655
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
656
|
+
|
|
657
|
+
// Next poll with same value
|
|
658
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
659
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
it('should handle errors in polling', async () => {
|
|
663
|
+
mockGhostService.getPost.mockRejectedValue(new Error('Network error'));
|
|
664
|
+
|
|
665
|
+
const callback = vi.fn();
|
|
666
|
+
resourceManager.subscribe('ghost/post/1', callback, {
|
|
667
|
+
enablePolling: true,
|
|
668
|
+
pollingInterval: 1000,
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
672
|
+
|
|
673
|
+
expect(callback).toHaveBeenCalledWith(
|
|
674
|
+
expect.objectContaining({
|
|
675
|
+
type: 'error',
|
|
676
|
+
uri: 'ghost/post/1',
|
|
677
|
+
error: 'Network error',
|
|
678
|
+
})
|
|
679
|
+
);
|
|
680
|
+
});
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
describe('notifySubscribers', () => {
|
|
684
|
+
it('should notify matching subscribers', async () => {
|
|
685
|
+
const callback = vi.fn();
|
|
686
|
+
resourceManager.subscribe('ghost/post/1', callback);
|
|
687
|
+
|
|
688
|
+
const updatedPost = { id: '1', title: 'Updated' };
|
|
689
|
+
resourceManager.notifyChange('ghost/post/1', updatedPost, 'update');
|
|
690
|
+
|
|
691
|
+
expect(callback).toHaveBeenCalledWith(
|
|
692
|
+
expect.objectContaining({
|
|
693
|
+
type: 'update',
|
|
694
|
+
uri: 'ghost/post/1',
|
|
695
|
+
data: updatedPost,
|
|
696
|
+
})
|
|
697
|
+
);
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
it('should notify subscribers with matching prefix', async () => {
|
|
701
|
+
const callback = vi.fn();
|
|
702
|
+
resourceManager.subscribe('ghost/post', callback);
|
|
703
|
+
|
|
704
|
+
const updatedPost = { id: '1', title: 'Updated' };
|
|
705
|
+
resourceManager.notifyChange('ghost/post/1', updatedPost, 'update');
|
|
706
|
+
|
|
707
|
+
expect(callback).toHaveBeenCalled();
|
|
708
|
+
});
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
describe('matchesSubscription', () => {
|
|
712
|
+
it('should match exact URIs', async () => {
|
|
713
|
+
const callback = vi.fn();
|
|
714
|
+
resourceManager.subscribe('ghost/post/1', callback);
|
|
715
|
+
resourceManager.notifyChange('ghost/post/1', {}, 'update');
|
|
716
|
+
|
|
717
|
+
expect(callback).toHaveBeenCalled();
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
it('should match when subscription is prefix of event', async () => {
|
|
721
|
+
const callback = vi.fn();
|
|
722
|
+
resourceManager.subscribe('ghost/post', callback);
|
|
723
|
+
resourceManager.notifyChange('ghost/post/123', {}, 'update');
|
|
724
|
+
|
|
725
|
+
expect(callback).toHaveBeenCalled();
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
it('should match when event is prefix of subscription', async () => {
|
|
729
|
+
const callback = vi.fn();
|
|
730
|
+
resourceManager.subscribe('ghost/post/1', callback);
|
|
731
|
+
resourceManager.notifyChange('ghost/post', {}, 'update');
|
|
732
|
+
|
|
733
|
+
expect(callback).toHaveBeenCalled();
|
|
734
|
+
});
|
|
735
|
+
});
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
describe('ResourceManager', () => {
|
|
739
|
+
describe('registerResource', () => {
|
|
740
|
+
it('should register a resource', () => {
|
|
741
|
+
const schema = { type: 'object' };
|
|
742
|
+
const resource = resourceManager.registerResource('test/resource', schema, {
|
|
743
|
+
description: 'Test resource',
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
expect(resource).toBeDefined();
|
|
747
|
+
expect(resource.name).toBe('test/resource');
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
it('should add resource to internal map', () => {
|
|
751
|
+
const schema = { type: 'object' };
|
|
752
|
+
resourceManager.registerResource('test/resource', schema);
|
|
753
|
+
|
|
754
|
+
const resources = resourceManager.listResources();
|
|
755
|
+
expect(resources).toHaveLength(1);
|
|
756
|
+
expect(resources[0].uri).toBe('test/resource');
|
|
757
|
+
});
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
describe('fetchResource', () => {
|
|
761
|
+
it('should fetch posts', async () => {
|
|
762
|
+
const post = { id: '1', title: 'Test Post' };
|
|
763
|
+
mockGhostService.getPost.mockResolvedValue(post);
|
|
764
|
+
|
|
765
|
+
const result = await resourceManager.fetchResource('ghost/post/1');
|
|
766
|
+
expect(result).toEqual(post);
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
it('should fetch tags', async () => {
|
|
770
|
+
const tag = { id: '1', name: 'Test Tag' };
|
|
771
|
+
mockGhostService.getTag.mockResolvedValue(tag);
|
|
772
|
+
|
|
773
|
+
const result = await resourceManager.fetchResource('ghost/tag/1');
|
|
774
|
+
expect(result).toEqual(tag);
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
it('should log errors', async () => {
|
|
778
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
779
|
+
mockGhostService.getPost.mockRejectedValue(new Error('API Error'));
|
|
780
|
+
|
|
781
|
+
await expect(resourceManager.fetchResource('ghost/post/1')).rejects.toThrow('API Error');
|
|
782
|
+
|
|
783
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
784
|
+
consoleSpy.mockRestore();
|
|
785
|
+
});
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
describe('listResources', () => {
|
|
789
|
+
it('should return empty array when no resources registered', () => {
|
|
790
|
+
const resources = resourceManager.listResources();
|
|
791
|
+
expect(resources).toEqual([]);
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
it('should return all registered resources', () => {
|
|
795
|
+
resourceManager.registerResource('ghost/posts', {});
|
|
796
|
+
resourceManager.registerResource('ghost/tags', {});
|
|
797
|
+
|
|
798
|
+
const resources = resourceManager.listResources();
|
|
799
|
+
expect(resources).toHaveLength(2);
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
it('should filter resources by namespace', () => {
|
|
803
|
+
resourceManager.registerResource('ghost/posts', {});
|
|
804
|
+
resourceManager.registerResource('other/items', {});
|
|
805
|
+
|
|
806
|
+
const resources = resourceManager.listResources({ namespace: 'ghost' });
|
|
807
|
+
expect(resources).toHaveLength(1);
|
|
808
|
+
expect(resources[0].uri).toBe('ghost/posts');
|
|
809
|
+
});
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
describe('invalidateCache', () => {
|
|
813
|
+
it('should invalidate all cache', async () => {
|
|
814
|
+
const post = { id: '1', title: 'Test' };
|
|
815
|
+
mockGhostService.getPost.mockResolvedValue(post);
|
|
816
|
+
|
|
817
|
+
await resourceManager.fetchResource('ghost/post/1');
|
|
818
|
+
expect(resourceManager.getCacheStats().size).toBe(1);
|
|
819
|
+
|
|
820
|
+
resourceManager.invalidateCache();
|
|
821
|
+
expect(resourceManager.getCacheStats().size).toBe(0);
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
it('should invalidate by pattern', async () => {
|
|
825
|
+
const post = { id: '1', title: 'Test' };
|
|
826
|
+
const tag = { id: '1', name: 'Test' };
|
|
827
|
+
mockGhostService.getPost.mockResolvedValue(post);
|
|
828
|
+
mockGhostService.getTag.mockResolvedValue(tag);
|
|
829
|
+
|
|
830
|
+
await resourceManager.fetchResource('ghost/post/1');
|
|
831
|
+
await resourceManager.fetchResource('ghost/tag/1');
|
|
832
|
+
expect(resourceManager.getCacheStats().size).toBe(2);
|
|
833
|
+
|
|
834
|
+
resourceManager.invalidateCache('post');
|
|
835
|
+
expect(resourceManager.getCacheStats().size).toBe(1);
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
it('should log invalidation', async () => {
|
|
839
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
840
|
+
|
|
841
|
+
resourceManager.invalidateCache();
|
|
842
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Cache invalidated'));
|
|
843
|
+
|
|
844
|
+
resourceManager.invalidateCache('test');
|
|
845
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('pattern: test'));
|
|
846
|
+
|
|
847
|
+
consoleSpy.mockRestore();
|
|
848
|
+
});
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
describe('notifyChange', () => {
|
|
852
|
+
it('should invalidate cache and notify subscribers', async () => {
|
|
853
|
+
const post = { id: '1', title: 'Test' };
|
|
854
|
+
mockGhostService.getPost.mockResolvedValue(post);
|
|
855
|
+
|
|
856
|
+
// Cache the resource
|
|
857
|
+
await resourceManager.fetchResource('ghost/post/1');
|
|
858
|
+
expect(resourceManager.getCacheStats().size).toBe(1);
|
|
859
|
+
|
|
860
|
+
const callback = vi.fn();
|
|
861
|
+
resourceManager.subscribe('ghost/post/1', callback);
|
|
862
|
+
|
|
863
|
+
// Notify change
|
|
864
|
+
resourceManager.notifyChange('ghost/post/1', { id: '1', title: 'Updated' }, 'update');
|
|
865
|
+
|
|
866
|
+
// Cache should be invalidated for matching pattern
|
|
867
|
+
expect(callback).toHaveBeenCalled();
|
|
868
|
+
});
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
describe('getCacheStats', () => {
|
|
872
|
+
it('should return cache statistics', () => {
|
|
873
|
+
const stats = resourceManager.getCacheStats();
|
|
874
|
+
|
|
875
|
+
expect(stats).toHaveProperty('size');
|
|
876
|
+
expect(stats).toHaveProperty('maxSize');
|
|
877
|
+
expect(stats).toHaveProperty('ttl');
|
|
878
|
+
expect(stats).toHaveProperty('keys');
|
|
879
|
+
});
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
describe('batchFetch', () => {
|
|
883
|
+
it('should fetch multiple resources', async () => {
|
|
884
|
+
const post = { id: '1', title: 'Post' };
|
|
885
|
+
const tag = { id: '1', name: 'Tag' };
|
|
886
|
+
mockGhostService.getPost.mockResolvedValue(post);
|
|
887
|
+
mockGhostService.getTag.mockResolvedValue(tag);
|
|
888
|
+
|
|
889
|
+
const { results, errors } = await resourceManager.batchFetch([
|
|
890
|
+
'ghost/post/1',
|
|
891
|
+
'ghost/tag/1',
|
|
892
|
+
]);
|
|
893
|
+
|
|
894
|
+
expect(results['ghost/post/1']).toEqual(post);
|
|
895
|
+
expect(results['ghost/tag/1']).toEqual(tag);
|
|
896
|
+
expect(Object.keys(errors)).toHaveLength(0);
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
it('should collect errors for failed fetches', async () => {
|
|
900
|
+
const post = { id: '1', title: 'Post' };
|
|
901
|
+
mockGhostService.getPost.mockResolvedValue(post);
|
|
902
|
+
mockGhostService.getTag.mockRejectedValue(new Error('Tag not found'));
|
|
903
|
+
|
|
904
|
+
const { results, errors } = await resourceManager.batchFetch([
|
|
905
|
+
'ghost/post/1',
|
|
906
|
+
'ghost/tag/1',
|
|
907
|
+
]);
|
|
908
|
+
|
|
909
|
+
expect(results['ghost/post/1']).toEqual(post);
|
|
910
|
+
expect(errors['ghost/tag/1']).toHaveProperty('message', 'Tag not found');
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
it('should fetch all resources in parallel', async () => {
|
|
914
|
+
const post = { id: '1', title: 'Post' };
|
|
915
|
+
const tag = { id: '1', name: 'Tag' };
|
|
916
|
+
mockGhostService.getPost.mockResolvedValue(post);
|
|
917
|
+
mockGhostService.getTag.mockResolvedValue(tag);
|
|
918
|
+
|
|
919
|
+
await resourceManager.batchFetch(['ghost/post/1', 'ghost/tag/1']);
|
|
920
|
+
|
|
921
|
+
expect(mockGhostService.getPost).toHaveBeenCalled();
|
|
922
|
+
expect(mockGhostService.getTag).toHaveBeenCalled();
|
|
923
|
+
});
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
describe('prefetch', () => {
|
|
927
|
+
it('should prefetch resources and return status', async () => {
|
|
928
|
+
const post = { id: '1', title: 'Post' };
|
|
929
|
+
mockGhostService.getPost.mockResolvedValue(post);
|
|
930
|
+
|
|
931
|
+
const prefetched = await resourceManager.prefetch(['ghost/post/1']);
|
|
932
|
+
|
|
933
|
+
expect(prefetched).toHaveLength(1);
|
|
934
|
+
expect(prefetched[0]).toEqual({
|
|
935
|
+
pattern: 'ghost/post/1',
|
|
936
|
+
status: 'success',
|
|
937
|
+
});
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
it('should handle prefetch errors', async () => {
|
|
941
|
+
mockGhostService.getPost.mockRejectedValue(new Error('Not found'));
|
|
942
|
+
|
|
943
|
+
const prefetched = await resourceManager.prefetch(['ghost/post/1']);
|
|
944
|
+
|
|
945
|
+
expect(prefetched).toHaveLength(1);
|
|
946
|
+
expect(prefetched[0]).toEqual({
|
|
947
|
+
pattern: 'ghost/post/1',
|
|
948
|
+
status: 'error',
|
|
949
|
+
error: 'Not found',
|
|
950
|
+
});
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
it('should prefetch multiple patterns', async () => {
|
|
954
|
+
const post = { id: '1', title: 'Post' };
|
|
955
|
+
const tag = { id: '1', name: 'Tag' };
|
|
956
|
+
mockGhostService.getPost.mockResolvedValue(post);
|
|
957
|
+
mockGhostService.getTag.mockResolvedValue(tag);
|
|
958
|
+
|
|
959
|
+
const prefetched = await resourceManager.prefetch(['ghost/post/1', 'ghost/tag/1']);
|
|
960
|
+
|
|
961
|
+
expect(prefetched).toHaveLength(2);
|
|
962
|
+
expect(prefetched.every((p) => p.status === 'success')).toBe(true);
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
it('should warm cache', async () => {
|
|
966
|
+
const post = { id: '1', title: 'Post' };
|
|
967
|
+
mockGhostService.getPost.mockResolvedValue(post);
|
|
968
|
+
|
|
969
|
+
await resourceManager.prefetch(['ghost/post/1']);
|
|
970
|
+
|
|
971
|
+
// Cache should be warm
|
|
972
|
+
await resourceManager.fetchResource('ghost/post/1');
|
|
973
|
+
expect(mockGhostService.getPost).toHaveBeenCalledTimes(1);
|
|
974
|
+
});
|
|
975
|
+
});
|
|
976
|
+
});
|
|
977
|
+
});
|