@aaronshaf/confluence-cli 0.1.15 → 1.0.1

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.
@@ -0,0 +1,182 @@
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
+ import { createValidFolder, createValidSpace } from './msw-schema-validation.js';
6
+
7
+ const testConfig = {
8
+ confluenceUrl: 'https://test.atlassian.net',
9
+ email: 'test@example.com',
10
+ apiToken: 'test-token',
11
+ };
12
+
13
+ describe('cn folder - API layer', () => {
14
+ describe('createFolder', () => {
15
+ test('creates a folder with spaceId resolved from getSpaceByKey', async () => {
16
+ let capturedBody: unknown;
17
+
18
+ server.use(
19
+ http.get('*/wiki/api/v2/spaces', ({ request }) => {
20
+ const url = new URL(request.url);
21
+ const keys = url.searchParams.get('keys');
22
+ return HttpResponse.json({ results: [createValidSpace({ id: 'space-456', key: keys ?? 'DOCS' })] });
23
+ }),
24
+ http.post('*/wiki/api/v2/folders', async ({ request }) => {
25
+ capturedBody = await request.json();
26
+ return HttpResponse.json(createValidFolder({ id: 'folder-new', title: 'My Folder' }));
27
+ }),
28
+ );
29
+
30
+ const client = new ConfluenceClient(testConfig);
31
+ const space = await client.getSpaceByKey('DOCS');
32
+ const folder = await client.createFolder({ spaceId: space.id, title: 'My Folder' });
33
+
34
+ expect(space.id).toBe('space-456');
35
+ expect(folder.id).toBe('folder-new');
36
+ expect(folder.title).toBe('My Folder');
37
+ expect((capturedBody as { spaceId: string }).spaceId).toBe('space-456');
38
+ });
39
+
40
+ test('creates a folder with a parentId', async () => {
41
+ let capturedBody: unknown;
42
+
43
+ server.use(
44
+ http.post('*/wiki/api/v2/folders', async ({ request }) => {
45
+ capturedBody = await request.json();
46
+ return HttpResponse.json(
47
+ createValidFolder({ id: 'folder-child', title: 'Child Folder', parentId: 'folder-parent' }),
48
+ );
49
+ }),
50
+ );
51
+
52
+ const client = new ConfluenceClient(testConfig);
53
+ await client.createFolder({ spaceId: 'space-123', title: 'Child Folder', parentId: 'folder-parent' });
54
+
55
+ expect((capturedBody as { parentId: string }).parentId).toBe('folder-parent');
56
+ });
57
+ });
58
+
59
+ describe('deleteFolder', () => {
60
+ test('deletes a folder by ID', async () => {
61
+ let deletedId = '';
62
+
63
+ server.use(
64
+ http.delete('*/wiki/api/v2/folders/:folderId', ({ params }) => {
65
+ deletedId = params.folderId as string;
66
+ return new HttpResponse(null, { status: 204 });
67
+ }),
68
+ );
69
+
70
+ const client = new ConfluenceClient(testConfig);
71
+ await client.deleteFolder('folder-123');
72
+
73
+ expect(deletedId).toBe('folder-123');
74
+ });
75
+
76
+ test('throws FolderNotFoundError for 404', async () => {
77
+ server.use(
78
+ http.delete('*/wiki/api/v2/folders/:folderId', () => {
79
+ return HttpResponse.json({ message: 'Not found' }, { status: 404 });
80
+ }),
81
+ );
82
+
83
+ const client = new ConfluenceClient(testConfig);
84
+ await expect(client.deleteFolder('nonexistent')).rejects.toThrow();
85
+ });
86
+ });
87
+
88
+ describe('folder list pagination', () => {
89
+ test('fetches a single page when total fits in one request', async () => {
90
+ server.use(
91
+ http.get('*/wiki/rest/api/search', ({ request }) => {
92
+ const url = new URL(request.url);
93
+ const start = Number(url.searchParams.get('start') ?? 0);
94
+ if (start === 0) {
95
+ return HttpResponse.json({
96
+ results: [
97
+ { content: { id: 'f1', type: 'folder', title: 'Folder 1' } },
98
+ { content: { id: 'f2', type: 'folder', title: 'Folder 2' } },
99
+ ],
100
+ totalSize: 2,
101
+ });
102
+ }
103
+ return HttpResponse.json({ results: [], totalSize: 2 });
104
+ }),
105
+ );
106
+
107
+ const client = new ConfluenceClient(testConfig);
108
+ const page1 = await client.search('type=folder AND space="TEST"', 100, 0);
109
+ expect(page1.results).toHaveLength(2);
110
+ expect(page1.totalSize).toBe(2);
111
+ });
112
+
113
+ test('paginates when totalSize exceeds page size', async () => {
114
+ const allFolders = Array.from({ length: 150 }, (_, i) => ({
115
+ content: { id: `f${i}`, type: 'folder', title: `Folder ${i}` },
116
+ }));
117
+
118
+ server.use(
119
+ http.get('*/wiki/rest/api/search', ({ request }) => {
120
+ const url = new URL(request.url);
121
+ const start = Number(url.searchParams.get('start') ?? 0);
122
+ const limit = Number(url.searchParams.get('limit') ?? 10);
123
+ const slice = allFolders.slice(start, start + limit);
124
+ return HttpResponse.json({ results: slice, totalSize: 150 });
125
+ }),
126
+ );
127
+
128
+ const client = new ConfluenceClient(testConfig);
129
+ const collected = [];
130
+ let start = 0;
131
+ const PAGE_SIZE = 100;
132
+
133
+ const first = await client.search('type=folder AND space="TEST"', PAGE_SIZE, start);
134
+ collected.push(...first.results);
135
+ const total = first.totalSize ?? first.results.length;
136
+
137
+ while (collected.length < total) {
138
+ start += PAGE_SIZE;
139
+ const page = await client.search('type=folder AND space="TEST"', PAGE_SIZE, start);
140
+ if (page.results.length === 0) break;
141
+ collected.push(...page.results);
142
+ }
143
+
144
+ expect(collected).toHaveLength(150);
145
+ });
146
+ });
147
+
148
+ describe('getPositionals (arg parsing)', () => {
149
+ // Test the arg parsing logic directly via the folderCommand behavior
150
+ test('search passes start param in URL', async () => {
151
+ const capturedUrls: string[] = [];
152
+
153
+ server.use(
154
+ http.get('*/wiki/rest/api/search', ({ request }) => {
155
+ capturedUrls.push(request.url);
156
+ return HttpResponse.json({ results: [], totalSize: 0 });
157
+ }),
158
+ );
159
+
160
+ const client = new ConfluenceClient(testConfig);
161
+ await client.search('type=folder', 100, 42);
162
+
163
+ expect(capturedUrls[0]).toContain('start=42');
164
+ });
165
+
166
+ test('search passes limit param in URL', async () => {
167
+ const capturedUrls: string[] = [];
168
+
169
+ server.use(
170
+ http.get('*/wiki/rest/api/search', ({ request }) => {
171
+ capturedUrls.push(request.url);
172
+ return HttpResponse.json({ results: [], totalSize: 0 });
173
+ }),
174
+ );
175
+
176
+ const client = new ConfluenceClient(testConfig);
177
+ await client.search('type=folder', 50, 0);
178
+
179
+ expect(capturedUrls[0]).toContain('limit=50');
180
+ });
181
+ });
182
+ });
@@ -26,6 +26,18 @@ import {
26
26
  * Individual tests should override these with server.use() for specific test scenarios.
27
27
  */
28
28
  export const handlers = [
29
+ // Confluence v1 spaces mock (offset-based pagination)
30
+ http.get('*/wiki/rest/api/space', ({ request }) => {
31
+ const url = new URL(request.url);
32
+ const limit = Number(url.searchParams.get('limit') ?? 25);
33
+ const start = Number(url.searchParams.get('start') ?? 0);
34
+ const spaces = [
35
+ { id: 1, key: 'TEST', name: 'Test Space', type: 'global' },
36
+ { id: 2, key: 'DOCS', name: 'Documentation', type: 'global' },
37
+ ].slice(start, start + limit);
38
+ return HttpResponse.json({ results: spaces, start, limit, size: spaces.length });
39
+ }),
40
+
29
41
  // Confluence spaces mock
30
42
  http.get('*/wiki/api/v2/spaces', ({ request }) => {
31
43
  const url = new URL(request.url);
@@ -42,7 +42,7 @@ describe('ConfluenceClient - spaces', () => {
42
42
 
43
43
  test('throws on 401', async () => {
44
44
  server.use(
45
- http.get('*/wiki/api/v2/spaces', () => {
45
+ http.get('*/wiki/rest/api/space', () => {
46
46
  return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 });
47
47
  }),
48
48
  );
@@ -0,0 +1,115 @@
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
+ import { createValidPage } from './msw-schema-validation.js';
6
+ import { findPositional } from '../cli/utils/args.js';
7
+ import { isValidFormat, VALID_FORMATS } from '../cli/utils/stdin.js';
8
+
9
+ const testConfig = {
10
+ confluenceUrl: 'https://test.atlassian.net',
11
+ email: 'test@example.com',
12
+ apiToken: 'test-token',
13
+ };
14
+
15
+ describe('ConfluenceClient - updatePage', () => {
16
+ test('updates page successfully', async () => {
17
+ server.use(
18
+ http.put('*/wiki/api/v2/pages/:pageId', async ({ request, params }) => {
19
+ const body = (await request.json()) as { title: string; body: { value: string } };
20
+ const page = createValidPage({
21
+ id: params.pageId as string,
22
+ title: body.title,
23
+ version: 2,
24
+ });
25
+ return HttpResponse.json(page);
26
+ }),
27
+ );
28
+
29
+ const client = new ConfluenceClient(testConfig);
30
+ const result = await client.updatePage({
31
+ id: 'page-123',
32
+ status: 'current',
33
+ title: 'Updated Title',
34
+ body: { representation: 'storage', value: '<p>New content</p>' },
35
+ version: { number: 2 },
36
+ });
37
+
38
+ expect(result.id).toBe('page-123');
39
+ });
40
+
41
+ test('fetches page without body when includeBody=false', async () => {
42
+ let requestUrl = '';
43
+ server.use(
44
+ http.get('*/wiki/api/v2/pages/:pageId', ({ request }) => {
45
+ requestUrl = request.url;
46
+ const page = createValidPage({ id: 'page-123', version: 3 });
47
+ return HttpResponse.json(page);
48
+ }),
49
+ );
50
+
51
+ const client = new ConfluenceClient(testConfig);
52
+ await client.getPage('page-123', false);
53
+ expect(requestUrl).not.toContain('body-format');
54
+ });
55
+
56
+ test('throws on 404', async () => {
57
+ server.use(
58
+ http.put('*/wiki/api/v2/pages/:pageId', () => {
59
+ return HttpResponse.json({ message: 'Not found' }, { status: 404 });
60
+ }),
61
+ );
62
+
63
+ const client = new ConfluenceClient(testConfig);
64
+ await expect(
65
+ client.updatePage({
66
+ id: 'nonexistent',
67
+ status: 'current',
68
+ title: 'Title',
69
+ body: { representation: 'storage', value: '<p>x</p>' },
70
+ version: { number: 2 },
71
+ }),
72
+ ).rejects.toThrow();
73
+ });
74
+ });
75
+
76
+ describe('format validation', () => {
77
+ test('accepts valid formats', () => {
78
+ for (const fmt of VALID_FORMATS) {
79
+ expect(isValidFormat(fmt)).toBe(true);
80
+ }
81
+ });
82
+
83
+ test('rejects markdown', () => {
84
+ expect(isValidFormat('markdown')).toBe(false);
85
+ });
86
+
87
+ test('rejects unknown format', () => {
88
+ expect(isValidFormat('html')).toBe(false);
89
+ });
90
+ });
91
+
92
+ describe('findPositional', () => {
93
+ test('finds simple positional arg', () => {
94
+ expect(findPositional(['123456'], ['--format', '--title'])).toBe('123456');
95
+ });
96
+
97
+ test('skips flag and its value, not confusing them with positional', () => {
98
+ // cn update --title 123 123 => subArgs: ['--title', '123', '123'] => positional is second 123
99
+ expect(findPositional(['--title', '123', '123'], ['--title'])).toBe('123');
100
+ });
101
+
102
+ test('id equals title value — skips by index not value', () => {
103
+ // cn update 123 --title 123 => subArgs: ['123', '--title', '123'] => positional is first 123
104
+ expect(findPositional(['123', '--title', '123'], ['--title'])).toBe('123');
105
+ });
106
+
107
+ test('returns undefined when no positional', () => {
108
+ expect(findPositional(['--title', 'Some Title'], ['--title'])).toBeUndefined();
109
+ });
110
+
111
+ test('create: title equals space value', () => {
112
+ // cn create ENG --space ENG => subArgs: ['ENG', '--space', 'ENG'] => positional is ENG
113
+ expect(findPositional(['ENG', '--space', 'ENG'], ['--space', '--parent', '--format'])).toBe('ENG');
114
+ });
115
+ });
@@ -1,89 +0,0 @@
1
- import { confirm } from '@inquirer/prompts';
2
- import chalk from 'chalk';
3
- import {
4
- checkFileForDuplicates,
5
- findBestDuplicate,
6
- findDuplicatePageIds,
7
- scanFilesForHealthCheck,
8
- type DuplicatePageId,
9
- } from '../../lib/health-check.js';
10
-
11
- /**
12
- * Display duplicate page_id information
13
- */
14
- export function displayDuplicates(duplicates: DuplicatePageId[]): void {
15
- console.log(chalk.red('Duplicate page_ids detected:'));
16
- for (const dup of duplicates) {
17
- const best = findBestDuplicate(dup.files);
18
- console.log(chalk.yellow(`\n page_id: ${dup.pageId}`));
19
- for (const file of dup.files) {
20
- const isBest = file.path === best.path;
21
- const marker = isBest ? chalk.green(' (keep)') : chalk.red(' (stale)');
22
- const version = file.version ? `v${file.version}` : 'v?';
23
- console.log(` ${isBest ? chalk.green('*') : chalk.red('x')} ${file.path}${marker} - ${version}`);
24
- }
25
- }
26
- console.log('');
27
- }
28
-
29
- /**
30
- * Check for duplicate page_ids before push and prompt for confirmation
31
- * Returns true if push should continue, false if user cancelled
32
- */
33
- export async function checkDuplicatesBeforePush(directory: string): Promise<boolean> {
34
- const allFiles = scanFilesForHealthCheck(directory);
35
- const duplicates = findDuplicatePageIds(allFiles);
36
-
37
- if (duplicates.length === 0) {
38
- return true;
39
- }
40
-
41
- displayDuplicates(duplicates);
42
-
43
- const shouldContinue = await confirm({
44
- message: 'Continue with push? (Stale files may cause version conflicts)',
45
- default: false,
46
- });
47
-
48
- if (!shouldContinue) {
49
- console.log(chalk.gray('Run "cn doctor" to fix duplicate page_ids.'));
50
- return false;
51
- }
52
- console.log('');
53
- return true;
54
- }
55
-
56
- /**
57
- * Display version conflict guidance, checking for duplicates that may explain the conflict
58
- * This is called when a version mismatch is detected during push
59
- */
60
- export function displayVersionConflictGuidance(directory: string, relativePath: string, remoteVersion: number): void {
61
- const { hasDuplicates, duplicates } = checkFileForDuplicates(directory, relativePath);
62
-
63
- if (hasDuplicates) {
64
- const newerDuplicate = duplicates.find((d) => (d.version || 0) >= remoteVersion);
65
- if (newerDuplicate) {
66
- console.log(chalk.yellow('Found another file with the same page_id:'));
67
- console.log(chalk.yellow(` ${newerDuplicate.path} (v${newerDuplicate.version || '?'})`));
68
- console.log('');
69
- console.log(chalk.gray('This usually happens when a page was moved on Confluence.'));
70
- console.log(chalk.gray(`The file at ${relativePath} appears to be stale.`));
71
- console.log('');
72
- console.log(chalk.gray('Recommended actions:'));
73
- console.log(chalk.gray(` 1. Delete the stale file: rm "${relativePath}"`));
74
- console.log(chalk.gray(` 2. Or run "cn doctor" to fix all duplicates`));
75
- } else {
76
- console.log(chalk.yellow('Found duplicate files with the same page_id:'));
77
- for (const dup of duplicates) {
78
- console.log(chalk.yellow(` ${dup.path} (v${dup.version || '?'})`));
79
- }
80
- console.log('');
81
- console.log(chalk.gray('Run "cn doctor" to identify and fix duplicates.'));
82
- }
83
- } else {
84
- console.log(chalk.yellow('The page has been modified on Confluence since your last pull.'));
85
- console.log(chalk.gray('Options:'));
86
- console.log(chalk.gray(` - Run "cn pull --page ${relativePath}" to get the latest version`));
87
- console.log(chalk.gray(` - Run "cn push ${relativePath} --force" to overwrite remote changes`));
88
- }
89
- }
@@ -1,113 +0,0 @@
1
- /**
2
- * File rename utilities for push operations
3
- */
4
-
5
- import { existsSync, mkdtempSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
6
- import { tmpdir } from 'node:os';
7
- import { basename, dirname, join } from 'node:path';
8
- import chalk from 'chalk';
9
- import { slugify, updateReferencesAfterRename } from '../../lib/markdown/index.js';
10
-
11
- // Index files that should not be renamed based on title (case-insensitive check)
12
- const INDEX_FILES = new Set(['index.md', 'readme.md']);
13
-
14
- /**
15
- * Result of file rename operation
16
- */
17
- export interface RenameResult {
18
- finalPath: string;
19
- wasRenamed: boolean;
20
- }
21
-
22
- /**
23
- * Handle file renaming when title changes
24
- * Returns the final local path for updating sync state
25
- * Uses atomic operations: writes to temp file first, then renames
26
- * Also updates references in other markdown files when a rename occurs
27
- */
28
- export function handleFileRename(
29
- filePath: string,
30
- originalRelativePath: string,
31
- expectedTitle: string,
32
- updatedMarkdown: string,
33
- spaceRoot?: string,
34
- ): RenameResult {
35
- const currentFilename = basename(filePath);
36
- const currentDir = dirname(filePath);
37
- const expectedSlug = slugify(expectedTitle);
38
- const expectedFilename = `${expectedSlug}.md`;
39
- // Track the current relative path (will be updated if file is renamed)
40
- const currentRelativePath = originalRelativePath.replace(/^\.\//, '');
41
- let finalLocalPath = currentRelativePath;
42
-
43
- const isIndexFile = INDEX_FILES.has(currentFilename.toLowerCase());
44
-
45
- // Write to temp file first for atomicity
46
- const tempDir = mkdtempSync(join(tmpdir(), 'cn-push-'));
47
- const tempFile = join(tempDir, 'temp.md');
48
- writeFileSync(tempFile, updatedMarkdown, 'utf-8');
49
-
50
- try {
51
- if (!isIndexFile && expectedFilename !== currentFilename && expectedSlug) {
52
- const newFilePath = join(currentDir, expectedFilename);
53
-
54
- if (existsSync(newFilePath)) {
55
- console.log(chalk.yellow(` Note: Keeping filename "${currentFilename}" (${expectedFilename} already exists)`));
56
- // Atomic rename: temp file -> original file
57
- renameSync(tempFile, filePath);
58
- return { finalPath: finalLocalPath, wasRenamed: false };
59
- }
60
-
61
- // Warn user about automatic rename
62
- console.log(chalk.cyan(` Note: File will be renamed to match page title`));
63
-
64
- // Atomic operations: remove old file, move temp to new location
65
- const backupPath = `${filePath}.bak`;
66
- renameSync(filePath, backupPath);
67
- try {
68
- renameSync(tempFile, newFilePath);
69
- // Clean up backup only after successful rename
70
- try {
71
- unlinkSync(backupPath);
72
- } catch {
73
- // Ignore cleanup errors
74
- }
75
- } catch (error) {
76
- // Restore from backup if rename fails
77
- try {
78
- renameSync(backupPath, filePath);
79
- } catch {
80
- // If restore fails, log the backup location
81
- console.error(chalk.red(` Error: Failed to rename file. Backup available at: ${backupPath}`));
82
- }
83
- throw error;
84
- }
85
-
86
- const relativeDir = dirname(currentRelativePath);
87
- const newRelativePath = relativeDir === '.' ? expectedFilename : join(relativeDir, expectedFilename);
88
- finalLocalPath = newRelativePath;
89
- console.log(chalk.cyan(` Renamed: ${currentFilename} → ${expectedFilename}`));
90
-
91
- // Update references in other markdown files that link to the old filename
92
- if (spaceRoot) {
93
- const updatedFiles = updateReferencesAfterRename(spaceRoot, currentRelativePath, newRelativePath);
94
- if (updatedFiles.length > 0) {
95
- console.log(chalk.cyan(` Updated ${updatedFiles.length} file(s) with new link path`));
96
- }
97
- }
98
-
99
- return { finalPath: finalLocalPath, wasRenamed: true };
100
- }
101
-
102
- // Atomic rename: temp file -> original file
103
- renameSync(tempFile, filePath);
104
- return { finalPath: finalLocalPath, wasRenamed: false };
105
- } finally {
106
- // Always clean up temp directory
107
- try {
108
- rmSync(tempDir, { recursive: true, force: true });
109
- } catch {
110
- // Ignore cleanup errors
111
- }
112
- }
113
- }