@jordancoin/notioncli 1.0.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.
@@ -0,0 +1,110 @@
1
+ // test/integration.test.js — Live API tests (optional, requires NOTION_API_KEY)
2
+
3
+ const { describe, it, before, after } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const { execSync } = require('child_process');
6
+ const path = require('path');
7
+
8
+ const SKIP = !process.env.NOTION_API_KEY;
9
+ const CLI = path.resolve(__dirname, '..', 'bin', 'notion.js');
10
+
11
+ function run(args, opts = {}) {
12
+ const result = execSync(`node ${CLI} ${args}`, {
13
+ encoding: 'utf-8',
14
+ timeout: 30000,
15
+ env: { ...process.env },
16
+ ...opts,
17
+ });
18
+ return result.trim();
19
+ }
20
+
21
+ function runJSON(args) {
22
+ const output = run(`--json ${args}`);
23
+ return JSON.parse(output);
24
+ }
25
+
26
+ describe('Integration tests (live API)', { skip: SKIP ? 'NOTION_API_KEY not set' : false }, () => {
27
+ describe('notion dbs', () => {
28
+ it('returns at least 1 database', () => {
29
+ const result = runJSON('dbs');
30
+ assert.ok(result.results, 'Expected results array');
31
+ assert.ok(result.results.length >= 1, `Expected at least 1 database, got ${result.results.length}`);
32
+ });
33
+ });
34
+
35
+ describe('notion search', () => {
36
+ it('search returns results', () => {
37
+ const result = runJSON('search "test"');
38
+ assert.ok(result.results, 'Expected results array');
39
+ });
40
+ });
41
+
42
+ describe('notion users', () => {
43
+ it('returns at least 1 user', () => {
44
+ const result = runJSON('users');
45
+ assert.ok(result.results, 'Expected results array');
46
+ assert.ok(result.results.length >= 1, `Expected at least 1 user, got ${result.results.length}`);
47
+ });
48
+ });
49
+
50
+ describe('CRUD round-trip', () => {
51
+ // This test requires a configured alias. We'll try to find one.
52
+ let alias;
53
+ let createdPageId;
54
+ const testName = `TEST_ENTRY_${Date.now()}`;
55
+
56
+ before(() => {
57
+ // Look for an alias in the config
58
+ try {
59
+ const output = run('alias list');
60
+ // Parse the table output to find at least one alias
61
+ const lines = output.split('\n');
62
+ // Skip header and separator (first 2 lines), grab first data line
63
+ if (lines.length >= 3) {
64
+ const dataLine = lines[2];
65
+ alias = dataLine.split(/\s+│\s+/)[0]?.trim();
66
+ }
67
+ } catch (e) {
68
+ // No aliases configured
69
+ }
70
+ });
71
+
72
+ it('add a page', { skip: !alias ? 'No alias configured for CRUD test' : false }, () => {
73
+ const result = runJSON(`add ${alias} --prop "Name=${testName}"`);
74
+ assert.ok(result.id, 'Expected page id in response');
75
+ createdPageId = result.id;
76
+ });
77
+
78
+ it('query to find the page', { skip: !alias ? 'No alias configured' : false }, () => {
79
+ if (!createdPageId) return;
80
+ const result = runJSON(`query ${alias} --filter "Name=${testName}"`);
81
+ assert.ok(result.results, 'Expected results');
82
+ assert.ok(result.results.length >= 1, 'Expected at least 1 result');
83
+ const found = result.results.some(p => p.id === createdPageId);
84
+ assert.ok(found, 'Expected to find created page');
85
+ });
86
+
87
+ it('get the page', { skip: !alias ? 'No alias configured' : false }, () => {
88
+ if (!createdPageId) return;
89
+ const result = runJSON(`get ${createdPageId}`);
90
+ assert.equal(result.id, createdPageId);
91
+ });
92
+
93
+ it('delete (archive) the page', { skip: !alias ? 'No alias configured' : false }, () => {
94
+ if (!createdPageId) return;
95
+ const result = runJSON(`delete ${createdPageId}`);
96
+ assert.equal(result.archived, true);
97
+ });
98
+
99
+ after(() => {
100
+ // Cleanup: ensure the page is archived even if a test failed
101
+ if (createdPageId) {
102
+ try {
103
+ run(`delete ${createdPageId}`);
104
+ } catch (e) {
105
+ // Already archived or doesn't exist
106
+ }
107
+ }
108
+ });
109
+ });
110
+ });
@@ -0,0 +1,378 @@
1
+ // test/mock.test.js — Command logic with mocked Notion client (no live API)
2
+
3
+ const { describe, it, beforeEach, afterEach } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const os = require('os');
8
+ const {
9
+ getConfigPaths,
10
+ loadConfig,
11
+ saveConfig,
12
+ buildFilterFromSchema,
13
+ UUID_REGEX,
14
+ pagesToRows,
15
+ printTable,
16
+ } = require('../lib/helpers');
17
+
18
+ // ─── Config management ────────────────────────────────────────────────────────
19
+
20
+ describe('Config management', () => {
21
+ let tmpDir;
22
+ let configDir;
23
+ let configPath;
24
+
25
+ beforeEach(() => {
26
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'notioncli-test-'));
27
+ configDir = path.join(tmpDir, 'notioncli');
28
+ configPath = path.join(configDir, 'config.json');
29
+ });
30
+
31
+ afterEach(() => {
32
+ fs.rmSync(tmpDir, { recursive: true, force: true });
33
+ });
34
+
35
+ it('loadConfig returns empty config for missing file', () => {
36
+ const config = loadConfig(configPath);
37
+ assert.deepEqual(config, { aliases: {} });
38
+ });
39
+
40
+ it('loadConfig returns empty config for corrupted file', () => {
41
+ fs.mkdirSync(configDir, { recursive: true });
42
+ fs.writeFileSync(configPath, 'not json at all!!!');
43
+ const config = loadConfig(configPath);
44
+ assert.deepEqual(config, { aliases: {} });
45
+ });
46
+
47
+ it('saveConfig creates directory and file', () => {
48
+ const config = { aliases: { test: { database_id: 'db1', data_source_id: 'ds1' } } };
49
+ saveConfig(config, configDir, configPath);
50
+ assert.ok(fs.existsSync(configPath));
51
+ });
52
+
53
+ it('loadConfig reads back saved config', () => {
54
+ const config = {
55
+ apiKey: 'ntn_test_key',
56
+ aliases: {
57
+ projects: { database_id: 'db-123', data_source_id: 'ds-456' },
58
+ },
59
+ };
60
+ saveConfig(config, configDir, configPath);
61
+ const loaded = loadConfig(configPath);
62
+ assert.equal(loaded.apiKey, 'ntn_test_key');
63
+ assert.deepEqual(loaded.aliases.projects, {
64
+ database_id: 'db-123',
65
+ data_source_id: 'ds-456',
66
+ });
67
+ });
68
+
69
+ it('saveConfig overwrites existing config', () => {
70
+ saveConfig({ aliases: { a: { database_id: '1', data_source_id: '1' } } }, configDir, configPath);
71
+ saveConfig({ aliases: { b: { database_id: '2', data_source_id: '2' } } }, configDir, configPath);
72
+ const loaded = loadConfig(configPath);
73
+ assert.ok(!loaded.aliases.a);
74
+ assert.ok(loaded.aliases.b);
75
+ });
76
+ });
77
+
78
+ // ─── getConfigPaths ────────────────────────────────────────────────────────────
79
+
80
+ describe('getConfigPaths', () => {
81
+ it('returns expected path structure with override', () => {
82
+ const paths = getConfigPaths('/tmp/custom-config');
83
+ assert.equal(paths.CONFIG_DIR, '/tmp/custom-config');
84
+ assert.equal(paths.CONFIG_PATH, '/tmp/custom-config/config.json');
85
+ });
86
+
87
+ it('returns default paths without override', () => {
88
+ const paths = getConfigPaths();
89
+ assert.ok(paths.CONFIG_DIR.includes('notioncli'));
90
+ assert.ok(paths.CONFIG_PATH.endsWith('config.json'));
91
+ });
92
+ });
93
+
94
+ // ─── resolveDb logic (pure parts) ──────────────────────────────────────────────
95
+
96
+ describe('resolveDb logic (pure)', () => {
97
+ let tmpDir, configDir, configPath;
98
+
99
+ beforeEach(() => {
100
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'notioncli-test-'));
101
+ configDir = path.join(tmpDir, 'notioncli');
102
+ configPath = path.join(configDir, 'config.json');
103
+ });
104
+
105
+ afterEach(() => {
106
+ fs.rmSync(tmpDir, { recursive: true, force: true });
107
+ });
108
+
109
+ it('known alias resolves to database_id + data_source_id', () => {
110
+ const config = {
111
+ aliases: {
112
+ projects: { database_id: 'db-aaa', data_source_id: 'ds-bbb' },
113
+ },
114
+ };
115
+ saveConfig(config, configDir, configPath);
116
+ const loaded = loadConfig(configPath);
117
+ const alias = 'projects';
118
+ const result = loaded.aliases[alias];
119
+ assert.ok(result);
120
+ assert.equal(result.database_id, 'db-aaa');
121
+ assert.equal(result.data_source_id, 'ds-bbb');
122
+ });
123
+
124
+ it('raw UUID is recognized by UUID_REGEX', () => {
125
+ assert.ok(UUID_REGEX.test('550e8400-e29b-41d4-a716-446655440000'));
126
+ assert.ok(UUID_REGEX.test('550e8400e29b41d4a716446655440000'));
127
+ });
128
+
129
+ it('unknown non-UUID string is rejected by UUID_REGEX', () => {
130
+ assert.ok(!UUID_REGEX.test('my-database'));
131
+ assert.ok(!UUID_REGEX.test('workouts'));
132
+ });
133
+
134
+ it('config with no aliases returns empty aliases object', () => {
135
+ saveConfig({ aliases: {} }, configDir, configPath);
136
+ const loaded = loadConfig(configPath);
137
+ assert.deepEqual(Object.keys(loaded.aliases), []);
138
+ });
139
+ });
140
+
141
+ // ─── resolvePageId logic (pure simulation) ─────────────────────────────────────
142
+
143
+ describe('resolvePageId logic (simulated)', () => {
144
+ // We simulate the resolvePageId logic without process.exit
145
+
146
+ function simulateResolvePageId(config, aliasOrId, filterStr, queryResults) {
147
+ // Known alias
148
+ if (config.aliases && config.aliases[aliasOrId]) {
149
+ if (!filterStr) {
150
+ return { error: 'filter_required' };
151
+ }
152
+ const dbIds = config.aliases[aliasOrId];
153
+ const results = queryResults || [];
154
+ if (results.length === 0) {
155
+ return { error: 'no_match' };
156
+ }
157
+ if (results.length > 1) {
158
+ return { error: 'multiple_matches', count: results.length, results };
159
+ }
160
+ return { pageId: results[0].id, dbIds };
161
+ }
162
+ // UUID check
163
+ if (!UUID_REGEX.test(aliasOrId)) {
164
+ return {
165
+ error: 'unknown_alias',
166
+ available: config.aliases ? Object.keys(config.aliases) : [],
167
+ };
168
+ }
169
+ return { pageId: aliasOrId, dbIds: null };
170
+ }
171
+
172
+ const config = {
173
+ aliases: {
174
+ projects: { database_id: 'db-1', data_source_id: 'ds-1' },
175
+ tasks: { database_id: 'db-2', data_source_id: 'ds-2' },
176
+ },
177
+ };
178
+
179
+ it('known alias + filter → single match → returns page ID', () => {
180
+ const result = simulateResolvePageId(config, 'projects', 'Name=Test', [
181
+ { id: 'page-123' },
182
+ ]);
183
+ assert.equal(result.pageId, 'page-123');
184
+ assert.deepEqual(result.dbIds, { database_id: 'db-1', data_source_id: 'ds-1' });
185
+ });
186
+
187
+ it('known alias + no filter → error', () => {
188
+ const result = simulateResolvePageId(config, 'projects', null, []);
189
+ assert.equal(result.error, 'filter_required');
190
+ });
191
+
192
+ it('known alias + filter → 0 results → error', () => {
193
+ const result = simulateResolvePageId(config, 'projects', 'Name=Nothing', []);
194
+ assert.equal(result.error, 'no_match');
195
+ });
196
+
197
+ it('known alias + filter → multiple results → error', () => {
198
+ const result = simulateResolvePageId(config, 'projects', 'Name=Dup', [
199
+ { id: 'page-1' },
200
+ { id: 'page-2' },
201
+ { id: 'page-3' },
202
+ ]);
203
+ assert.equal(result.error, 'multiple_matches');
204
+ assert.equal(result.count, 3);
205
+ });
206
+
207
+ it('raw UUID → returns as-is with null dbIds', () => {
208
+ const uuid = '550e8400-e29b-41d4-a716-446655440000';
209
+ const result = simulateResolvePageId(config, uuid, null, []);
210
+ assert.equal(result.pageId, uuid);
211
+ assert.equal(result.dbIds, null);
212
+ });
213
+
214
+ it('unknown non-UUID string → error with available aliases', () => {
215
+ const result = simulateResolvePageId(config, 'unknown-db', null, []);
216
+ assert.equal(result.error, 'unknown_alias');
217
+ assert.ok(result.available.includes('projects'));
218
+ assert.ok(result.available.includes('tasks'));
219
+ });
220
+ });
221
+
222
+ // ─── getApiKey logic (pure simulation) ─────────────────────────────────────────
223
+
224
+ describe('getApiKey logic (simulated)', () => {
225
+ function simulateGetApiKey(envKey, configApiKey) {
226
+ if (envKey) return { key: envKey };
227
+ if (configApiKey) return { key: configApiKey };
228
+ return { error: 'no_key' };
229
+ }
230
+
231
+ it('returns env var when set', () => {
232
+ const result = simulateGetApiKey('ntn_env_key', 'ntn_config_key');
233
+ assert.equal(result.key, 'ntn_env_key');
234
+ });
235
+
236
+ it('returns config key when env var not set', () => {
237
+ const result = simulateGetApiKey(null, 'ntn_config_key');
238
+ assert.equal(result.key, 'ntn_config_key');
239
+ });
240
+
241
+ it('returns error when neither is set', () => {
242
+ const result = simulateGetApiKey(null, null);
243
+ assert.equal(result.error, 'no_key');
244
+ });
245
+
246
+ it('env var takes priority over config', () => {
247
+ const result = simulateGetApiKey('ntn_env', 'ntn_config');
248
+ assert.equal(result.key, 'ntn_env');
249
+ });
250
+ });
251
+
252
+ // ─── getDbSchema logic (simulated) ─────────────────────────────────────────────
253
+
254
+ describe('getDbSchema logic (simulated)', () => {
255
+ function simulateGetDbSchema(dsProperties) {
256
+ const schema = {};
257
+ for (const [name, prop] of Object.entries(dsProperties)) {
258
+ schema[name.toLowerCase()] = { type: prop.type, name };
259
+ }
260
+ return schema;
261
+ }
262
+
263
+ it('maps properties to lowercase keys with original name preserved', () => {
264
+ const props = {
265
+ 'Task Name': { type: 'title' },
266
+ 'Status': { type: 'select' },
267
+ 'Due Date': { type: 'date' },
268
+ };
269
+ const schema = simulateGetDbSchema(props);
270
+ assert.deepEqual(schema['task name'], { type: 'title', name: 'Task Name' });
271
+ assert.deepEqual(schema['status'], { type: 'select', name: 'Status' });
272
+ assert.deepEqual(schema['due date'], { type: 'date', name: 'Due Date' });
273
+ });
274
+
275
+ it('handles empty properties', () => {
276
+ const schema = simulateGetDbSchema({});
277
+ assert.deepEqual(schema, {});
278
+ });
279
+
280
+ it('handles various property types', () => {
281
+ const props = {
282
+ 'Name': { type: 'title' },
283
+ 'Description': { type: 'rich_text' },
284
+ 'Count': { type: 'number' },
285
+ 'Tags': { type: 'multi_select' },
286
+ 'Done': { type: 'checkbox' },
287
+ 'URL': { type: 'url' },
288
+ 'Email': { type: 'email' },
289
+ 'Phone': { type: 'phone_number' },
290
+ 'Stage': { type: 'status' },
291
+ 'Created': { type: 'created_time' },
292
+ 'Author': { type: 'created_by' },
293
+ };
294
+ const schema = simulateGetDbSchema(props);
295
+ assert.equal(Object.keys(schema).length, 11);
296
+ assert.equal(schema['name'].type, 'title');
297
+ assert.equal(schema['url'].type, 'url');
298
+ });
299
+ });
300
+
301
+ // ─── buildFilter via buildFilterFromSchema ─────────────────────────────────────
302
+
303
+ describe('buildFilter (via buildFilterFromSchema)', () => {
304
+ const schema = {
305
+ name: { type: 'title', name: 'Name' },
306
+ notes: { type: 'rich_text', name: 'Notes' },
307
+ priority: { type: 'select', name: 'Priority' },
308
+ labels: { type: 'multi_select', name: 'Labels' },
309
+ score: { type: 'number', name: 'Score' },
310
+ active: { type: 'checkbox', name: 'Active' },
311
+ deadline: { type: 'date', name: 'Deadline' },
312
+ phase: { type: 'status', name: 'Phase' },
313
+ website: { type: 'url', name: 'Website' },
314
+ };
315
+
316
+ it('title → contains filter', () => {
317
+ const r = buildFilterFromSchema(schema, 'Name=Project Alpha');
318
+ assert.deepEqual(r.filter, { property: 'Name', title: { contains: 'Project Alpha' } });
319
+ });
320
+
321
+ it('rich_text → contains filter', () => {
322
+ const r = buildFilterFromSchema(schema, 'Notes=important');
323
+ assert.deepEqual(r.filter, { property: 'Notes', rich_text: { contains: 'important' } });
324
+ });
325
+
326
+ it('select → equals filter', () => {
327
+ const r = buildFilterFromSchema(schema, 'Priority=High');
328
+ assert.deepEqual(r.filter, { property: 'Priority', select: { equals: 'High' } });
329
+ });
330
+
331
+ it('multi_select → contains filter', () => {
332
+ const r = buildFilterFromSchema(schema, 'Labels=urgent');
333
+ assert.deepEqual(r.filter, { property: 'Labels', multi_select: { contains: 'urgent' } });
334
+ });
335
+
336
+ it('number → equals filter (numeric)', () => {
337
+ const r = buildFilterFromSchema(schema, 'Score=95');
338
+ assert.deepEqual(r.filter, { property: 'Score', number: { equals: 95 } });
339
+ });
340
+
341
+ it('checkbox true → equals true', () => {
342
+ const r = buildFilterFromSchema(schema, 'Active=true');
343
+ assert.deepEqual(r.filter, { property: 'Active', checkbox: { equals: true } });
344
+ });
345
+
346
+ it('checkbox false → equals false', () => {
347
+ const r = buildFilterFromSchema(schema, 'Active=false');
348
+ assert.deepEqual(r.filter, { property: 'Active', checkbox: { equals: false } });
349
+ });
350
+
351
+ it('date → equals filter', () => {
352
+ const r = buildFilterFromSchema(schema, 'Deadline=2024-12-31');
353
+ assert.deepEqual(r.filter, { property: 'Deadline', date: { equals: '2024-12-31' } });
354
+ });
355
+
356
+ it('status → equals filter', () => {
357
+ const r = buildFilterFromSchema(schema, 'Phase=Done');
358
+ assert.deepEqual(r.filter, { property: 'Phase', status: { equals: 'Done' } });
359
+ });
360
+
361
+ it('unknown type → generic equals filter', () => {
362
+ const r = buildFilterFromSchema(schema, 'Website=https://example.com');
363
+ assert.deepEqual(r.filter, { property: 'Website', url: { equals: 'https://example.com' } });
364
+ });
365
+
366
+ it('missing property → error with available list', () => {
367
+ const r = buildFilterFromSchema(schema, 'NonExistent=value');
368
+ assert.ok(r.error);
369
+ assert.ok(Array.isArray(r.available));
370
+ assert.ok(r.available.includes('Name'));
371
+ });
372
+
373
+ it('no equals sign → error', () => {
374
+ const r = buildFilterFromSchema(schema, 'justAString');
375
+ assert.ok(r.error);
376
+ assert.ok(r.error.includes('Invalid filter format'));
377
+ });
378
+ });