@aaronshaf/confluence-cli 0.1.15

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.
Files changed (94) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +69 -0
  3. package/package.json +73 -0
  4. package/src/cli/commands/attachments.ts +113 -0
  5. package/src/cli/commands/clone.ts +188 -0
  6. package/src/cli/commands/comments.ts +56 -0
  7. package/src/cli/commands/create.ts +58 -0
  8. package/src/cli/commands/delete.ts +46 -0
  9. package/src/cli/commands/doctor.ts +161 -0
  10. package/src/cli/commands/duplicate-check.ts +89 -0
  11. package/src/cli/commands/file-rename.ts +113 -0
  12. package/src/cli/commands/folder-hierarchy.ts +241 -0
  13. package/src/cli/commands/info.ts +56 -0
  14. package/src/cli/commands/labels.ts +53 -0
  15. package/src/cli/commands/move.ts +23 -0
  16. package/src/cli/commands/open.ts +145 -0
  17. package/src/cli/commands/pull.ts +241 -0
  18. package/src/cli/commands/push-errors.ts +40 -0
  19. package/src/cli/commands/push.ts +699 -0
  20. package/src/cli/commands/search.ts +62 -0
  21. package/src/cli/commands/setup.ts +124 -0
  22. package/src/cli/commands/spaces.ts +42 -0
  23. package/src/cli/commands/status.ts +88 -0
  24. package/src/cli/commands/tree.ts +190 -0
  25. package/src/cli/help.ts +425 -0
  26. package/src/cli/index.ts +413 -0
  27. package/src/cli/utils/browser.ts +34 -0
  28. package/src/cli/utils/progress-reporter.ts +49 -0
  29. package/src/cli.ts +6 -0
  30. package/src/lib/config.ts +156 -0
  31. package/src/lib/confluence-client/attachment-operations.ts +221 -0
  32. package/src/lib/confluence-client/client.ts +653 -0
  33. package/src/lib/confluence-client/comment-operations.ts +60 -0
  34. package/src/lib/confluence-client/folder-operations.ts +203 -0
  35. package/src/lib/confluence-client/index.ts +47 -0
  36. package/src/lib/confluence-client/label-operations.ts +102 -0
  37. package/src/lib/confluence-client/page-operations.ts +270 -0
  38. package/src/lib/confluence-client/search-operations.ts +60 -0
  39. package/src/lib/confluence-client/types.ts +329 -0
  40. package/src/lib/confluence-client/user-operations.ts +58 -0
  41. package/src/lib/dependency-sorter.ts +233 -0
  42. package/src/lib/errors.ts +237 -0
  43. package/src/lib/file-scanner.ts +195 -0
  44. package/src/lib/formatters.ts +314 -0
  45. package/src/lib/health-check.ts +204 -0
  46. package/src/lib/markdown/converter.ts +427 -0
  47. package/src/lib/markdown/frontmatter.ts +116 -0
  48. package/src/lib/markdown/html-converter.ts +398 -0
  49. package/src/lib/markdown/index.ts +21 -0
  50. package/src/lib/markdown/link-converter.ts +189 -0
  51. package/src/lib/markdown/reference-updater.ts +251 -0
  52. package/src/lib/markdown/slugify.ts +32 -0
  53. package/src/lib/page-state.ts +195 -0
  54. package/src/lib/resolve-page-target.ts +33 -0
  55. package/src/lib/space-config.ts +264 -0
  56. package/src/lib/sync/cleanup.ts +50 -0
  57. package/src/lib/sync/folder-path.ts +61 -0
  58. package/src/lib/sync/index.ts +2 -0
  59. package/src/lib/sync/link-resolution-pass.ts +139 -0
  60. package/src/lib/sync/sync-engine.ts +681 -0
  61. package/src/lib/sync/sync-specific.ts +221 -0
  62. package/src/lib/sync/types.ts +42 -0
  63. package/src/test/attachments.test.ts +68 -0
  64. package/src/test/clone.test.ts +373 -0
  65. package/src/test/comments.test.ts +53 -0
  66. package/src/test/config.test.ts +209 -0
  67. package/src/test/confluence-client.test.ts +535 -0
  68. package/src/test/delete.test.ts +39 -0
  69. package/src/test/dependency-sorter.test.ts +384 -0
  70. package/src/test/errors.test.ts +199 -0
  71. package/src/test/file-rename.test.ts +305 -0
  72. package/src/test/file-scanner.test.ts +331 -0
  73. package/src/test/folder-hierarchy.test.ts +337 -0
  74. package/src/test/formatters.test.ts +213 -0
  75. package/src/test/html-converter.test.ts +399 -0
  76. package/src/test/info.test.ts +56 -0
  77. package/src/test/labels.test.ts +70 -0
  78. package/src/test/link-conversion-integration.test.ts +189 -0
  79. package/src/test/link-converter.test.ts +413 -0
  80. package/src/test/link-resolution-pass.test.ts +368 -0
  81. package/src/test/markdown.test.ts +443 -0
  82. package/src/test/mocks/handlers.ts +228 -0
  83. package/src/test/move.test.ts +53 -0
  84. package/src/test/msw-schema-validation.ts +151 -0
  85. package/src/test/page-state.test.ts +542 -0
  86. package/src/test/push.test.ts +551 -0
  87. package/src/test/reference-updater.test.ts +293 -0
  88. package/src/test/resolve-page-target.test.ts +55 -0
  89. package/src/test/search.test.ts +64 -0
  90. package/src/test/setup-msw.ts +75 -0
  91. package/src/test/space-config.test.ts +516 -0
  92. package/src/test/spaces.test.ts +53 -0
  93. package/src/test/sync-engine.test.ts +486 -0
  94. package/src/types/turndown-plugin-gfm.d.ts +9 -0
@@ -0,0 +1,443 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { MarkdownConverter } from '../lib/markdown/converter.js';
3
+ import { slugify, generateUniqueFilename } from '../lib/markdown/slugify.js';
4
+ import {
5
+ createFrontmatter,
6
+ serializeMarkdown,
7
+ parseMarkdown,
8
+ extractPageId,
9
+ extractH1Title,
10
+ stripH1Title,
11
+ } from '../lib/markdown/frontmatter.js';
12
+
13
+ describe('slugify', () => {
14
+ test('converts title to lowercase', () => {
15
+ expect(slugify('Hello World')).toBe('hello-world');
16
+ });
17
+
18
+ test('replaces spaces with hyphens', () => {
19
+ expect(slugify('hello world test')).toBe('hello-world-test');
20
+ });
21
+
22
+ test('removes special characters', () => {
23
+ expect(slugify('Hello! World?')).toBe('hello-world');
24
+ expect(slugify('Test (with) [brackets]')).toBe('test-with-brackets');
25
+ });
26
+
27
+ test('collapses multiple hyphens', () => {
28
+ expect(slugify('hello---world')).toBe('hello-world');
29
+ expect(slugify('test - - test')).toBe('test-test');
30
+ });
31
+
32
+ test('trims hyphens from start and end', () => {
33
+ expect(slugify(' hello world ')).toBe('hello-world');
34
+ expect(slugify('-hello-world-')).toBe('hello-world');
35
+ });
36
+
37
+ test('handles empty string', () => {
38
+ expect(slugify('')).toBe('');
39
+ });
40
+
41
+ test('handles string with only special characters', () => {
42
+ expect(slugify('!@#$%')).toBe('');
43
+ });
44
+ });
45
+
46
+ describe('generateUniqueFilename', () => {
47
+ test('returns simple filename when no conflicts', () => {
48
+ const existing = new Set<string>();
49
+ expect(generateUniqueFilename('Hello World', existing)).toBe('hello-world.md');
50
+ });
51
+
52
+ test('appends counter for conflicts', () => {
53
+ const existing = new Set(['hello-world.md']);
54
+ expect(generateUniqueFilename('Hello World', existing)).toBe('hello-world-2.md');
55
+ });
56
+
57
+ test('increments counter for multiple conflicts', () => {
58
+ const existing = new Set(['hello-world.md', 'hello-world-2.md', 'hello-world-3.md']);
59
+ expect(generateUniqueFilename('Hello World', existing)).toBe('hello-world-4.md');
60
+ });
61
+
62
+ test('supports custom extension', () => {
63
+ const existing = new Set<string>();
64
+ expect(generateUniqueFilename('test', existing, '.txt')).toBe('test.txt');
65
+ });
66
+ });
67
+
68
+ describe('createFrontmatter', () => {
69
+ test('creates frontmatter from page', () => {
70
+ const page = {
71
+ id: 'page-123',
72
+ title: 'Test Page',
73
+ spaceId: 'space-123',
74
+ parentId: 'page-parent',
75
+ authorId: 'user-123',
76
+ createdAt: '2024-01-01T00:00:00Z',
77
+ version: {
78
+ number: 1,
79
+ createdAt: '2024-01-01T00:00:00Z',
80
+ authorId: 'user-456',
81
+ },
82
+ _links: {
83
+ webui: '/spaces/TEST/pages/page-123',
84
+ },
85
+ };
86
+
87
+ const frontmatter = createFrontmatter(page, [], 'Parent Page', 'https://test.atlassian.net');
88
+
89
+ expect(frontmatter.page_id).toBe('page-123');
90
+ expect(frontmatter.title).toBe('Test Page');
91
+ expect(frontmatter.version).toBe(1);
92
+ expect(frontmatter.parent_id).toBe('page-parent');
93
+ expect(frontmatter.parent_title).toBe('Parent Page');
94
+ expect(frontmatter.url).toBe('https://test.atlassian.net/wiki/spaces/TEST/pages/page-123');
95
+ expect(frontmatter.synced_at).toBeDefined();
96
+ });
97
+
98
+ test('includes labels', () => {
99
+ const page = {
100
+ id: 'page-123',
101
+ title: 'Test Page',
102
+ spaceId: 'space-123',
103
+ };
104
+
105
+ const labels = [
106
+ { id: 'label-1', name: 'important' },
107
+ { id: 'label-2', name: 'draft' },
108
+ ];
109
+
110
+ const frontmatter = createFrontmatter(page, labels);
111
+
112
+ expect(frontmatter.labels).toEqual(['important', 'draft']);
113
+ });
114
+
115
+ test('includes child_count when provided', () => {
116
+ const page = {
117
+ id: 'page-123',
118
+ title: 'Test Page',
119
+ spaceId: 'space-123',
120
+ };
121
+
122
+ const frontmatter = createFrontmatter(page, [], undefined, undefined, undefined, undefined, 5);
123
+
124
+ expect(frontmatter.child_count).toBe(5);
125
+ });
126
+
127
+ test('child_count is undefined when not provided', () => {
128
+ const page = {
129
+ id: 'page-123',
130
+ title: 'Test Page',
131
+ spaceId: 'space-123',
132
+ };
133
+
134
+ const frontmatter = createFrontmatter(page, []);
135
+
136
+ expect(frontmatter.child_count).toBeUndefined();
137
+ });
138
+
139
+ test('child_count can be zero', () => {
140
+ const page = {
141
+ id: 'page-123',
142
+ title: 'Test Page',
143
+ spaceId: 'space-123',
144
+ };
145
+
146
+ const frontmatter = createFrontmatter(page, [], undefined, undefined, undefined, undefined, 0);
147
+
148
+ expect(frontmatter.child_count).toBe(0);
149
+ });
150
+ });
151
+
152
+ describe('serializeMarkdown and parseMarkdown', () => {
153
+ test('serializes and parses markdown with frontmatter', () => {
154
+ const frontmatter = {
155
+ page_id: 'page-123',
156
+ title: 'Test Page',
157
+ space_key: 'TEST',
158
+ synced_at: '2024-01-01T00:00:00Z',
159
+ };
160
+ const content = '# Hello World\n\nThis is test content.';
161
+
162
+ const markdown = serializeMarkdown(frontmatter, content);
163
+ const parsed = parseMarkdown(markdown);
164
+
165
+ expect(parsed.frontmatter.page_id).toBe('page-123');
166
+ expect(parsed.frontmatter.title).toBe('Test Page');
167
+ expect(parsed.content.trim()).toBe(content);
168
+ });
169
+
170
+ test('handles empty content', () => {
171
+ const frontmatter = {
172
+ page_id: 'page-123',
173
+ title: 'Empty Page',
174
+ space_key: 'TEST',
175
+ synced_at: '2024-01-01T00:00:00Z',
176
+ };
177
+
178
+ const markdown = serializeMarkdown(frontmatter, '');
179
+ const parsed = parseMarkdown(markdown);
180
+
181
+ expect(parsed.frontmatter.page_id).toBe('page-123');
182
+ expect(parsed.content.trim()).toBe('');
183
+ });
184
+ });
185
+
186
+ describe('extractPageId', () => {
187
+ test('extracts page ID from markdown', () => {
188
+ const markdown = `---
189
+ page_id: page-123
190
+ title: Test
191
+ ---
192
+
193
+ Content here`;
194
+
195
+ expect(extractPageId(markdown)).toBe('page-123');
196
+ });
197
+
198
+ test('returns undefined when no frontmatter', () => {
199
+ const markdown = '# Just content\n\nNo frontmatter here.';
200
+ expect(extractPageId(markdown)).toBeUndefined();
201
+ });
202
+
203
+ test('returns undefined when no page_id in frontmatter', () => {
204
+ const markdown = `---
205
+ title: Test
206
+ ---
207
+
208
+ Content`;
209
+
210
+ expect(extractPageId(markdown)).toBeUndefined();
211
+ });
212
+ });
213
+
214
+ describe('extractH1Title', () => {
215
+ test('extracts H1 from content', () => {
216
+ const content = '# My Page Title\n\nSome content here.';
217
+ expect(extractH1Title(content)).toBe('My Page Title');
218
+ });
219
+
220
+ test('returns first H1 when multiple exist', () => {
221
+ const content = '# First Title\n\n## Subtitle\n\n# Second Title';
222
+ expect(extractH1Title(content)).toBe('First Title');
223
+ });
224
+
225
+ test('returns undefined when no H1 exists', () => {
226
+ const content = '## Subtitle\n\nContent without H1.';
227
+ expect(extractH1Title(content)).toBeUndefined();
228
+ });
229
+
230
+ test('returns undefined for empty content', () => {
231
+ expect(extractH1Title('')).toBeUndefined();
232
+ });
233
+
234
+ test('handles H1 with leading/trailing whitespace', () => {
235
+ const content = '# Spaced Title \n\nContent';
236
+ expect(extractH1Title(content)).toBe('Spaced Title');
237
+ });
238
+
239
+ test('does not match H1 inside code blocks', () => {
240
+ // This regex matches the first # at start of line, which may be in code
241
+ // Current implementation would match - but that's acceptable since code blocks
242
+ // at the very start of a file are unusual
243
+ const content = 'Some text\n# Real Title\n\nMore content';
244
+ expect(extractH1Title(content)).toBe('Real Title');
245
+ });
246
+
247
+ test('matches H1 not at start of content', () => {
248
+ const content = '\n\n# Title After Newlines\n\nContent';
249
+ expect(extractH1Title(content)).toBe('Title After Newlines');
250
+ });
251
+ });
252
+
253
+ describe('stripH1Title', () => {
254
+ test('strips H1 from start of content', () => {
255
+ const content = '# My Title\n\nSome content here.';
256
+ expect(stripH1Title(content)).toBe('Some content here.');
257
+ });
258
+
259
+ test('strips H1 with extra newlines', () => {
260
+ const content = '# Title\n\n\nContent with extra newlines.';
261
+ expect(stripH1Title(content)).toBe('Content with extra newlines.');
262
+ });
263
+
264
+ test('returns content unchanged if no H1', () => {
265
+ const content = 'No heading here.\n\nJust paragraphs.';
266
+ expect(stripH1Title(content)).toBe('No heading here.\n\nJust paragraphs.');
267
+ });
268
+
269
+ test('only strips first H1', () => {
270
+ const content = '# First Title\n\nContent\n\n# Second Title\n\nMore content';
271
+ expect(stripH1Title(content)).toBe('Content\n\n# Second Title\n\nMore content');
272
+ });
273
+
274
+ test('handles content starting with H1 with no following content', () => {
275
+ const content = '# Just a Title';
276
+ expect(stripH1Title(content)).toBe('');
277
+ });
278
+
279
+ test('handles empty content', () => {
280
+ expect(stripH1Title('')).toBe('');
281
+ });
282
+ });
283
+
284
+ describe('MarkdownConverter', () => {
285
+ test('converts simple HTML to markdown', () => {
286
+ const converter = new MarkdownConverter();
287
+ const html = '<p>Hello <strong>world</strong>!</p>';
288
+ const markdown = converter.convert(html);
289
+
290
+ expect(markdown).toContain('Hello');
291
+ expect(markdown).toContain('**world**');
292
+ });
293
+
294
+ test('converts headings', () => {
295
+ const converter = new MarkdownConverter();
296
+ const html = '<h1>Title</h1><h2>Subtitle</h2>';
297
+ const markdown = converter.convert(html);
298
+
299
+ expect(markdown).toContain('# Title');
300
+ expect(markdown).toContain('## Subtitle');
301
+ });
302
+
303
+ test('converts lists', () => {
304
+ const converter = new MarkdownConverter();
305
+ const html = '<ul><li>Item 1</li><li>Item 2</li></ul>';
306
+ const markdown = converter.convert(html);
307
+
308
+ // Turndown uses 3 spaces after bullet marker
309
+ expect(markdown).toContain('- Item 1');
310
+ expect(markdown).toContain('- Item 2');
311
+ });
312
+
313
+ test('converts links', () => {
314
+ const converter = new MarkdownConverter();
315
+ const html = '<a href="https://example.com">Link</a>';
316
+ const markdown = converter.convert(html);
317
+
318
+ expect(markdown).toContain('[Link](https://example.com)');
319
+ });
320
+
321
+ test('handles code blocks', () => {
322
+ const converter = new MarkdownConverter();
323
+ const html = '<pre><code>const x = 1;</code></pre>';
324
+ const markdown = converter.convert(html);
325
+
326
+ expect(markdown).toContain('const x = 1;');
327
+ });
328
+
329
+ test('handles Confluence code macro with language', () => {
330
+ const converter = new MarkdownConverter();
331
+ const html = `<ac:structured-macro ac:name="code" ac:schema-version="1">
332
+ <ac:parameter ac:name="language">bash</ac:parameter>
333
+ <ac:plain-text-body><![CDATA[echo "hello world"
334
+ ls -la]]></ac:plain-text-body>
335
+ </ac:structured-macro>`;
336
+ const markdown = converter.convert(html);
337
+
338
+ expect(markdown).toContain('```bash');
339
+ expect(markdown).toContain('echo "hello world"');
340
+ expect(markdown).toContain('ls -la');
341
+ expect(markdown).toContain('```');
342
+ });
343
+
344
+ test('handles Confluence code macro without language', () => {
345
+ const converter = new MarkdownConverter();
346
+ const html = `<ac:structured-macro ac:name="code" ac:schema-version="1">
347
+ <ac:plain-text-body><![CDATA[some plain code]]></ac:plain-text-body>
348
+ </ac:structured-macro>`;
349
+ const markdown = converter.convert(html);
350
+
351
+ expect(markdown).toContain('```');
352
+ expect(markdown).toContain('some plain code');
353
+ });
354
+
355
+ test('handles Confluence code macro with special characters', () => {
356
+ const converter = new MarkdownConverter();
357
+ const html = `<ac:structured-macro ac:name="code" ac:schema-version="1">
358
+ <ac:parameter ac:name="language">javascript</ac:parameter>
359
+ <ac:plain-text-body><![CDATA[if (x < 5 && y > 3) {
360
+ console.log("test");
361
+ }]]></ac:plain-text-body>
362
+ </ac:structured-macro>`;
363
+ const markdown = converter.convert(html);
364
+
365
+ expect(markdown).toContain('```javascript');
366
+ expect(markdown).toContain('if (x < 5 && y > 3)');
367
+ expect(markdown).toContain('console.log("test")');
368
+ });
369
+
370
+ test('converts tables with GFM plugin', () => {
371
+ const converter = new MarkdownConverter();
372
+ const html = `
373
+ <table>
374
+ <thead>
375
+ <tr><th>Col 1</th><th>Col 2</th></tr>
376
+ </thead>
377
+ <tbody>
378
+ <tr><td>A</td><td>B</td></tr>
379
+ </tbody>
380
+ </table>
381
+ `;
382
+ const markdown = converter.convert(html);
383
+
384
+ expect(markdown).toContain('Col 1');
385
+ expect(markdown).toContain('Col 2');
386
+ expect(markdown).toContain('|');
387
+ });
388
+
389
+ test('removes empty paragraphs', () => {
390
+ const converter = new MarkdownConverter();
391
+ const html = '<p></p><p>Content</p><p></p>';
392
+ const markdown = converter.convert(html);
393
+
394
+ expect(markdown.trim()).toBe('Content');
395
+ });
396
+
397
+ test('convertPage creates markdown with frontmatter and H1 heading', () => {
398
+ const converter = new MarkdownConverter();
399
+ const page = {
400
+ id: 'page-123',
401
+ title: 'Test Page',
402
+ spaceId: 'space-123',
403
+ body: {
404
+ storage: {
405
+ value: '<p>Hello World</p>',
406
+ },
407
+ },
408
+ };
409
+
410
+ const { markdown } = converter.convertPage(page);
411
+
412
+ expect(markdown).toContain('page_id: page-123');
413
+ expect(markdown).toContain('title: Test Page');
414
+ // H1 heading should be added from page title
415
+ expect(markdown).toContain('# Test Page');
416
+ expect(markdown).toContain('Hello World');
417
+ });
418
+
419
+ test('converts Confluence user references to @mentions', () => {
420
+ const converter = new MarkdownConverter();
421
+ const html = `
422
+ <p>Contact <ac:link><ri:user ri:account-id="5f123abc" /></ac:link> for help.</p>
423
+ `;
424
+ const markdown = converter.convert(html);
425
+
426
+ expect(markdown).toContain('@5f123abc');
427
+ expect(markdown).not.toContain('ac:link');
428
+ expect(markdown).not.toContain('ri:user');
429
+ });
430
+
431
+ test('converts standalone ri:user elements to @mentions', () => {
432
+ const converter = new MarkdownConverter();
433
+ const html = `
434
+ <table>
435
+ <tr><td><ri:user ri:account-id="user123" /></td></tr>
436
+ </table>
437
+ `;
438
+ const markdown = converter.convert(html);
439
+
440
+ expect(markdown).toContain('@user123');
441
+ expect(markdown).not.toContain('ri:user');
442
+ });
443
+ });
@@ -0,0 +1,228 @@
1
+ import { HttpResponse, http } from 'msw';
2
+ import {
3
+ AttachmentsResponseSchema,
4
+ CommentsResponseSchema,
5
+ FolderSchema,
6
+ SearchResponseSchema,
7
+ SpaceSchema,
8
+ SpacesResponseSchema,
9
+ PagesResponseSchema,
10
+ PageSchema,
11
+ UserSchema,
12
+ } from '../../lib/confluence-client/types.js';
13
+ import {
14
+ createValidAttachment,
15
+ createValidFolder,
16
+ createValidPage,
17
+ createValidSpace,
18
+ validateAndReturn,
19
+ } from '../msw-schema-validation.js';
20
+
21
+ /**
22
+ * Shared MSW handlers with schema validation
23
+ * These handlers ensure all mock responses conform to our Effect schemas
24
+ *
25
+ * IMPORTANT: These are DEFAULT/FALLBACK handlers that prevent unhandled requests.
26
+ * Individual tests should override these with server.use() for specific test scenarios.
27
+ */
28
+ export const handlers = [
29
+ // Confluence spaces mock
30
+ http.get('*/wiki/api/v2/spaces', ({ request }) => {
31
+ const url = new URL(request.url);
32
+ const keys = url.searchParams.get('keys');
33
+
34
+ if (keys) {
35
+ // Return specific space
36
+ const space = createValidSpace({ key: keys });
37
+ const response = { results: [space] };
38
+ return HttpResponse.json(validateAndReturn(SpacesResponseSchema, response, 'Spaces by key'));
39
+ }
40
+
41
+ // Return default spaces list
42
+ const spaces = [
43
+ createValidSpace({ id: 'space-1', key: 'TEST', name: 'Test Space' }),
44
+ createValidSpace({ id: 'space-2', key: 'DOCS', name: 'Documentation' }),
45
+ ];
46
+
47
+ return HttpResponse.json(validateAndReturn(SpacesResponseSchema, { results: spaces }, 'Spaces List'));
48
+ }),
49
+
50
+ // Confluence single space mock
51
+ http.get('*/wiki/api/v2/spaces/:spaceId', ({ params }) => {
52
+ const space = createValidSpace({ id: params.spaceId as string });
53
+ return HttpResponse.json(validateAndReturn(SpaceSchema, space, 'Single Space'));
54
+ }),
55
+
56
+ // Confluence pages in space mock
57
+ http.get('*/wiki/api/v2/spaces/:spaceId/pages', ({ params }) => {
58
+ const pages = [
59
+ createValidPage({ id: 'page-1', title: 'Home', spaceId: params.spaceId as string }),
60
+ createValidPage({
61
+ id: 'page-2',
62
+ title: 'Getting Started',
63
+ spaceId: params.spaceId as string,
64
+ parentId: 'page-1',
65
+ }),
66
+ ];
67
+
68
+ return HttpResponse.json(validateAndReturn(PagesResponseSchema, { results: pages }, 'Pages in Space'));
69
+ }),
70
+
71
+ // Confluence single page mock
72
+ http.get('*/wiki/api/v2/pages/:pageId', ({ params }) => {
73
+ const page = createValidPage({
74
+ id: params.pageId as string,
75
+ title: 'Test Page',
76
+ body: '<p>Test content</p>',
77
+ });
78
+
79
+ return HttpResponse.json(validateAndReturn(PageSchema, page, 'Single Page'));
80
+ }),
81
+
82
+ // Confluence child pages mock
83
+ http.get('*/wiki/api/v2/pages/:pageId/children', () => {
84
+ return HttpResponse.json(validateAndReturn(PagesResponseSchema, { results: [] }, 'Child Pages'));
85
+ }),
86
+
87
+ // Confluence labels mock
88
+ http.get('*/wiki/api/v2/pages/:pageId/labels', () => {
89
+ return HttpResponse.json({ results: [] });
90
+ }),
91
+
92
+ // Confluence folder mock
93
+ http.get('*/wiki/api/v2/folders/:folderId', ({ params }) => {
94
+ const folder = createValidFolder({
95
+ id: params.folderId as string,
96
+ title: 'Test Folder',
97
+ parentId: 'page-1',
98
+ parentType: 'page',
99
+ });
100
+ return HttpResponse.json(validateAndReturn(FolderSchema, folder, 'Single Folder'));
101
+ }),
102
+
103
+ // Confluence create folder mock
104
+ http.post('*/wiki/api/v2/folders', async ({ request }) => {
105
+ const body = (await request.json()) as { spaceId: string; title: string; parentId?: string };
106
+ const folder = createValidFolder({
107
+ id: `folder-${Date.now()}`,
108
+ title: body.title,
109
+ parentId: body.parentId || null,
110
+ });
111
+ return HttpResponse.json(validateAndReturn(FolderSchema, folder, 'Created Folder'));
112
+ }),
113
+
114
+ // Confluence move page mock (v1 API)
115
+ // Response body varies and is not validated by the client
116
+ http.put('*/wiki/rest/api/content/:pageId/move/:position/:targetId', () => {
117
+ return HttpResponse.json({});
118
+ }),
119
+
120
+ // Confluence footer comments mock
121
+ http.get('*/wiki/api/v2/pages/:pageId/footer-comments', () => {
122
+ return HttpResponse.json(validateAndReturn(CommentsResponseSchema, { results: [] }, 'Footer Comments'));
123
+ }),
124
+
125
+ // Confluence attachments mock
126
+ http.get('*/wiki/api/v2/pages/:pageId/attachments', () => {
127
+ return HttpResponse.json(validateAndReturn(AttachmentsResponseSchema, { results: [] }, 'Attachments'));
128
+ }),
129
+
130
+ // Upload attachment mock (v1 API)
131
+ http.post('*/wiki/rest/api/content/:pageId/child/attachment', () => {
132
+ const attachment = createValidAttachment({ id: `att-${Date.now()}`, title: 'uploaded-file.png' });
133
+ return HttpResponse.json({ results: [attachment] });
134
+ }),
135
+
136
+ // Delete attachment mock
137
+ http.delete('*/wiki/api/v2/attachments/:attachmentId', () => {
138
+ return new HttpResponse(null, { status: 204 });
139
+ }),
140
+
141
+ // Delete page mock
142
+ http.delete('*/wiki/api/v2/pages/:pageId', () => {
143
+ return new HttpResponse(null, { status: 204 });
144
+ }),
145
+
146
+ // Add label mock (v1 API)
147
+ http.post('*/wiki/rest/api/content/:pageId/label', () => {
148
+ return HttpResponse.json([]);
149
+ }),
150
+
151
+ // Remove label mock (v1 API)
152
+ http.delete('*/wiki/rest/api/content/:pageId/label/:labelName', () => {
153
+ return new HttpResponse(null, { status: 204 });
154
+ }),
155
+
156
+ // Search mock (v1 API)
157
+ http.get('*/wiki/rest/api/search', ({ request }) => {
158
+ const url = new URL(request.url);
159
+ const cql = url.searchParams.get('cql') || '';
160
+ if (cql.includes('type=folder')) {
161
+ return HttpResponse.json(validateAndReturn(SearchResponseSchema, { results: [] }, 'Search (folder)'));
162
+ }
163
+ return HttpResponse.json(
164
+ validateAndReturn(
165
+ SearchResponseSchema,
166
+ {
167
+ results: [
168
+ {
169
+ id: 'page-123',
170
+ title: 'Test Page',
171
+ type: 'page',
172
+ content: {
173
+ id: 'page-123',
174
+ type: 'page',
175
+ title: 'Test Page',
176
+ _links: { webui: '/wiki/spaces/TEST/pages/page-123' },
177
+ },
178
+ excerpt: 'Test content excerpt',
179
+ },
180
+ ],
181
+ totalSize: 1,
182
+ start: 0,
183
+ limit: 10,
184
+ },
185
+ 'Search',
186
+ ),
187
+ );
188
+ }),
189
+
190
+ // Confluence user mock (v1 API - v2 doesn't have user endpoint)
191
+ http.get('*/wiki/rest/api/user', ({ request }) => {
192
+ const url = new URL(request.url);
193
+ const accountId = url.searchParams.get('accountId') || 'unknown';
194
+ // Generate unique user data based on accountId for realistic test coverage
195
+ const shortId = accountId.slice(0, 8);
196
+ const user = {
197
+ accountId,
198
+ displayName: `User ${shortId}`,
199
+ email: `user-${shortId}@example.com`,
200
+ };
201
+ return HttpResponse.json(validateAndReturn(UserSchema, user, 'User'));
202
+ }),
203
+
204
+ // CATCH-ALL: Return 404 for any unhandled requests
205
+ http.get('*', ({ request }) => {
206
+ console.warn(`[MSW] Unhandled GET request: ${request.url}`);
207
+ return HttpResponse.json(
208
+ { errorMessages: ['Unhandled request - add a handler for this endpoint'] },
209
+ { status: 404 },
210
+ );
211
+ }),
212
+
213
+ http.post('*', ({ request }) => {
214
+ console.warn(`[MSW] Unhandled POST request: ${request.url}`);
215
+ return HttpResponse.json(
216
+ { errorMessages: ['Unhandled request - add a handler for this endpoint'] },
217
+ { status: 404 },
218
+ );
219
+ }),
220
+
221
+ http.put('*', ({ request }) => {
222
+ console.warn(`[MSW] Unhandled PUT request: ${request.url}`);
223
+ return HttpResponse.json(
224
+ { errorMessages: ['Unhandled request - add a handler for this endpoint'] },
225
+ { status: 404 },
226
+ );
227
+ }),
228
+ ];