diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index 72d9a98..76d266f 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { main } from './cli.js'; import { factories, handlers, server } from '@aignostics/sdk/test'; import { http, HttpResponse } from 'msw'; +import { ZodError } from 'zod'; // Mock process.exit to prevent test runner from exiting const mockExit = vi.fn(); @@ -521,4 +522,310 @@ describe('CLI Integration Tests', () => { expect(mockExit).not.toHaveBeenCalled(); }); }); + + describe('environment validation', () => { + it('should accept valid production environment', async () => { + process.argv = ['node', 'cli.js', 'info', '--environment', 'production']; + + await main(); + + expect(consoleSpy.log).toHaveBeenCalledWith('Aignostics Platform SDK'); + expect(consoleSpy.error).not.toHaveBeenCalled(); + }); + + it('should accept valid staging environment', async () => { + process.argv = ['node', 'cli.js', 'info', '--environment', 'staging']; + + await main(); + + expect(consoleSpy.log).toHaveBeenCalledWith('Aignostics Platform SDK'); + expect(consoleSpy.error).not.toHaveBeenCalled(); + }); + + it('should reject invalid environment', async () => { + process.argv = ['node', 'cli.js', 'test-api', '--environment', 'invalid-env']; + + try { + await main(); + } catch (error) { + // Error is expected when validation fails + expect((error as ZodError).message).toMatch( + /Invalid option: expected one of "production"|"staging"|"develop"/ + ); + } + + // Verify that an error was logged and process exited + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('should use production as default environment', async () => { + process.argv = ['node', 'cli.js', 'info']; + + await main(); + + expect(consoleSpy.log).toHaveBeenCalledWith('Aignostics Platform SDK'); + expect(consoleSpy.error).not.toHaveBeenCalled(); + }); + + it('should accept develop environment', async () => { + process.argv = ['node', 'cli.js', 'info', '--environment', 'develop']; + + await main(); + + expect(consoleSpy.log).toHaveBeenCalledWith('Aignostics Platform SDK'); + expect(consoleSpy.error).not.toHaveBeenCalled(); + }); + }); + + describe('logout command', () => { + it('should logout successfully', async () => { + process.argv = ['node', 'cli.js', 'logout', '--environment', 'production']; + + await main(); + + expect(consoleSpy.error).not.toHaveBeenCalled(); + expect(mockExit).not.toHaveBeenCalled(); + }); + + it('should logout from staging environment', async () => { + process.argv = ['node', 'cli.js', 'logout', '--environment', 'staging']; + + await main(); + + expect(consoleSpy.error).not.toHaveBeenCalled(); + expect(mockExit).not.toHaveBeenCalled(); + }); + }); + + describe('status command', () => { + it('should check authentication status successfully', async () => { + process.argv = ['node', 'cli.js', 'status', '--environment', 'production']; + + await main(); + + expect(consoleSpy.error).not.toHaveBeenCalled(); + expect(mockExit).not.toHaveBeenCalled(); + }); + + it('should check status for staging environment', async () => { + process.argv = ['node', 'cli.js', 'status', '--environment', 'staging']; + + await main(); + + expect(consoleSpy.error).not.toHaveBeenCalled(); + expect(mockExit).not.toHaveBeenCalled(); + }); + }); + + describe('login command without refresh token', () => { + it('should initiate login flow without refresh token', async () => { + process.argv = ['node', 'cli.js', 'login', '--environment', 'production']; + + await main(); + + // Login should complete without errors (mocked) + expect(mockExit).not.toHaveBeenCalled(); + }); + + it('should login with staging environment', async () => { + process.argv = ['node', 'cli.js', 'login', '--environment', 'staging']; + + await main(); + + expect(mockExit).not.toHaveBeenCalled(); + }); + }); + + describe('list-application-runs with options', () => { + it('should support filtering by customMetadata', async () => { + process.argv = ['node', 'cli.js', 'list-application-runs', '--customMetadata', '$.key=value']; + + await main(); + + expect(consoleSpy.log).toHaveBeenCalledWith( + 'Application runs:', + expect.stringContaining('run_id') + ); + }); + + it('should support sort option', async () => { + process.argv = ['node', 'cli.js', 'list-application-runs', '--sort', '["run_id"]']; + + await main(); + + expect(consoleSpy.log).toHaveBeenCalledWith( + 'Application runs:', + expect.stringContaining('run_id') + ); + }); + + it('should support multiple filters combined', async () => { + process.argv = [ + 'node', + 'cli.js', + 'list-application-runs', + '--applicationId', + 'app1', + '--applicationVersion', + 'v1.0.0', + '--sort', + '["-submitted_at"]', + ]; + + await main(); + + expect(consoleSpy.log).toHaveBeenCalledWith( + 'Application runs:', + expect.stringContaining('run_id') + ); + }); + }); + + describe('create-run with items', () => { + it('should create application run with items', async () => { + process.argv = [ + 'node', + 'cli.js', + 'create-run', + 'test-app', + 'v1.0.0', + '--items', + '[{"wsi_id": "wsi-123"}]', + ]; + + await main(); + + expect(consoleSpy.log).toHaveBeenCalledWith( + '✅ Application run created successfully:', + expect.stringContaining('run_id') + ); + }); + + it('should handle invalid items JSON', async () => { + process.argv = [ + 'node', + 'cli.js', + 'create-run', + 'test-app', + 'v1.0.0', + '--items', + 'not-valid-json', + ]; + + await main(); + + expect(consoleSpy.error).toHaveBeenCalledWith('❌ Invalid items JSON:', expect.any(Error)); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('should handle non-array items JSON', async () => { + process.argv = [ + 'node', + 'cli.js', + 'create-run', + 'test-app', + 'v1.0.0', + '--items', + '{"wsi_id": "wsi-123"}', + ]; + + await main(); + + expect(consoleSpy.error).toHaveBeenCalledWith('❌ Invalid items JSON:', expect.any(Error)); + expect(mockExit).toHaveBeenCalledWith(1); + }); + }); + + describe('error handling', () => { + it('should handle API errors gracefully for test-api', async () => { + server.use(http.get('*/v1/applications', () => HttpResponse.error())); + + process.argv = ['node', 'cli.js', 'test-api']; + + await main(); + + expect(consoleSpy.error).toHaveBeenCalledWith('❌ API connection failed:', expect.any(Error)); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('should handle API errors for get-run', async () => { + server.use(http.get('*/v1/runs/:runId', () => HttpResponse.error())); + + process.argv = ['node', 'cli.js', 'get-run', 'run-1']; + + await main(); + + expect(consoleSpy.error).toHaveBeenCalledWith('❌ Failed to get run:', expect.any(Error)); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('should handle API errors for cancel-run', async () => { + server.use(http.post('*/v1/runs/:runId/cancel', () => HttpResponse.error())); + + process.argv = ['node', 'cli.js', 'cancel-run', 'run-1']; + + await main(); + + expect(consoleSpy.error).toHaveBeenCalledWith( + '❌ Failed to cancel application run:', + expect.any(Error) + ); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('should handle API errors for list-run-results', async () => { + server.use(http.get('*/v1/runs/:runId/items', () => HttpResponse.error())); + + process.argv = ['node', 'cli.js', 'list-run-results', 'run-1']; + + await main(); + + expect(consoleSpy.error).toHaveBeenCalledWith( + '❌ Failed to list run results:', + expect.any(Error) + ); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('should handle API errors for list-application-runs', async () => { + server.use(http.get('*/v1/runs', () => HttpResponse.error())); + + process.argv = ['node', 'cli.js', 'list-application-runs']; + + await main(); + + expect(consoleSpy.error).toHaveBeenCalledWith( + '❌ Failed to list application runs:', + expect.any(Error) + ); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('should handle API errors for create-run', async () => { + server.use(http.post('*/v1/runs', () => HttpResponse.error())); + + process.argv = ['node', 'cli.js', 'create-run', 'test-app', 'v1.0.0']; + + await main(); + + expect(consoleSpy.error).toHaveBeenCalledWith( + '❌ Failed to create application run:', + expect.any(Error) + ); + expect(mockExit).toHaveBeenCalledWith(1); + }); + }); + + describe('unknown command handling', () => { + it('should handle unknown command', async () => { + process.argv = ['node', 'cli.js', 'unknown-command']; + + await main(); + + expect(mockExit).toHaveBeenCalledWith(1); + expect(consoleSpy.error).toHaveBeenCalledWith( + expect.stringContaining('Unknown argument: unknown-command') + ); + }); + }); }); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 076473f..ac30f22 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,5 +1,6 @@ import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; +import { z } from 'zod'; import { AuthService } from './utils/auth.js'; import { FileSystemTokenStorage } from './utils/token-storage.js'; @@ -25,6 +26,11 @@ import { AuthenticationError } from '@aignostics/sdk'; // Create a shared auth service instance for the CLI const authService = new AuthService(new FileSystemTokenStorage()); +// Zod schema for environment validation +const environmentSchema = z.enum( + Object.keys(environmentConfig) as [EnvironmentKey, ...EnvironmentKey[]] +); + /** * CLI for the Aignostics Platform SDK */ @@ -44,14 +50,19 @@ export async function main() { 'test-api', 'Test API connection', yargs => yargs, - // TODO: [TSSDK-20] eliminate the need for casting here - argv => testApi(argv.environment as EnvironmentKey, authService) + argv => { + const env = environmentSchema.parse(argv.environment); + return testApi(env, authService); + } ) .command( 'list-applications', 'List applications', yargs => yargs, - argv => listApplications(argv.environment as EnvironmentKey, authService) + argv => { + const env = environmentSchema.parse(argv.environment); + return listApplications(env, authService); + } ) .command( 'list-application-versions ', @@ -62,8 +73,10 @@ export async function main() { type: 'string', demandOption: true, }), - argv => - listApplicationVersions(argv.environment as EnvironmentKey, authService, argv.applicationId) + argv => { + const env = environmentSchema.parse(argv.environment); + return listApplicationVersions(env, authService, argv.applicationId); + } ) .command( 'get-application-version-details ', @@ -80,13 +93,15 @@ export async function main() { type: 'string', demandOption: true, }), - argv => - getApplicationVersionDetails( - argv.environment as EnvironmentKey, + argv => { + const env = environmentSchema.parse(argv.environment); + return getApplicationVersionDetails( + env, authService, argv.applicationId, argv.versionNumber - ) + ); + } ) .command( 'list-application-runs', @@ -110,13 +125,15 @@ export async function main() { 'Sort by field (e.g., "run_id", "-status", "submitted_at"). Fields: run_id, application_version_id, organization_id, status, submitted_at, submitted_by.', type: 'string', }), - argv => - listApplicationRuns(argv.environment as EnvironmentKey, authService, { + argv => { + const env = environmentSchema.parse(argv.environment); + return listApplicationRuns(env, authService, { applicationId: argv.applicationId, applicationVersion: argv.applicationVersion, customMetadata: argv.customMetadata, sort: argv.sort, - }) + }); + } ) .command( 'get-run ', @@ -127,7 +144,10 @@ export async function main() { type: 'string', demandOption: true, }), - argv => getRun(argv.environment as EnvironmentKey, authService, argv.applicationRunId) + argv => { + const env = environmentSchema.parse(argv.environment); + return getRun(env, authService, argv.applicationRunId); + } ) .command( 'cancel-run ', @@ -138,8 +158,10 @@ export async function main() { type: 'string', demandOption: true, }), - argv => - cancelApplicationRun(argv.environment as EnvironmentKey, authService, argv.applicationRunId) + argv => { + const env = environmentSchema.parse(argv.environment); + return cancelApplicationRun(env, authService, argv.applicationRunId); + } ) .command( 'list-run-results ', @@ -150,7 +172,10 @@ export async function main() { type: 'string', demandOption: true, }), - argv => listRunResults(argv.environment as EnvironmentKey, authService, argv.applicationRunId) + argv => { + const env = environmentSchema.parse(argv.environment); + return listRunResults(env, authService, argv.applicationRunId); + } ) .command( 'create-run ', @@ -172,14 +197,16 @@ export async function main() { type: 'string', default: '[]', }), - argv => - createApplicationRun( - argv.environment as EnvironmentKey, + argv => { + const env = environmentSchema.parse(argv.environment); + return createApplicationRun( + env, authService, argv.applicationId, argv.versionNumber, argv.items - ) + ); + } ) .command( 'login', @@ -191,22 +218,21 @@ export async function main() { demandOption: false, }), async argv => { + const env = environmentSchema.parse(argv.environment); if (argv.refreshToken) { - await handleLoginWithRefreshToken( - argv.environment as EnvironmentKey, - argv.refreshToken, - authService - ); + await handleLoginWithRefreshToken(env, argv.refreshToken, authService); return; } - await handleLogin(argv.environment as EnvironmentKey, authService); + await handleLogin(env, authService); } ) .command('logout', 'Logout and remove stored token', {}, async argv => { - await handleLogout(argv.environment as EnvironmentKey, authService); + const env = environmentSchema.parse(argv.environment); + await handleLogout(env, authService); }) .command('status', 'Check authentication status', {}, async argv => { - await handleStatus(argv.environment as EnvironmentKey, authService); + const env = environmentSchema.parse(argv.environment); + await handleStatus(env, authService); }) .help() .alias('help', 'h')