@modelriver/cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,249 @@
1
+ const ApiClient = require('../lib/api-client');
2
+ const WebSocketClient = require('../lib/websocket-client');
3
+ const config = require('../lib/config');
4
+ const Logger = require('../utils/logger');
5
+ const Formatter = require('../utils/formatter');
6
+
7
+ /**
8
+ * Normalize WebSocket URL for production environments
9
+ * Converts ws:// to wss:// for port 443 or production domains
10
+ */
11
+ function normalizeWebSocketUrl(url) {
12
+ if (!url) return url;
13
+
14
+ try {
15
+ // Handle URLs like 'ws://api.modelriver.com:443/socket'
16
+ // Convert to 'wss://api.modelriver.com/socket'
17
+ const wsUrl = new URL(url);
18
+
19
+ // If port is 443, use wss:// and remove port
20
+ if (wsUrl.port === '443') {
21
+ wsUrl.protocol = 'wss:';
22
+ wsUrl.port = ''; // Remove port for standard https
23
+ }
24
+
25
+ // If it's a production domain (ends with modelriver.com), ensure wss://
26
+ if (wsUrl.hostname.endsWith('modelriver.com') && wsUrl.protocol === 'ws:') {
27
+ wsUrl.protocol = 'wss:';
28
+ }
29
+
30
+ return wsUrl.toString().replace(/\/$/, ''); // Remove trailing slash
31
+ } catch (e) {
32
+ // If URL parsing fails, return original
33
+ return url;
34
+ }
35
+ }
36
+
37
+ async function websocketCommand(options) {
38
+ const { workflow, message, payload, channelId, projectId, apiKey, apiUrl, verbose } = options;
39
+
40
+ let wsToken, websocketChannel, websocketUrl, channelIdToUse, projectIdToUse;
41
+
42
+ try {
43
+ const apiClient = new ApiClient(apiKey, apiUrl);
44
+
45
+ if (channelId && projectId) {
46
+ // Connect to existing channel
47
+ const spinner = Logger.spinner('Getting reconnect token...');
48
+ spinner.start();
49
+
50
+ try {
51
+ const reconnectResponse = await apiClient.reconnectAsync(channelId);
52
+ wsToken = reconnectResponse.ws_token;
53
+ websocketChannel = reconnectResponse.websocket_channel;
54
+ websocketUrl = normalizeWebSocketUrl(reconnectResponse.websocket_url || config.getWebSocketUrl());
55
+ channelIdToUse = channelId;
56
+ projectIdToUse = projectId;
57
+
58
+ spinner.succeed(`Reconnect token obtained for channel ${channelId}`);
59
+ } catch (error) {
60
+ spinner.fail('Failed to get reconnect token');
61
+ Logger.error(error.response?.data?.message || error.message);
62
+ process.exit(1);
63
+ }
64
+ } else if (workflow) {
65
+ // Make async request first
66
+ const spinner = Logger.spinner('Making async request...');
67
+ spinner.start();
68
+
69
+ try {
70
+ const requestPayload = payload
71
+ ? JSON.parse(payload)
72
+ : {
73
+ workflow,
74
+ messages: [{ role: 'user', content: message || 'Test from CLI' }]
75
+ };
76
+
77
+ if (verbose) {
78
+ Logger.info(`\n> API URL: ${apiClient.apiUrl}`);
79
+ Logger.info(`> Workflow: ${workflow}`);
80
+ Logger.info(`> Payload: ${JSON.stringify(requestPayload, null, 2)}`);
81
+ }
82
+
83
+ const response = await apiClient.createAsyncRequest(requestPayload);
84
+
85
+ wsToken = response.ws_token;
86
+ websocketChannel = response.websocket_channel;
87
+ websocketUrl = normalizeWebSocketUrl(response.websocket_url || config.getWebSocketUrl());
88
+ channelIdToUse = response.channel_id;
89
+ projectIdToUse = response.project_id;
90
+
91
+ spinner.succeed(`Request queued: ${channelIdToUse}`);
92
+ Logger.info(`\n> Channel ID: ${channelIdToUse}`);
93
+ Logger.info(`> Project ID: ${projectIdToUse}`);
94
+ } catch (error) {
95
+ spinner.fail('Failed to create async request');
96
+
97
+ if (error.response) {
98
+ // HTTP error response
99
+ const status = error.response.status;
100
+ let data = error.response.data;
101
+
102
+ // Handle case where response.data is a plain string
103
+ if (typeof data === 'string') {
104
+ data = { message: data };
105
+ }
106
+
107
+ Logger.error(`Request failed with status code ${status}`);
108
+
109
+ // Log full response for debugging
110
+ if (verbose) {
111
+ Logger.info(`\nFull error response: ${JSON.stringify(data, null, 2)}`);
112
+ Logger.info(`Response headers: ${JSON.stringify(error.response.headers, null, 2)}`);
113
+ }
114
+
115
+ if (data?.error) {
116
+ Logger.error(`Error: ${data.error}`);
117
+ }
118
+ if (data?.message) {
119
+ Logger.error(`Message: ${data.message}`);
120
+ }
121
+ if (data?.details) {
122
+ Logger.error(`Details: ${data.details}`);
123
+ }
124
+
125
+ // If response is just "Forbidden" string, provide more context
126
+ if (typeof error.response.data === 'string' && error.response.data === 'Forbidden') {
127
+ Logger.warning('\nReceived plain "Forbidden" response - this may indicate:');
128
+ Logger.warning('- Host header mismatch (API expects specific host)');
129
+ Logger.warning('- Route not matching expected pattern');
130
+ Logger.warning('- Middleware blocking the request');
131
+ }
132
+
133
+ if (status === 403) {
134
+ Logger.warning('\nPossible causes:');
135
+ Logger.warning('1. API key is invalid or revoked');
136
+ Logger.warning('2. API key does not have access to this project');
137
+ Logger.warning('3. Workflow does not exist or is not accessible');
138
+ Logger.warning('4. API key does not have required providers enabled');
139
+ Logger.warning('\nTo fix:');
140
+ Logger.warning('- Verify your API key in the ModelRiver dashboard');
141
+ Logger.warning('- Check that the workflow name is correct (case-sensitive)');
142
+ Logger.warning('- Ensure the API key has access to the project');
143
+ Logger.warning('- Run with --verbose to see full error details');
144
+ } else if (status === 401) {
145
+ Logger.error('Authentication failed - API key is invalid or missing');
146
+ Logger.warning('Check that MODELRIVER_API_KEY is set correctly');
147
+ }
148
+ } else if (error.request) {
149
+ Logger.error('No response received from server');
150
+ Logger.error('Check your network connection and API URL');
151
+ if (verbose) {
152
+ Logger.info(`Request was: ${error.config?.method?.toUpperCase()} ${error.config?.url}`);
153
+ }
154
+ } else {
155
+ Logger.error(`Error: ${error.message}`);
156
+ }
157
+
158
+ process.exit(1);
159
+ }
160
+ } else {
161
+ Logger.error('Error: --workflow or (--channel-id and --project-id) required');
162
+ process.exit(1);
163
+ }
164
+
165
+ // Connect to WebSocket
166
+ const connectSpinner = Logger.spinner('Connecting to WebSocket...');
167
+ connectSpinner.start();
168
+
169
+ if (verbose) {
170
+ Logger.info(`\n> WebSocket URL: ${websocketUrl.replace(wsToken, '***')}`);
171
+ Logger.info(`> Channel: ${websocketChannel}`);
172
+ }
173
+
174
+ const wsClient = new WebSocketClient(websocketUrl, wsToken, verbose);
175
+
176
+ try {
177
+ await wsClient.connect();
178
+ connectSpinner.succeed('WebSocket connected');
179
+
180
+ // Join channel
181
+ const joinSpinner = Logger.spinner('Joining channel...');
182
+ joinSpinner.start();
183
+
184
+ try {
185
+ await wsClient.joinChannel(websocketChannel);
186
+ joinSpinner.succeed('Channel joined');
187
+ Logger.warning('\n> Waiting for response...\n');
188
+ } catch (error) {
189
+ joinSpinner.fail('Failed to join channel');
190
+ Logger.error(error.message);
191
+ wsClient.close();
192
+ process.exit(1);
193
+ }
194
+
195
+ // Set up response listener BEFORE waiting (to catch responses that arrive quickly)
196
+ let responseReceived = false;
197
+
198
+ // Timeout after 5 minutes
199
+ const timeoutId = setTimeout(() => {
200
+ if (!responseReceived) {
201
+ Logger.error('\n❌ Timeout: No response received after 5 minutes');
202
+ Logger.error(' Possible causes:');
203
+ Logger.error(' 1. Workflow is still processing (try waiting longer)');
204
+ Logger.error(' 2. Workflow failed on the server (check server logs)');
205
+ Logger.error(' 3. Server did not broadcast the response');
206
+ wsClient.close();
207
+ process.exit(1);
208
+ }
209
+ }, 5 * 60 * 1000);
210
+
211
+ wsClient.onResponse((payload) => {
212
+ responseReceived = true;
213
+ clearTimeout(timeoutId);
214
+ Logger.success('\n✅ Response received:\n');
215
+ console.log(Formatter.json(payload));
216
+ wsClient.close();
217
+ process.exit(0);
218
+ });
219
+
220
+ if (verbose) {
221
+ Logger.info('✅ Response listener registered and ready');
222
+ Logger.info('⏳ Waiting for server to send "response" event...');
223
+ Logger.info(' (This may take a while for production workflows)');
224
+ }
225
+
226
+ wsClient.onClose(() => {
227
+ clearTimeout(timeoutId);
228
+ if (!responseReceived) {
229
+ Logger.warning('\n> WebSocket closed before response received');
230
+ Logger.warning(' The server may have closed the connection without sending a response.');
231
+ Logger.warning(' Check server logs or try the request again.');
232
+ }
233
+ });
234
+
235
+ } catch (error) {
236
+ connectSpinner.fail('WebSocket connection failed');
237
+ Logger.error(error.message);
238
+ process.exit(1);
239
+ }
240
+
241
+ } catch (error) {
242
+ Logger.error(`Unexpected error: ${error.message}`);
243
+ process.exit(1);
244
+ }
245
+ }
246
+
247
+ module.exports = { websocketCommand };
248
+
249
+
package/src/index.js ADDED
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { Command } = require('commander');
4
+ const { loginCommand } = require('./commands/login');
5
+ const { listenCommand } = require('./commands/listen');
6
+ const { forwardCommand } = require('./commands/forward');
7
+ const { websocketCommand } = require('./commands/websocket');
8
+ const { triggerCommand } = require('./commands/trigger');
9
+ const { testWebhookCommand } = require('./commands/test-webhook');
10
+ const { listWebhooks, verifyWebhook } = require('./commands/webhook');
11
+ const config = require('./lib/config');
12
+
13
+ const program = new Command();
14
+
15
+ program
16
+ .name('modelriver')
17
+ .description('ModelRiver CLI for testing webhooks and WebSockets from production')
18
+ .version('1.0.0');
19
+
20
+ // Global options
21
+ program
22
+ .option('-k, --api-key <key>', 'ModelRiver API key (or set MODELRIVER_API_KEY)')
23
+ .option('--api-url <url>', 'API base URL (default: https://api.modelriver.com)')
24
+ .option('-p, --project-id <id>', 'Project ID (or set MODELRIVER_PROJECT_ID)');
25
+
26
+ // Login command - interactive setup
27
+ program
28
+ .command('login')
29
+ .description('Configure your ModelRiver API key and forward URL interactively')
30
+ .action((options) => {
31
+ loginCommand(options);
32
+ });
33
+
34
+ // Listen command with alias 'l'
35
+ program
36
+ .command('listen')
37
+ .alias('l')
38
+ .description('Forward webhooks from production to local server via WebSocket')
39
+ .option('-k, --api-key <key>', 'ModelRiver API key (or set MODELRIVER_API_KEY)')
40
+ .option('--api-url <url>', 'API base URL (default: https://api.modelriver.com)')
41
+ .option('-p, --port <port>', 'Local server port (default: 3001)')
42
+ .option('-f, --forward', 'Forward to external server (do not start internal server)')
43
+ .option('--print', 'Print webhook payloads to console')
44
+ .option('-v, --verbose', 'Verbose output')
45
+ .action((options) => {
46
+ listenCommand({
47
+ ...options,
48
+ apiKey: options.apiKey || program.opts().apiKey,
49
+ apiUrl: options.apiUrl || program.opts().apiUrl
50
+ });
51
+ });
52
+
53
+ // Forward command with alias 'f' - simplified webhook forwarding
54
+ program
55
+ .command('forward')
56
+ .alias('f')
57
+ .description('Forward webhooks using saved configuration (shortcut for listen --forward --print)')
58
+ .option('-p, --port <port>', 'Override target port')
59
+ .option('-v, --verbose', 'Verbose output')
60
+ .action((options) => {
61
+ forwardCommand(options);
62
+ });
63
+
64
+ // WebSocket command with alias 'ws'
65
+ program
66
+ .command('websocket')
67
+ .alias('ws')
68
+ .description('Test WebSocket connection to production')
69
+ .option('-w, --workflow <name>', 'Workflow name')
70
+ .option('-m, --message <text>', 'Test message')
71
+ .option('-P, --payload <json>', 'Custom JSON payload')
72
+ .option('-c, --channel-id <id>', 'Existing channel ID (requires --project-id)')
73
+ .option('-v, --verbose', 'Verbose output')
74
+ .action((options) => {
75
+ websocketCommand({
76
+ ...options,
77
+ apiKey: options.apiKey || program.opts().apiKey,
78
+ apiUrl: options.apiUrl || program.opts().apiUrl,
79
+ projectId: options.projectId || program.opts().projectId
80
+ });
81
+ });
82
+
83
+ // Test Webhook command
84
+ program
85
+ .command('test-webhook')
86
+ .description('Test webhook delivery - creates webhook, makes request, waits for response')
87
+ .option('-w, --workflow <name>', 'Workflow name')
88
+ .option('-m, --message <text>', 'Test message')
89
+ .option('-P, --payload <json>', 'Custom JSON payload')
90
+ .option('-u, --webhook-url <url>', 'Webhook URL (if not provided, starts local server on --port)')
91
+ .option('-s, --secret <secret>', 'Webhook secret (optional, auto-generated if not provided)')
92
+ .option('-p, --port <port>', 'Local server port when using local server (default: 3001)')
93
+ .option('-v, --verbose', 'Verbose output')
94
+ .action((options) => {
95
+ testWebhookCommand({
96
+ ...options,
97
+ apiKey: options.apiKey || program.opts().apiKey,
98
+ apiUrl: options.apiUrl || program.opts().apiUrl
99
+ });
100
+ });
101
+
102
+ // Trigger command with alias 't'
103
+ program
104
+ .command('trigger')
105
+ .alias('t')
106
+ .description('Send test async request')
107
+ .option('-w, --workflow <name>', 'Workflow name')
108
+ .option('-m, --message <text>', 'Test message')
109
+ .option('-P, --payload <json>', 'Custom JSON payload')
110
+ .option('-u, --webhook-url <url>', 'Webhook URL to receive response')
111
+ .option('--print-channel', 'Print channel details')
112
+ .action((options) => {
113
+ triggerCommand({
114
+ ...options,
115
+ apiKey: options.apiKey || program.opts().apiKey,
116
+ apiUrl: options.apiUrl || program.opts().apiUrl
117
+ });
118
+ });
119
+
120
+ // Webhook commands
121
+ const webhookCmd = program
122
+ .command('webhook')
123
+ .description('Webhook management commands (read-only and verification)');
124
+
125
+ webhookCmd
126
+ .command('list')
127
+ .description('List all webhooks')
128
+ .option('-v, --verbose', 'Show detailed information')
129
+ .action((options) => {
130
+ listWebhooks({
131
+ ...options,
132
+ apiKey: options.apiKey || program.opts().apiKey,
133
+ apiUrl: options.apiUrl || program.opts().apiUrl
134
+ });
135
+ });
136
+
137
+ webhookCmd
138
+ .command('verify')
139
+ .description('Verify webhook signature')
140
+ .requiredOption('-p, --payload <file|json>', 'Payload file path or JSON string')
141
+ .requiredOption('-s, --signature <sig>', 'X-ModelRiver-Signature header value')
142
+ .requiredOption('-t, --timestamp <ts>', 'X-ModelRiver-Timestamp header value')
143
+ .requiredOption('--secret <secret>', 'Webhook secret')
144
+ .action(verifyWebhook);
145
+
146
+ // Parse arguments
147
+ program.parse();
148
+
149
+ // Show help if no command provided
150
+ if (!process.argv.slice(2).length) {
151
+ program.outputHelp();
152
+ }
153
+
154
+
@@ -0,0 +1,173 @@
1
+ const axios = require('axios');
2
+ const config = require('./config');
3
+
4
+ class ApiClient {
5
+ constructor(apiKey = null, apiUrl = null) {
6
+ this.apiKey = apiKey || config.getApiKey();
7
+ this.apiUrl = (apiUrl || config.getApiUrl()).replace(/\/$/, '');
8
+
9
+ if (!this.apiKey) {
10
+ throw new Error('API key is required. Set MODELRIVER_API_KEY env var or use --api-key flag.');
11
+ }
12
+
13
+ // Validate API key format
14
+ if (!this.apiKey.startsWith('mr_live_') && !this.apiKey.startsWith('mr_test_')) {
15
+ throw new Error('Invalid API key format. API keys must start with "mr_live_" or "mr_test_".');
16
+ }
17
+
18
+ // Determine API path: /v1 in production (api.modelriver.com), /api/v1 in dev/test
19
+ try {
20
+ const apiUrlObj = new URL(this.apiUrl);
21
+ const isProduction = apiUrlObj.hostname === 'api.modelriver.com' ||
22
+ apiUrlObj.hostname.endsWith('.modelriver.com');
23
+ this.apiPath = isProduction ? '/v1' : '/api/v1';
24
+ } catch (e) {
25
+ // Fallback to dev path if URL parsing fails
26
+ this.apiPath = '/api/v1';
27
+ }
28
+
29
+ // Debug: Log API key info if DEBUG is set
30
+ if (process.env.DEBUG) {
31
+ console.log('[DEBUG] API Key (first 30 chars):', this.apiKey ? this.apiKey.substring(0, 30) + '...' : 'NOT SET');
32
+ console.log('[DEBUG] Authorization header will be:', this.apiKey ? `Bearer ${this.apiKey.substring(0, 30)}...` : 'NOT SET');
33
+ }
34
+
35
+ this.client = axios.create({
36
+ baseURL: this.apiUrl,
37
+ headers: {
38
+ 'Authorization': `Bearer ${this.apiKey}`,
39
+ 'Content-Type': 'application/json'
40
+ },
41
+ validateStatus: function (status) {
42
+ return status < 500; // Don't throw on 4xx errors, we'll handle them
43
+ }
44
+ });
45
+ }
46
+
47
+
48
+ /**
49
+ * Create async AI request
50
+ * @param {object} payload - Request payload {workflow, messages, format?}
51
+ * @returns {Promise<object>} - Response with channel_id, ws_token, etc.
52
+ */
53
+ async createAsyncRequest(payload) {
54
+ const response = await this.client.post(`${this.apiPath}/ai/async`, payload);
55
+
56
+ // Check for error status codes
57
+ if (response.status >= 400) {
58
+ const error = new Error(`Request failed with status ${response.status}`);
59
+ error.response = response;
60
+ throw error;
61
+ }
62
+
63
+ return response.data;
64
+ }
65
+
66
+ /**
67
+ * Get reconnect token for existing channel
68
+ * @param {string} channelId - Channel ID
69
+ * @returns {Promise<object>} - Response with ws_token
70
+ */
71
+ async reconnectAsync(channelId) {
72
+ const response = await this.client.post(`${this.apiPath}/ai/reconnect`, {
73
+ channel_id: channelId
74
+ });
75
+ return response.data;
76
+ }
77
+
78
+ /**
79
+ * List all webhooks for the project
80
+ * @returns {Promise<Array>} - Array of webhook objects
81
+ */
82
+ async listWebhooks() {
83
+ const response = await this.client.get(`${this.apiPath}/webhooks`);
84
+ return response.data.webhooks || [];
85
+ }
86
+
87
+ /**
88
+ * Get a specific webhook
89
+ * @param {string} webhookId - Webhook ID
90
+ * @returns {Promise<object>} - Webhook object
91
+ */
92
+ async getWebhook(webhookId) {
93
+ const response = await this.client.get(`${this.apiPath}/webhooks/${webhookId}`);
94
+ return response.data;
95
+ }
96
+
97
+ /**
98
+ * Create a new webhook
99
+ * @param {object} webhook - {url, description?, secret?}
100
+ * @returns {Promise<object>} - Created webhook with secret
101
+ */
102
+ async createWebhook(webhook) {
103
+ const response = await this.client.post(`${this.apiPath}/webhooks`, webhook);
104
+ return response.data;
105
+ }
106
+
107
+ /**
108
+ * Update a webhook
109
+ * @param {string} webhookId - Webhook ID
110
+ * @param {object} updates - {url?, description?, enabled?, secret?}
111
+ * @returns {Promise<object>} - Updated webhook
112
+ */
113
+ async updateWebhook(webhookId, updates) {
114
+ const response = await this.client.put(`${this.apiPath}/webhooks/${webhookId}`, updates);
115
+ return response.data;
116
+ }
117
+
118
+ /**
119
+ * Delete a webhook
120
+ * @param {string} webhookId - Webhook ID
121
+ * @returns {Promise<void>}
122
+ */
123
+ async deleteWebhook(webhookId) {
124
+ await this.client.delete(`${this.apiPath}/webhooks/${webhookId}`);
125
+ }
126
+
127
+ /**
128
+ * Connect CLI and get WebSocket token
129
+ * @returns {Promise<object>} - {ws_token, websocket_url, project_id, user_id, channel}
130
+ */
131
+ async connectCLI() {
132
+ try {
133
+ const url = `${this.apiPath}/cli/connect`;
134
+
135
+ // Debug: Log the request details
136
+ if (process.env.DEBUG || this.verbose) {
137
+ console.log('[DEBUG] Making request to:', `${this.apiUrl}${url}`);
138
+ console.log('[DEBUG] API Key (first 30 chars):', this.apiKey ? this.apiKey.substring(0, 30) + '...' : 'NOT SET');
139
+ console.log('[DEBUG] Authorization header:', this.apiKey ? `Bearer ${this.apiKey.substring(0, 30)}...` : 'NOT SET');
140
+ }
141
+
142
+ const response = await this.client.post(url, {});
143
+
144
+ if (response.status >= 400) {
145
+ const errorMsg = response.data?.error || response.data?.message || `HTTP ${response.status}`;
146
+ const error = new Error(`Failed to connect CLI: ${errorMsg}`);
147
+ error.response = response;
148
+ throw error;
149
+ }
150
+
151
+ return response.data;
152
+ } catch (error) {
153
+ // Handle network errors or axios errors
154
+ if (error.response) {
155
+ // Server responded with error
156
+ const errorMsg = error.response.data?.error || error.response.data?.message || `HTTP ${error.response.status}`;
157
+ const newError = new Error(`Failed to connect CLI: ${errorMsg}`);
158
+ newError.response = error.response;
159
+ throw newError;
160
+ } else if (error.request) {
161
+ // Request was made but no response received
162
+ throw new Error(`Failed to connect CLI: No response from server. Is the backend running?`);
163
+ } else {
164
+ // Something else happened
165
+ throw new Error(`Failed to connect CLI: ${error.message}`);
166
+ }
167
+ }
168
+ }
169
+ }
170
+
171
+ module.exports = ApiClient;
172
+
173
+
@@ -0,0 +1,101 @@
1
+ // Mock fs to prevent reading from actual ~/.modelriver/config.json
2
+ jest.mock('fs', () => ({
3
+ existsSync: jest.fn(() => false),
4
+ readFileSync: jest.fn(() => '{}'),
5
+ writeFileSync: jest.fn(),
6
+ mkdirSync: jest.fn(),
7
+ }));
8
+
9
+ describe('ApiClient', () => {
10
+ const originalEnv = process.env;
11
+
12
+ beforeEach(() => {
13
+ jest.resetModules();
14
+ // Re-apply the mock after resetModules
15
+ jest.doMock('fs', () => ({
16
+ existsSync: jest.fn(() => false),
17
+ readFileSync: jest.fn(() => '{}'),
18
+ writeFileSync: jest.fn(),
19
+ mkdirSync: jest.fn(),
20
+ }));
21
+ process.env = { ...originalEnv };
22
+ // Clear any config env vars first
23
+ delete process.env.MODELRIVER_API_KEY;
24
+ // Then set a valid API key for tests that need it
25
+ process.env.MODELRIVER_API_KEY = 'mr_live_validtestkey123';
26
+ });
27
+
28
+ afterAll(() => {
29
+ process.env = originalEnv;
30
+ });
31
+
32
+ describe('constructor validation', () => {
33
+ it('should throw error when API key is missing', () => {
34
+ delete process.env.MODELRIVER_API_KEY;
35
+ const ApiClient = require('./api-client');
36
+
37
+ expect(() => new ApiClient(null, 'https://api.modelriver.com'))
38
+ .toThrow('API key is required');
39
+ });
40
+
41
+ it('should throw error for invalid API key format', () => {
42
+ const ApiClient = require('./api-client');
43
+
44
+ expect(() => new ApiClient('invalid-key-format', 'https://api.modelriver.com'))
45
+ .toThrow('Invalid API key format');
46
+ });
47
+
48
+ it('should accept mr_live_ prefixed keys', () => {
49
+ const ApiClient = require('./api-client');
50
+
51
+ expect(() => new ApiClient('mr_live_abc123', 'https://api.modelriver.com'))
52
+ .not.toThrow();
53
+ });
54
+
55
+ it('should accept mr_test_ prefixed keys', () => {
56
+ const ApiClient = require('./api-client');
57
+
58
+ expect(() => new ApiClient('mr_test_abc123', 'https://api.modelriver.com'))
59
+ .not.toThrow();
60
+ });
61
+ });
62
+
63
+ describe('apiPath detection', () => {
64
+ it('should use /v1 for production URLs', () => {
65
+ const ApiClient = require('./api-client');
66
+ const client = new ApiClient('mr_live_test123', 'https://api.modelriver.com');
67
+
68
+ expect(client.apiPath).toBe('/v1');
69
+ });
70
+
71
+ it('should use /v1 for *.modelriver.com subdomains', () => {
72
+ const ApiClient = require('./api-client');
73
+ const client = new ApiClient('mr_live_test123', 'https://staging.modelriver.com');
74
+
75
+ expect(client.apiPath).toBe('/v1');
76
+ });
77
+
78
+ it('should use /api/v1 for localhost', () => {
79
+ const ApiClient = require('./api-client');
80
+ const client = new ApiClient('mr_live_test123', 'http://localhost:4000');
81
+
82
+ expect(client.apiPath).toBe('/api/v1');
83
+ });
84
+
85
+ it('should use /api/v1 for non-production URLs', () => {
86
+ const ApiClient = require('./api-client');
87
+ const client = new ApiClient('mr_live_test123', 'http://my-dev-server.local');
88
+
89
+ expect(client.apiPath).toBe('/api/v1');
90
+ });
91
+ });
92
+
93
+ describe('URL normalization', () => {
94
+ it('should strip trailing slashes from API URL', () => {
95
+ const ApiClient = require('./api-client');
96
+ const client = new ApiClient('mr_live_test123', 'https://api.modelriver.com/');
97
+
98
+ expect(client.apiUrl).toBe('https://api.modelriver.com');
99
+ });
100
+ });
101
+ });