diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..10c93d7 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,26 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: ['18', '20', '22'] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - run: npm ci + + - run: npm test diff --git a/package-lock.json b/package-lock.json index 28041c8..714a1dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,12 +7,12 @@ "": { "name": "kit-cli", "version": "1.0.0", + "license": "MIT", "dependencies": { "chalk": "^5.4.1", "cli-table3": "^0.6.5", "commander": "^13.1.0", - "conf": "^13.1.0", - "ora": "^8.2.0" + "conf": "^13.1.0" }, "bin": { "kit": "bin/kit.js" @@ -64,18 +64,6 @@ } } }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, "node_modules/atomically": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.1.1.tgz", @@ -98,33 +86,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "license": "MIT", - "dependencies": { - "restore-cursor": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/cli-table3": { "version": "0.6.5", "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", @@ -242,18 +203,6 @@ ], "license": "BSD-3-Clause" }, - "node_modules/get-east-asian-width": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", - "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -263,30 +212,6 @@ "node": ">=8" } }, - "node_modules/is-interactive": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", - "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-unicode-supported": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -299,34 +224,6 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, - "node_modules/log-symbols": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", - "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", - "license": "MIT", - "dependencies": { - "chalk": "^5.3.0", - "is-unicode-supported": "^1.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-symbols/node_modules/is-unicode-supported": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", - "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/mimic-function": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", @@ -339,67 +236,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", - "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", - "license": "MIT", - "dependencies": { - "chalk": "^5.3.0", - "cli-cursor": "^5.0.0", - "cli-spinners": "^2.9.2", - "is-interactive": "^2.0.0", - "is-unicode-supported": "^2.0.0", - "log-symbols": "^6.0.0", - "stdin-discarder": "^0.2.2", - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "license": "MIT" - }, - "node_modules/ora/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -409,22 +245,6 @@ "node": ">=0.10.0" } }, - "node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "license": "MIT", - "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -437,30 +257,6 @@ "node": ">=10" } }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/stdin-discarder": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", - "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -496,21 +292,6 @@ "node": ">=8" } }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/stubborn-fs": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz", diff --git a/scripts/client.test.js b/scripts/client.test.js new file mode 100644 index 0000000..7e754d7 --- /dev/null +++ b/scripts/client.test.js @@ -0,0 +1,612 @@ +/** + * Tests for src/client.js + * + * HTTP method tests (get, post, put, del, paginate) require KIT_API_KEY env var + * and work best without an active OAuth session. In CI, set KIT_API_KEY=test-key. + * If an OAuth session exists, fetch is still mocked so no real API calls are made. + */ +import { test, describe, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { + validatePathSegment, + validateNumericId, + safeJsonParse, + KitApiError, + get, + post, + put, + del, + paginate, +} from '../src/client.js'; +import config from '../src/config.js'; + +const TEST_API_KEY = 'test-api-key-abcde12345'; +const _originalFetch = globalThis.fetch; + +/** + * Save and restore the config's OAuth token fields around a block of tests. + * This prevents an existing (possibly expired) stored token from triggering a + * real network refresh during tests that mock fetch. + */ +function oauthSnapshot() { + return { + accessToken: config.get('accessToken'), + refreshToken: config.get('refreshToken'), + tokenExpiresAt: config.get('tokenExpiresAt'), + }; +} +function clearOAuth() { + config.set('accessToken', ''); + config.set('refreshToken', ''); + config.set('tokenExpiresAt', 0); +} +function restoreOAuth(snap) { + config.set('accessToken', snap.accessToken); + config.set('refreshToken', snap.refreshToken); + config.set('tokenExpiresAt', snap.tokenExpiresAt); +} + +// ── helpers ──────────────────────────────────────────────────────────────── + +/** + * Intercept process.exit so tests that exercise error paths don't kill the + * process. Returns a restore function and accessors for what was recorded. + */ +function mockExit() { + const orig = process.exit; + let _called = false; + let _code; + process.exit = (code) => { + _called = true; + _code = code; + throw new Error(`process.exit(${code})`); + }; + return { + called: () => _called, + code: () => _code, + restore: () => { process.exit = orig; }, + }; +} + +/** Intercept console.error so test output stays clean. */ +function captureConsoleError() { + const orig = console.error; + const lines = []; + console.error = (...args) => lines.push(args.join(' ')); + return { lines, restore: () => { console.error = orig; } }; +} + +/** Build a minimal fetch mock that returns a successful response. */ +function mockFetchOk(body, status = 200) { + globalThis.fetch = async () => ({ + ok: true, + status, + statusText: 'OK', + json: async () => body, + }); +} + +// ── KitApiError ──────────────────────────────────────────────────────────── + +describe('KitApiError', () => { + test('is an instance of Error', () => { + assert.ok(new KitApiError(500, ['Oops']) instanceof Error); + }); + + test('sets name to KitApiError', () => { + assert.equal(new KitApiError(400, ['Bad']).name, 'KitApiError'); + }); + + test('sets status property', () => { + assert.equal(new KitApiError(403, ['Forbidden']).status, 403); + }); + + test('sets errors array', () => { + const err = new KitApiError(422, ['Email invalid', 'Name too long']); + assert.deepEqual(err.errors, ['Email invalid', 'Name too long']); + }); + + test('joins errors with semicolon for message', () => { + const err = new KitApiError(422, ['Email invalid', 'Name too long']); + assert.equal(err.message, 'Email invalid; Name too long'); + }); + + test('single error becomes message directly', () => { + assert.equal(new KitApiError(404, ['Not found']).message, 'Not found'); + }); +}); + +// ── validatePathSegment ──────────────────────────────────────────────────── + +describe('validatePathSegment', () => { + test('returns the value unchanged for a plain identifier', () => { + assert.equal(validatePathSegment('12345'), '12345'); + }); + + test('returns URL-encoded value for a string with spaces', () => { + assert.equal(validatePathSegment('hello world'), 'hello%20world'); + }); + + test('rejects value containing forward slash', () => { + const { restore } = mockExit(); + const err = captureConsoleError(); + assert.throws(() => validatePathSegment('../etc/passwd'), /process\.exit/); + err.restore(); + restore(); + }); + + test('rejects value containing backslash', () => { + const m = mockExit(); + const err = captureConsoleError(); + assert.throws(() => validatePathSegment('foo\\bar'), /process\.exit/); + err.restore(); + m.restore(); + }); + + test('rejects value containing a dot', () => { + const m = mockExit(); + const err = captureConsoleError(); + assert.throws(() => validatePathSegment('foo.bar'), /process\.exit/); + err.restore(); + m.restore(); + }); + + test('rejects value containing hash', () => { + const m = mockExit(); + const err = captureConsoleError(); + assert.throws(() => validatePathSegment('foo#bar'), /process\.exit/); + err.restore(); + m.restore(); + }); + + test('rejects value containing question mark', () => { + const m = mockExit(); + const err = captureConsoleError(); + assert.throws(() => validatePathSegment('foo?bar'), /process\.exit/); + err.restore(); + m.restore(); + }); + + test('rejects value containing ampersand', () => { + const m = mockExit(); + const err = captureConsoleError(); + assert.throws(() => validatePathSegment('foo&bar'), /process\.exit/); + err.restore(); + m.restore(); + }); + + test('rejects empty string', () => { + const m = mockExit(); + const err = captureConsoleError(); + assert.throws(() => validatePathSegment(''), /process\.exit/); + err.restore(); + m.restore(); + }); + + test('includes custom label in error message', () => { + const m = mockExit(); + const err = captureConsoleError(); + assert.throws(() => validatePathSegment('bad/id', 'Subscriber ID'), /process\.exit/); + err.restore(); + m.restore(); + assert.ok(err.lines[0].includes('Subscriber ID')); + }); + + test('exits with code 1', () => { + const m = mockExit(); + const err = captureConsoleError(); + assert.throws(() => validatePathSegment('bad/id'), /process\.exit/); + err.restore(); + assert.equal(m.code(), 1); + m.restore(); + }); +}); + +// ── validateNumericId ────────────────────────────────────────────────────── + +describe('validateNumericId', () => { + test('returns the number for a valid integer string', () => { + assert.equal(validateNumericId('42'), 42); + }); + + test('returns the number for a valid integer number', () => { + assert.equal(validateNumericId(100), 100); + }); + + test('returns the number for a large integer', () => { + assert.equal(validateNumericId('999999999'), 999999999); + }); + + test('rejects zero', () => { + const m = mockExit(); + const err = captureConsoleError(); + assert.throws(() => validateNumericId(0), /process\.exit/); + err.restore(); + m.restore(); + }); + + test('rejects negative integer', () => { + const m = mockExit(); + const err = captureConsoleError(); + assert.throws(() => validateNumericId(-1), /process\.exit/); + err.restore(); + m.restore(); + }); + + test('rejects float', () => { + const m = mockExit(); + const err = captureConsoleError(); + assert.throws(() => validateNumericId(1.5), /process\.exit/); + err.restore(); + m.restore(); + }); + + test('rejects non-numeric string', () => { + const m = mockExit(); + const err = captureConsoleError(); + assert.throws(() => validateNumericId('abc'), /process\.exit/); + err.restore(); + m.restore(); + }); + + test('rejects NaN', () => { + const m = mockExit(); + const err = captureConsoleError(); + assert.throws(() => validateNumericId(NaN), /process\.exit/); + err.restore(); + m.restore(); + }); + + test('rejects empty string', () => { + const m = mockExit(); + const err = captureConsoleError(); + assert.throws(() => validateNumericId(''), /process\.exit/); + err.restore(); + m.restore(); + }); + + test('includes custom label in error message', () => { + const m = mockExit(); + const err = captureConsoleError(); + assert.throws(() => validateNumericId('bad', 'Tag ID'), /process\.exit/); + err.restore(); + m.restore(); + assert.ok(err.lines[0].includes('Tag ID')); + }); + + test('exits with code 1', () => { + const m = mockExit(); + const err = captureConsoleError(); + assert.throws(() => validateNumericId(-5), /process\.exit/); + err.restore(); + assert.equal(m.code(), 1); + m.restore(); + }); +}); + +// ── safeJsonParse ────────────────────────────────────────────────────────── + +describe('safeJsonParse', () => { + test('parses a valid JSON object', () => { + assert.deepEqual(safeJsonParse('{"a":1}'), { a: 1 }); + }); + + test('parses a valid JSON array', () => { + assert.deepEqual(safeJsonParse('[1,2,3]'), [1, 2, 3]); + }); + + test('parses JSON null', () => { + assert.equal(safeJsonParse('null'), null); + }); + + test('parses JSON number', () => { + assert.equal(safeJsonParse('42'), 42); + }); + + test('parses nested object', () => { + const input = '{"subscriber":{"id":1,"email":"test@example.com"}}'; + assert.deepEqual(safeJsonParse(input), { subscriber: { id: 1, email: 'test@example.com' } }); + }); + + test('rejects invalid JSON', () => { + const m = mockExit(); + const err = captureConsoleError(); + assert.throws(() => safeJsonParse('{bad json}'), /process\.exit/); + err.restore(); + m.restore(); + }); + + test('rejects empty string', () => { + const m = mockExit(); + const err = captureConsoleError(); + assert.throws(() => safeJsonParse(''), /process\.exit/); + err.restore(); + m.restore(); + }); + + test('rejects trailing comma', () => { + const m = mockExit(); + const err = captureConsoleError(); + assert.throws(() => safeJsonParse('{"a":1,}'), /process\.exit/); + err.restore(); + m.restore(); + }); + + test('includes custom label in error message', () => { + const m = mockExit(); + const err = captureConsoleError(); + assert.throws(() => safeJsonParse('bad', 'Custom Fields JSON'), /process\.exit/); + err.restore(); + m.restore(); + assert.ok(err.lines[0].includes('Custom Fields JSON')); + }); + + test('exits with code 1', () => { + const m = mockExit(); + const err = captureConsoleError(); + assert.throws(() => safeJsonParse('oops'), /process\.exit/); + err.restore(); + assert.equal(m.code(), 1); + m.restore(); + }); +}); + +// ── HTTP client methods ──────────────────────────────────────────────────── + +describe('HTTP client', () => { + let _oauthSnap; + + before(() => { + process.env.KIT_API_KEY = TEST_API_KEY; + _oauthSnap = oauthSnapshot(); + clearOAuth(); // force API-key auth path; avoids expired-token refresh + }); + + after(() => { + delete process.env.KIT_API_KEY; + restoreOAuth(_oauthSnap); + globalThis.fetch = _originalFetch; + }); + + test('get() sends a GET request to the correct URL', async () => { + let captured; + globalThis.fetch = async (url, opts) => { + captured = { url, opts }; + return { ok: true, status: 200, json: async () => ({ subscribers: [] }) }; + }; + await get('/subscribers'); + assert.ok(captured.url.startsWith('https://api.kit.com/v4/subscribers')); + assert.equal(captured.opts.method, 'GET'); + }); + + test('get() includes Accept header', async () => { + let captured; + globalThis.fetch = async (url, opts) => { captured = { url, opts }; return { ok: true, status: 200, json: async () => ({}) }; }; + await get('/subscribers'); + assert.equal(captured.opts.headers['Accept'], 'application/json'); + }); + + test('get() appends query params to URL', async () => { + let capturedUrl; + globalThis.fetch = async (url) => { capturedUrl = url; return { ok: true, status: 200, json: async () => ({}) }; }; + await get('/subscribers', { per_page: 10, after: 'cursor1' }); + const parsed = new URL(capturedUrl); + assert.equal(parsed.searchParams.get('per_page'), '10'); + assert.equal(parsed.searchParams.get('after'), 'cursor1'); + }); + + test('get() skips null query params', async () => { + let capturedUrl; + globalThis.fetch = async (url) => { capturedUrl = url; return { ok: true, status: 200, json: async () => ({}) }; }; + await get('/subscribers', { page: 1, sort: null, filter: undefined, name: '' }); + const parsed = new URL(capturedUrl); + assert.equal(parsed.searchParams.get('page'), '1'); + assert.ok(!parsed.searchParams.has('sort')); + assert.ok(!parsed.searchParams.has('filter')); + assert.ok(!parsed.searchParams.has('name')); + }); + + test('post() sends a POST request with JSON body', async () => { + let captured; + globalThis.fetch = async (url, opts) => { + captured = { url, opts }; + return { ok: true, status: 200, json: async () => ({ subscriber: {} }) }; + }; + await post('/subscribers', { email: 'new@example.com' }); + assert.equal(captured.opts.method, 'POST'); + assert.equal(captured.opts.headers['Content-Type'], 'application/json'); + assert.deepEqual(JSON.parse(captured.opts.body), { email: 'new@example.com' }); + }); + + test('put() sends a PUT request with JSON body', async () => { + let captured; + globalThis.fetch = async (url, opts) => { + captured = { url, opts }; + return { ok: true, status: 200, json: async () => ({ subscriber: {} }) }; + }; + await put('/subscribers/123', { first_name: 'Alice' }); + assert.equal(captured.opts.method, 'PUT'); + assert.equal(captured.opts.headers['Content-Type'], 'application/json'); + assert.deepEqual(JSON.parse(captured.opts.body), { first_name: 'Alice' }); + }); + + test('del() sends a DELETE request', async () => { + let captured; + globalThis.fetch = async (url, opts) => { captured = { url, opts }; return { ok: true, status: 204 }; }; + await del('/subscribers/123'); + assert.equal(captured.opts.method, 'DELETE'); + }); + + test('returns null for 204 No Content', async () => { + globalThis.fetch = async () => ({ ok: true, status: 204 }); + const result = await get('/something'); + assert.equal(result, null); + }); + + test('returns parsed JSON for 200 response', async () => { + const payload = { subscribers: [{ id: 1 }] }; + mockFetchOk(payload); + const result = await get('/subscribers'); + assert.deepEqual(result, payload); + }); + + test('throws KitApiError for 404 with errors array', async () => { + globalThis.fetch = async () => ({ + ok: false, status: 404, statusText: 'Not Found', + json: async () => ({ errors: ['Subscriber not found'] }), + }); + await assert.rejects( + () => get('/subscribers/999'), + (err) => { + assert.ok(err instanceof KitApiError); + assert.equal(err.status, 404); + assert.deepEqual(err.errors, ['Subscriber not found']); + return true; + } + ); + }); + + test('throws KitApiError for 401 with message field', async () => { + globalThis.fetch = async () => ({ + ok: false, status: 401, statusText: 'Unauthorized', + json: async () => ({ message: 'Invalid API key' }), + }); + await assert.rejects( + () => get('/subscribers'), + (err) => { + assert.ok(err instanceof KitApiError); + assert.equal(err.status, 401); + assert.deepEqual(err.errors, ['Invalid API key']); + return true; + } + ); + }); + + test('throws KitApiError with statusText when response body is not parseable', async () => { + globalThis.fetch = async () => ({ + ok: false, status: 500, statusText: 'Internal Server Error', + json: async () => { throw new Error('not json'); }, + }); + await assert.rejects( + () => get('/subscribers'), + (err) => { + assert.ok(err instanceof KitApiError); + assert.equal(err.status, 500); + assert.deepEqual(err.errors, ['Internal Server Error']); + return true; + } + ); + }); + + test('del() sends request body when provided', async () => { + let captured; + globalThis.fetch = async (url, opts) => { captured = { url, opts }; return { ok: true, status: 204 }; }; + await del('/tags/subscribers', { subscriber_ids: [1, 2] }); + assert.equal(captured.opts.method, 'DELETE'); + assert.deepEqual(JSON.parse(captured.opts.body), { subscriber_ids: [1, 2] }); + }); +}); + +// ── paginate ─────────────────────────────────────────────────────────────── + +describe('paginate', () => { + let _oauthSnap; + + before(() => { + process.env.KIT_API_KEY = TEST_API_KEY; + _oauthSnap = oauthSnapshot(); + clearOAuth(); + }); + + after(() => { + delete process.env.KIT_API_KEY; + restoreOAuth(_oauthSnap); + globalThis.fetch = _originalFetch; + }); + + test('collects items from a single-page response', async () => { + globalThis.fetch = async () => ({ + ok: true, status: 200, + json: async () => ({ + tags: [{ id: 1 }, { id: 2 }], + pagination: { has_next_page: false }, + }), + }); + const result = await paginate('/tags', {}, 'tags'); + assert.deepEqual(result, [{ id: 1 }, { id: 2 }]); + }); + + test('collects items across multiple pages', async () => { + let callCount = 0; + globalThis.fetch = async (url) => { + callCount++; + const after = new URL(url).searchParams.get('after'); + if (!after) { + return { + ok: true, status: 200, + json: async () => ({ + subscribers: [{ id: 1 }, { id: 2 }], + pagination: { has_next_page: true, end_cursor: 'cursor1' }, + }), + }; + } + return { + ok: true, status: 200, + json: async () => ({ + subscribers: [{ id: 3 }], + pagination: { has_next_page: false }, + }), + }; + }; + const result = await paginate('/subscribers', {}, 'subscribers'); + assert.equal(callCount, 2); + assert.deepEqual(result, [{ id: 1 }, { id: 2 }, { id: 3 }]); + }); + + test('returns empty array when response has no items', async () => { + globalThis.fetch = async () => ({ + ok: true, status: 200, + json: async () => ({ subscribers: [], pagination: { has_next_page: false } }), + }); + const result = await paginate('/subscribers', {}, 'subscribers'); + assert.deepEqual(result, []); + }); + + test('auto-detects array key when dataKey not provided', async () => { + globalThis.fetch = async () => ({ + ok: true, status: 200, + json: async () => ({ + forms: [{ id: 5, name: 'My Form' }], + pagination: { has_next_page: false }, + }), + }); + const result = await paginate('/forms'); + assert.deepEqual(result, [{ id: 5, name: 'My Form' }]); + }); + + test('passes initial cursor from query options', async () => { + let capturedUrl; + globalThis.fetch = async (url) => { + capturedUrl = url; + return { + ok: true, status: 200, + json: async () => ({ subscribers: [], pagination: { has_next_page: false } }), + }; + }; + await paginate('/subscribers', { after: 'startCursor' }, 'subscribers'); + assert.ok(new URL(capturedUrl).searchParams.get('after') === 'startCursor'); + }); + + test('passes extra query params to each page request', async () => { + const capturedParams = []; + globalThis.fetch = async (url) => { + capturedParams.push(Object.fromEntries(new URL(url).searchParams)); + return { + ok: true, status: 200, + json: async () => ({ subscribers: [], pagination: { has_next_page: false } }), + }; + }; + await paginate('/subscribers', { email_address: 'test@example.com' }, 'subscribers'); + assert.equal(capturedParams[0].email_address, 'test@example.com'); + }); +}); diff --git a/scripts/config.test.js b/scripts/config.test.js new file mode 100644 index 0000000..50b686d --- /dev/null +++ b/scripts/config.test.js @@ -0,0 +1,191 @@ +/** + * Tests for src/config.js + * + * Only tests that do NOT write to the config file are included here, so the + * suite is safe to run in any environment without polluting the developer's + * stored credentials. Writing tests are intentionally omitted. + */ +import { test, describe, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { setApiKey, getApiKey, getOAuthClientId, getOAuthRedirectUri } from '../src/config.js'; + +// ── setApiKey – validation (throws before writing to disk) ───────────────── + +describe('setApiKey validation', () => { + test('throws for empty string', () => { + assert.throws( + () => setApiKey(''), + { message: 'API key must be a non-empty string.' } + ); + }); + + test('throws for whitespace-only string', () => { + assert.throws( + () => setApiKey(' '), + { message: 'API key must be a non-empty string.' } + ); + }); + + test('throws for non-string value (number)', () => { + assert.throws( + () => setApiKey(12345), + { message: 'API key must be a non-empty string.' } + ); + }); + + test('throws for null', () => { + assert.throws( + () => setApiKey(null), + { message: 'API key must be a non-empty string.' } + ); + }); + + test('throws for undefined', () => { + assert.throws( + () => setApiKey(undefined), + { message: 'API key must be a non-empty string.' } + ); + }); + + test('throws when key exceeds 256 characters', () => { + assert.throws( + () => setApiKey('a'.repeat(257)), + { message: 'API key is too long (max 256 characters).' } + ); + }); + + test('does NOT throw for a key of exactly 256 characters', () => { + // 256-char key is at the boundary; it should write — but we just check + // the validation logic does not throw. The actual write may succeed or + // fail depending on the environment, which is acceptable here. + // We catch any error that is NOT a validation error. + try { + setApiKey('a'.repeat(256)); + } catch (err) { + assert.notEqual(err.message, 'API key is too long (max 256 characters).'); + } + }); + + test('throws for key containing null byte (control char)', () => { + assert.throws( + () => setApiKey('key\x00here'), + { message: 'API key contains invalid control characters.' } + ); + }); + + test('throws for key containing newline', () => { + assert.throws( + () => setApiKey('key\nvalue'), + { message: 'API key contains invalid control characters.' } + ); + }); + + test('throws for key containing carriage return', () => { + assert.throws( + () => setApiKey('key\rvalue'), + { message: 'API key contains invalid control characters.' } + ); + }); + + test('throws for key containing tab character', () => { + assert.throws( + () => setApiKey('key\there'), + { message: 'API key contains invalid control characters.' } + ); + }); + + test('throws for key containing DEL character (0x7f)', () => { + assert.throws( + () => setApiKey('key\x7fhere'), + { message: 'API key contains invalid control characters.' } + ); + }); +}); + +// ── getApiKey – environment variable override ────────────────────────────── + +describe('getApiKey', () => { + let _saved; + + before(() => { + _saved = process.env.KIT_API_KEY; + delete process.env.KIT_API_KEY; + }); + + after(() => { + if (_saved !== undefined) { + process.env.KIT_API_KEY = _saved; + } else { + delete process.env.KIT_API_KEY; + } + }); + + test('returns KIT_API_KEY env var when set', () => { + process.env.KIT_API_KEY = 'env-key-xyz'; + assert.equal(getApiKey(), 'env-key-xyz'); + delete process.env.KIT_API_KEY; + }); + + test('env var takes precedence over any stored config value', () => { + process.env.KIT_API_KEY = 'env-priority-key'; + const result = getApiKey(); + delete process.env.KIT_API_KEY; + assert.equal(result, 'env-priority-key'); + }); +}); + +// ── getOAuthClientId – environment variable override ────────────────────── + +describe('getOAuthClientId', () => { + let _saved; + + before(() => { + _saved = process.env.KIT_CLIENT_ID; + delete process.env.KIT_CLIENT_ID; + }); + + after(() => { + if (_saved !== undefined) { + process.env.KIT_CLIENT_ID = _saved; + } else { + delete process.env.KIT_CLIENT_ID; + } + }); + + test('returns KIT_CLIENT_ID env var when set', () => { + process.env.KIT_CLIENT_ID = 'client-id-abc'; + assert.equal(getOAuthClientId(), 'client-id-abc'); + delete process.env.KIT_CLIENT_ID; + }); +}); + +// ── getOAuthRedirectUri – environment variable override ─────────────────── + +describe('getOAuthRedirectUri', () => { + let _saved; + + before(() => { + _saved = process.env.KIT_REDIRECT_URI; + delete process.env.KIT_REDIRECT_URI; + }); + + after(() => { + if (_saved !== undefined) { + process.env.KIT_REDIRECT_URI = _saved; + } else { + delete process.env.KIT_REDIRECT_URI; + } + }); + + test('returns KIT_REDIRECT_URI env var when set', () => { + process.env.KIT_REDIRECT_URI = 'http://localhost:9876/callback'; + assert.equal(getOAuthRedirectUri(), 'http://localhost:9876/callback'); + delete process.env.KIT_REDIRECT_URI; + }); + + test('returns empty string when neither env nor config has a value', () => { + // With no env var and a fresh config, redirect URI defaults to '' + const val = getOAuthRedirectUri(); + assert.equal(typeof val, 'string'); + }); +}); diff --git a/scripts/output.test.js b/scripts/output.test.js new file mode 100644 index 0000000..e65d756 --- /dev/null +++ b/scripts/output.test.js @@ -0,0 +1,362 @@ +/** + * Tests for src/output.js + */ +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { + formatOutput, + printDetail, + printSuccess, + printError, + printPagination, + withErrorHandler, +} from '../src/output.js'; +import { KitApiError } from '../src/client.js'; + +// ── helpers ──────────────────────────────────────────────────────────────── + +/** Redirect console.log + console.error and return captured lines. */ +function capture() { + const logs = []; + const errors = []; + const origLog = console.log; + const origErr = console.error; + console.log = (...args) => logs.push(args.join(' ')); + console.error = (...args) => errors.push(args.join(' ')); + return { + logs, + errors, + restore() { + console.log = origLog; + console.error = origErr; + }, + }; +} + +const SAMPLE_COLUMNS = [ + { header: 'ID', accessor: (r) => r.id }, + { header: 'Email', accessor: (r) => r.email }, +]; + +const SAMPLE_FIELDS = [ + { label: 'ID', accessor: (d) => d.id }, + { label: 'Email', accessor: (d) => d.email }, + { label: 'State', accessor: (d) => d.state }, +]; + +// ── printSuccess ─────────────────────────────────────────────────────────── + +describe('printSuccess', () => { + test('logs the message text', () => { + const c = capture(); + printSuccess('Subscriber created'); + c.restore(); + assert.equal(c.logs.length, 1); + assert.ok(c.logs[0].includes('Subscriber created')); + }); + + test('includes a Unicode checkmark (✓)', () => { + const c = capture(); + printSuccess('Done'); + c.restore(); + assert.ok(c.logs[0].includes('\u2713')); + }); + + test('writes to console.log, not console.error', () => { + const c = capture(); + printSuccess('OK'); + c.restore(); + assert.equal(c.errors.length, 0); + }); +}); + +// ── printError ───────────────────────────────────────────────────────────── + +describe('printError', () => { + test('formats a KitApiError with status and message', () => { + const c = capture(); + printError(new KitApiError(404, ['Not found'])); + c.restore(); + assert.equal(c.errors.length, 1); + assert.ok(c.errors[0].includes('404')); + assert.ok(c.errors[0].includes('Not found')); + }); + + test('formats a KitApiError with multiple errors', () => { + const c = capture(); + printError(new KitApiError(422, ['Email invalid', 'Name too long'])); + c.restore(); + assert.ok(c.errors[0].includes('Email invalid')); + assert.ok(c.errors[0].includes('Name too long')); + }); + + test('formats a generic Error with its message', () => { + const c = capture(); + printError(new Error('Network failure')); + c.restore(); + assert.equal(c.errors.length, 1); + assert.ok(c.errors[0].includes('Network failure')); + }); + + test('writes to console.error, not console.log', () => { + const c = capture(); + printError(new Error('Oops')); + c.restore(); + assert.equal(c.logs.length, 0); + }); +}); + +// ── printPagination ──────────────────────────────────────────────────────── + +describe('printPagination', () => { + test('prints nothing for null', () => { + const c = capture(); + printPagination(null); + c.restore(); + assert.equal(c.logs.length, 0); + }); + + test('prints nothing when no next or previous page', () => { + const c = capture(); + printPagination({ has_next_page: false, has_previous_page: false }); + c.restore(); + assert.equal(c.logs.length, 0); + }); + + test('shows next cursor when has_next_page is true', () => { + const c = capture(); + printPagination({ has_next_page: true, end_cursor: 'abc123', has_previous_page: false }); + c.restore(); + assert.equal(c.logs.length, 1); + assert.ok(c.logs[0].includes('--after abc123')); + }); + + test('shows prev cursor when has_previous_page is true', () => { + const c = capture(); + printPagination({ has_previous_page: true, start_cursor: 'xyz789', has_next_page: false }); + c.restore(); + assert.equal(c.logs.length, 1); + assert.ok(c.logs[0].includes('--before xyz789')); + }); + + test('shows both cursors when both pages are available', () => { + const c = capture(); + printPagination({ + has_previous_page: true, start_cursor: 'prev-cur', + has_next_page: true, end_cursor: 'next-cur', + }); + c.restore(); + assert.equal(c.logs.length, 1); + assert.ok(c.logs[0].includes('--before prev-cur')); + assert.ok(c.logs[0].includes('--after next-cur')); + }); + + test('cursor values appear verbatim', () => { + const c = capture(); + printPagination({ has_next_page: true, end_cursor: 'eyJpZCI6OTl9' }); + c.restore(); + assert.ok(c.logs[0].includes('eyJpZCI6OTl9')); + }); +}); + +// ── withErrorHandler ─────────────────────────────────────────────────────── + +describe('withErrorHandler', () => { + test('returns a function', () => { + assert.equal(typeof withErrorHandler(async () => {}), 'function'); + }); + + test('passes all arguments through to the wrapped function', async () => { + let received; + const wrapped = withErrorHandler(async (...args) => { received = args; }); + await wrapped('a', 'b', 'c'); + assert.deepEqual(received, ['a', 'b', 'c']); + }); + + test('resolves normally when wrapped function succeeds', async () => { + let executed = false; + const wrapped = withErrorHandler(async () => { executed = true; }); + await wrapped(); + assert.ok(executed); + }); + + test('calls process.exit(1) when wrapped function throws', async () => { + const origExit = process.exit; + let exitCode; + process.exit = (c) => { exitCode = c; }; + const c = capture(); + await withErrorHandler(async () => { throw new Error('Boom'); })(); + c.restore(); + process.exit = origExit; + assert.equal(exitCode, 1); + }); + + test('prints the error before exiting', async () => { + const origExit = process.exit; + process.exit = () => {}; + const c = capture(); + await withErrorHandler(async () => { throw new Error('Test error message'); })(); + c.restore(); + process.exit = origExit; + assert.ok(c.errors[0].includes('Test error message')); + }); + + test('handles KitApiError and includes status code', async () => { + const origExit = process.exit; + process.exit = () => {}; + const c = capture(); + await withErrorHandler(async () => { throw new KitApiError(403, ['Forbidden']); })(); + c.restore(); + process.exit = origExit; + assert.ok(c.errors[0].includes('403')); + assert.ok(c.errors[0].includes('Forbidden')); + }); +}); + +// ── formatOutput ─────────────────────────────────────────────────────────── + +describe('formatOutput', () => { + test('emits pretty-printed JSON when format is "json"', () => { + const data = [{ id: 1, email: 'a@b.com' }]; + const c = capture(); + formatOutput(data, SAMPLE_COLUMNS, { format: 'json' }); + c.restore(); + assert.equal(c.logs.length, 1); + assert.deepEqual(JSON.parse(c.logs[0]), data); + }); + + test('JSON output is indented (pretty-printed)', () => { + const c = capture(); + formatOutput([{ id: 1 }], [], { format: 'json' }); + c.restore(); + assert.ok(c.logs[0].includes('\n')); + }); + + test('prints "No results found" for an empty array in table mode', () => { + const c = capture(); + formatOutput([], SAMPLE_COLUMNS, { format: 'table' }); + c.restore(); + assert.ok(c.logs[0].includes('No results found')); + }); + + test('prints "No results found" for null in table mode', () => { + const c = capture(); + formatOutput(null, SAMPLE_COLUMNS, { format: 'table' }); + c.restore(); + assert.ok(c.logs[0].includes('No results found')); + }); + + test('includes column header values in table output', () => { + const c = capture(); + formatOutput([{ id: 1, email: 'x@y.com' }], SAMPLE_COLUMNS, { format: 'table' }); + c.restore(); + const out = c.logs.join('\n'); + assert.ok(out.includes('ID')); + assert.ok(out.includes('Email')); + }); + + test('includes row data values in table output', () => { + const c = capture(); + formatOutput([{ id: 42, email: 'hello@example.com' }], SAMPLE_COLUMNS, { format: 'table' }); + c.restore(); + const out = c.logs.join('\n'); + assert.ok(out.includes('42')); + assert.ok(out.includes('hello@example.com')); + }); + + test('renders null accessor value as a dash', () => { + const c = capture(); + formatOutput([{ id: null, email: undefined }], SAMPLE_COLUMNS, { format: 'table' }); + c.restore(); + const out = c.logs.join('\n'); + assert.ok(out.includes('-')); + }); + + test('shows N result(s) count for array data', () => { + const c = capture(); + formatOutput([{ id: 1 }, { id: 2 }, { id: 3 }], SAMPLE_COLUMNS, { format: 'table' }); + c.restore(); + const out = c.logs.join('\n'); + assert.ok(out.includes('3 result(s)')); + }); + + test('does NOT show result count for a single non-array object', () => { + const c = capture(); + formatOutput({ id: 1, email: 'a@b.com' }, SAMPLE_COLUMNS, { format: 'table' }); + c.restore(); + const out = c.logs.join('\n'); + assert.ok(!out.includes('result(s)')); + }); + + test('renders a single object as a one-row table', () => { + const c = capture(); + formatOutput({ id: 7, email: 'single@example.com' }, SAMPLE_COLUMNS, { format: 'table' }); + c.restore(); + const out = c.logs.join('\n'); + assert.ok(out.includes('7')); + assert.ok(out.includes('single@example.com')); + }); +}); + +// ── printDetail ──────────────────────────────────────────────────────────── + +describe('printDetail', () => { + test('emits pretty-printed JSON when format is "json"', () => { + const data = { id: 1, email: 'a@b.com', state: 'active' }; + const c = capture(); + printDetail(data, SAMPLE_FIELDS, { format: 'json' }); + c.restore(); + assert.deepEqual(JSON.parse(c.logs[0]), data); + }); + + test('prints one line per field in table mode', () => { + const data = { id: 5, email: 'x@y.com', state: 'active' }; + const c = capture(); + printDetail(data, SAMPLE_FIELDS, { format: 'table' }); + c.restore(); + assert.equal(c.logs.length, SAMPLE_FIELDS.length); + }); + + test('each line contains the field label', () => { + const data = { id: 5, email: 'x@y.com', state: 'active' }; + const c = capture(); + printDetail(data, SAMPLE_FIELDS, { format: 'table' }); + c.restore(); + assert.ok(c.logs[0].includes('ID')); + assert.ok(c.logs[1].includes('Email')); + assert.ok(c.logs[2].includes('State')); + }); + + test('each line contains the field value', () => { + const data = { id: 99, email: 'detail@example.com', state: 'inactive' }; + const c = capture(); + printDetail(data, SAMPLE_FIELDS, { format: 'table' }); + c.restore(); + assert.ok(c.logs[0].includes('99')); + assert.ok(c.logs[1].includes('detail@example.com')); + assert.ok(c.logs[2].includes('inactive')); + }); + + test('renders null accessor value as a dash', () => { + const data = { id: null, email: undefined, state: 'active' }; + const c = capture(); + printDetail(data, SAMPLE_FIELDS, { format: 'table' }); + c.restore(); + assert.ok(c.logs[0].includes('-')); + assert.ok(c.logs[1].includes('-')); + }); + + test('labels are padded to the same width', () => { + const fields = [ + { label: 'ID', accessor: (d) => d.id }, + { label: 'Email Address', accessor: (d) => d.email }, + ]; + const c = capture(); + printDetail({ id: 1, email: 'a@b.com' }, fields, { format: 'table' }); + c.restore(); + // Both lines should start with the label padded to the same column + const firstLabelEnd = c.logs[0].indexOf('1'); + const secondLabelEnd = c.logs[1].indexOf('a@'); + assert.equal(firstLabelEnd, secondLabelEnd); + }); +});