diff --git a/.gitignore b/.gitignore index 69d7555..ce5f46f 100644 --- a/.gitignore +++ b/.gitignore @@ -21,5 +21,8 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts +# coverage +/coverage + # local docs /docs diff --git a/AGENTS.md b/AGENTS.md index bb5f39d..04454ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,7 @@ This file provides guidance to AI agents when working with code in this reposito pnpm build # Build CLI with tsup (output: dist/) pnpm dev # Run CLI from source via tsx (e.g. pnpm dev -- members list) pnpm test # Run all tests with vitest +pnpm test:coverage # Run tests with v8 coverage reporting pnpm check # Lint and format check (Biome via ultracite) pnpm fix # Auto-fix lint and format issues pnpm type-check # TypeScript type checking without emit diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f03c27c..c3c9deb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -2,7 +2,7 @@ > **Project**: Memberstack CLI > **Repository**: https://github.com/Flash-Brew-Digital/memberstack-cli -> **Last updated**: 2026-02-17 +> **Last updated**: 2026-02-22 ## Overview @@ -57,16 +57,20 @@ memberstack-cli/ │ │ │ └── core/ # Core library tests │ ├── auth.test.ts +│ ├── csv.test.ts │ ├── graphql-client.test.ts +│ ├── index.test.ts │ ├── no-color.test.ts │ ├── oauth.test.ts +│ ├── program.test.ts │ ├── program-options.test.ts │ ├── quiet.test.ts +│ ├── token-storage.test.ts │ └── utils.test.ts │ ├── dist/ # Compiled output (ESM) ├── tsup.config.ts # Bundler config (esbuild via tsup) -├── vitest.config.ts # Test config (mockReset, restoreMocks) +├── vitest.config.ts # Test config (mockReset, restoreMocks, v8 coverage) ├── biome.jsonc # Linter/formatter (Biome via Ultracite) └── package.json # Node >=20, pnpm, type: module ``` @@ -168,12 +172,12 @@ All user-facing output (tables, spinners, messages) goes to **stderr**. JSON out | `open` | Opens browser for OAuth login | | `papaparse` | CSV parsing and generation | -Dev: `tsup` (bundler), `tsx` (dev runner), `typescript`, `vitest` (tests), `biome` via `ultracite` (lint/format). +Dev: `tsup` (bundler), `tsx` (dev runner), `typescript`, `vitest` (tests), `@vitest/coverage-v8` (coverage), `biome` via `ultracite` (lint/format). ## Build & CI - **Build**: `tsup` compiles `src/index.ts` to ESM in `dist/` -- **Test**: `vitest` with mocked GraphQL client and spinner, covers all commands and core libraries +- **Test**: `vitest` with mocked GraphQL client and spinner, covers all commands and core libraries (`pnpm test:coverage` for v8 coverage report) - **Lint**: Biome via `ultracite` (`pnpm check` / `pnpm fix`) - **Type check**: `tsc --noEmit` (`pnpm type-check`) - **PR checks** (`.github/workflows/pr-checks.yml`): type-check, lint, build, test on Node 24 diff --git a/package.json b/package.json index a1a36c7..8b6a4f4 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "build": "tsup", "dev": "tsx src/index.ts", "test": "vitest run", + "test:coverage": "vitest run --coverage", "type-check": "tsc --noEmit", "check": "ultracite check", "fix": "ultracite fix", @@ -69,6 +70,7 @@ "@biomejs/biome": "2.4.2", "@types/node": "^25.2.3", "@types/papaparse": "^5.5.2", + "@vitest/coverage-v8": "^4.0.18", "lint-staged": "^16.2.7", "tsup": "^8.5.1", "tsx": "^4.21.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 38f0cff..03aeba7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,6 +36,9 @@ importers: '@types/papaparse': specifier: ^5.5.2 version: 5.5.2 + '@vitest/coverage-v8': + specifier: ^4.0.18 + version: 4.0.18(vitest@4.0.18(@types/node@25.2.3)(tsx@4.21.0)(yaml@2.8.2)) lint-staged: specifier: ^16.2.7 version: 16.2.7 @@ -57,6 +60,27 @@ importers: packages: + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@biomejs/biome@2.4.2': resolution: {integrity: sha512-vVE/FqLxNLbvYnFDYg3Xfrh1UdFhmPT5i+yPT9GE2nTUgI4rkqo5krw5wK19YHBd7aE7J6r91RRmb8RWwkjy6w==} engines: {node: '>=14.21.3'} @@ -453,6 +477,15 @@ packages: '@types/papaparse@5.5.2': resolution: {integrity: sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==} + '@vitest/coverage-v8@4.0.18': + resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==} + peerDependencies: + '@vitest/browser': 4.0.18 + vitest: 4.0.18 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@4.0.18': resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} @@ -510,6 +543,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@0.3.11: + resolution: {integrity: sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==} + balanced-match@4.0.2: resolution: {integrity: sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==} engines: {node: 20 || >=22} @@ -662,6 +698,13 @@ packages: resolution: {integrity: sha512-/g3B0mC+4x724v1TgtBlBtt2hPi/EWptsIAmXUx9Z2rvBYleQcsrmaOzd5LyL50jf/Soi83ZDJmw2+XqvH/EeA==} engines: {node: 20 || >=22} + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + is-docker@3.0.0: resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -692,6 +735,18 @@ packages: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + jackspeak@4.2.3: resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} engines: {node: 20 || >=22} @@ -700,6 +755,9 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + jsonc-parser@3.3.1: resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} @@ -734,6 +792,13 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.2: + resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -874,6 +939,11 @@ packages: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -931,6 +1001,10 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -1113,6 +1187,21 @@ packages: snapshots: + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bcoe/v8-coverage@1.0.2': {} + '@biomejs/biome@2.4.2': optionalDependencies: '@biomejs/cli-darwin-arm64': 2.4.2 @@ -1350,6 +1439,20 @@ snapshots: dependencies: '@types/node': 25.2.3 + '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@25.2.3)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.0.18 + ast-v8-to-istanbul: 0.3.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.2 + obug: 2.1.1 + std-env: 3.10.0 + tinyrainbow: 3.0.3 + vitest: 4.0.18(@types/node@25.2.3)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/expect@4.0.18': dependencies: '@standard-schema/spec': 1.1.0 @@ -1405,6 +1508,12 @@ snapshots: assertion-error@2.0.1: {} + ast-v8-to-istanbul@0.3.11: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + balanced-match@4.0.2: dependencies: jackspeak: 4.2.3 @@ -1550,6 +1659,10 @@ snapshots: minipass: 7.1.2 path-scurry: 2.0.1 + has-flag@4.0.0: {} + + html-escaper@2.0.2: {} + is-docker@3.0.0: {} is-fullwidth-code-point@3.0.0: {} @@ -1570,12 +1683,27 @@ snapshots: dependencies: is-inside-container: 1.0.0 + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + jackspeak@4.2.3: dependencies: '@isaacs/cliui': 9.0.0 joycon@3.1.1: {} + js-tokens@10.0.0: {} + jsonc-parser@3.3.1: {} lilconfig@3.1.3: {} @@ -1617,6 +1745,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.2: + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.4 + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -1759,6 +1897,8 @@ snapshots: run-applescript@7.1.0: {} + semver@7.7.4: {} + siginfo@2.0.0: {} signal-exit@4.1.0: {} @@ -1815,6 +1955,10 @@ snapshots: tinyglobby: 0.2.15 ts-interface-checker: 0.1.13 + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + thenify-all@1.6.0: dependencies: thenify: 3.3.1 diff --git a/tests/commands/apps.test.ts b/tests/commands/apps.test.ts index 2506486..0d9f4ce 100644 --- a/tests/commands/apps.test.ts +++ b/tests/commands/apps.test.ts @@ -91,8 +91,128 @@ describe("apps", () => { ); }); - it("handles graphql errors gracefully", async () => { - graphqlRequest.mockRejectedValueOnce(new Error("Network error")); + it("create sends optional wordpress and template fields", async () => { + const app = { id: "app_3", name: "WP App", status: "ACTIVE" }; + graphqlRequest.mockResolvedValueOnce({ createApp: app }); + + await runCommand(appsCommand, [ + "create", + "--name", + "WP App", + "--stack", + "WORDPRESS", + "--wordpress-page-builder", + "ELEMENTOR", + "--template-id", + "tmpl_1", + ]); + + const call = graphqlRequest.mock.calls[0][0]; + expect(call.variables.input.wordpressPageBuilder).toBe("ELEMENTOR"); + expect(call.variables.input.templateId).toBe("tmpl_1"); + }); + + it("update sends boolean and numeric fields", async () => { + const app = { id: "app_1", name: "My App", status: "ACTIVE" }; + graphqlRequest.mockResolvedValueOnce({ updateApp: app }); + + await runCommand(appsCommand, [ + "update", + "--captcha-enabled", + "--prevent-disposable-emails", + "--require-user-2fa", + "--disable-concurrent-logins", + "--member-session-duration-days", + "30", + "--allow-member-self-delete", + ]); + + const call = graphqlRequest.mock.calls[0][0]; + expect(call.variables.input.captchaEnabled).toBe(true); + expect(call.variables.input.preventDisposableEmails).toBe(true); + expect(call.variables.input.requireUser2FA).toBe(true); + expect(call.variables.input.disableConcurrentLogins).toBe(true); + expect(call.variables.input.memberSessionDurationDays).toBe(30); + expect(call.variables.input.allowMemberSelfDelete).toBe(true); + }); + + it("update sends stack, status, and business fields", async () => { + const app = { id: "app_1", name: "My App", status: "ACTIVE" }; + graphqlRequest.mockResolvedValueOnce({ updateApp: app }); + + await runCommand(appsCommand, [ + "update", + "--stack", + "WEBFLOW", + "--status", + "ACTIVE", + "--business-entity-name", + "Acme Inc", + "--terms-of-service-url", + "https://example.com/tos", + "--privacy-policy-url", + "https://example.com/privacy", + "--wordpress-page-builder", + "GUTENBERG", + ]); + + const call = graphqlRequest.mock.calls[0][0]; + expect(call.variables.input.stack).toBe("WEBFLOW"); + expect(call.variables.input.status).toBe("ACTIVE"); + expect(call.variables.input.businessEntityName).toBe("Acme Inc"); + expect(call.variables.input.termsOfServiceURL).toBe( + "https://example.com/tos" + ); + expect(call.variables.input.privacyPolicyURL).toBe( + "https://example.com/privacy" + ); + expect(call.variables.input.wordpressPageBuilder).toBe("GUTENBERG"); + }); + + it("create handles errors gracefully", async () => { + graphqlRequest.mockRejectedValueOnce(new Error("Validation error")); + + const original = process.exitCode; + await runCommand(appsCommand, [ + "create", + "--name", + "Bad", + "--stack", + "REACT", + ]); + expect(process.exitCode).toBe(1); + process.exitCode = original; + }); + + it("update handles errors gracefully", async () => { + graphqlRequest.mockRejectedValueOnce(new Error("Update failed")); + + const original = process.exitCode; + await runCommand(appsCommand, ["update", "--name", "Test"]); + expect(process.exitCode).toBe(1); + process.exitCode = original; + }); + + it("delete handles errors gracefully", async () => { + graphqlRequest.mockRejectedValueOnce(new Error("Not found")); + + const original = process.exitCode; + await runCommand(appsCommand, ["delete", "--app-id", "app_bad"]); + expect(process.exitCode).toBe(1); + process.exitCode = original; + }); + + it("restore handles errors gracefully", async () => { + graphqlRequest.mockRejectedValueOnce(new Error("Cannot restore")); + + const original = process.exitCode; + await runCommand(appsCommand, ["restore", "--app-id", "app_bad"]); + expect(process.exitCode).toBe(1); + process.exitCode = original; + }); + + it("handles non-Error exceptions", async () => { + graphqlRequest.mockRejectedValueOnce("string error"); const original = process.exitCode; await runCommand(appsCommand, ["current"]); diff --git a/tests/commands/custom-fields.test.ts b/tests/commands/custom-fields.test.ts index b477df5..6ce7d9a 100644 --- a/tests/commands/custom-fields.test.ts +++ b/tests/commands/custom-fields.test.ts @@ -115,9 +115,87 @@ describe("custom-fields", () => { ); }); - it("handles errors gracefully", async () => { + it("create with plan-ids", async () => { + graphqlRequest.mockResolvedValueOnce({ createCustomField: mockField }); + + await runCommand(customFieldsCommand, [ + "create", + "--key", + "role", + "--label", + "Role", + "--plan-ids", + "pln_1", + "pln_2", + ]); + + const call = graphqlRequest.mock.calls[0][0]; + expect(call.variables.input.planIds).toEqual(["pln_1", "pln_2"]); + }); + + it("update with optional fields", async () => { + graphqlRequest.mockResolvedValueOnce({ updateCustomField: mockField }); + + await runCommand(customFieldsCommand, [ + "update", + "cf_1", + "--label", + "Updated", + "--hidden", + "--table-hidden", + "--visibility", + "PRIVATE", + "--restrict-to-admin", + ]); + + const call = graphqlRequest.mock.calls[0][0]; + expect(call.variables.input.hidden).toBe(true); + expect(call.variables.input.tableHidden).toBe(true); + expect(call.variables.input.visibility).toBe("PRIVATE"); + expect(call.variables.input.restrictToAdmin).toBe(true); + }); + + it("create handles errors gracefully", async () => { + graphqlRequest.mockRejectedValueOnce(new Error("Validation error")); + + const original = process.exitCode; + await runCommand(customFieldsCommand, [ + "create", + "--key", + "bad", + "--label", + "Bad", + ]); + expect(process.exitCode).toBe(1); + process.exitCode = original; + }); + + it("update handles errors gracefully", async () => { + graphqlRequest.mockRejectedValueOnce(new Error("Not found")); + + const original = process.exitCode; + await runCommand(customFieldsCommand, [ + "update", + "cf_bad", + "--label", + "Test", + ]); + expect(process.exitCode).toBe(1); + process.exitCode = original; + }); + + it("delete handles errors gracefully", async () => { graphqlRequest.mockRejectedValueOnce(new Error("Forbidden")); + const original = process.exitCode; + await runCommand(customFieldsCommand, ["delete", "cf_bad"]); + expect(process.exitCode).toBe(1); + process.exitCode = original; + }); + + it("handles non-Error exceptions", async () => { + graphqlRequest.mockRejectedValueOnce("string error"); + const original = process.exitCode; await runCommand(customFieldsCommand, ["list"]); expect(process.exitCode).toBe(1); diff --git a/tests/commands/members.test.ts b/tests/commands/members.test.ts index d26bd64..77fed96 100644 --- a/tests/commands/members.test.ts +++ b/tests/commands/members.test.ts @@ -5,12 +5,15 @@ vi.mock("yocto-spinner", () => ({ default: () => createMockSpinner() })); vi.mock("../../src/lib/program.js", () => ({ program: { opts: () => ({}) }, })); +const readInputFile = vi.fn(); +const writeOutputFile = vi.fn(); vi.mock("../../src/lib/csv.js", () => ({ - readInputFile: vi.fn(), - writeOutputFile: vi.fn(), + readInputFile: (...args: unknown[]) => readInputFile(...args), + writeOutputFile: (...args: unknown[]) => writeOutputFile(...args), })); +const mockWriteFile = vi.fn().mockResolvedValue(undefined); vi.mock("node:fs/promises", () => ({ - writeFile: vi.fn().mockResolvedValue(undefined), + writeFile: (...args: unknown[]) => mockWriteFile(...args), })); const graphqlRequest = vi.fn(); @@ -238,4 +241,444 @@ describe("members", () => { expect(process.exitCode).toBe(1); process.exitCode = original; }); + + it("list writes members to file", async () => { + graphqlRequest.mockResolvedValueOnce({ + getMembers: { + edges: [{ node: mockMember }], + pageInfo: { endCursor: null }, + }, + }); + + await runCommand(membersCommand, ["list"]); + + expect(mockWriteFile).toHaveBeenCalledWith( + expect.stringContaining("members.json"), + expect.stringContaining("mem_1") + ); + }); + + it("list --all auto-paginates across multiple pages", async () => { + const edges = Array.from({ length: 200 }, (_, i) => ({ + node: { ...mockMember, id: `mem_${i}` }, + })); + graphqlRequest + .mockResolvedValueOnce({ + getMembers: { edges, pageInfo: { endCursor: "c1" } }, + }) + .mockResolvedValueOnce({ + getMembers: { + edges: [{ node: { ...mockMember, id: "mem_200" } }], + pageInfo: { endCursor: null }, + }, + }); + + await runCommand(membersCommand, ["list", "--all"]); + + expect(graphqlRequest).toHaveBeenCalledTimes(2); + const written = JSON.parse(mockWriteFile.mock.calls[0][1] as string); + expect(written).toHaveLength(201); + }); + + it("list --order DESC reverses results", async () => { + const mem2 = { ...mockMember, id: "mem_2", auth: { email: "b@test.com" } }; + graphqlRequest.mockResolvedValueOnce({ + getMembers: { + edges: [{ node: mockMember }, { node: mem2 }], + pageInfo: { endCursor: null }, + }, + }); + + await runCommand(membersCommand, ["list", "--order", "DESC"]); + + const written = JSON.parse(mockWriteFile.mock.calls[0][1] as string); + expect(written[0].id).toBe("mem_2"); + }); + + it("list handles empty result", async () => { + graphqlRequest.mockResolvedValueOnce({ + getMembers: { edges: [], pageInfo: { endCursor: null } }, + }); + + await runCommand(membersCommand, ["list"]); + + expect(mockWriteFile).not.toHaveBeenCalled(); + }); + + it("create passes plans, custom fields, meta data, and login redirect", async () => { + graphqlRequest.mockResolvedValueOnce({ + signupMemberEmailPassword: { member: mockMember }, + }); + + await runCommand(membersCommand, [ + "create", + "--email", + "new@test.com", + "--password", + "secret123", + "--plans", + "pln_1", + "--custom-fields", + "company=Acme", + "--meta-data", + "source=cli", + "--login-redirect", + "https://example.com", + ]); + + const call = graphqlRequest.mock.calls[0][0]; + expect(call.variables.input.plans).toEqual([{ planId: "pln_1" }]); + expect(call.variables.input.customFields).toEqual({ company: "Acme" }); + expect(call.variables.input.metaData).toEqual({ source: "cli" }); + expect(call.variables.input.loginRedirect).toBe("https://example.com"); + }); + + it("update --email sends separate updateMemberAuth mutation", async () => { + graphqlRequest + .mockResolvedValueOnce({ updateMemberAuth: mockMember }) + .mockResolvedValueOnce({ updateMember: mockMember }); + + await runCommand(membersCommand, [ + "update", + "mem_1", + "--email", + "new@test.com", + "--custom-fields", + "company=Acme", + ]); + + expect(graphqlRequest.mock.calls[0][0].query).toContain("updateMemberAuth"); + expect(graphqlRequest.mock.calls[0][0].variables.input).toEqual({ + memberId: "mem_1", + email: "new@test.com", + }); + expect(graphqlRequest.mock.calls[1][0].query).toContain("updateMember"); + }); + + it("update with no options prints error", async () => { + const original = process.exitCode; + await runCommand(membersCommand, ["update", "mem_1"]); + expect(process.exitCode).toBe(1); + process.exitCode = original; + }); + + it("export fetches all members and writes output", async () => { + graphqlRequest.mockResolvedValueOnce({ + getMembers: { + edges: [{ node: mockMember }], + pageInfo: { endCursor: null }, + }, + }); + writeOutputFile.mockResolvedValueOnce(undefined); + + await runCommand(membersCommand, ["export", "--format", "csv"]); + + expect(writeOutputFile).toHaveBeenCalledWith( + expect.stringContaining("members.csv"), + expect.arrayContaining([ + expect.objectContaining({ email: "test@example.com" }), + ]), + "csv" + ); + }); + + it("import creates members from file rows", async () => { + readInputFile.mockResolvedValueOnce([ + { email: "a@test.com", password: "pass1" }, + { email: "b@test.com", password: "pass2" }, + ]); + graphqlRequest + .mockResolvedValueOnce({ + signupMemberEmailPassword: { member: mockMember }, + }) + .mockResolvedValueOnce({ + signupMemberEmailPassword: { member: mockMember }, + }); + + await runCommand(membersCommand, ["import", "--file", "members.csv"]); + + expect(readInputFile).toHaveBeenCalledWith("members.csv"); + expect(graphqlRequest).toHaveBeenCalledTimes(2); + }); + + it("import skips rows missing email or password", async () => { + readInputFile.mockResolvedValueOnce([{ email: "a@test.com" }]); + + await runCommand(membersCommand, ["import", "--file", "members.csv"]); + + expect(graphqlRequest).not.toHaveBeenCalled(); + }); + + it("import continues on row failure", async () => { + readInputFile.mockResolvedValueOnce([ + { email: "a@test.com", password: "pass1" }, + { email: "b@test.com", password: "pass2" }, + ]); + graphqlRequest + .mockRejectedValueOnce(new Error("Duplicate")) + .mockResolvedValueOnce({ + signupMemberEmailPassword: { member: mockMember }, + }); + + await runCommand(membersCommand, ["import", "--file", "members.csv"]); + + expect(graphqlRequest).toHaveBeenCalledTimes(2); + }); + + it("import passes plans, login redirect, and prefixed fields", async () => { + readInputFile.mockResolvedValueOnce([ + { + email: "a@test.com", + password: "pass1", + plans: "pln_1, pln_2", + loginRedirect: "/dashboard", + "customFields.company": "Acme", + "metaData.source": "csv", + }, + ]); + graphqlRequest.mockResolvedValueOnce({ + signupMemberEmailPassword: { member: mockMember }, + }); + + await runCommand(membersCommand, ["import", "--file", "members.csv"]); + + const input = graphqlRequest.mock.calls[0][0].variables.input; + expect(input.plans).toEqual([{ planId: "pln_1" }, { planId: "pln_2" }]); + expect(input.loginRedirect).toBe("/dashboard"); + expect(input.customFields).toEqual({ company: "Acme" }); + expect(input.metaData).toEqual({ source: "csv" }); + }); + + it("find filters by custom field values", async () => { + const matched = { ...mockMember, customFields: { company: "Acme" } }; + const unmatched = { + ...mockMember, + id: "mem_2", + customFields: { company: "Other" }, + }; + graphqlRequest.mockResolvedValueOnce({ + getMembers: { + edges: [{ node: matched }, { node: unmatched }], + pageInfo: { endCursor: null }, + }, + }); + + await runCommand(membersCommand, ["find", "--field", "company=Acme"]); + + expect(graphqlRequest).toHaveBeenCalled(); + }); + + it("find with --plan uses server-side filter", async () => { + graphqlRequest.mockResolvedValueOnce({ + getMembers: { + edges: [{ node: mockMember }], + pageInfo: { endCursor: null }, + }, + }); + + await runCommand(membersCommand, ["find", "--plan", "pln_1"]); + + expect(graphqlRequest).toHaveBeenCalledWith( + expect.objectContaining({ + variables: expect.objectContaining({ + filters: { planIds: ["pln_1"] }, + }), + }) + ); + }); + + it("find with --plan and --field fetches all then filters locally", async () => { + const memberWithPlan = { + ...mockMember, + customFields: { company: "Acme" }, + planConnections: [ + { + id: "pc_1", + status: "ACTIVE", + type: "FREE", + active: true, + plan: { id: "pln_1", name: "Free" }, + }, + ], + }; + graphqlRequest.mockResolvedValueOnce({ + getMembers: { + edges: [{ node: memberWithPlan }], + pageInfo: { endCursor: null }, + }, + }); + + await runCommand(membersCommand, [ + "find", + "--plan", + "pln_1", + "--field", + "company=Acme", + ]); + + // Should NOT pass planIds filter since --field is also present + expect(graphqlRequest).toHaveBeenCalledWith( + expect.objectContaining({ + variables: expect.not.objectContaining({ filters: expect.anything() }), + }) + ); + }); + + it("stats computes member statistics", async () => { + const activeMember = { + ...mockMember, + planConnections: [ + { + id: "pc_1", + status: "ACTIVE", + type: "FREE", + active: true, + plan: { id: "pln_1", name: "Free" }, + }, + ], + }; + graphqlRequest.mockResolvedValueOnce({ + getMembers: { + edges: [{ node: activeMember }, { node: mockMember }], + pageInfo: { endCursor: null }, + }, + }); + + await runCommand(membersCommand, ["stats"]); + + expect(graphqlRequest).toHaveBeenCalled(); + }); + + it("bulk-update processes rows and updates members", async () => { + readInputFile.mockResolvedValueOnce([ + { id: "mem_1", "customFields.company": "Acme" }, + ]); + graphqlRequest.mockResolvedValueOnce({ updateMember: mockMember }); + + await runCommand(membersCommand, ["bulk-update", "--file", "updates.csv"]); + + expect(graphqlRequest).toHaveBeenCalledWith( + expect.objectContaining({ + variables: expect.objectContaining({ + input: expect.objectContaining({ + memberId: "mem_1", + customFields: { company: "Acme" }, + }), + }), + }) + ); + }); + + it("bulk-update --dry-run previews without calling API", async () => { + readInputFile.mockResolvedValueOnce([ + { id: "mem_1", "customFields.company": "Acme" }, + ]); + + await runCommand(membersCommand, [ + "bulk-update", + "--file", + "updates.csv", + "--dry-run", + ]); + + expect(graphqlRequest).not.toHaveBeenCalled(); + }); + + it("bulk-update skips rows missing id", async () => { + readInputFile.mockResolvedValueOnce([{ email: "a@test.com" }]); + + await runCommand(membersCommand, ["bulk-update", "--file", "updates.csv"]); + + expect(graphqlRequest).not.toHaveBeenCalled(); + }); + + it("bulk-update with email triggers updateMemberAuth", async () => { + readInputFile.mockResolvedValueOnce([ + { id: "mem_1", email: "new@test.com" }, + ]); + graphqlRequest.mockResolvedValueOnce({ updateMemberAuth: mockMember }); + + await runCommand(membersCommand, ["bulk-update", "--file", "updates.csv"]); + + expect(graphqlRequest.mock.calls[0][0].query).toContain("updateMemberAuth"); + }); + + it("bulk-add-plan with no-plan filter targets only planless members", async () => { + const noPlan = { ...mockMember, planConnections: [] }; + const hasPlan = { + ...mockMember, + id: "mem_2", + auth: { email: "b@test.com" }, + planConnections: [ + { + id: "pc_1", + status: "ACTIVE", + type: "FREE", + active: true, + plan: { id: "pln_1", name: "Free" }, + }, + ], + }; + graphqlRequest + .mockResolvedValueOnce({ + getMembers: { + edges: [{ node: noPlan }, { node: hasPlan }], + pageInfo: { endCursor: null }, + }, + }) + .mockResolvedValueOnce({ addFreePlan: { id: "pln_1", name: "Free" } }); + + await runCommand(membersCommand, [ + "bulk-add-plan", + "--plan", + "pln_1", + "--filter", + "no-plan", + ]); + + const addCalls = graphqlRequest.mock.calls.filter((c) => + c[0].query.includes("addFreePlan") + ); + expect(addCalls).toHaveLength(1); + }); + + it("bulk-add-plan --dry-run previews without adding plans", async () => { + graphqlRequest.mockResolvedValueOnce({ + getMembers: { + edges: [{ node: mockMember }], + pageInfo: { endCursor: null }, + }, + }); + + await runCommand(membersCommand, [ + "bulk-add-plan", + "--plan", + "pln_1", + "--filter", + "all", + "--dry-run", + ]); + + expect(graphqlRequest).toHaveBeenCalledTimes(1); + }); + + it("bulk-add-plan rejects unknown filter", async () => { + graphqlRequest.mockResolvedValueOnce({ + getMembers: { + edges: [], + pageInfo: { endCursor: null }, + }, + }); + + const original = process.exitCode; + await runCommand(membersCommand, [ + "bulk-add-plan", + "--plan", + "pln_1", + "--filter", + "unknown", + ]); + expect(process.exitCode).toBe(1); + process.exitCode = original; + }); }); diff --git a/tests/commands/permissions.test.ts b/tests/commands/permissions.test.ts index 21f0256..2d6a7c6 100644 --- a/tests/commands/permissions.test.ts +++ b/tests/commands/permissions.test.ts @@ -179,7 +179,23 @@ describe("permissions", () => { expect(call.variables.input.permissionId).toBe("perm_1"); }); - it("handles errors gracefully", async () => { + it("update with description only", async () => { + graphqlRequest.mockResolvedValueOnce({ + updatePermission: mockPermission, + }); + + await runCommand(permissionsCommand, [ + "update", + "perm_1", + "--description", + "Updated desc", + ]); + + const call = graphqlRequest.mock.calls[0][0]; + expect(call.variables.input.description).toBe("Updated desc"); + }); + + it("list handles errors gracefully", async () => { graphqlRequest.mockRejectedValueOnce(new Error("Unauthorized")); const original = process.exitCode; @@ -187,4 +203,96 @@ describe("permissions", () => { expect(process.exitCode).toBe(1); process.exitCode = original; }); + + it("create handles errors gracefully", async () => { + graphqlRequest.mockRejectedValueOnce(new Error("Duplicate name")); + + const original = process.exitCode; + await runCommand(permissionsCommand, ["create", "--name", "bad"]); + expect(process.exitCode).toBe(1); + process.exitCode = original; + }); + + it("update handles errors gracefully", async () => { + graphqlRequest.mockRejectedValueOnce(new Error("Not found")); + + const original = process.exitCode; + await runCommand(permissionsCommand, [ + "update", + "perm_bad", + "--name", + "test", + ]); + expect(process.exitCode).toBe(1); + process.exitCode = original; + }); + + it("delete handles errors gracefully", async () => { + graphqlRequest.mockRejectedValueOnce(new Error("In use")); + + const original = process.exitCode; + await runCommand(permissionsCommand, ["delete", "perm_bad"]); + expect(process.exitCode).toBe(1); + process.exitCode = original; + }); + + it("link-plan handles errors gracefully", async () => { + graphqlRequest.mockRejectedValueOnce(new Error("Plan not found")); + + const original = process.exitCode; + await runCommand(permissionsCommand, [ + "link-plan", + "--plan-id", + "pln_bad", + "--permission-id", + "perm_1", + ]); + expect(process.exitCode).toBe(1); + process.exitCode = original; + }); + + it("unlink-plan handles errors gracefully", async () => { + graphqlRequest.mockRejectedValueOnce(new Error("Not linked")); + + const original = process.exitCode; + await runCommand(permissionsCommand, [ + "unlink-plan", + "--plan-id", + "pln_1", + "--permission-id", + "perm_bad", + ]); + expect(process.exitCode).toBe(1); + process.exitCode = original; + }); + + it("link-member handles errors gracefully", async () => { + graphqlRequest.mockRejectedValueOnce(new Error("Member not found")); + + const original = process.exitCode; + await runCommand(permissionsCommand, [ + "link-member", + "--member-id", + "mem_bad", + "--permission-id", + "perm_1", + ]); + expect(process.exitCode).toBe(1); + process.exitCode = original; + }); + + it("unlink-member handles errors gracefully", async () => { + graphqlRequest.mockRejectedValueOnce(new Error("Not linked")); + + const original = process.exitCode; + await runCommand(permissionsCommand, [ + "unlink-member", + "--member-id", + "mem_1", + "--permission-id", + "perm_bad", + ]); + expect(process.exitCode).toBe(1); + process.exitCode = original; + }); }); diff --git a/tests/commands/prices.test.ts b/tests/commands/prices.test.ts index bdd132a..eba5415 100644 --- a/tests/commands/prices.test.ts +++ b/tests/commands/prices.test.ts @@ -140,7 +140,107 @@ describe("prices", () => { ); }); - it("handles errors gracefully", async () => { + it("create with setup fee fields", async () => { + graphqlRequest.mockResolvedValueOnce({ createPrice: mockPrice }); + + await runCommand(pricesCommand, [ + "create", + "--plan-id", + "pln_1", + "--name", + "Pro", + "--amount", + "29.99", + "--type", + "SUBSCRIPTION", + "--setup-fee-amount", + "10", + "--setup-fee-name", + "Onboarding", + "--setup-fee-enabled", + "--free-trial-requires-card", + ]); + + const call = graphqlRequest.mock.calls[0][0]; + expect(call.variables.input.setupFeeAmount).toBe(10); + expect(call.variables.input.setupFeeName).toBe("Onboarding"); + expect(call.variables.input.setupFeeEnabled).toBe(true); + expect(call.variables.input.freeTrialRequiresCard).toBe(true); + }); + + it("create with expiration and cancel-at-period-end", async () => { + graphqlRequest.mockResolvedValueOnce({ createPrice: mockPrice }); + + await runCommand(pricesCommand, [ + "create", + "--plan-id", + "pln_1", + "--name", + "Limited", + "--amount", + "49", + "--type", + "ONETIME", + "--expiration-count", + "6", + "--expiration-interval", + "MONTHS", + "--cancel-at-period-end", + ]); + + const call = graphqlRequest.mock.calls[0][0]; + expect(call.variables.input.expirationCount).toBe(6); + expect(call.variables.input.expirationInterval).toBe("MONTHS"); + expect(call.variables.input.cancelAtPeriodEnd).toBe(true); + }); + + it("update with all optional fields", async () => { + graphqlRequest.mockResolvedValueOnce({ updatePrice: mockPrice }); + + await runCommand(pricesCommand, [ + "update", + "prc_1", + "--type", + "SUBSCRIPTION", + "--currency", + "eur", + "--interval-type", + "YEARLY", + "--interval-count", + "1", + "--setup-fee-amount", + "5", + "--setup-fee-name", + "Setup", + "--setup-fee-enabled", + "--free-trial-enabled", + "--free-trial-requires-card", + "--free-trial-days", + "7", + "--expiration-count", + "12", + "--expiration-interval", + "MONTHS", + "--cancel-at-period-end", + ]); + + const call = graphqlRequest.mock.calls[0][0]; + expect(call.variables.input.type).toBe("SUBSCRIPTION"); + expect(call.variables.input.currency).toBe("eur"); + expect(call.variables.input.intervalType).toBe("YEARLY"); + expect(call.variables.input.intervalCount).toBe(1); + expect(call.variables.input.setupFeeAmount).toBe(5); + expect(call.variables.input.setupFeeName).toBe("Setup"); + expect(call.variables.input.setupFeeEnabled).toBe(true); + expect(call.variables.input.freeTrialEnabled).toBe(true); + expect(call.variables.input.freeTrialRequiresCard).toBe(true); + expect(call.variables.input.freeTrialDays).toBe(7); + expect(call.variables.input.expirationCount).toBe(12); + expect(call.variables.input.expirationInterval).toBe("MONTHS"); + expect(call.variables.input.cancelAtPeriodEnd).toBe(true); + }); + + it("create handles errors gracefully", async () => { graphqlRequest.mockRejectedValueOnce(new Error("Stripe error")); const original = process.exitCode; @@ -158,4 +258,40 @@ describe("prices", () => { expect(process.exitCode).toBe(1); process.exitCode = original; }); + + it("update handles errors gracefully", async () => { + graphqlRequest.mockRejectedValueOnce(new Error("Invalid price")); + + const original = process.exitCode; + await runCommand(pricesCommand, ["update", "prc_bad", "--name", "Test"]); + expect(process.exitCode).toBe(1); + process.exitCode = original; + }); + + it("activate handles errors gracefully", async () => { + graphqlRequest.mockRejectedValueOnce(new Error("Already active")); + + const original = process.exitCode; + await runCommand(pricesCommand, ["activate", "prc_bad"]); + expect(process.exitCode).toBe(1); + process.exitCode = original; + }); + + it("deactivate handles errors gracefully", async () => { + graphqlRequest.mockRejectedValueOnce(new Error("Cannot deactivate")); + + const original = process.exitCode; + await runCommand(pricesCommand, ["deactivate", "prc_bad"]); + expect(process.exitCode).toBe(1); + process.exitCode = original; + }); + + it("delete handles errors gracefully", async () => { + graphqlRequest.mockRejectedValueOnce(new Error("In use")); + + const original = process.exitCode; + await runCommand(pricesCommand, ["delete", "prc_bad"]); + expect(process.exitCode).toBe(1); + process.exitCode = original; + }); }); diff --git a/tests/commands/records.test.ts b/tests/commands/records.test.ts index fc1a270..74d1093 100644 --- a/tests/commands/records.test.ts +++ b/tests/commands/records.test.ts @@ -5,9 +5,11 @@ vi.mock("yocto-spinner", () => ({ default: () => createMockSpinner() })); vi.mock("../../src/lib/program.js", () => ({ program: { opts: () => ({}) }, })); +const readInputFile = vi.fn(); +const writeOutputFile = vi.fn(); vi.mock("../../src/lib/csv.js", () => ({ - readInputFile: vi.fn(), - writeOutputFile: vi.fn(), + readInputFile: (...args: unknown[]) => readInputFile(...args), + writeOutputFile: (...args: unknown[]) => writeOutputFile(...args), })); const graphqlRequest = vi.fn(); @@ -121,4 +123,240 @@ describe("records", () => { expect(process.exitCode).toBe(1); process.exitCode = original; }); + + it("query resolves table and sends parsed JSON body", async () => { + graphqlRequest + .mockResolvedValueOnce({ dataTable: { id: "tbl_1" } }) + .mockResolvedValueOnce({ + dataRecords: { + edges: [{ node: mockRecord }], + totalCount: 1, + }, + }); + + await runCommand(recordsCommand, [ + "query", + "users", + "--query", + '{"filter":{"fieldFilters":{"name":{"equals":"Alice"}}}}', + ]); + + expect(graphqlRequest).toHaveBeenCalledTimes(2); + const queryCall = graphqlRequest.mock.calls[1][0]; + expect(queryCall.variables.tableId).toBe("tbl_1"); + expect(queryCall.variables.filter).toEqual({ + fieldFilters: { name: { equals: "Alice" } }, + }); + }); + + it("export fetches all records and writes output file", async () => { + graphqlRequest + .mockResolvedValueOnce({ dataTable: { id: "tbl_1" } }) + .mockResolvedValueOnce({ + dataRecords: { + edges: [{ node: mockRecord }], + pageInfo: { endCursor: null }, + }, + }); + writeOutputFile.mockResolvedValueOnce(undefined); + + await runCommand(recordsCommand, ["export", "users", "--format", "csv"]); + + expect(writeOutputFile).toHaveBeenCalledWith( + expect.stringContaining("records-users.csv"), + expect.arrayContaining([expect.objectContaining({ id: "rec_1" })]), + "csv" + ); + }); + + it("export with --output writes to custom path", async () => { + graphqlRequest + .mockResolvedValueOnce({ dataTable: { id: "tbl_1" } }) + .mockResolvedValueOnce({ + dataRecords: { + edges: [{ node: mockRecord }], + pageInfo: { endCursor: null }, + }, + }); + writeOutputFile.mockResolvedValueOnce(undefined); + + await runCommand(recordsCommand, [ + "export", + "users", + "--output", + "custom.json", + ]); + + expect(writeOutputFile).toHaveBeenCalledWith( + expect.stringContaining("custom.json"), + expect.any(Array), + "json" + ); + }); + + it("import creates records from file rows", async () => { + graphqlRequest + .mockResolvedValueOnce({ dataTable: { id: "tbl_1" } }) + .mockResolvedValueOnce({ createDataRecord: mockRecord }) + .mockResolvedValueOnce({ createDataRecord: mockRecord }); + readInputFile.mockResolvedValueOnce([ + { name: "Alice", age: "30" }, + { name: "Bob", age: "25" }, + ]); + + await runCommand(recordsCommand, [ + "import", + "users", + "--file", + "records.csv", + ]); + + expect(readInputFile).toHaveBeenCalledWith("records.csv"); + expect(graphqlRequest).toHaveBeenCalledTimes(3); + }); + + it("import skips rows with no data fields", async () => { + graphqlRequest.mockResolvedValueOnce({ dataTable: { id: "tbl_1" } }); + readInputFile.mockResolvedValueOnce([ + { id: "rec_1", createdAt: "2024-01-01" }, + ]); + + await runCommand(recordsCommand, [ + "import", + "users", + "--file", + "records.csv", + ]); + + expect(graphqlRequest).toHaveBeenCalledTimes(1); + }); + + it("import continues on row failure", async () => { + graphqlRequest + .mockResolvedValueOnce({ dataTable: { id: "tbl_1" } }) + .mockRejectedValueOnce(new Error("Validation error")) + .mockResolvedValueOnce({ createDataRecord: mockRecord }); + readInputFile.mockResolvedValueOnce([{ name: "Bad" }, { name: "Good" }]); + + await runCommand(recordsCommand, [ + "import", + "users", + "--file", + "records.csv", + ]); + + expect(graphqlRequest).toHaveBeenCalledTimes(3); + }); + + it("import strips data. prefix from field keys", async () => { + graphqlRequest + .mockResolvedValueOnce({ dataTable: { id: "tbl_1" } }) + .mockResolvedValueOnce({ createDataRecord: mockRecord }); + readInputFile.mockResolvedValueOnce([{ "data.name": "Alice" }]); + + await runCommand(recordsCommand, [ + "import", + "users", + "--file", + "records.csv", + ]); + + const createCall = graphqlRequest.mock.calls[1][0]; + expect(createCall.variables.input.data).toEqual({ name: "Alice" }); + }); + + it("bulk-update processes rows and updates records", async () => { + readInputFile.mockResolvedValueOnce([{ id: "rec_1", name: "Updated" }]); + graphqlRequest.mockResolvedValueOnce({ updateDataRecord: mockRecord }); + + await runCommand(recordsCommand, ["bulk-update", "--file", "updates.csv"]); + + expect(graphqlRequest).toHaveBeenCalledWith( + expect.objectContaining({ + variables: expect.objectContaining({ + input: { id: "rec_1", data: { name: "Updated" } }, + }), + }) + ); + }); + + it("bulk-update --dry-run previews without calling API", async () => { + readInputFile.mockResolvedValueOnce([{ id: "rec_1", name: "Updated" }]); + + await runCommand(recordsCommand, [ + "bulk-update", + "--file", + "updates.csv", + "--dry-run", + ]); + + expect(graphqlRequest).not.toHaveBeenCalled(); + }); + + it("bulk-update skips rows missing id", async () => { + readInputFile.mockResolvedValueOnce([{ name: "No ID" }]); + + await runCommand(recordsCommand, ["bulk-update", "--file", "updates.csv"]); + + expect(graphqlRequest).not.toHaveBeenCalled(); + }); + + it("bulk-delete deletes matching records", async () => { + graphqlRequest + .mockResolvedValueOnce({ dataTable: { id: "tbl_1" } }) + .mockResolvedValueOnce({ + dataRecords: { + edges: [{ node: mockRecord }], + pageInfo: { endCursor: null }, + }, + }) + .mockResolvedValueOnce({ deleteDataRecord: "rec_1" }); + + await runCommand(recordsCommand, [ + "bulk-delete", + "users", + "--where", + "name equals Alice", + ]); + + expect(graphqlRequest).toHaveBeenCalledTimes(3); + const deleteCall = graphqlRequest.mock.calls[2][0]; + expect(deleteCall.variables.input).toEqual({ id: "rec_1" }); + }); + + it("bulk-delete --dry-run previews without deleting", async () => { + graphqlRequest + .mockResolvedValueOnce({ dataTable: { id: "tbl_1" } }) + .mockResolvedValueOnce({ + dataRecords: { + edges: [{ node: mockRecord }], + pageInfo: { endCursor: null }, + }, + }); + + await runCommand(recordsCommand, [ + "bulk-delete", + "users", + "--where", + "name equals Alice", + "--dry-run", + ]); + + expect(graphqlRequest).toHaveBeenCalledTimes(2); + }); + + it("bulk-delete with no matching records exits early", async () => { + graphqlRequest + .mockResolvedValueOnce({ dataTable: { id: "tbl_1" } }) + .mockResolvedValueOnce({ + dataRecords: { + edges: [], + pageInfo: { endCursor: null }, + }, + }); + + await runCommand(recordsCommand, ["bulk-delete", "users"]); + + expect(graphqlRequest).toHaveBeenCalledTimes(2); + }); }); diff --git a/tests/commands/tables.test.ts b/tests/commands/tables.test.ts index d9e32a7..7b7de29 100644 --- a/tests/commands/tables.test.ts +++ b/tests/commands/tables.test.ts @@ -169,9 +169,97 @@ describe("tables", () => { ); }); - it("handles errors gracefully", async () => { + it("describe with no fields", async () => { + const emptyTable = { ...mockTable, fields: [] }; + graphqlRequest.mockResolvedValueOnce({ dataTable: emptyTable }); + + await runCommand(tablesCommand, ["describe", "empty"]); + + expect(graphqlRequest).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { key: "empty" }, + }) + ); + }); + + it("describe with referenced table", async () => { + const tableWithRef = { + ...mockTable, + fields: [ + { + id: "fld_2", + key: "author", + name: "Author", + type: "RELATION", + required: false, + defaultValue: null, + tableOrder: 1, + referencedTableId: "tbl_2", + referencedTable: { id: "tbl_2", key: "authors", name: "Authors" }, + }, + ], + }; + graphqlRequest.mockResolvedValueOnce({ dataTable: tableWithRef }); + + await runCommand(tablesCommand, ["describe", "users"]); + + expect(graphqlRequest).toHaveBeenCalled(); + }); + + it("get handles errors gracefully", async () => { graphqlRequest.mockRejectedValueOnce(new Error("Not found")); + const original = process.exitCode; + await runCommand(tablesCommand, ["get", "bad_key"]); + expect(process.exitCode).toBe(1); + process.exitCode = original; + }); + + it("describe handles errors gracefully", async () => { + graphqlRequest.mockRejectedValueOnce(new Error("Not found")); + + const original = process.exitCode; + await runCommand(tablesCommand, ["describe", "bad_key"]); + expect(process.exitCode).toBe(1); + process.exitCode = original; + }); + + it("create handles errors gracefully", async () => { + graphqlRequest.mockRejectedValueOnce(new Error("Duplicate key")); + + const original = process.exitCode; + await runCommand(tablesCommand, [ + "create", + "--name", + "Bad", + "--key", + "bad", + ]); + expect(process.exitCode).toBe(1); + process.exitCode = original; + }); + + it("update handles errors gracefully", async () => { + graphqlRequest.mockRejectedValueOnce(new Error("Not found")); + + const original = process.exitCode; + await runCommand(tablesCommand, ["update", "tbl_bad", "--name", "Test"]); + expect(process.exitCode).toBe(1); + process.exitCode = original; + }); + + it("delete handles errors gracefully", async () => { + graphqlRequest.mockRejectedValueOnce(new Error("Cannot delete")); + + const original = process.exitCode; + await runCommand(tablesCommand, ["delete", "tbl_bad"]); + expect(process.exitCode).toBe(1); + process.exitCode = original; + }); + + it("list handles errors gracefully", async () => { + graphqlRequest.mockRejectedValueOnce(new Error("Network error")); + const original = process.exitCode; await runCommand(tablesCommand, ["list"]); expect(process.exitCode).toBe(1); diff --git a/tests/core/auth.test.ts b/tests/core/auth.test.ts index 9778fde..6c88754 100644 --- a/tests/core/auth.test.ts +++ b/tests/core/auth.test.ts @@ -4,21 +4,24 @@ import { runCommand } from "../commands/helpers.js"; const loadTokens = vi.fn(); const clearTokens = vi.fn(); const getValidAccessToken = vi.fn(); +const saveTokens = vi.fn(); const revokeToken = vi.fn(); +const registerClient = vi.fn(); +const exchangeCodeForTokens = vi.fn(); vi.mock("../../src/lib/token-storage.js", () => ({ loadTokens: (...args: unknown[]) => loadTokens(...args), clearTokens: (...args: unknown[]) => clearTokens(...args), - saveTokens: vi.fn(), + saveTokens: (...args: unknown[]) => saveTokens(...args), getValidAccessToken: (...args: unknown[]) => getValidAccessToken(...args), })); vi.mock("../../src/lib/oauth.js", () => ({ - registerClient: vi.fn(), - generateCodeVerifier: vi.fn().mockReturnValue("verifier"), - generateCodeChallenge: vi.fn().mockReturnValue("challenge"), - generateState: vi.fn().mockReturnValue("state"), - buildAuthorizationUrl: vi.fn().mockReturnValue("https://auth.example.com"), - exchangeCodeForTokens: vi.fn(), + registerClient: (...args: unknown[]) => registerClient(...args), + generateCodeVerifier: () => "verifier", + generateCodeChallenge: () => "challenge", + generateState: () => "test_state", + buildAuthorizationUrl: () => "https://auth.example.com", + exchangeCodeForTokens: (...args: unknown[]) => exchangeCodeForTokens(...args), revokeToken: (...args: unknown[]) => revokeToken(...args), })); vi.mock("open", () => ({ default: vi.fn() })); @@ -32,6 +35,42 @@ vi.mock("../../src/lib/program.js", () => ({ program: { opts: () => ({}) }, })); +let callbackHandler: ((req: unknown, res: unknown) => void) | null = null; + +const invokeCallback = (req: unknown, res: unknown): void => { + if (!callbackHandler) { + throw new Error("callbackHandler not set"); + } + callbackHandler(req, res); +}; + +vi.mock("node:http", () => ({ + createServer: (handler?: (req: unknown, res: unknown) => void) => { + if (handler) { + callbackHandler = handler; + } + const server: Record = {}; + server.listen = (...args: unknown[]) => { + const cb = args.find((a) => typeof a === "function") as + | (() => void) + | undefined; + if (cb) { + queueMicrotask(cb); + } + return server; + }; + server.close = (cb?: () => void) => { + if (cb) { + queueMicrotask(cb); + } + return server; + }; + server.address = () => ({ port: 3456 }); + server.on = () => server; + return server; + }, +})); + const graphqlRequest = vi.fn(); vi.mock("../../src/lib/graphql-client.js", () => ({ graphqlRequest: (...args: unknown[]) => graphqlRequest(...args), @@ -40,6 +79,179 @@ vi.mock("../../src/lib/graphql-client.js", () => ({ const { authCommand } = await import("../../src/commands/auth.js"); describe("auth", () => { + describe("login", () => { + it("completes OAuth flow successfully", async () => { + registerClient.mockResolvedValueOnce("client_123"); + exchangeCodeForTokens.mockResolvedValueOnce({ + access_token: "at_abc", + refresh_token: "rt_abc", + expires_in: 3600, + }); + saveTokens.mockResolvedValueOnce(undefined); + + callbackHandler = null; + const promise = runCommand(authCommand, ["login"]); + + await vi.waitFor(() => { + expect(callbackHandler).not.toBeNull(); + }); + + const res = { writeHead: vi.fn(), end: vi.fn() }; + invokeCallback({ url: "/callback?code=auth_code&state=test_state" }, res); + + await promise; + + expect(registerClient).toHaveBeenCalled(); + expect(exchangeCodeForTokens).toHaveBeenCalledWith( + expect.objectContaining({ + clientId: "client_123", + code: "auth_code", + codeVerifier: "verifier", + }) + ); + expect(saveTokens).toHaveBeenCalled(); + expect(res.writeHead).toHaveBeenCalledWith(200, { + "Content-Type": "text/html", + }); + }); + + it("handles authorization error in callback", async () => { + registerClient.mockResolvedValueOnce("client_123"); + + callbackHandler = null; + const original = process.exitCode; + const promise = runCommand(authCommand, ["login"]); + + await vi.waitFor(() => { + expect(callbackHandler).not.toBeNull(); + }); + + const res = { writeHead: vi.fn(), end: vi.fn() }; + invokeCallback( + { url: "/callback?error=access_denied&error_description=User+denied" }, + res + ); + + await promise; + + expect(process.exitCode).toBe(1); + expect(res.writeHead).toHaveBeenCalledWith(400, { + "Content-Type": "text/html", + }); + process.exitCode = original; + }); + + it("handles missing code/state in callback", async () => { + registerClient.mockResolvedValueOnce("client_123"); + + callbackHandler = null; + const original = process.exitCode; + const promise = runCommand(authCommand, ["login"]); + + await vi.waitFor(() => { + expect(callbackHandler).not.toBeNull(); + }); + + const res = { writeHead: vi.fn(), end: vi.fn() }; + invokeCallback({ url: "/callback" }, res); + + await promise; + + expect(process.exitCode).toBe(1); + expect(res.writeHead).toHaveBeenCalledWith(400, { + "Content-Type": "text/html", + }); + process.exitCode = original; + }); + + it("handles state mismatch in callback", async () => { + registerClient.mockResolvedValueOnce("client_123"); + + callbackHandler = null; + const original = process.exitCode; + const promise = runCommand(authCommand, ["login"]); + + await vi.waitFor(() => { + expect(callbackHandler).not.toBeNull(); + }); + + const res = { writeHead: vi.fn(), end: vi.fn() }; + invokeCallback( + { url: "/callback?code=auth_code&state=wrong_state" }, + res + ); + + await promise; + + expect(process.exitCode).toBe(1); + expect(res.writeHead).toHaveBeenCalledWith(400, { + "Content-Type": "text/html", + }); + process.exitCode = original; + }); + + it("returns 404 for non-callback paths", async () => { + registerClient.mockResolvedValueOnce("client_123"); + exchangeCodeForTokens.mockResolvedValueOnce({ + access_token: "at_abc", + refresh_token: "rt_abc", + expires_in: 3600, + }); + saveTokens.mockResolvedValueOnce(undefined); + + callbackHandler = null; + const promise = runCommand(authCommand, ["login"]); + + await vi.waitFor(() => { + expect(callbackHandler).not.toBeNull(); + }); + + const notFoundRes = { writeHead: vi.fn(), end: vi.fn() }; + invokeCallback({ url: "/favicon.ico" }, notFoundRes); + + expect(notFoundRes.writeHead).toHaveBeenCalledWith(404); + expect(notFoundRes.end).toHaveBeenCalledWith("Not found"); + + const res = { writeHead: vi.fn(), end: vi.fn() }; + invokeCallback({ url: "/callback?code=auth_code&state=test_state" }, res); + + await promise; + + expect(saveTokens).toHaveBeenCalled(); + }); + + it("handles registration failure", async () => { + registerClient.mockRejectedValueOnce(new Error("Registration failed")); + + callbackHandler = null; + const original = process.exitCode; + await runCommand(authCommand, ["login"]); + + expect(process.exitCode).toBe(1); + process.exitCode = original; + }); + + it("handles error param without description", async () => { + registerClient.mockResolvedValueOnce("client_123"); + + callbackHandler = null; + const original = process.exitCode; + const promise = runCommand(authCommand, ["login"]); + + await vi.waitFor(() => { + expect(callbackHandler).not.toBeNull(); + }); + + const res = { writeHead: vi.fn(), end: vi.fn() }; + invokeCallback({ url: "/callback?error=server_error" }, res); + + await promise; + + expect(process.exitCode).toBe(1); + process.exitCode = original; + }); + }); + describe("logout", () => { it("clears tokens and revokes refresh token", async () => { loadTokens.mockResolvedValueOnce({ diff --git a/tests/core/csv.test.ts b/tests/core/csv.test.ts new file mode 100644 index 0000000..f887458 --- /dev/null +++ b/tests/core/csv.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it, vi } from "vitest"; + +const mockReadFile = vi.fn(); +const mockWriteFile = vi.fn(); +vi.mock("node:fs/promises", () => ({ + readFile: (...args: unknown[]) => mockReadFile(...args), + writeFile: (...args: unknown[]) => mockWriteFile(...args), +})); + +const { + readCsvFile, + readInputFile, + flattenObject, + writeOutputFile, + unflattenObject, +} = await import("../../src/lib/csv.js"); + +describe("csv", () => { + describe("flattenObject", () => { + it("flattens a simple object", () => { + const result = flattenObject({ name: "Alice", age: 30 }); + expect(result).toEqual({ name: "Alice", age: "30" }); + }); + + it("flattens nested objects with dot notation", () => { + const result = flattenObject({ + user: { name: "Alice", address: { city: "NYC" } }, + }); + expect(result).toEqual({ + "user.name": "Alice", + "user.address.city": "NYC", + }); + }); + + it("joins arrays with commas", () => { + const result = flattenObject({ tags: ["a", "b", "c"] }); + expect(result).toEqual({ tags: "a, b, c" }); + }); + + it("converts null and undefined to empty strings", () => { + const result = flattenObject({ a: null, b: undefined }); + expect(result).toEqual({ a: "", b: "" }); + }); + + it("converts booleans to strings", () => { + const result = flattenObject({ active: true, deleted: false }); + expect(result).toEqual({ active: "true", deleted: "false" }); + }); + + it("returns empty object for empty input", () => { + const result = flattenObject({}); + expect(result).toEqual({}); + }); + }); + + describe("unflattenObject", () => { + it("returns flat keys as-is", () => { + const result = unflattenObject({ name: "Alice", age: "30" }); + expect(result).toEqual({ name: "Alice", age: "30" }); + }); + + it("unflattens dotted keys into nested objects", () => { + const result = unflattenObject({ + "user.name": "Alice", + "user.email": "alice@test.com", + }); + expect(result).toEqual({ + user: { name: "Alice", email: "alice@test.com" }, + }); + }); + + it("skips empty string values", () => { + const result = unflattenObject({ name: "Alice", empty: "" }); + expect(result).toEqual({ name: "Alice" }); + }); + + it("handles deeply dotted keys", () => { + const result = unflattenObject({ "a.b.c": "deep" }); + expect(result).toEqual({ a: { "b.c": "deep" } }); + }); + + it("returns empty object for empty input", () => { + const result = unflattenObject({}); + expect(result).toEqual({}); + }); + }); + + describe("readCsvFile", () => { + it("parses a valid CSV file", async () => { + mockReadFile.mockResolvedValueOnce("name,age\nAlice,30\nBob,25\n"); + + const result = await readCsvFile("/data.csv"); + expect(result).toEqual([ + { name: "Alice", age: "30" }, + { name: "Bob", age: "25" }, + ]); + expect(mockReadFile).toHaveBeenCalledWith("/data.csv", "utf-8"); + }); + + it("skips empty lines", async () => { + mockReadFile.mockResolvedValueOnce("name,age\nAlice,30\n\n\nBob,25\n"); + + const result = await readCsvFile("/data.csv"); + expect(result).toEqual([ + { name: "Alice", age: "30" }, + { name: "Bob", age: "25" }, + ]); + }); + + it("throws on CSV parse errors", async () => { + mockReadFile.mockResolvedValueOnce('"unclosed quote'); + + await expect(readCsvFile("/bad.csv")).rejects.toThrow("CSV parse error:"); + }); + }); + + describe("readInputFile", () => { + it("reads a JSON file by extension", async () => { + const data = [{ name: "Alice" }]; + mockReadFile.mockResolvedValueOnce(JSON.stringify(data)); + + const result = await readInputFile("/data.json"); + expect(result).toEqual(data); + }); + + it("reads a .JSON file case-insensitively", async () => { + const data = [{ name: "Bob" }]; + mockReadFile.mockResolvedValueOnce(JSON.stringify(data)); + + const result = await readInputFile("/DATA.JSON"); + expect(result).toEqual(data); + }); + + it("falls back to CSV for non-JSON extensions", async () => { + mockReadFile.mockResolvedValueOnce("name,age\nAlice,30\n"); + + const result = await readInputFile("/data.csv"); + expect(result).toEqual([{ name: "Alice", age: "30" }]); + }); + }); + + describe("writeOutputFile", () => { + it("writes JSON format with pretty printing and trailing newline", async () => { + mockWriteFile.mockResolvedValueOnce(undefined); + + const data = [{ name: "Alice", age: 30 }]; + await writeOutputFile("/out.json", data, "json"); + + expect(mockWriteFile).toHaveBeenCalledWith( + "/out.json", + `${JSON.stringify(data, null, 2)}\n` + ); + }); + + it("writes CSV format with flattened data and trailing newline", async () => { + mockWriteFile.mockResolvedValueOnce(undefined); + + const data = [{ name: "Alice", meta: { role: "admin" } }]; + await writeOutputFile("/out.csv", data, "csv"); + + const written = mockWriteFile.mock.calls[0][1] as string; + expect(written).toContain("name"); + expect(written).toContain("meta.role"); + expect(written).toContain("Alice"); + expect(written).toContain("admin"); + expect(written.endsWith("\n")).toBe(true); + }); + + it("defaults to JSON for non-csv format values", async () => { + mockWriteFile.mockResolvedValueOnce(undefined); + + await writeOutputFile("/out.txt", [{ a: 1 }], "other"); + + const written = mockWriteFile.mock.calls[0][1] as string; + expect(JSON.parse(written)).toEqual([{ a: 1 }]); + }); + }); +}); diff --git a/tests/core/index.test.ts b/tests/core/index.test.ts new file mode 100644 index 0000000..80c8b3b --- /dev/null +++ b/tests/core/index.test.ts @@ -0,0 +1,144 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const originalArgv = [...process.argv]; +const originalNoColor = process.env.NO_COLOR; + +const mockAddCommand = vi.fn(); +const mockParseAsync = vi.fn().mockResolvedValue(undefined); +const mockAction = vi.fn(); +const mockHelp = vi.fn(); + +vi.mock("../../src/lib/program.js", () => ({ + program: { + action: (...args: unknown[]) => mockAction(...args), + addCommand: (...args: unknown[]) => mockAddCommand(...args), + parseAsync: (...args: unknown[]) => mockParseAsync(...args), + help: (...args: unknown[]) => mockHelp(...args), + }, +})); + +vi.mock("../../src/commands/apps.js", () => ({ appsCommand: "apps" })); +vi.mock("../../src/commands/auth.js", () => ({ authCommand: "auth" })); +vi.mock("../../src/commands/custom-fields.js", () => ({ + customFieldsCommand: "custom-fields", +})); +vi.mock("../../src/commands/members.js", () => ({ + membersCommand: "members", +})); +vi.mock("../../src/commands/permissions.js", () => ({ + permissionsCommand: "permissions", +})); +vi.mock("../../src/commands/plans.js", () => ({ plansCommand: "plans" })); +vi.mock("../../src/commands/prices.js", () => ({ pricesCommand: "prices" })); +vi.mock("../../src/commands/providers.js", () => ({ + providersCommand: "providers", +})); +vi.mock("../../src/commands/records.js", () => ({ + recordsCommand: "records", +})); +vi.mock("../../src/commands/skills.js", () => ({ + skillsCommand: "skills", +})); +vi.mock("../../src/commands/tables.js", () => ({ tablesCommand: "tables" })); +vi.mock("../../src/commands/users.js", () => ({ usersCommand: "users" })); +vi.mock("../../src/commands/whoami.js", () => ({ + whoamiCommand: "whoami", +})); + +describe("index", () => { + let stderrSpy: ReturnType; + + beforeEach(() => { + vi.resetModules(); + mockAddCommand.mockClear(); + mockParseAsync.mockClear(); + mockAction.mockClear(); + mockHelp.mockClear(); + stderrSpy = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + }); + + afterEach(() => { + process.argv = originalArgv; + stderrSpy.mockRestore(); + if (originalNoColor === undefined) { + // biome-ignore lint/performance/noDelete: process.env requires delete to unset + delete process.env.NO_COLOR; + } else { + process.env.NO_COLOR = originalNoColor; + } + }); + + it("prints banner by default", async () => { + process.argv = ["node", "memberstack"]; + // biome-ignore lint/performance/noDelete: process.env requires delete to unset + delete process.env.NO_COLOR; + + await import("../../src/index.js"); + + expect(stderrSpy).toHaveBeenCalled(); + }); + + it("suppresses banner with --quiet", async () => { + process.argv = ["node", "memberstack", "--quiet"]; + + await import("../../src/index.js"); + + expect(stderrSpy).not.toHaveBeenCalled(); + }); + + it("suppresses banner with -q", async () => { + process.argv = ["node", "memberstack", "-q"]; + + await import("../../src/index.js"); + + expect(stderrSpy).not.toHaveBeenCalled(); + }); + + it("sets NO_COLOR when --no-color is in argv", async () => { + process.argv = ["node", "memberstack", "--no-color"]; + // biome-ignore lint/performance/noDelete: process.env requires delete to unset + delete process.env.NO_COLOR; + + await import("../../src/index.js"); + + expect(process.env.NO_COLOR).toBe("1"); + }); + + it("sets NO_COLOR when NO_COLOR env is already set", async () => { + process.argv = ["node", "memberstack"]; + process.env.NO_COLOR = "true"; + + await import("../../src/index.js"); + + expect(process.env.NO_COLOR).toBe("1"); + }); + + it("registers all 13 commands", async () => { + process.argv = ["node", "memberstack"]; + + await import("../../src/index.js"); + + expect(mockAddCommand).toHaveBeenCalledTimes(13); + }); + + it("calls parseAsync", async () => { + process.argv = ["node", "memberstack"]; + + await import("../../src/index.js"); + + expect(mockParseAsync).toHaveBeenCalled(); + }); + + it("sets default action to show help", async () => { + process.argv = ["node", "memberstack"]; + + await import("../../src/index.js"); + + expect(mockAction).toHaveBeenCalledWith(expect.any(Function)); + const actionCallback = mockAction.mock.calls[0][0] as () => void; + actionCallback(); + expect(mockHelp).toHaveBeenCalled(); + }); +}); diff --git a/tests/core/program.test.ts b/tests/core/program.test.ts new file mode 100644 index 0000000..f978bbf --- /dev/null +++ b/tests/core/program.test.ts @@ -0,0 +1,100 @@ +import { Command } from "commander"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const { program } = await import("../../src/lib/program.js"); + +// Add a temporary subcommand so we can trigger preAction hooks via parseAsync +const testSub = new Command("_test_hook").action(() => { + // noop +}); + +describe("program", () => { + it("is named memberstack", () => { + expect(program.name()).toBe("memberstack"); + }); + + it("has a description mentioning Memberstack", () => { + expect(program.description()).toContain("Memberstack"); + }); + + it("has a version set", () => { + // In test environment __VERSION__ is not defined, so falls back to "dev" + expect(program.version()).toBe("dev"); + }); + + it("has usage string", () => { + expect(program.usage()).toContain(""); + }); + + it("registers --json option with -j shorthand", () => { + const opt = program.options.find( + (o: { long?: string }) => o.long === "--json" + ); + expect(opt).toBeDefined(); + }); + + it("registers --quiet option with -q shorthand", () => { + const opt = program.options.find( + (o: { long?: string }) => o.long === "--quiet" + ); + expect(opt).toBeDefined(); + }); + + it("registers --mode option", () => { + const opt = program.options.find( + (o: { long?: string }) => o.long === "--mode" + ); + expect(opt).toBeDefined(); + }); + + it("registers --live and --sandbox shorthand options", () => { + const live = program.options.find( + (o: { long?: string }) => o.long === "--live" + ); + const sandbox = program.options.find( + (o: { long?: string }) => o.long === "--sandbox" + ); + expect(live).toBeDefined(); + expect(sandbox).toBeDefined(); + }); + + it("configureHelp places --help first in visible options", () => { + const help = program.createHelp(); + const opts = help.visibleOptions(program); + expect(opts.length).toBeGreaterThan(1); + expect(opts[0].long).toBe("--help"); + }); + + it("has help information with options", () => { + const helpInfo = program.helpInformation(); + expect(helpInfo).toContain("memberstack"); + expect(helpInfo).toContain("--help"); + expect(helpInfo).toContain("--version"); + }); + + describe("preAction hook", () => { + beforeAll(() => { + program.addCommand(testSub); + }); + + afterAll(() => { + // Reset mode back to default + program.setOptionValueWithSource("mode", "sandbox", "default"); + }); + + it("sets mode to live when --live is passed", async () => { + await program.parseAsync(["node", "test", "--live", "_test_hook"]); + expect(program.opts().mode).toBe("live"); + }); + + it("sets mode to sandbox when --sandbox is passed", async () => { + await program.parseAsync(["node", "test", "--sandbox", "_test_hook"]); + expect(program.opts().mode).toBe("sandbox"); + }); + + it("keeps default mode when neither flag is passed", async () => { + await program.parseAsync(["node", "test", "_test_hook"]); + expect(program.opts().mode).toBe("sandbox"); + }); + }); +}); diff --git a/tests/core/token-storage.test.ts b/tests/core/token-storage.test.ts new file mode 100644 index 0000000..9e498bd --- /dev/null +++ b/tests/core/token-storage.test.ts @@ -0,0 +1,318 @@ +import { describe, expect, it, vi } from "vitest"; + +const mockMkdir = vi.fn(); +const mockReadFile = vi.fn(); +const mockRm = vi.fn(); +const mockWriteFile = vi.fn(); +vi.mock("node:fs/promises", () => ({ + mkdir: (...args: unknown[]) => mockMkdir(...args), + readFile: (...args: unknown[]) => mockReadFile(...args), + rm: (...args: unknown[]) => mockRm(...args), + writeFile: (...args: unknown[]) => mockWriteFile(...args), +})); + +vi.mock("node:os", () => ({ + homedir: () => "/mock-home", +})); + +vi.mock("../../src/lib/constants.js", () => ({ + TOKEN_STORAGE_DIR: ".memberstack", + TOKEN_STORAGE_FILE: "auth.json", +})); + +const mockRefreshAccessToken = vi.fn(); +vi.mock("../../src/lib/oauth.js", () => ({ + refreshAccessToken: (...args: unknown[]) => mockRefreshAccessToken(...args), +})); + +const { saveTokens, loadTokens, clearTokens, getValidAccessToken, getAppId } = + await import("../../src/lib/token-storage.js"); + +const TOKEN_PATH = "/mock-home/.memberstack/auth.json"; +const TOKEN_DIR = "/mock-home/.memberstack"; + +/** Build a base64url-encoded JWT with the given payload. */ +const buildJwt = (payload: Record): string => { + const header = Buffer.from(JSON.stringify({ alg: "HS256" })).toString( + "base64url" + ); + const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); + return `${header}.${body}.signature`; +}; + +describe("token-storage", () => { + describe("saveTokens", () => { + it("creates the storage directory with restricted permissions", async () => { + mockMkdir.mockResolvedValueOnce(undefined); + mockWriteFile.mockResolvedValueOnce(undefined); + + await saveTokens( + { access_token: "at_1", expires_in: 3600, token_type: "Bearer" }, + "client_1" + ); + + expect(mockMkdir).toHaveBeenCalledWith(TOKEN_DIR, { + recursive: true, + mode: 0o700, + }); + }); + + it("writes token data with restricted file permissions", async () => { + mockMkdir.mockResolvedValueOnce(undefined); + mockWriteFile.mockResolvedValueOnce(undefined); + + await saveTokens( + { access_token: "at_1", expires_in: 3600, token_type: "Bearer" }, + "client_1" + ); + + expect(mockWriteFile).toHaveBeenCalledWith( + TOKEN_PATH, + expect.any(String), + { mode: 0o600 } + ); + }); + + it("stores the correct fields including computed expires_at", async () => { + mockMkdir.mockResolvedValueOnce(undefined); + mockWriteFile.mockResolvedValueOnce(undefined); + + const now = Math.floor(Date.now() / 1000); + + await saveTokens( + { + access_token: "at_1", + refresh_token: "rt_1", + expires_in: 3600, + token_type: "Bearer", + }, + "client_1" + ); + + const written = JSON.parse(mockWriteFile.mock.calls[0][1] as string); + expect(written.access_token).toBe("at_1"); + expect(written.refresh_token).toBe("rt_1"); + expect(written.client_id).toBe("client_1"); + expect(written.expires_at).toBeGreaterThanOrEqual(now + 3600); + expect(written.expires_at).toBeLessThanOrEqual(now + 3601); + }); + + it("parses app_id from a valid JWT access token", async () => { + mockMkdir.mockResolvedValueOnce(undefined); + mockWriteFile.mockResolvedValueOnce(undefined); + + const jwt = buildJwt({ appId: "app_123" }); + + await saveTokens( + { access_token: jwt, expires_in: 3600, token_type: "Bearer" }, + "client_1" + ); + + const written = JSON.parse(mockWriteFile.mock.calls[0][1] as string); + expect(written.app_id).toBe("app_123"); + }); + + it("sets app_id to undefined for a non-JWT access token", async () => { + mockMkdir.mockResolvedValueOnce(undefined); + mockWriteFile.mockResolvedValueOnce(undefined); + + await saveTokens( + { access_token: "plain-token", expires_in: 3600, token_type: "Bearer" }, + "client_1" + ); + + const written = JSON.parse(mockWriteFile.mock.calls[0][1] as string); + expect(written.app_id).toBeUndefined(); + }); + + it("sets app_id to undefined when JWT payload has no appId", async () => { + mockMkdir.mockResolvedValueOnce(undefined); + mockWriteFile.mockResolvedValueOnce(undefined); + + const jwt = buildJwt({ sub: "user_1" }); + + await saveTokens( + { access_token: jwt, expires_in: 3600, token_type: "Bearer" }, + "client_1" + ); + + const written = JSON.parse(mockWriteFile.mock.calls[0][1] as string); + expect(written.app_id).toBeUndefined(); + }); + + it("sets app_id to undefined when JWT payload is invalid base64", async () => { + mockMkdir.mockResolvedValueOnce(undefined); + mockWriteFile.mockResolvedValueOnce(undefined); + + await saveTokens( + { + access_token: "header.!!!invalid!!!.signature", + expires_in: 3600, + token_type: "Bearer", + }, + "client_1" + ); + + const written = JSON.parse(mockWriteFile.mock.calls[0][1] as string); + expect(written.app_id).toBeUndefined(); + }); + }); + + describe("loadTokens", () => { + it("reads and parses stored tokens", async () => { + const stored = { + access_token: "at_1", + refresh_token: "rt_1", + expires_at: 9_999_999_999, + client_id: "client_1", + app_id: "app_1", + }; + mockReadFile.mockResolvedValueOnce(JSON.stringify(stored)); + + const result = await loadTokens(); + expect(result).toEqual(stored); + expect(mockReadFile).toHaveBeenCalledWith(TOKEN_PATH, "utf-8"); + }); + + it("returns null when token file does not exist", async () => { + mockReadFile.mockRejectedValueOnce(new Error("ENOENT")); + + const result = await loadTokens(); + expect(result).toBeNull(); + }); + }); + + describe("clearTokens", () => { + it("removes the token file", async () => { + mockRm.mockResolvedValueOnce(undefined); + + await clearTokens(); + expect(mockRm).toHaveBeenCalledWith(TOKEN_PATH); + }); + + it("does not throw when file does not exist", async () => { + mockRm.mockRejectedValueOnce(new Error("ENOENT")); + + await expect(clearTokens()).resolves.toBeUndefined(); + }); + }); + + describe("getValidAccessToken", () => { + it("returns null when no tokens are stored", async () => { + mockReadFile.mockRejectedValueOnce(new Error("ENOENT")); + + const result = await getValidAccessToken(); + expect(result).toBeNull(); + }); + + it("returns the access token when it has not expired", async () => { + const stored = { + access_token: "at_valid", + refresh_token: "rt_1", + expires_at: Math.floor(Date.now() / 1000) + 3600, + client_id: "client_1", + }; + mockReadFile.mockResolvedValueOnce(JSON.stringify(stored)); + + const result = await getValidAccessToken(); + expect(result).toBe("at_valid"); + }); + + it("returns null when token is expired and no refresh token exists", async () => { + const stored = { + access_token: "at_expired", + expires_at: 0, + client_id: "client_1", + }; + mockReadFile.mockResolvedValueOnce(JSON.stringify(stored)); + + const result = await getValidAccessToken(); + expect(result).toBeNull(); + }); + + it("refreshes and returns new token when expired with refresh token", async () => { + const stored = { + access_token: "at_expired", + refresh_token: "rt_1", + expires_at: 0, + client_id: "client_1", + }; + mockReadFile.mockResolvedValueOnce(JSON.stringify(stored)); + mockMkdir.mockResolvedValueOnce(undefined); + mockWriteFile.mockResolvedValueOnce(undefined); + + mockRefreshAccessToken.mockResolvedValueOnce({ + access_token: "at_refreshed", + expires_in: 3600, + token_type: "Bearer", + }); + + const result = await getValidAccessToken(); + expect(result).toBe("at_refreshed"); + expect(mockRefreshAccessToken).toHaveBeenCalledWith({ + clientId: "client_1", + refreshToken: "rt_1", + }); + }); + + it("returns null when refresh fails", async () => { + const stored = { + access_token: "at_expired", + refresh_token: "rt_bad", + expires_at: 0, + client_id: "client_1", + }; + mockReadFile.mockResolvedValueOnce(JSON.stringify(stored)); + mockRefreshAccessToken.mockRejectedValueOnce(new Error("refresh failed")); + + const result = await getValidAccessToken(); + expect(result).toBeNull(); + }); + + it("treats tokens within the 60-second buffer as expired", async () => { + const stored = { + access_token: "at_almost_expired", + expires_at: Math.floor(Date.now() / 1000) + 30, + client_id: "client_1", + }; + mockReadFile.mockResolvedValueOnce(JSON.stringify(stored)); + + const result = await getValidAccessToken(); + expect(result).toBeNull(); + }); + }); + + describe("getAppId", () => { + it("returns the stored app_id", async () => { + const stored = { + access_token: "at_1", + expires_at: 9_999_999_999, + client_id: "client_1", + app_id: "app_42", + }; + mockReadFile.mockResolvedValueOnce(JSON.stringify(stored)); + + const result = await getAppId(); + expect(result).toBe("app_42"); + }); + + it("returns null when no tokens are stored", async () => { + mockReadFile.mockRejectedValueOnce(new Error("ENOENT")); + + const result = await getAppId(); + expect(result).toBeNull(); + }); + + it("returns null when app_id is not set", async () => { + const stored = { + access_token: "at_1", + expires_at: 9_999_999_999, + client_id: "client_1", + }; + mockReadFile.mockResolvedValueOnce(JSON.stringify(stored)); + + const result = await getAppId(); + expect(result).toBeNull(); + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 284bc7e..c318f34 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,5 +5,12 @@ export default defineConfig({ globals: true, restoreMocks: true, mockReset: true, + coverage: { + provider: "v8", + include: ["src/**/*.ts"], + exclude: ["src/lib/types.ts"], + reporter: ["text", "html"], + reportsDirectory: "coverage", + }, }, });