confluence-cli 1.19.0 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1003 +0,0 @@
1
- const fs = require('fs');
2
- const os = require('os');
3
- const path = require('path');
4
- const FormData = require('form-data');
5
- const ConfluenceClient = require('../lib/confluence-client');
6
- const MockAdapter = require('axios-mock-adapter');
7
-
8
- const removeDirRecursive = (dir) => {
9
- if (!dir) return;
10
- try {
11
- if (fs.rmSync) {
12
- fs.rmSync(dir, { recursive: true, force: true });
13
- return;
14
- }
15
- } catch (error) {
16
- void error;
17
- }
18
-
19
- if (!fs.existsSync(dir)) return;
20
-
21
- fs.readdirSync(dir).forEach((entry) => {
22
- const entryPath = path.join(dir, entry);
23
- const stats = fs.lstatSync(entryPath);
24
- if (stats.isDirectory()) {
25
- removeDirRecursive(entryPath);
26
- } else {
27
- try {
28
- fs.unlinkSync(entryPath);
29
- } catch (error) {
30
- void error;
31
- }
32
- }
33
- });
34
-
35
- try {
36
- fs.rmdirSync(dir);
37
- } catch (error) {
38
- void error;
39
- }
40
- };
41
-
42
- describe('ConfluenceClient', () => {
43
- let client;
44
-
45
- beforeEach(() => {
46
- client = new ConfluenceClient({
47
- domain: 'test.atlassian.net',
48
- token: 'test-token'
49
- });
50
- });
51
-
52
- describe('api path handling', () => {
53
- test('defaults to /rest/api when path is not provided', () => {
54
- const defaultClient = new ConfluenceClient({
55
- domain: 'example.com',
56
- token: 'no-path-token'
57
- });
58
-
59
- expect(defaultClient.baseURL).toBe('https://example.com/rest/api');
60
- });
61
-
62
- test('normalizes custom api paths', () => {
63
- const customClient = new ConfluenceClient({
64
- domain: 'cloud.example',
65
- token: 'custom-path',
66
- apiPath: 'wiki/rest/api/'
67
- });
68
-
69
- expect(customClient.baseURL).toBe('https://cloud.example/wiki/rest/api');
70
- });
71
- });
72
-
73
- describe('authentication setup', () => {
74
- test('uses bearer token headers by default', () => {
75
- const bearerClient = new ConfluenceClient({
76
- domain: 'test.atlassian.net',
77
- token: 'bearer-token'
78
- });
79
-
80
- expect(bearerClient.client.defaults.headers.Authorization).toBe('Bearer bearer-token');
81
- });
82
-
83
- test('builds basic auth headers when email is provided', () => {
84
- const basicClient = new ConfluenceClient({
85
- domain: 'test.atlassian.net',
86
- token: 'basic-token',
87
- authType: 'basic',
88
- email: 'user@example.com'
89
- });
90
-
91
- const encoded = Buffer.from('user@example.com:basic-token').toString('base64');
92
- expect(basicClient.client.defaults.headers.Authorization).toBe(`Basic ${encoded}`);
93
- });
94
-
95
- test('throws when basic auth is missing an email', () => {
96
- expect(() => new ConfluenceClient({
97
- domain: 'test.atlassian.net',
98
- token: 'missing-email',
99
- authType: 'basic'
100
- })).toThrow('Basic authentication requires an email address.');
101
- });
102
- });
103
-
104
- describe('extractPageId', () => {
105
- test('should return numeric page ID as is', async () => {
106
- expect(await client.extractPageId('123456789')).toBe('123456789');
107
- expect(await client.extractPageId(123456789)).toBe(123456789);
108
- });
109
-
110
- test('should extract page ID from URL with pageId parameter', async () => {
111
- const url = 'https://test.atlassian.net/wiki/spaces/TEST/pages/123456789/Page+Title';
112
- expect(await client.extractPageId(url + '?pageId=987654321')).toBe('987654321');
113
- });
114
-
115
- test('should extract page ID from pretty URL path', async () => {
116
- const url = 'https://test.atlassian.net/wiki/spaces/TEST/pages/123456789/Page+Title';
117
- expect(await client.extractPageId(url)).toBe('123456789');
118
- });
119
-
120
- test('should resolve display URLs', async () => {
121
- // Mock the API response for display URL resolution
122
- const mock = new MockAdapter(client.client);
123
-
124
- mock.onGet('/content').reply(200, {
125
- results: [{
126
- id: '12345',
127
- title: 'Page Title',
128
- _links: { webui: '/display/TEST/Page+Title' }
129
- }]
130
- });
131
-
132
- const displayUrl = 'https://test.atlassian.net/display/TEST/Page+Title';
133
- expect(await client.extractPageId(displayUrl)).toBe('12345');
134
-
135
- mock.restore();
136
- });
137
-
138
- test('should resolve nested display URLs', async () => {
139
- // Mock the API response for display URL resolution
140
- const mock = new MockAdapter(client.client);
141
-
142
- mock.onGet('/content').reply(200, {
143
- results: [{
144
- id: '67890',
145
- title: 'Child Page',
146
- _links: { webui: '/display/TEST/Parent/Child+Page' }
147
- }]
148
- });
149
-
150
- const displayUrl = 'https://test.atlassian.net/display/TEST/Parent/Child+Page';
151
- expect(await client.extractPageId(displayUrl)).toBe('67890');
152
-
153
- mock.restore();
154
- });
155
-
156
- test('should throw error when display URL cannot be resolved', async () => {
157
- const mock = new MockAdapter(client.client);
158
-
159
- // Mock empty result
160
- mock.onGet('/content').reply(200, {
161
- results: []
162
- });
163
-
164
- const displayUrl = 'https://test.atlassian.net/display/TEST/NonExistentPage';
165
- await expect(client.extractPageId(displayUrl)).rejects.toThrow(/Could not resolve page ID/);
166
-
167
- mock.restore();
168
- });
169
- });
170
-
171
- describe('markdownToStorage', () => {
172
- test('should convert basic markdown to native Confluence storage format', () => {
173
- const markdown = '# Hello World\n\nThis is a **test** page with *italic* text.';
174
- const result = client.markdownToStorage(markdown);
175
-
176
- expect(result).toContain('<h1>Hello World</h1>');
177
- expect(result).toContain('<p>This is a <strong>test</strong> page with <em>italic</em> text.</p>');
178
- expect(result).not.toContain('<ac:structured-macro ac:name="html">');
179
- });
180
-
181
- test('should convert code blocks to Confluence code macro', () => {
182
- const markdown = '```javascript\nconsole.log("Hello World");\n```';
183
- const result = client.markdownToStorage(markdown);
184
-
185
- expect(result).toContain('<ac:structured-macro ac:name="code">');
186
- expect(result).toContain('<ac:parameter ac:name="language">javascript</ac:parameter>');
187
- expect(result).toContain('console.log(&quot;Hello World&quot;);');
188
- });
189
-
190
- test('should convert lists to native Confluence format', () => {
191
- const markdown = '- Item 1\n- Item 2\n\n1. First\n2. Second';
192
- const result = client.markdownToStorage(markdown);
193
-
194
- expect(result).toContain('<ul>');
195
- expect(result).toContain('<li><p>Item 1</p></li>');
196
- expect(result).toContain('<ol>');
197
- expect(result).toContain('<li><p>First</p></li>');
198
- });
199
-
200
- test('should convert Confluence admonitions', () => {
201
- const markdown = '[!info]\nThis is an info message';
202
- const result = client.markdownToStorage(markdown);
203
-
204
- expect(result).toContain('<ac:structured-macro ac:name="info">');
205
- expect(result).toContain('This is an info message');
206
- });
207
-
208
- test('should convert tables to native Confluence format', () => {
209
- const markdown = '| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |';
210
- const result = client.markdownToStorage(markdown);
211
-
212
- expect(result).toContain('<table>');
213
- expect(result).toContain('<th><p>Header 1</p></th>');
214
- expect(result).toContain('<td><p>Cell 1</p></td>');
215
- });
216
-
217
- test('should convert links to Confluence link format', () => {
218
- const markdown = '[Example Link](https://example.com)';
219
- const result = client.markdownToStorage(markdown);
220
-
221
- expect(result).toContain('<ac:link>');
222
- expect(result).toContain('ri:value="https://example.com"');
223
- expect(result).toContain('Example Link');
224
- });
225
- });
226
-
227
- describe('markdownToNativeStorage', () => {
228
- test('should act as an alias to htmlToConfluenceStorage via markdown render', () => {
229
- const markdown = '# Native Storage Test';
230
- const result = client.markdownToNativeStorage(markdown);
231
-
232
- expect(result).toContain('<h1>Native Storage Test</h1>');
233
- });
234
-
235
- test('should handle code blocks correctly', () => {
236
- const markdown = '```javascript\nconst a = 1;\n```';
237
- const result = client.markdownToNativeStorage(markdown);
238
-
239
- expect(result).toContain('<ac:structured-macro ac:name="code">');
240
- expect(result).toContain('const a = 1;');
241
- });
242
- });
243
-
244
- describe('storageToMarkdown', () => {
245
- test('should convert Confluence storage format to markdown', () => {
246
- const storage = '<h1>Hello World</h1><p>This is a <strong>test</strong> page.</p>';
247
- const result = client.storageToMarkdown(storage);
248
-
249
- expect(result).toContain('# Hello World');
250
- expect(result).toContain('**test**');
251
- });
252
-
253
- test('should convert Confluence code macro to markdown', () => {
254
- const storage = '<ac:structured-macro ac:name="code"><ac:parameter ac:name="language">javascript</ac:parameter><ac:plain-text-body><![CDATA[console.log("Hello");]]></ac:plain-text-body></ac:structured-macro>';
255
- const result = client.storageToMarkdown(storage);
256
-
257
- expect(result).toContain('```javascript');
258
- expect(result).toContain('console.log("Hello");');
259
- expect(result).toContain('```');
260
- });
261
-
262
- test('should convert Confluence macros to admonitions', () => {
263
- const storage = '<ac:structured-macro ac:name="info"><ac:rich-text-body><p>This is info</p></ac:rich-text-body></ac:structured-macro>';
264
- const result = client.storageToMarkdown(storage);
265
-
266
- expect(result).toContain('[!info]');
267
- expect(result).toContain('This is info');
268
- });
269
-
270
- test('should convert Confluence links to markdown', () => {
271
- const storage = '<ac:link><ri:url ri:value="https://example.com" /><ac:plain-text-link-body><![CDATA[Example]]></ac:plain-text-link-body></ac:link>';
272
- const result = client.storageToMarkdown(storage);
273
-
274
- expect(result).toContain('[Example](https://example.com)');
275
- });
276
- });
277
-
278
- describe('htmlToMarkdown', () => {
279
- test('should convert basic HTML to markdown', () => {
280
- const html = '<h2>Title</h2><p>Some <strong>bold</strong> and <em>italic</em> text.</p>';
281
- const result = client.htmlToMarkdown(html);
282
-
283
- expect(result).toContain('## Title');
284
- expect(result).toContain('**bold**');
285
- expect(result).toContain('*italic*');
286
- });
287
-
288
- test('should convert HTML lists to markdown', () => {
289
- const html = '<ul><li><p>Item 1</p></li><li><p>Item 2</p></li></ul>';
290
- const result = client.htmlToMarkdown(html);
291
-
292
- expect(result).toContain('- Item 1');
293
- expect(result).toContain('- Item 2');
294
- });
295
-
296
- test('should convert HTML tables to markdown', () => {
297
- const html = '<table><tr><th><p>Header</p></th></tr><tr><td><p>Cell</p></td></tr></table>';
298
- const result = client.htmlToMarkdown(html);
299
-
300
- expect(result).toContain('| Header |');
301
- expect(result).toContain('| --- |');
302
- expect(result).toContain('| Cell |');
303
- });
304
- });
305
-
306
- describe('search', () => {
307
- test('should wrap query in text search by default', async () => {
308
- const mock = new MockAdapter(client.client);
309
- mock.onGet('/search').reply((config) => {
310
- expect(config.params.cql).toBe('text ~ "architecture decisions"');
311
- expect(config.params.limit).toBe(10);
312
- return [200, { results: [] }];
313
- });
314
-
315
- const results = await client.search('architecture decisions');
316
- expect(results).toEqual([]);
317
-
318
- mock.restore();
319
- });
320
-
321
- test('should pass raw CQL when rawCql is true', async () => {
322
- const mock = new MockAdapter(client.client);
323
- const rawQuery = 'contributor = currentUser() order by lastmodified desc';
324
- mock.onGet('/search').reply((config) => {
325
- expect(config.params.cql).toBe(rawQuery);
326
- return [200, {
327
- results: [{
328
- content: { id: '123', title: 'Test Page', type: 'page' },
329
- excerpt: 'test excerpt'
330
- }]
331
- }];
332
- });
333
-
334
- const results = await client.search(rawQuery, 10, true);
335
- expect(results).toHaveLength(1);
336
- expect(results[0].id).toBe('123');
337
- expect(results[0].title).toBe('Test Page');
338
-
339
- mock.restore();
340
- });
341
-
342
- test('should escape double quotes in text search query', async () => {
343
- const mock = new MockAdapter(client.client);
344
- mock.onGet('/search').reply((config) => {
345
- expect(config.params.cql).toBe('text ~ "test \\"quoted\\" term"');
346
- return [200, { results: [] }];
347
- });
348
-
349
- const results = await client.search('test "quoted" term');
350
- expect(results).toEqual([]);
351
-
352
- mock.restore();
353
- });
354
-
355
- test('should respect limit parameter', async () => {
356
- const mock = new MockAdapter(client.client);
357
- mock.onGet('/search').reply((config) => {
358
- expect(config.params.limit).toBe(5);
359
- return [200, { results: [] }];
360
- });
361
-
362
- await client.search('test', 5);
363
-
364
- mock.restore();
365
- });
366
- });
367
-
368
- describe('page creation and updates', () => {
369
- test('should have required methods for page management', () => {
370
- expect(typeof client.createPage).toBe('function');
371
- expect(typeof client.updatePage).toBe('function');
372
- expect(typeof client.getPageForEdit).toBe('function');
373
- expect(typeof client.createChildPage).toBe('function');
374
- expect(typeof client.findPageByTitle).toBe('function');
375
- expect(typeof client.deletePage).toBe('function');
376
- });
377
- });
378
-
379
- describe('deletePage', () => {
380
- test('should delete a page by ID', async () => {
381
- const mock = new MockAdapter(client.client);
382
- mock.onDelete('/content/123456789').reply(204);
383
-
384
- await expect(client.deletePage('123456789')).resolves.toEqual({ id: '123456789' });
385
-
386
- mock.restore();
387
- });
388
-
389
- test('should delete a page by URL', async () => {
390
- const mock = new MockAdapter(client.client);
391
- mock.onDelete('/content/987654321').reply(204);
392
-
393
- await expect(
394
- client.deletePage('https://test.atlassian.net/wiki/viewpage.action?pageId=987654321')
395
- ).resolves.toEqual({ id: '987654321' });
396
-
397
- mock.restore();
398
- });
399
- });
400
-
401
- describe('movePage', () => {
402
- test('should move a page by ID', async () => {
403
- const mock = new MockAdapter(client.client);
404
-
405
- mock.onGet('/content/123456789').reply(200, {
406
- id: '123456789',
407
- title: 'Original Title',
408
- version: { number: 5 },
409
- body: { storage: { value: '<p>Original content</p>' } },
410
- space: { key: 'TEST' }
411
- });
412
-
413
- mock.onGet('/content/987654321').reply(200, {
414
- id: '987654321',
415
- space: { key: 'TEST' }
416
- });
417
-
418
- mock.onPut('/content/123456789').reply(200, {
419
- id: '123456789',
420
- title: 'Original Title',
421
- version: { number: 6 },
422
- ancestors: [{ id: '987654321' }]
423
- });
424
-
425
- const result = await client.movePage('123456789', '987654321');
426
-
427
- expect(result.id).toBe('123456789');
428
- expect(result.version.number).toBe(6);
429
- expect(result.ancestors).toEqual([{ id: '987654321' }]);
430
-
431
- mock.restore();
432
- });
433
-
434
- test('should move a page with new title', async () => {
435
- const mock = new MockAdapter(client.client);
436
-
437
- mock.onGet('/content/555666777').reply(200, {
438
- id: '555666777',
439
- title: 'Old Title',
440
- version: { number: 2 },
441
- body: { storage: { value: '<p>Page content</p>' } },
442
- space: { key: 'DOCS' }
443
- });
444
-
445
- mock.onGet('/content/888999000').reply(200, {
446
- id: '888999000',
447
- space: { key: 'DOCS' }
448
- });
449
-
450
- mock.onPut('/content/555666777').reply(200, {
451
- id: '555666777',
452
- title: 'New Title',
453
- version: { number: 3 },
454
- ancestors: [{ id: '888999000' }]
455
- });
456
-
457
- const result = await client.movePage('555666777', '888999000', 'New Title');
458
-
459
- expect(result.title).toBe('New Title');
460
- expect(result.version.number).toBe(3);
461
- expect(result.ancestors).toEqual([{ id: '888999000' }]);
462
-
463
- mock.restore();
464
- });
465
-
466
- test('should move a page using URL for pageId', async () => {
467
- const mock = new MockAdapter(client.client);
468
- const pageUrl = 'https://test.atlassian.net/wiki/viewpage.action?pageId=111222333';
469
-
470
- mock.onGet('/content/111222333').reply(200, {
471
- id: '111222333',
472
- title: 'Test Page',
473
- version: { number: 1 },
474
- body: { storage: { value: '<p>Content</p>' } },
475
- space: { key: 'TEST' }
476
- });
477
-
478
- mock.onGet('/content/444555666').reply(200, {
479
- id: '444555666',
480
- space: { key: 'TEST' }
481
- });
482
-
483
- mock.onPut('/content/111222333').reply(200, {
484
- id: '111222333',
485
- title: 'Test Page',
486
- version: { number: 2 },
487
- ancestors: [{ id: '444555666' }]
488
- });
489
-
490
- const result = await client.movePage(pageUrl, '444555666');
491
-
492
- expect(result.id).toBe('111222333');
493
- expect(result.version.number).toBe(2);
494
-
495
- mock.restore();
496
- });
497
-
498
- test('should move a page using URLs for both parameters', async () => {
499
- const mock = new MockAdapter(client.client);
500
- const pageUrl = 'https://test.atlassian.net/wiki/viewpage.action?pageId=777888999';
501
- const parentUrl = 'https://test.atlassian.net/wiki/viewpage.action?pageId=111000111';
502
-
503
- mock.onGet('/content/777888999').reply(200, {
504
- id: '777888999',
505
- title: 'Source Page',
506
- version: { number: 3 },
507
- body: { storage: { value: '<p>Page content</p>' } },
508
- space: { key: 'DOCS' }
509
- });
510
-
511
- mock.onGet('/content/111000111').reply(200, {
512
- id: '111000111',
513
- space: { key: 'DOCS' }
514
- });
515
-
516
- mock.onPut('/content/777888999').reply(200, {
517
- id: '777888999',
518
- title: 'Source Page',
519
- version: { number: 4 },
520
- ancestors: [{ id: '111000111' }]
521
- });
522
-
523
- const result = await client.movePage(pageUrl, parentUrl);
524
-
525
- expect(result.id).toBe('777888999');
526
- expect(result.version.number).toBe(4);
527
-
528
- mock.restore();
529
- });
530
-
531
- test('should throw error when moving page across spaces', async () => {
532
- const mock = new MockAdapter(client.client);
533
-
534
- mock.onGet('/content/123456789').reply(200, {
535
- id: '123456789',
536
- title: 'Page in Space A',
537
- version: { number: 1 },
538
- body: { storage: { value: '<p>Content</p>' } },
539
- space: { key: 'SPACEA' }
540
- });
541
-
542
- mock.onGet('/content/987654321').reply(200, {
543
- id: '987654321',
544
- space: { key: 'SPACEB' }
545
- });
546
-
547
- await expect(
548
- client.movePage('123456789', '987654321')
549
- ).rejects.toThrow('Cannot move page across spaces');
550
-
551
- mock.restore();
552
- });
553
- });
554
-
555
- describe('page tree operations', () => {
556
- test('should have required methods for tree operations', () => {
557
- expect(typeof client.getChildPages).toBe('function');
558
- expect(typeof client.getAllDescendantPages).toBe('function');
559
- expect(typeof client.copyPageTree).toBe('function');
560
- expect(typeof client.buildPageTree).toBe('function');
561
- expect(typeof client.shouldExcludePage).toBe('function');
562
- });
563
-
564
- test('should correctly exclude pages based on patterns', () => {
565
- const patterns = ['temp*', 'test*', '*draft*'];
566
-
567
- expect(client.shouldExcludePage('temporary document', patterns)).toBe(true);
568
- expect(client.shouldExcludePage('test page', patterns)).toBe(true);
569
- expect(client.shouldExcludePage('my draft page', patterns)).toBe(true);
570
- expect(client.shouldExcludePage('normal document', patterns)).toBe(false);
571
- expect(client.shouldExcludePage('production page', patterns)).toBe(false);
572
- });
573
-
574
- test('should handle empty exclude patterns', () => {
575
- expect(client.shouldExcludePage('any page', [])).toBe(false);
576
- expect(client.shouldExcludePage('any page', null)).toBe(false);
577
- expect(client.shouldExcludePage('any page', undefined)).toBe(false);
578
- });
579
-
580
- test('globToRegExp should escape regex metacharacters and match case-insensitively', () => {
581
- const patterns = [
582
- 'file.name*', // dot should be literal
583
- '[draft]?', // brackets should be literal
584
- 'Plan (Q1)?', // parentheses literal, ? wildcard
585
- 'DATA*SET', // case-insensitive
586
- ];
587
- const rx = patterns.map(p => client.globToRegExp(p));
588
- expect('file.name.v1').toMatch(rx[0]);
589
- expect('filexname').not.toMatch(rx[0]);
590
- expect('[draft]1').toMatch(rx[1]);
591
- expect('[draft]AB').not.toMatch(rx[1]);
592
- expect('Plan (Q1)A').toMatch(rx[2]);
593
- expect('Plan Q1A').not.toMatch(rx[2]);
594
- expect('data big set').toMatch(rx[3]);
595
- });
596
-
597
- test('buildPageTree should link children by parentId and collect orphans at root', () => {
598
- const rootId = 'root';
599
- const pages = [
600
- { id: 'a', title: 'A', parentId: rootId },
601
- { id: 'b', title: 'B', parentId: 'a' },
602
- { id: 'c', title: 'C', parentId: 'missing' }, // orphan
603
- ];
604
- const tree = client.buildPageTree(pages, rootId);
605
- // tree should contain A and C at top-level (B is child of A)
606
- const topTitles = tree.map(n => n.title).sort();
607
- expect(topTitles).toEqual(['A', 'C']);
608
- const a = tree.find(n => n.title === 'A');
609
- expect(a.children.map(n => n.title)).toEqual(['B']);
610
- });
611
-
612
- test('exclude parser should tolerate spaces and empty items', () => {
613
- const raw = ' temp* , , *draft* ,,test? ';
614
- const patterns = raw.split(',').map(p => p.trim()).filter(Boolean);
615
- expect(patterns).toEqual(['temp*', '*draft*', 'test?']);
616
- expect(client.shouldExcludePage('temp file', patterns)).toBe(true);
617
- expect(client.shouldExcludePage('my draft page', patterns)).toBe(true);
618
- expect(client.shouldExcludePage('test1', patterns)).toBe(true);
619
- expect(client.shouldExcludePage('production', patterns)).toBe(false);
620
- });
621
- });
622
-
623
- describe('comments', () => {
624
- test('should list comments with location filter', async () => {
625
- const mock = new MockAdapter(client.client);
626
- mock.onGet('/content/123/child/comment').reply(config => {
627
- expect(config.params.location).toBe('inline');
628
- expect(config.params.expand).toContain('body.storage');
629
- expect(config.params.expand).toContain('ancestors');
630
- return [200, {
631
- results: [
632
- {
633
- id: 'c1',
634
- status: 'current',
635
- body: { storage: { value: '<p>Hello</p>' } },
636
- history: { createdBy: { displayName: 'Ada' }, createdDate: '2025-01-01' },
637
- version: { number: 1 },
638
- ancestors: [{ id: 'c0', type: 'comment' }],
639
- extensions: {
640
- location: 'inline',
641
- inlineProperties: { selection: 'Hello', originalSelection: 'Hello' },
642
- resolution: { status: 'open' }
643
- }
644
- }
645
- ],
646
- _links: { next: '/rest/api/content/123/child/comment?start=2' }
647
- }];
648
- });
649
-
650
- const page = await client.listComments('123', { location: 'inline' });
651
- expect(page.results).toHaveLength(1);
652
- expect(page.results[0].location).toBe('inline');
653
- expect(page.results[0].resolution).toBe('open');
654
- expect(page.results[0].parentId).toBe('c0');
655
- expect(page.nextStart).toBe(2);
656
-
657
- mock.restore();
658
- });
659
-
660
- test('should create inline comment with inline properties', async () => {
661
- const mock = new MockAdapter(client.client);
662
- mock.onPost('/content').reply(config => {
663
- const payload = JSON.parse(config.data);
664
- expect(payload.type).toBe('comment');
665
- expect(payload.container.id).toBe('123');
666
- expect(payload.body.storage.value).toBe('<p>Hi</p>');
667
- expect(payload.ancestors[0].id).toBe('c0');
668
- expect(payload.extensions.location).toBe('inline');
669
- expect(payload.extensions.inlineProperties.originalSelection).toBe('Hi');
670
- expect(payload.extensions.inlineProperties.markerRef).toBe('comment-1');
671
- return [200, { id: 'c1', type: 'comment' }];
672
- });
673
-
674
- await client.createComment('123', '<p>Hi</p>', 'storage', {
675
- parentId: 'c0',
676
- location: 'inline',
677
- inlineProperties: {
678
- selection: 'Hi',
679
- originalSelection: 'Hi',
680
- markerRef: 'comment-1'
681
- }
682
- });
683
-
684
- mock.restore();
685
- });
686
-
687
- test('should delete a comment by ID', async () => {
688
- const mock = new MockAdapter(client.client);
689
- mock.onDelete('/content/456').reply(204);
690
-
691
- await expect(client.deleteComment('456')).resolves.toEqual({ id: '456' });
692
-
693
- mock.restore();
694
- });
695
- });
696
-
697
- describe('attachments', () => {
698
- test('should have required methods for attachment handling', () => {
699
- expect(typeof client.listAttachments).toBe('function');
700
- expect(typeof client.getAllAttachments).toBe('function');
701
- expect(typeof client.downloadAttachment).toBe('function');
702
- expect(typeof client.uploadAttachment).toBe('function');
703
- expect(typeof client.deleteAttachment).toBe('function');
704
- });
705
-
706
- test('matchesPattern should respect glob patterns', () => {
707
- expect(client.matchesPattern('report.png', '*.png')).toBe(true);
708
- expect(client.matchesPattern('report.png', '*.jpg')).toBe(false);
709
- expect(client.matchesPattern('report.png', ['*.jpg', 'report.*'])).toBe(true);
710
- expect(client.matchesPattern('report.png', null)).toBe(true);
711
- expect(client.matchesPattern('report.png', [])).toBe(true);
712
- });
713
-
714
- test('parseNextStart should read start query param when present', () => {
715
- expect(client.parseNextStart('/rest/api/content/1/child/attachment?start=25')).toBe(25);
716
- expect(client.parseNextStart('/rest/api/content/1/child/attachment?limit=50')).toBeNull();
717
- expect(client.parseNextStart(null)).toBeNull();
718
- });
719
-
720
- test('uploadAttachment should send multipart request with Atlassian token header', async () => {
721
- const mock = new MockAdapter(client.client);
722
- const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'confluence-cli-'));
723
- const tempFile = path.join(tempDir, 'upload.txt');
724
- fs.writeFileSync(tempFile, 'hello');
725
-
726
- try {
727
- mock.onPost('/content/123/child/attachment').reply((config) => {
728
- expect(config.headers['X-Atlassian-Token']).toBe('nocheck');
729
- const contentType = config.headers['content-type'] || config.headers['Content-Type'];
730
- expect(contentType).toContain('multipart/form-data');
731
- expect(config.data).toBeInstanceOf(FormData);
732
- return [200, {
733
- results: [{
734
- id: '1',
735
- title: 'upload.txt',
736
- version: { number: 2 },
737
- _links: { download: '/download' }
738
- }]
739
- }];
740
- });
741
-
742
- const response = await client.uploadAttachment('123', tempFile, { comment: 'note', minorEdit: true });
743
- expect(response.results[0].title).toBe('upload.txt');
744
- } finally {
745
- mock.restore();
746
- removeDirRecursive(tempDir);
747
- }
748
- });
749
-
750
- test('uploadAttachment should use PUT when replace is true', async () => {
751
- const mock = new MockAdapter(client.client);
752
- const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'confluence-cli-'));
753
- const tempFile = path.join(tempDir, 'replace.txt');
754
- fs.writeFileSync(tempFile, 'replace');
755
-
756
- try {
757
- mock.onPut('/content/456/child/attachment').reply(200, {
758
- results: [{
759
- id: '2',
760
- title: 'replace.txt',
761
- version: { number: 3 },
762
- _links: { download: '/download' }
763
- }]
764
- });
765
-
766
- const response = await client.uploadAttachment('456', tempFile, { replace: true });
767
- expect(response.results[0].title).toBe('replace.txt');
768
- } finally {
769
- mock.restore();
770
- removeDirRecursive(tempDir);
771
- }
772
- });
773
-
774
- test('deleteAttachment should call delete endpoint', async () => {
775
- const mock = new MockAdapter(client.client);
776
- mock.onDelete('/content/123/child/attachment/999').reply(204);
777
-
778
- await expect(client.deleteAttachment('123', '999')).resolves.toEqual({ id: '999', pageId: '123' });
779
-
780
- mock.restore();
781
- });
782
- });
783
-
784
- describe('content properties', () => {
785
- test('should have required methods for property handling', () => {
786
- expect(typeof client.listProperties).toBe('function');
787
- expect(typeof client.getProperty).toBe('function');
788
- expect(typeof client.setProperty).toBe('function');
789
- expect(typeof client.deleteProperty).toBe('function');
790
- });
791
-
792
- test('listProperties should return results with pagination info', async () => {
793
- const mock = new MockAdapter(client.client);
794
- mock.onGet('/content/123/property').reply(200, {
795
- results: [
796
- { key: 'color', value: { hex: '#ff0000' }, version: { number: 1 } },
797
- { key: 'status', value: 'active', version: { number: 3 } }
798
- ],
799
- _links: { next: '/rest/api/content/123/property?start=2&limit=25' }
800
- });
801
-
802
- const response = await client.listProperties('123');
803
- expect(response.results).toHaveLength(2);
804
- expect(response.results[0].key).toBe('color');
805
- expect(response.results[1].key).toBe('status');
806
- expect(response.nextStart).toBe(2);
807
-
808
- mock.restore();
809
- });
810
-
811
- test('listProperties should return empty results when no properties exist', async () => {
812
- const mock = new MockAdapter(client.client);
813
- mock.onGet('/content/456/property').reply(200, { results: [] });
814
-
815
- const response = await client.listProperties('456');
816
- expect(response.results).toEqual([]);
817
- expect(response.nextStart).toBeNull();
818
-
819
- mock.restore();
820
- });
821
-
822
- test('listProperties should resolve page URLs', async () => {
823
- const mock = new MockAdapter(client.client);
824
- mock.onGet('/content/789/property').reply(200, { results: [] });
825
-
826
- const response = await client.listProperties('https://test.atlassian.net/wiki/viewpage.action?pageId=789');
827
- expect(response.results).toEqual([]);
828
-
829
- mock.restore();
830
- });
831
-
832
- test('listProperties should pass limit and start as query params', async () => {
833
- const mock = new MockAdapter(client.client);
834
- mock.onGet('/content/123/property').reply((config) => {
835
- expect(config.params.limit).toBe(5);
836
- expect(config.params.start).toBe(10);
837
- return [200, { results: [] }];
838
- });
839
-
840
- await client.listProperties('123', { limit: 5, start: 10 });
841
-
842
- mock.restore();
843
- });
844
-
845
- test('getAllProperties should accumulate results across pages', async () => {
846
- const mock = new MockAdapter(client.client);
847
- let callCount = 0;
848
- mock.onGet('/content/123/property').reply((config) => {
849
- callCount++;
850
- if (callCount === 1) {
851
- expect(config.params.start).toBe(0);
852
- return [200, {
853
- results: [{ key: 'a', value: 1, version: { number: 1 } }],
854
- _links: { next: '/rest/api/content/123/property?start=1&limit=1' }
855
- }];
856
- }
857
- expect(config.params.start).toBe(1);
858
- return [200, {
859
- results: [{ key: 'b', value: 2, version: { number: 1 } }]
860
- }];
861
- });
862
-
863
- const results = await client.getAllProperties('123', { pageSize: 1 });
864
- expect(results).toHaveLength(2);
865
- expect(results[0].key).toBe('a');
866
- expect(results[1].key).toBe('b');
867
-
868
- mock.restore();
869
- });
870
-
871
- test('getProperty should return property data', async () => {
872
- const mock = new MockAdapter(client.client);
873
- mock.onGet('/content/123/property/color').reply(200, {
874
- key: 'color',
875
- value: { hex: '#ff0000' },
876
- version: { number: 2 }
877
- });
878
-
879
- const result = await client.getProperty('123', 'color');
880
- expect(result.key).toBe('color');
881
- expect(result.value.hex).toBe('#ff0000');
882
-
883
- mock.restore();
884
- });
885
-
886
- test('getProperty should throw on 404', async () => {
887
- const mock = new MockAdapter(client.client);
888
- mock.onGet('/content/123/property/missing').reply(404, { message: 'Not found' });
889
-
890
- await expect(client.getProperty('123', 'missing')).rejects.toThrow();
891
-
892
- mock.restore();
893
- });
894
-
895
- test('setProperty should create new property with version 1', async () => {
896
- const mock = new MockAdapter(client.client);
897
- mock.onGet('/content/123/property/newkey').reply(404);
898
- mock.onPut('/content/123/property/newkey').reply((config) => {
899
- const body = JSON.parse(config.data);
900
- expect(body.version.number).toBe(1);
901
- expect(body.key).toBe('newkey');
902
- return [200, body];
903
- });
904
-
905
- const result = await client.setProperty('123', 'newkey', { data: true });
906
- expect(result.version.number).toBe(1);
907
-
908
- mock.restore();
909
- });
910
-
911
- test('setProperty should auto-increment version for existing property', async () => {
912
- const mock = new MockAdapter(client.client);
913
- mock.onGet('/content/123/property/existing').reply(200, {
914
- key: 'existing',
915
- value: 'old',
916
- version: { number: 5 }
917
- });
918
- mock.onPut('/content/123/property/existing').reply((config) => {
919
- const body = JSON.parse(config.data);
920
- expect(body.version.number).toBe(6);
921
- return [200, body];
922
- });
923
-
924
- const result = await client.setProperty('123', 'existing', 'new');
925
- expect(result.version.number).toBe(6);
926
-
927
- mock.restore();
928
- });
929
-
930
- test('setProperty should propagate non-404 errors', async () => {
931
- const mock = new MockAdapter(client.client);
932
- mock.onGet('/content/123/property/broken').reply(500);
933
-
934
- await expect(client.setProperty('123', 'broken', 'val')).rejects.toThrow();
935
-
936
- mock.restore();
937
- });
938
-
939
- test('deleteProperty should call delete endpoint', async () => {
940
- const mock = new MockAdapter(client.client);
941
- mock.onDelete('/content/123/property/color').reply(204);
942
-
943
- const result = await client.deleteProperty('123', 'color');
944
- expect(result).toEqual({ pageId: '123', key: 'color' });
945
-
946
- mock.restore();
947
- });
948
-
949
- test('getProperty should URL-encode keys with reserved characters', async () => {
950
- const mock = new MockAdapter(client.client);
951
- mock.onGet('/content/123/property/my%20prop%2Fkey').reply(200, {
952
- key: 'my prop/key',
953
- value: { ok: true },
954
- version: { number: 1 }
955
- });
956
-
957
- const result = await client.getProperty('123', 'my prop/key');
958
- expect(result.key).toBe('my prop/key');
959
- expect(result.value.ok).toBe(true);
960
-
961
- mock.restore();
962
- });
963
-
964
- test('setProperty should URL-encode keys with reserved characters', async () => {
965
- const mock = new MockAdapter(client.client);
966
- mock.onGet('/content/123/property/my%20prop%2Fkey').reply(404);
967
- mock.onPut('/content/123/property/my%20prop%2Fkey').reply((config) => {
968
- const body = JSON.parse(config.data);
969
- expect(body.key).toBe('my prop/key');
970
- expect(body.version.number).toBe(1);
971
- return [200, body];
972
- });
973
-
974
- const result = await client.setProperty('123', 'my prop/key', { test: true });
975
- expect(result.key).toBe('my prop/key');
976
-
977
- mock.restore();
978
- });
979
-
980
- test('deleteProperty should URL-encode keys with reserved characters', async () => {
981
- const mock = new MockAdapter(client.client);
982
- mock.onDelete('/content/123/property/my%20prop%2Fkey').reply(204);
983
-
984
- const result = await client.deleteProperty('123', 'my prop/key');
985
- expect(result).toEqual({ pageId: '123', key: 'my prop/key' });
986
-
987
- mock.restore();
988
- });
989
-
990
- test('deleteProperty should resolve page URLs', async () => {
991
- const mock = new MockAdapter(client.client);
992
- mock.onDelete('/content/789/property/status').reply(204);
993
-
994
- const result = await client.deleteProperty(
995
- 'https://test.atlassian.net/wiki/viewpage.action?pageId=789',
996
- 'status'
997
- );
998
- expect(result).toEqual({ pageId: '789', key: 'status' });
999
-
1000
- mock.restore();
1001
- });
1002
- });
1003
- });