@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,399 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { HtmlConverter } from '../lib/markdown/html-converter.js';
3
+
4
+ describe('HtmlConverter', () => {
5
+ test('converts simple markdown to HTML', () => {
6
+ const converter = new HtmlConverter();
7
+ const { html } = converter.convert('Hello **world**!');
8
+
9
+ expect(html).toContain('<strong>world</strong>');
10
+ });
11
+
12
+ test('converts headings', () => {
13
+ const converter = new HtmlConverter();
14
+ const { html } = converter.convert('# Heading 1\n## Heading 2');
15
+
16
+ expect(html).toContain('<h1>Heading 1</h1>');
17
+ expect(html).toContain('<h2>Heading 2</h2>');
18
+ });
19
+
20
+ test('converts lists', () => {
21
+ const converter = new HtmlConverter();
22
+ const { html } = converter.convert('- Item 1\n- Item 2');
23
+
24
+ expect(html).toContain('<ul>');
25
+ expect(html).toContain('<li>');
26
+ expect(html).toContain('Item 1');
27
+ });
28
+
29
+ test('converts inline formatting inside list items', () => {
30
+ const converter = new HtmlConverter();
31
+ const markdown =
32
+ '- **[ger](https://github.com/aaronshaf/ger)** - Gerrit CLI for code review\n- **[jk](https://github.com/aaronshaf/jk)** - Jenkins CLI';
33
+ const { html } = converter.convert(markdown);
34
+
35
+ expect(html).toContain('<ul>');
36
+ expect(html).toContain('<strong><a href="https://github.com/aaronshaf/ger">ger</a></strong>');
37
+ expect(html).toContain('<strong><a href="https://github.com/aaronshaf/jk">jk</a></strong>');
38
+ expect(html).toContain('Gerrit CLI for code review');
39
+ expect(html).not.toContain('**[ger]'); // Should not contain raw markdown
40
+ });
41
+
42
+ test('converts italic and strikethrough', () => {
43
+ const converter = new HtmlConverter();
44
+ const { html } = converter.convert('Text with *italic* and ~~strikethrough~~ formatting.');
45
+
46
+ expect(html).toContain('<em>italic</em>');
47
+ expect(html).toContain('<del>strikethrough</del>');
48
+ });
49
+
50
+ test('converts line breaks and horizontal rules', () => {
51
+ const converter = new HtmlConverter();
52
+ const { html } = converter.convert('Line 1 \nLine 2\n\n---\n\nLine 3');
53
+
54
+ expect(html).toContain('<br />');
55
+ expect(html).toContain('<hr />');
56
+ });
57
+
58
+ test('converts links', () => {
59
+ const converter = new HtmlConverter();
60
+ const { html } = converter.convert('[Example](https://example.com)');
61
+
62
+ expect(html).toContain('<a href="https://example.com">Example</a>');
63
+ });
64
+
65
+ test('converts code blocks to Confluence macro', () => {
66
+ const converter = new HtmlConverter();
67
+ const markdown = '```javascript\nconst x = 1;\n```';
68
+ const { html } = converter.convert(markdown);
69
+
70
+ expect(html).toContain('ac:structured-macro');
71
+ expect(html).toContain('ac:name="code"');
72
+ expect(html).toContain('javascript');
73
+ expect(html).toContain('const x = 1;');
74
+ });
75
+
76
+ test('converts tables', () => {
77
+ const converter = new HtmlConverter();
78
+ const markdown = '| Col 1 | Col 2 |\n|-------|-------|\n| A | B |';
79
+ const { html } = converter.convert(markdown);
80
+
81
+ expect(html).toContain('<table>');
82
+ expect(html).toContain('<th>Col 1</th>');
83
+ expect(html).toContain('<td>A</td>');
84
+ });
85
+
86
+ test('converts blockquotes', () => {
87
+ const converter = new HtmlConverter();
88
+ const { html } = converter.convert('> This is a quote');
89
+
90
+ expect(html).toContain('<blockquote>');
91
+ expect(html).toContain('This is a quote');
92
+ });
93
+
94
+ test('converts info panel syntax to Confluence macro', () => {
95
+ const converter = new HtmlConverter();
96
+ const { html } = converter.convert('> **Info:** This is an info message');
97
+
98
+ expect(html).toContain('ac:structured-macro');
99
+ expect(html).toContain('ac:name="info"');
100
+ expect(html).toContain('This is an info message');
101
+ });
102
+
103
+ test('ensures XHTML compliance with self-closing tags', () => {
104
+ const converter = new HtmlConverter();
105
+ const { html } = converter.convert('Line 1\n\nLine 2\n\n---');
106
+
107
+ expect(html).toContain('<hr />');
108
+ expect(html).not.toContain('<hr>');
109
+ });
110
+
111
+ test('warns about local image references', () => {
112
+ const converter = new HtmlConverter();
113
+ const { warnings } = converter.convert('![Image](./local-image.png)');
114
+
115
+ expect(warnings.length).toBeGreaterThan(0);
116
+ expect(warnings.some((w) => w.includes('local-image.png'))).toBe(true);
117
+ });
118
+
119
+ test('strips @ prefix from mentions', () => {
120
+ const converter = new HtmlConverter();
121
+ const { html, warnings } = converter.convert('Contact @user123 for help.');
122
+
123
+ // @ should be stripped, leaving just the username
124
+ expect(html).toContain('user123');
125
+ expect(html).not.toContain('@user123');
126
+ // No warning since we handle it by stripping
127
+ expect(warnings.some((w) => w.includes('mentions'))).toBe(false);
128
+ });
129
+
130
+ test('does not strip @ from email addresses', () => {
131
+ const converter = new HtmlConverter();
132
+ const { html } = converter.convert('Email us at support@example.com for help.');
133
+
134
+ // Email addresses should be preserved
135
+ expect(html).toContain('support@example.com');
136
+ });
137
+
138
+ test('warns about task lists', () => {
139
+ const converter = new HtmlConverter();
140
+ const { warnings } = converter.convert('- [x] Done\n- [ ] Todo');
141
+
142
+ expect(warnings.length).toBeGreaterThan(0);
143
+ expect(warnings.some((w) => w.includes('Task list'))).toBe(true);
144
+ });
145
+
146
+ test('escapes CDATA sections in code blocks', () => {
147
+ const converter = new HtmlConverter();
148
+ const markdown = '```javascript\nconst x = "]]>";\n```';
149
+ const { html } = converter.convert(markdown);
150
+
151
+ // Should escape ]]> to prevent breaking CDATA
152
+ expect(html).toContain(']]]]><![CDATA[>');
153
+ expect(html).not.toContain('const x = "]]>";');
154
+ });
155
+
156
+ test('sanitizes language identifiers in code blocks', () => {
157
+ const converter = new HtmlConverter();
158
+ const markdown = '```javascript"><script>alert("xss")</script><x lang="evil\nconsole.log("test");\n```';
159
+ const { html } = converter.convert(markdown);
160
+
161
+ // Should strip dangerous characters from language (quotes, angle brackets, etc.)
162
+ expect(html).not.toContain('"><script>');
163
+ expect(html).not.toContain('<x lang="evil');
164
+ // Should keep alphanumeric parts
165
+ expect(html).toContain('javascript');
166
+ // The sanitized version should only have safe characters
167
+ expect(html).toMatch(/<ac:parameter ac:name="language">[a-zA-Z0-9\-_+]*<\/ac:parameter>/);
168
+ });
169
+
170
+ test('sanitizes script tags in HTML passthrough', () => {
171
+ const converter = new HtmlConverter();
172
+ // Use block-level HTML which marked processes through the html renderer
173
+ const markdown = '<div>\nHello\n</div>\n<script>alert("xss")</script>\n<div>\nworld\n</div>';
174
+ const { html, warnings } = converter.convert(markdown);
175
+
176
+ // Should remove script tags
177
+ expect(html).not.toContain('<script>');
178
+ expect(html).not.toContain('alert("xss")');
179
+ expect(html).toContain('Hello');
180
+ expect(html).toContain('world');
181
+ // Should warn about sanitization
182
+ expect(warnings.some((w) => w.includes('unsafe HTML'))).toBe(true);
183
+ });
184
+
185
+ test('sanitizes event handlers in HTML passthrough', () => {
186
+ const converter = new HtmlConverter();
187
+ const markdown = '<div onclick="alert(\'xss\')">Click me</div>';
188
+ const { html, warnings } = converter.convert(markdown);
189
+
190
+ // Should remove event handlers
191
+ expect(html).not.toContain('onclick');
192
+ expect(html).not.toContain('alert');
193
+ expect(html).toContain('Click me');
194
+ // Should warn about sanitization
195
+ expect(warnings.some((w) => w.includes('unsafe HTML'))).toBe(true);
196
+ });
197
+
198
+ test('sanitizes javascript: protocol in links', () => {
199
+ const converter = new HtmlConverter();
200
+ const markdown = '<a href="javascript:alert(\'xss\')">Click</a>';
201
+ const { html, warnings } = converter.convert(markdown);
202
+
203
+ // Should replace javascript: with safe URL
204
+ expect(html).not.toContain('javascript:');
205
+ expect(html).toContain('href="#"');
206
+ expect(html).toContain('Click');
207
+ // Should warn about sanitization
208
+ expect(warnings.some((w) => w.includes('unsafe HTML'))).toBe(true);
209
+ });
210
+
211
+ test('allows safe HTML passthrough', () => {
212
+ const converter = new HtmlConverter();
213
+ const markdown = '<div class="highlight">Safe HTML</div>';
214
+ const { html, warnings } = converter.convert(markdown);
215
+
216
+ // Should pass through safe HTML
217
+ expect(html).toContain('<div class="highlight">');
218
+ expect(html).toContain('Safe HTML');
219
+ // Should not warn
220
+ expect(warnings.some((w) => w.includes('unsafe HTML'))).toBe(false);
221
+ });
222
+
223
+ test('converts relative .md links to Confluence page links', () => {
224
+ const converter = new HtmlConverter();
225
+ const pageLookupMap = {
226
+ titleToPage: new Map(),
227
+ idToPage: new Map([
228
+ [
229
+ 'page-123',
230
+ {
231
+ pageId: 'page-123',
232
+ version: 1,
233
+ localPath: 'architecture/overview.md',
234
+ title: 'Architecture Overview',
235
+ },
236
+ ],
237
+ ]),
238
+ pathToPage: new Map([
239
+ [
240
+ 'architecture/overview.md',
241
+ {
242
+ pageId: 'page-123',
243
+ version: 1,
244
+ localPath: 'architecture/overview.md',
245
+ title: 'Architecture Overview',
246
+ },
247
+ ],
248
+ ]),
249
+ };
250
+
251
+ const markdown = 'See [Architecture](./architecture/overview.md) for details.';
252
+ const { html } = converter.convert(markdown, '/test/space', 'home.md', 'TEST', pageLookupMap);
253
+
254
+ expect(html).toContain('<ac:link>');
255
+ expect(html).toContain('ri:page ri:content-title="Architecture Overview"');
256
+ expect(html).toContain('ri:space-key="TEST"');
257
+ expect(html).toContain('<ac:plain-text-link-body><![CDATA[Architecture]]></ac:plain-text-link-body>');
258
+ });
259
+
260
+ test('warns about broken local links', () => {
261
+ const converter = new HtmlConverter();
262
+ const pageLookupMap = {
263
+ titleToPage: new Map(),
264
+ idToPage: new Map(),
265
+ pathToPage: new Map(),
266
+ };
267
+
268
+ const markdown = 'See [Missing](./non-existent.md).';
269
+ const { warnings } = converter.convert(markdown, '/test/space', 'home.md', 'TEST', pageLookupMap);
270
+
271
+ expect(warnings.length).toBeGreaterThan(0);
272
+ expect(warnings.some((w) => w.includes('non-existent.md'))).toBe(true);
273
+ });
274
+
275
+ test('preserves external links unchanged', () => {
276
+ const converter = new HtmlConverter();
277
+ const pageLookupMap = {
278
+ titleToPage: new Map(),
279
+ idToPage: new Map(),
280
+ pathToPage: new Map(),
281
+ };
282
+
283
+ const markdown = 'See [Google](https://google.com) and [Example](https://example.com/page.md).';
284
+ const { html } = converter.convert(markdown, '/test/space', 'home.md', 'TEST', pageLookupMap);
285
+
286
+ expect(html).toContain('<a href="https://google.com">Google</a>');
287
+ expect(html).toContain('<a href="https://example.com/page.md">Example</a>');
288
+ expect(html).not.toContain('<ac:link>');
289
+ });
290
+
291
+ test('properly escapes XML special characters in link titles', () => {
292
+ const converter = new HtmlConverter();
293
+ const pageLookupMap = {
294
+ titleToPage: new Map(),
295
+ idToPage: new Map([
296
+ [
297
+ 'page-123',
298
+ {
299
+ pageId: 'page-123',
300
+ version: 1,
301
+ localPath: 'special.md',
302
+ title: 'Page with <Special> & "Chars"',
303
+ },
304
+ ],
305
+ ]),
306
+ pathToPage: new Map([
307
+ [
308
+ 'special.md',
309
+ {
310
+ pageId: 'page-123',
311
+ version: 1,
312
+ localPath: 'special.md',
313
+ title: 'Page with <Special> & "Chars"',
314
+ },
315
+ ],
316
+ ]),
317
+ };
318
+
319
+ const markdown = 'See [Special Page](./special.md).';
320
+ const { html } = converter.convert(markdown, '/test/space', 'home.md', 'TEST', pageLookupMap);
321
+
322
+ // Verify XML special characters are properly escaped
323
+ expect(html).toContain('ri:content-title="Page with &lt;Special&gt; &amp; &quot;Chars&quot;"');
324
+ expect(html).not.toContain('ri:content-title="Page with <Special> & "Chars""');
325
+ });
326
+
327
+ test('handles links in code blocks without converting them', () => {
328
+ const converter = new HtmlConverter();
329
+ const pageLookupMap = {
330
+ titleToPage: new Map(),
331
+ idToPage: new Map([
332
+ [
333
+ 'page-123',
334
+ {
335
+ pageId: 'page-123',
336
+ version: 1,
337
+ localPath: 'page.md',
338
+ title: 'Test Page',
339
+ },
340
+ ],
341
+ ]),
342
+ pathToPage: new Map([
343
+ [
344
+ 'page.md',
345
+ {
346
+ pageId: 'page-123',
347
+ version: 1,
348
+ localPath: 'page.md',
349
+ title: 'Test Page',
350
+ },
351
+ ],
352
+ ]),
353
+ };
354
+
355
+ const markdown = '```\n[Link](./page.md)\n```';
356
+ const { html } = converter.convert(markdown, '/test/space', 'home.md', 'TEST', pageLookupMap);
357
+
358
+ // Links inside code blocks should NOT be converted to Confluence links
359
+ expect(html).not.toContain('<ac:link>');
360
+ expect(html).toContain('[Link](./page.md)');
361
+ });
362
+
363
+ test('handles inline code with link-like syntax without converting', () => {
364
+ const converter = new HtmlConverter();
365
+ const pageLookupMap = {
366
+ titleToPage: new Map(),
367
+ idToPage: new Map([
368
+ [
369
+ 'page-123',
370
+ {
371
+ pageId: 'page-123',
372
+ version: 1,
373
+ localPath: 'page.md',
374
+ title: 'Test Page',
375
+ },
376
+ ],
377
+ ]),
378
+ pathToPage: new Map([
379
+ [
380
+ 'page.md',
381
+ {
382
+ pageId: 'page-123',
383
+ version: 1,
384
+ localPath: 'page.md',
385
+ title: 'Test Page',
386
+ },
387
+ ],
388
+ ]),
389
+ };
390
+
391
+ const markdown = 'Use the syntax `[Link](./page.md)` in markdown.';
392
+ const { html } = converter.convert(markdown, '/test/space', 'home.md', 'TEST', pageLookupMap);
393
+
394
+ // Inline code should NOT be converted
395
+ expect(html).toContain('<code>[Link](./page.md)</code>');
396
+ // But if we have a real link, it should be converted
397
+ expect(html.match(/<ac:link>/g)).toBeNull();
398
+ });
399
+ });
@@ -0,0 +1,56 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { HttpResponse, http } from 'msw';
3
+ import { ConfluenceClient } from '../lib/confluence-client/client.js';
4
+ import { createValidPage } from './msw-schema-validation.js';
5
+ import { server } from './setup-msw.js';
6
+
7
+ const testConfig = {
8
+ confluenceUrl: 'https://test.atlassian.net',
9
+ email: 'test@example.com',
10
+ apiToken: 'test-token',
11
+ };
12
+
13
+ describe('ConfluenceClient - getPage (info)', () => {
14
+ test('returns page without body when includeBody=false', async () => {
15
+ const page = createValidPage({ id: 'page-123', title: 'My Page' });
16
+ server.use(
17
+ http.get('*/wiki/api/v2/pages/:pageId', () => {
18
+ return HttpResponse.json(page);
19
+ }),
20
+ );
21
+
22
+ const client = new ConfluenceClient(testConfig);
23
+ const result = await client.getPage('page-123', false);
24
+ expect(result.id).toBe('page-123');
25
+ expect(result.title).toBe('My Page');
26
+ });
27
+
28
+ test('returns labels for a page', async () => {
29
+ server.use(
30
+ http.get('*/wiki/api/v2/pages/:pageId/labels', () => {
31
+ return HttpResponse.json({
32
+ results: [
33
+ { id: 'lbl-1', name: 'docs', prefix: 'global' },
34
+ { id: 'lbl-2', name: 'public', prefix: 'global' },
35
+ ],
36
+ });
37
+ }),
38
+ );
39
+
40
+ const client = new ConfluenceClient(testConfig);
41
+ const labels = await client.getAllLabels('page-123');
42
+ expect(labels.length).toBe(2);
43
+ expect(labels[0].name).toBe('docs');
44
+ });
45
+
46
+ test('throws on 404', async () => {
47
+ server.use(
48
+ http.get('*/wiki/api/v2/pages/:pageId', () => {
49
+ return HttpResponse.json({ error: 'Not found' }, { status: 404 });
50
+ }),
51
+ );
52
+
53
+ const client = new ConfluenceClient(testConfig);
54
+ await expect(client.getPage('nonexistent', false)).rejects.toThrow();
55
+ });
56
+ });
@@ -0,0 +1,70 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { HttpResponse, http } from 'msw';
3
+ import { ConfluenceClient } from '../lib/confluence-client/client.js';
4
+ import { server } from './setup-msw.js';
5
+
6
+ const testConfig = {
7
+ confluenceUrl: 'https://test.atlassian.net',
8
+ email: 'test@example.com',
9
+ apiToken: 'test-token',
10
+ };
11
+
12
+ describe('ConfluenceClient - label operations', () => {
13
+ test('lists labels for a page', async () => {
14
+ server.use(
15
+ http.get('*/wiki/api/v2/pages/:pageId/labels', () => {
16
+ return HttpResponse.json({
17
+ results: [
18
+ { id: 'label-1', name: 'documentation', prefix: 'global' },
19
+ { id: 'label-2', name: 'draft', prefix: 'global' },
20
+ ],
21
+ });
22
+ }),
23
+ );
24
+
25
+ const client = new ConfluenceClient(testConfig);
26
+ const labels = await client.getAllLabels('page-123');
27
+ expect(labels.length).toBe(2);
28
+ expect(labels[0].name).toBe('documentation');
29
+ expect(labels[1].name).toBe('draft');
30
+ });
31
+
32
+ test('adds a label', async () => {
33
+ let requestBody: unknown;
34
+ server.use(
35
+ http.post('*/wiki/rest/api/content/:pageId/label', async ({ request }) => {
36
+ requestBody = await request.json();
37
+ return HttpResponse.json([]);
38
+ }),
39
+ );
40
+
41
+ const client = new ConfluenceClient(testConfig);
42
+ await client.addLabel('page-123', 'documentation');
43
+ expect(requestBody).toEqual([{ prefix: 'global', name: 'documentation' }]);
44
+ });
45
+
46
+ test('removes a label', async () => {
47
+ let deletedLabel = '';
48
+ server.use(
49
+ http.delete('*/wiki/rest/api/content/:pageId/label/:labelName', ({ params }) => {
50
+ deletedLabel = params.labelName as string;
51
+ return new HttpResponse(null, { status: 204 });
52
+ }),
53
+ );
54
+
55
+ const client = new ConfluenceClient(testConfig);
56
+ await client.removeLabel('page-123', 'draft');
57
+ expect(deletedLabel).toBe('draft');
58
+ });
59
+
60
+ test('throws on 401 when adding label', async () => {
61
+ server.use(
62
+ http.post('*/wiki/rest/api/content/:pageId/label', () => {
63
+ return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 });
64
+ }),
65
+ );
66
+
67
+ const client = new ConfluenceClient(testConfig);
68
+ await expect(client.addLabel('page-123', 'documentation')).rejects.toThrow();
69
+ });
70
+ });