confluence-cli 1.18.0 → 1.20.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.
@@ -31,8 +31,9 @@ jobs:
31
31
  runs-on: ubuntu-latest
32
32
  steps:
33
33
  - uses: actions/checkout@v3
34
- - name: Run npm audit
35
- run: npm audit --audit-level moderate
34
+ - run: npm ci
35
+ - name: Run npm audit (production only)
36
+ run: npm audit --audit-level moderate --omit=dev
36
37
 
37
38
  publish:
38
39
  needs: [test, security]
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # [1.20.0](https://github.com/pchuri/confluence-cli/compare/v1.19.0...v1.20.0) (2026-02-26)
2
+
3
+
4
+ ### Features
5
+
6
+ * add JSON output format to attachments command ([#45](https://github.com/pchuri/confluence-cli/issues/45)) ([b512ffb](https://github.com/pchuri/confluence-cli/commit/b512ffbea6cd083879f5030c10db72cef32302c2)), closes [#44](https://github.com/pchuri/confluence-cli/issues/44)
7
+
8
+ # [1.19.0](https://github.com/pchuri/confluence-cli/compare/v1.18.0...v1.19.0) (2026-02-20)
9
+
10
+
11
+ ### Features
12
+
13
+ * add --cql flag to search command for raw CQL queries ([#40](https://github.com/pchuri/confluence-cli/issues/40)) ([311f5a9](https://github.com/pchuri/confluence-cli/commit/311f5a98bfd175c6b7902c55b4dd0687b2a0d8c0)), closes [#39](https://github.com/pchuri/confluence-cli/issues/39)
14
+
1
15
  # [1.18.0](https://github.com/pchuri/confluence-cli/compare/v1.17.0...v1.18.0) (2026-02-15)
2
16
 
3
17
 
package/README.md CHANGED
@@ -68,7 +68,7 @@ npx confluence-cli
68
68
  confluence init
69
69
  ```
70
70
 
71
- The wizard helps you choose the right API endpoint and authentication method. It recommends `/wiki/rest/api` for Atlassian Cloud domains (e.g., `*.atlassian.net`) and `/rest/api` for self-hosted/Data Center instances, then prompts for Basic (email + token) or Bearer authentication.
71
+ The wizard helps you choose the right API endpoint and authentication method. It recommends `/wiki/rest/api` for Atlassian Cloud domains (e.g., `*.atlassian.net`) and `/rest/api` for self-hosted/Data Center instances, then prompts for Basic (email/username + token/password) or Bearer authentication.
72
72
 
73
73
  ### Option 2: Non-interactive Setup (CLI Flags)
74
74
 
@@ -97,16 +97,16 @@ confluence init --email "user@example.com" --token "your-api-token"
97
97
  - `-d, --domain <domain>` - Confluence domain (e.g., `company.atlassian.net`)
98
98
  - `-p, --api-path <path>` - REST API path (e.g., `/wiki/rest/api`)
99
99
  - `-a, --auth-type <type>` - Authentication type: `basic` or `bearer`
100
- - `-e, --email <email>` - Email for basic authentication
101
- - `-t, --token <token>` - API token
100
+ - `-e, --email <email>` - Email or username for basic authentication
101
+ - `-t, --token <token>` - API token or password
102
102
 
103
103
  ⚠️ **Security note:** While flags work, storing tokens in shell history is risky. Prefer environment variables (Option 3) for production environments.
104
104
 
105
105
  ### Option 3: Environment Variables
106
106
  ```bash
107
107
  export CONFLUENCE_DOMAIN="your-domain.atlassian.net"
108
- export CONFLUENCE_API_TOKEN="your-api-token"
109
- export CONFLUENCE_EMAIL="your.email@example.com" # required when using basic auth
108
+ export CONFLUENCE_API_TOKEN="your-api-token" # or password for on-premise (alias: CONFLUENCE_PASSWORD)
109
+ export CONFLUENCE_EMAIL="your.email@example.com" # required for basic auth (alias: CONFLUENCE_USERNAME for on-premise)
110
110
  export CONFLUENCE_API_PATH="/wiki/rest/api" # Cloud default; use /rest/api for Server/DC
111
111
  # Optional: set to 'bearer' for self-hosted/Data Center instances
112
112
  export CONFLUENCE_AUTH_TYPE="basic"
@@ -116,11 +116,14 @@ export CONFLUENCE_AUTH_TYPE="basic"
116
116
 
117
117
  ### Getting Your API Token
118
118
 
119
+ **Atlassian Cloud:**
119
120
  1. Go to [Atlassian Account Settings](https://id.atlassian.com/manage-profile/security/api-tokens)
120
121
  2. Click "Create API token"
121
122
  3. Give it a label (e.g., "confluence-cli")
122
123
  4. Copy the generated token
123
124
 
125
+ **On-premise / Data Center:** Use your Confluence username and password for basic authentication.
126
+
124
127
  ## Usage
125
128
 
126
129
  ### Read a Page
package/bin/confluence.js CHANGED
@@ -20,7 +20,7 @@ program
20
20
  .option('-d, --domain <domain>', 'Confluence domain')
21
21
  .option('-p, --api-path <path>', 'REST API path')
22
22
  .option('-a, --auth-type <type>', 'Authentication type (basic or bearer)')
23
- .option('-e, --email <email>', 'Email for basic auth')
23
+ .option('-e, --email <email>', 'Email or username for basic auth')
24
24
  .option('-t, --token <token>', 'API token')
25
25
  .action(async (options) => {
26
26
  await initConfig(options);
@@ -75,11 +75,12 @@ program
75
75
  .command('search <query>')
76
76
  .description('Search for Confluence pages')
77
77
  .option('-l, --limit <limit>', 'Limit number of results', '10')
78
+ .option('--cql', 'Pass query as raw CQL instead of text search')
78
79
  .action(async (query, options) => {
79
80
  const analytics = new Analytics();
80
81
  try {
81
82
  const client = new ConfluenceClient(getConfig());
82
- const results = await client.search(query, parseInt(options.limit));
83
+ const results = await client.search(query, parseInt(options.limit), options.cql);
83
84
 
84
85
  if (results.length === 0) {
85
86
  console.log(chalk.yellow('No results found.'));
@@ -418,6 +419,7 @@ program
418
419
  .option('-p, --pattern <glob>', 'Filter attachments by filename (e.g., "*.png")')
419
420
  .option('-d, --download', 'Download matching attachments')
420
421
  .option('--dest <directory>', 'Directory to save downloads (default: current directory)', '.')
422
+ .option('-f, --format <format>', 'Output format (text, json)', 'text')
421
423
  .action(async (pageId, options) => {
422
424
  const analytics = new Analytics();
423
425
  try {
@@ -430,22 +432,47 @@ program
430
432
  throw new Error('Limit must be a positive number.');
431
433
  }
432
434
 
435
+ const format = (options.format || 'text').toLowerCase();
436
+ if (!['text', 'json'].includes(format)) {
437
+ throw new Error('Format must be one of: text, json');
438
+ }
439
+
433
440
  const attachments = await client.getAllAttachments(pageId, { maxResults });
434
441
  const filtered = pattern ? attachments.filter(att => client.matchesPattern(att.title, pattern)) : attachments;
435
442
 
436
443
  if (filtered.length === 0) {
437
- console.log(chalk.yellow('No attachments found.'));
444
+ if (format === 'json') {
445
+ console.log(JSON.stringify({ attachmentCount: 0, attachments: [] }, null, 2));
446
+ } else {
447
+ console.log(chalk.yellow('No attachments found.'));
448
+ }
438
449
  analytics.track('attachments', true);
439
450
  return;
440
451
  }
441
452
 
442
- console.log(chalk.blue(`Found ${filtered.length} attachment${filtered.length === 1 ? '' : 's'}:`));
443
- filtered.forEach((att, index) => {
444
- const sizeKb = att.fileSize ? `${Math.max(1, Math.round(att.fileSize / 1024))} KB` : 'unknown size';
445
- const typeLabel = att.mediaType || 'unknown';
446
- console.log(`${index + 1}. ${chalk.green(att.title)} (ID: ${att.id})`);
447
- console.log(` Type: ${chalk.gray(typeLabel)} • Size: ${chalk.gray(sizeKb)} • Version: ${chalk.gray(att.version)}`);
448
- });
453
+ if (format === 'json' && !options.download) {
454
+ const output = {
455
+ attachmentCount: filtered.length,
456
+ attachments: filtered.map(att => ({
457
+ id: att.id,
458
+ title: att.title,
459
+ mediaType: att.mediaType || '',
460
+ fileSize: att.fileSize,
461
+ fileSizeFormatted: att.fileSize ? `${Math.max(1, Math.round(att.fileSize / 1024))} KB` : 'unknown size',
462
+ version: att.version,
463
+ downloadLink: att.downloadLink
464
+ }))
465
+ };
466
+ console.log(JSON.stringify(output, null, 2));
467
+ } else if (!options.download) {
468
+ console.log(chalk.blue(`Found ${filtered.length} attachment${filtered.length === 1 ? '' : 's'}:`));
469
+ filtered.forEach((att, index) => {
470
+ const sizeKb = att.fileSize ? `${Math.max(1, Math.round(att.fileSize / 1024))} KB` : 'unknown size';
471
+ const typeLabel = att.mediaType || 'unknown';
472
+ console.log(`${index + 1}. ${chalk.green(att.title)} (ID: ${att.id})`);
473
+ console.log(` Type: ${chalk.gray(typeLabel)} • Size: ${chalk.gray(sizeKb)} • Version: ${chalk.gray(att.version)}`);
474
+ });
475
+ }
449
476
 
450
477
  if (options.download) {
451
478
  const fs = require('fs');
@@ -474,17 +501,28 @@ program
474
501
  writer.on('finish', resolve);
475
502
  });
476
503
 
477
- let downloaded = 0;
504
+ const downloadResults = [];
478
505
  for (const attachment of filtered) {
479
506
  const targetPath = uniquePathFor(destDir, attachment.title);
480
- // Pass the full attachment object so downloadAttachment can use downloadLink directly
481
507
  const dataStream = await client.downloadAttachment(pageId, attachment);
482
508
  await writeStream(dataStream, targetPath);
483
- downloaded += 1;
484
- console.log(`⬇️ ${chalk.green(attachment.title)} -> ${chalk.gray(targetPath)}`);
509
+ downloadResults.push({ title: attachment.title, id: attachment.id, savedTo: targetPath });
510
+ if (format !== 'json') {
511
+ console.log(`⬇️ ${chalk.green(attachment.title)} -> ${chalk.gray(targetPath)}`);
512
+ }
485
513
  }
486
514
 
487
- console.log(chalk.green(`Downloaded ${downloaded} attachment${downloaded === 1 ? '' : 's'} to ${destDir}`));
515
+ if (format === 'json') {
516
+ const output = {
517
+ attachmentCount: filtered.length,
518
+ downloaded: downloadResults.length,
519
+ destination: destDir,
520
+ attachments: downloadResults
521
+ };
522
+ console.log(JSON.stringify(output, null, 2));
523
+ } else {
524
+ console.log(chalk.green(`Downloaded ${downloadResults.length} attachment${downloadResults.length === 1 ? '' : 's'} to ${destDir}`));
525
+ }
488
526
  }
489
527
 
490
528
  analytics.track('attachments', true);
package/lib/config.js CHANGED
@@ -8,7 +8,7 @@ const CONFIG_DIR = path.join(os.homedir(), '.confluence-cli');
8
8
  const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
9
9
 
10
10
  const AUTH_CHOICES = [
11
- { name: 'Basic (email + API token)', value: 'basic' },
11
+ { name: 'Basic (credentials)', value: 'basic' },
12
12
  { name: 'Bearer token', value: 'bearer' }
13
13
  ];
14
14
 
@@ -91,7 +91,7 @@ const validateCliOptions = (options) => {
91
91
  // Check if basic auth is provided with email
92
92
  const normAuthType = options.authType ? normalizeAuthType(options.authType, Boolean(options.email)) : null;
93
93
  if (normAuthType === 'basic' && !options.email) {
94
- errors.push('--email is required when using basic authentication');
94
+ errors.push('--email is required when using basic authentication (use your username for on-premise)');
95
95
  }
96
96
 
97
97
  return errors;
@@ -175,12 +175,12 @@ const promptForMissingValues = async (providedValues) => {
175
175
  questions.push({
176
176
  type: 'input',
177
177
  name: 'email',
178
- message: 'Confluence email (used with API token):',
178
+ message: 'Email / username:',
179
179
  when: (responses) => {
180
180
  const authType = providedValues.authType || responses.authType;
181
181
  return authType === 'basic';
182
182
  },
183
- validate: requiredInput('Email')
183
+ validate: requiredInput('Email / username')
184
184
  });
185
185
  }
186
186
 
@@ -189,8 +189,8 @@ const promptForMissingValues = async (providedValues) => {
189
189
  questions.push({
190
190
  type: 'password',
191
191
  name: 'token',
192
- message: 'API Token:',
193
- validate: requiredInput('API Token')
192
+ message: 'API token / password:',
193
+ validate: requiredInput('API token / password')
194
194
  });
195
195
  }
196
196
 
@@ -258,15 +258,15 @@ async function initConfig(cliOptions = {}) {
258
258
  {
259
259
  type: 'input',
260
260
  name: 'email',
261
- message: 'Confluence email (used with API token):',
261
+ message: 'Email / username:',
262
262
  when: (responses) => responses.authType === 'basic',
263
- validate: requiredInput('Email')
263
+ validate: requiredInput('Email / username')
264
264
  },
265
265
  {
266
266
  type: 'password',
267
267
  name: 'token',
268
- message: 'API Token:',
269
- validate: requiredInput('API Token')
268
+ message: 'API token / password:',
269
+ validate: requiredInput('API token / password')
270
270
  }
271
271
  ]);
272
272
 
@@ -351,8 +351,8 @@ async function initConfig(cliOptions = {}) {
351
351
 
352
352
  function getConfig() {
353
353
  const envDomain = process.env.CONFLUENCE_DOMAIN || process.env.CONFLUENCE_HOST;
354
- const envToken = process.env.CONFLUENCE_API_TOKEN;
355
- const envEmail = process.env.CONFLUENCE_EMAIL;
354
+ const envToken = process.env.CONFLUENCE_API_TOKEN || process.env.CONFLUENCE_PASSWORD;
355
+ const envEmail = process.env.CONFLUENCE_EMAIL || process.env.CONFLUENCE_USERNAME;
356
356
  const envAuthType = process.env.CONFLUENCE_AUTH_TYPE;
357
357
  const envApiPath = process.env.CONFLUENCE_API_PATH;
358
358
 
@@ -368,8 +368,8 @@ function getConfig() {
368
368
  }
369
369
 
370
370
  if (authType === 'basic' && !envEmail) {
371
- console.error(chalk.red('❌ Basic authentication requires CONFLUENCE_EMAIL.'));
372
- console.log(chalk.yellow('Set CONFLUENCE_EMAIL or switch to bearer auth by setting CONFLUENCE_AUTH_TYPE=bearer.'));
371
+ console.error(chalk.red('❌ Basic authentication requires CONFLUENCE_EMAIL or CONFLUENCE_USERNAME.'));
372
+ console.log(chalk.yellow('Set CONFLUENCE_EMAIL (or CONFLUENCE_USERNAME for on-premise) or switch to bearer auth by setting CONFLUENCE_AUTH_TYPE=bearer.'));
373
373
  process.exit(1);
374
374
  }
375
375
 
@@ -385,7 +385,7 @@ function getConfig() {
385
385
  if (!fs.existsSync(CONFIG_FILE)) {
386
386
  console.error(chalk.red('❌ No configuration found!'));
387
387
  console.log(chalk.yellow('Please run "confluence init" to set up your configuration.'));
388
- console.log(chalk.gray('Or set environment variables: CONFLUENCE_DOMAIN, CONFLUENCE_API_TOKEN, CONFLUENCE_EMAIL, and optionally CONFLUENCE_API_PATH.'));
388
+ console.log(chalk.gray('Or set environment variables: CONFLUENCE_DOMAIN, CONFLUENCE_API_TOKEN (or CONFLUENCE_PASSWORD), CONFLUENCE_EMAIL (or CONFLUENCE_USERNAME), and optionally CONFLUENCE_API_PATH.'));
389
389
  process.exit(1);
390
390
  }
391
391
 
@@ -404,8 +404,8 @@ function getConfig() {
404
404
  }
405
405
 
406
406
  if (authType === 'basic' && !trimmedEmail) {
407
- console.error(chalk.red('❌ Basic authentication requires an email address.'));
408
- console.log(chalk.yellow('Please rerun "confluence init" to add your Confluence email.'));
407
+ console.error(chalk.red('❌ Basic authentication requires an email address or username.'));
408
+ console.log(chalk.yellow('Please rerun "confluence init" to add your Confluence email or username.'));
409
409
  process.exit(1);
410
410
  }
411
411
 
@@ -42,7 +42,7 @@ class ConfluenceClient {
42
42
 
43
43
  buildBasicAuthHeader() {
44
44
  if (!this.email) {
45
- throw new Error('Basic authentication requires an email address.');
45
+ throw new Error('Basic authentication requires an email address or username.');
46
46
  }
47
47
 
48
48
  const encodedCredentials = Buffer.from(`${this.email}:${this.token}`).toString('base64');
@@ -221,10 +221,11 @@ class ConfluenceClient {
221
221
  /**
222
222
  * Search for pages
223
223
  */
224
- async search(query, limit = 10) {
224
+ async search(query, limit = 10, rawCql = false) {
225
+ const cql = rawCql ? query : `text ~ "${String(query).replace(/"/g, '\\"')}"`;
225
226
  const response = await this.client.get('/search', {
226
227
  params: {
227
- cql: `text ~ "${query}"`,
228
+ cql,
228
229
  limit: limit
229
230
  }
230
231
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "confluence-cli",
3
- "version": "1.18.0",
3
+ "version": "1.20.0",
4
4
  "description": "A command-line interface for Atlassian Confluence with page creation and editing capabilities",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -0,0 +1,79 @@
1
+ const { getConfig } = require('../lib/config');
2
+
3
+ // Save and restore all relevant env vars around each test
4
+ const ENV_KEYS = [
5
+ 'CONFLUENCE_DOMAIN', 'CONFLUENCE_HOST',
6
+ 'CONFLUENCE_API_TOKEN', 'CONFLUENCE_PASSWORD',
7
+ 'CONFLUENCE_EMAIL', 'CONFLUENCE_USERNAME',
8
+ 'CONFLUENCE_AUTH_TYPE', 'CONFLUENCE_API_PATH'
9
+ ];
10
+
11
+ describe('getConfig env var aliases', () => {
12
+ const saved = {};
13
+
14
+ beforeEach(() => {
15
+ for (const key of ENV_KEYS) {
16
+ saved[key] = process.env[key];
17
+ delete process.env[key];
18
+ }
19
+ });
20
+
21
+ afterEach(() => {
22
+ for (const key of ENV_KEYS) {
23
+ if (saved[key] !== undefined) {
24
+ process.env[key] = saved[key];
25
+ } else {
26
+ delete process.env[key];
27
+ }
28
+ }
29
+ });
30
+
31
+ test('CONFLUENCE_USERNAME is used when CONFLUENCE_EMAIL is not set', () => {
32
+ process.env.CONFLUENCE_DOMAIN = 'on-prem.example.com';
33
+ process.env.CONFLUENCE_PASSWORD = 'secret';
34
+ process.env.CONFLUENCE_USERNAME = 'admin';
35
+ process.env.CONFLUENCE_AUTH_TYPE = 'basic';
36
+
37
+ const config = getConfig();
38
+ expect(config.email).toBe('admin');
39
+ expect(config.token).toBe('secret');
40
+ });
41
+
42
+ test('CONFLUENCE_EMAIL takes priority over CONFLUENCE_USERNAME', () => {
43
+ process.env.CONFLUENCE_DOMAIN = 'cloud.atlassian.net';
44
+ process.env.CONFLUENCE_API_TOKEN = 'api-token';
45
+ process.env.CONFLUENCE_EMAIL = 'user@example.com';
46
+ process.env.CONFLUENCE_USERNAME = 'admin';
47
+ process.env.CONFLUENCE_AUTH_TYPE = 'basic';
48
+
49
+ const config = getConfig();
50
+ expect(config.email).toBe('user@example.com');
51
+ });
52
+
53
+ test('CONFLUENCE_PASSWORD is used when CONFLUENCE_API_TOKEN is not set', () => {
54
+ process.env.CONFLUENCE_DOMAIN = 'on-prem.example.com';
55
+ process.env.CONFLUENCE_PASSWORD = 'my-password';
56
+ process.env.CONFLUENCE_USERNAME = 'admin';
57
+ process.env.CONFLUENCE_AUTH_TYPE = 'basic';
58
+
59
+ const config = getConfig();
60
+ expect(config.token).toBe('my-password');
61
+ });
62
+
63
+ test('CONFLUENCE_API_TOKEN takes priority over CONFLUENCE_PASSWORD', () => {
64
+ process.env.CONFLUENCE_DOMAIN = 'cloud.atlassian.net';
65
+ process.env.CONFLUENCE_API_TOKEN = 'api-token';
66
+ process.env.CONFLUENCE_PASSWORD = 'password';
67
+
68
+ const config = getConfig();
69
+ expect(config.token).toBe('api-token');
70
+ });
71
+
72
+ test('CONFLUENCE_HOST alias still works for domain', () => {
73
+ process.env.CONFLUENCE_HOST = 'host.example.com';
74
+ process.env.CONFLUENCE_API_TOKEN = 'token';
75
+
76
+ const config = getConfig();
77
+ expect(config.domain).toBe('host.example.com');
78
+ });
79
+ });
@@ -97,7 +97,7 @@ describe('ConfluenceClient', () => {
97
97
  domain: 'test.atlassian.net',
98
98
  token: 'missing-email',
99
99
  authType: 'basic'
100
- })).toThrow('Basic authentication requires an email address.');
100
+ })).toThrow('Basic authentication requires an email address or username.');
101
101
  });
102
102
  });
103
103
 
@@ -303,6 +303,68 @@ describe('ConfluenceClient', () => {
303
303
  });
304
304
  });
305
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
+
306
368
  describe('page creation and updates', () => {
307
369
  test('should have required methods for page management', () => {
308
370
  expect(typeof client.createPage).toBe('function');
@@ -709,6 +771,66 @@ describe('ConfluenceClient', () => {
709
771
  }
710
772
  });
711
773
 
774
+ test('normalizeAttachment should return all fields needed for JSON output', () => {
775
+ const raw = {
776
+ id: '101',
777
+ title: 'diagram.png',
778
+ metadata: { mediaType: 'image/png' },
779
+ extensions: { fileSize: 204800 },
780
+ version: { number: 3 },
781
+ _links: { download: '/download/attachments/123/diagram.png' }
782
+ };
783
+ const result = client.normalizeAttachment(raw);
784
+ expect(result).toEqual({
785
+ id: '101',
786
+ title: 'diagram.png',
787
+ mediaType: 'image/png',
788
+ fileSize: 204800,
789
+ version: 3,
790
+ downloadLink: expect.stringContaining('/download/attachments/123/diagram.png')
791
+ });
792
+ });
793
+
794
+ test('normalizeAttachment should handle missing metadata gracefully', () => {
795
+ const raw = {
796
+ id: '102',
797
+ title: 'readme.txt',
798
+ version: { number: 1 },
799
+ _links: {}
800
+ };
801
+ const result = client.normalizeAttachment(raw);
802
+ expect(result.mediaType).toBe('');
803
+ expect(result.fileSize).toBe(0);
804
+ expect(result.version).toBe(1);
805
+ expect(result.downloadLink).toBeNull();
806
+ });
807
+
808
+ test('getAllAttachments should return normalized attachment objects', async () => {
809
+ const mock = new MockAdapter(client.client);
810
+ mock.onGet('/content/123/child/attachment').reply(200, {
811
+ results: [{
812
+ id: '201',
813
+ title: 'report.pdf',
814
+ metadata: { mediaType: 'application/pdf' },
815
+ extensions: { fileSize: 512000 },
816
+ version: { number: 2 },
817
+ _links: { download: '/download/attachments/123/report.pdf' }
818
+ }],
819
+ _links: {}
820
+ });
821
+
822
+ const attachments = await client.getAllAttachments('123');
823
+ expect(attachments).toHaveLength(1);
824
+ expect(attachments[0]).toHaveProperty('id', '201');
825
+ expect(attachments[0]).toHaveProperty('title', 'report.pdf');
826
+ expect(attachments[0]).toHaveProperty('mediaType', 'application/pdf');
827
+ expect(attachments[0]).toHaveProperty('fileSize', 512000);
828
+ expect(attachments[0]).toHaveProperty('version', 2);
829
+ expect(attachments[0]).toHaveProperty('downloadLink');
830
+
831
+ mock.restore();
832
+ });
833
+
712
834
  test('deleteAttachment should call delete endpoint', async () => {
713
835
  const mock = new MockAdapter(client.client);
714
836
  mock.onDelete('/content/123/child/attachment/999').reply(204);