@modelriver/cli 1.2.3 → 1.2.4

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.
package/README.md CHANGED
@@ -54,6 +54,7 @@ For convenience, the CLI supports short aliases:
54
54
  | `forward` | `f` | Forward using saved config |
55
55
  | `trigger` | `t` | Send async request |
56
56
  | `websocket` | `ws` | Test WebSocket connection |
57
+ | `callback` | - | Complete event-driven callback |
57
58
  | `test-webhook` | - | Test webhook delivery end-to-end |
58
59
 
59
60
  Example:
@@ -183,6 +184,38 @@ modelriver listen --port 3002 --forward --print
183
184
  Data: {"result": "..."}
184
185
  ```
185
186
 
187
+ **Event-driven example:**
188
+ ```
189
+ [timestamp] Webhook received via WebSocket:
190
+ Channel ID: abc-123-def
191
+ Status: ai_generated (callback required)
192
+ Type: task.ai_generated
193
+ Event: schema_result_ready
194
+ Callback URL: https://api.modelriver.com/v1/callback/abc-123-def
195
+ Customer data: { "ticket_id": "TCK-10042" }
196
+ AI response: { ... }
197
+
198
+ > Event-driven webhook — complete the flow with a callback:
199
+ > modelriver callback --channel-id abc-123-def --data '{"summary":"..."}'
200
+ ```
201
+
202
+ ### `modelriver callback` - Complete Event-driven Flows
203
+
204
+ After `listen` receives a `task.ai_generated` webhook, complete the flow:
205
+
206
+ ```bash
207
+ # Using channel ID
208
+ modelriver callback \
209
+ --channel-id abc-123-def \
210
+ --data '{"summary":"Escalated to auth","priority":"high"}' \
211
+ --task-id TCK-10042
212
+
213
+ # Using full callback URL from the webhook
214
+ modelriver callback \
215
+ --callback-url "https://api.modelriver.com/v1/callback/abc-123-def" \
216
+ --data '{"summary":"Escalated to auth"}'
217
+ ```
218
+
186
219
  ### `modelriver websocket` - Test WebSocket Connection
187
220
 
188
221
  Test WebSocket connections to production and receive real-time responses.
@@ -231,8 +264,8 @@ Send a test async request and get channel details.
231
264
  # Basic trigger
232
265
  modelriver trigger --workflow my-workflow --message "Test message"
233
266
 
234
- # With custom payload
235
- modelriver trigger --workflow my-workflow --payload '{"messages": [...]}'
267
+ # With custom payload (workflow can be in --payload or via --workflow)
268
+ modelriver trigger --workflow my-workflow --payload '{"messages": [...], "metadata": {"ticket_id":"TCK-1"}}'
236
269
 
237
270
  # Create webhook to receive response
238
271
  modelriver trigger --workflow my-workflow --message "Test" --webhook-url https://webhook.site/your-id
@@ -241,6 +274,17 @@ modelriver trigger --workflow my-workflow --message "Test" --webhook-url https:/
241
274
  modelriver trigger --workflow my-workflow --message "Test" --print-channel
242
275
  ```
243
276
 
277
+ For **event-driven** workflows, after triggering:
278
+
279
+ ```bash
280
+ # Terminal 1
281
+ modelriver listen --print
282
+
283
+ # Terminal 2
284
+ modelriver trigger --workflow my-event-workflow --message "Test" --print-channel
285
+ modelriver callback --channel-id CHANNEL_ID --data '{"enriched":true}'
286
+ ```
287
+
244
288
  **Example Output:**
245
289
  ```
246
290
  ✓ Async request created
@@ -344,6 +388,23 @@ export MODELRIVER_API_KEY=mr_live_YOUR_KEY
344
388
  modelriver trigger --workflow my-workflow --message "Test" --print-channel
345
389
  ```
346
390
 
391
+ ### Example 4: Event-driven Workflow End-to-End
392
+
393
+ ```bash
394
+ # Terminal 1: Listen for webhooks
395
+ export MODELRIVER_API_KEY=mr_live_YOUR_KEY
396
+ modelriver listen --print
397
+
398
+ # Terminal 2: Trigger + callback
399
+ modelriver trigger \
400
+ --workflow my-event-workflow \
401
+ --payload '{"messages":[{"role":"user","content":"Test"}],"metadata":{"ticket_id":"TCK-1"}}' \
402
+ --print-channel
403
+
404
+ # After Terminal 1 shows task.ai_generated:
405
+ modelriver callback --channel-id CHANNEL_ID --data '{"summary":"Done"}'
406
+ ```
407
+
347
408
  ## Webhook Payload Format
348
409
 
349
410
  Webhooks are sent as HTTP POST requests with the following format:
@@ -410,6 +471,7 @@ The CLI uses these ModelRiver API endpoints:
410
471
  - `POST /v1/webhooks` - Create a new webhook
411
472
  - `DELETE /v1/webhooks/:id` - Delete a webhook
412
473
  - `POST /v1/cli/connect` - Connect CLI and get WebSocket token
474
+ - `POST /v1/callback/:channel_id` - Complete event-driven callback
413
475
 
414
476
  **Development** (localhost or custom API URL):
415
477
  - Same endpoints but with `/api/v1` prefix instead of `/v1`
@@ -429,6 +491,7 @@ Tests cover critical components including:
429
491
  - Configuration loading
430
492
  - API client validation
431
493
  - Output formatting
494
+ - Event-driven webhook payload helpers
432
495
 
433
496
 
434
497
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@modelriver/cli",
3
- "version": "1.2.3",
3
+ "version": "1.2.4",
4
4
  "description": "ModelRiver CLI for testing webhooks and WebSockets from production",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -47,6 +47,8 @@
47
47
  "files": [
48
48
  "bin/",
49
49
  "src/",
50
- "README.md"
50
+ "README.md",
51
+ "!src/**/*.test.js",
52
+ "!**/*.test.js"
51
53
  ]
52
54
  }
@@ -0,0 +1,81 @@
1
+ const ApiClient = require('../lib/api-client');
2
+ const Logger = require('../utils/logger');
3
+ const Formatter = require('../utils/formatter');
4
+
5
+ async function callbackCommand(options) {
6
+ const {
7
+ channelId,
8
+ callbackUrl,
9
+ data,
10
+ taskId,
11
+ metadata,
12
+ payload,
13
+ apiKey,
14
+ apiUrl,
15
+ } = options;
16
+
17
+ if (!callbackUrl && !channelId) {
18
+ Logger.error('Error: --channel-id or --callback-url is required');
19
+ process.exit(1);
20
+ }
21
+
22
+ let body;
23
+
24
+ try {
25
+ if (payload) {
26
+ body = JSON.parse(payload);
27
+ } else {
28
+ body = {};
29
+
30
+ if (data) {
31
+ body.data = typeof data === 'string' ? JSON.parse(data) : data;
32
+ }
33
+
34
+ if (taskId) {
35
+ body.task_id = taskId;
36
+ }
37
+
38
+ if (metadata) {
39
+ body.metadata =
40
+ typeof metadata === 'string' ? JSON.parse(metadata) : metadata;
41
+ }
42
+ }
43
+ } catch (error) {
44
+ Logger.error(`Invalid JSON: ${error.message}`);
45
+ process.exit(1);
46
+ }
47
+
48
+ if (body.data !== undefined && (typeof body.data !== 'object' || Array.isArray(body.data))) {
49
+ Logger.error('Error: "data" must be a JSON object');
50
+ process.exit(1);
51
+ }
52
+
53
+ try {
54
+ const apiClient = new ApiClient(apiKey, apiUrl);
55
+ const targetUrl =
56
+ callbackUrl ||
57
+ `${apiClient.apiUrl}${apiClient.apiPath}/callback/${channelId}`;
58
+
59
+ const spinner = Logger.spinner(`Sending callback to ${targetUrl}...`);
60
+ spinner.start();
61
+
62
+ const response = await apiClient.sendCallback(targetUrl, body);
63
+
64
+ if (response.status >= 400) {
65
+ spinner.fail('Callback failed');
66
+ Logger.error(response.data?.error || response.data?.message || `HTTP ${response.status}`);
67
+ if (response.data) {
68
+ console.log(Formatter.json(response.data));
69
+ }
70
+ process.exit(1);
71
+ }
72
+
73
+ spinner.succeed('Callback accepted');
74
+ console.log(Formatter.json(response.data));
75
+ } catch (error) {
76
+ Logger.error(error.message);
77
+ process.exit(1);
78
+ }
79
+ }
80
+
81
+ module.exports = { callbackCommand };
@@ -5,6 +5,10 @@ const WebhookVerifier = require('../lib/webhook-verifier');
5
5
  const Logger = require('../utils/logger');
6
6
  const Formatter = require('../utils/formatter');
7
7
  const { parseForwardUrl } = require('../utils/url-helpers');
8
+ const {
9
+ printWebhookDetails,
10
+ resolveCallbackUrl,
11
+ } = require('../utils/webhook-payload');
8
12
 
9
13
  async function listenCommand(options) {
10
14
  let { apiKey, apiUrl, print, port, verbose, forward, forwardUrl } = options;
@@ -68,7 +72,7 @@ async function listenCommand(options) {
68
72
 
69
73
  // Debug: Show API key info if verbose
70
74
  if (verbose) {
71
- Logger.info(`> API URL: ${apiUrl}`);
75
+ Logger.info(`> API URL: ${apiClient.apiUrl}`);
72
76
  Logger.info(`> API Path: ${apiClient.apiPath}`);
73
77
  Logger.info(`> API Key (first 30 chars): ${apiKey.substring(0, 30)}...`);
74
78
  }
@@ -152,20 +156,27 @@ async function listenCommand(options) {
152
156
  );
153
157
  }
154
158
 
155
- if (print) {
156
- const timestampStr = Formatter.timestamp(new Date());
157
- Logger.log(`\n[${timestampStr}] Webhook received:`);
158
- Logger.log(` Channel ID: ${channel_id || 'N/A'}`);
159
- Logger.log(` Status: ${data?.status || 'N/A'}`);
160
- if (isValid !== null) {
161
- Logger.log(` Signature: ${isValid ? '✓ Valid' : '✗ Invalid'}`);
162
- }
163
- if (data?.data) {
164
- Logger.log(` Data: ${Formatter.json(data.data)}`);
165
- }
166
- if (data?.meta) {
167
- Logger.log(` Meta: ${Formatter.json(data.meta)}`);
168
- }
159
+ // Skip printing when this request came from our own WebSocket forwarder
160
+ // (already printed in the WebSocket handler). Still print external POSTs.
161
+ const isInternalForward = req.headers['x-modelriver-cli-internal'] === '1';
162
+ if (print && !isInternalForward) {
163
+ printWebhookDetails(
164
+ {
165
+ channel_id,
166
+ data,
167
+ callback_url: req.body.callback_url || resolveCallbackUrl(req.body),
168
+ },
169
+ {
170
+ Logger,
171
+ Formatter,
172
+ WebhookVerifier,
173
+ webhookSecret,
174
+ signature,
175
+ timestamp: payloadTimestamp || timestamp,
176
+ apiKey,
177
+ label: 'Webhook received (local server)',
178
+ }
179
+ );
169
180
  }
170
181
 
171
182
  // Always return 200 to acknowledge receipt
@@ -175,6 +186,25 @@ async function listenCommand(options) {
175
186
  server = app.listen(localPort, 'localhost', () => {
176
187
  Logger.success(`> Local server listening on http://localhost:${localPort}/webhook`);
177
188
  });
189
+
190
+ server.on('error', (error) => {
191
+ if (error.code === 'EADDRINUSE') {
192
+ spinner.fail(`Port ${localPort} is already in use`);
193
+ Logger.error(`Another process is listening on port ${localPort}.`);
194
+ Logger.warning(`> Use a different port: modelriver listen --print --port ${Number(localPort) + 1}`);
195
+ Logger.warning('> Or stop the other process and try again');
196
+ if (wsClient) {
197
+ wsClient.close();
198
+ }
199
+ process.exit(1);
200
+ }
201
+
202
+ Logger.error(`Local server error: ${error.message}`);
203
+ if (wsClient) {
204
+ wsClient.close();
205
+ }
206
+ process.exit(1);
207
+ });
178
208
  } else {
179
209
  Logger.success(`> Forwarding webhooks to http://localhost:${localPort}/webhook`);
180
210
  }
@@ -184,25 +214,16 @@ async function listenCommand(options) {
184
214
  const { channel_id, timestamp, data, signature } = webhookPayload;
185
215
 
186
216
  if (print) {
187
- const timestampStr = Formatter.timestamp(new Date());
188
- Logger.log(`\n[${timestampStr}] Webhook received via WebSocket:`);
189
- Logger.log(` Channel ID: ${channel_id || 'N/A'}`);
190
- Logger.log(` Status: ${data?.status || 'N/A'}`);
191
- if (signature && webhookSecret) {
192
- const isValid = WebhookVerifier.verify(
193
- signature,
194
- data,
195
- webhookSecret,
196
- timestamp.toString()
197
- );
198
- Logger.log(` Signature: ${isValid ? '✓ Valid' : '✗ Invalid'}`);
199
- }
200
- if (data?.data) {
201
- Logger.log(` Data: ${Formatter.json(data.data)}`);
202
- }
203
- if (data?.meta) {
204
- Logger.log(` Meta: ${Formatter.json(data.meta)}`);
205
- }
217
+ printWebhookDetails(webhookPayload, {
218
+ Logger,
219
+ Formatter,
220
+ WebhookVerifier,
221
+ webhookSecret,
222
+ signature,
223
+ timestamp,
224
+ apiKey,
225
+ label: 'Webhook received via WebSocket',
226
+ });
206
227
  }
207
228
 
208
229
  // Forward to local server
@@ -211,7 +232,8 @@ async function listenCommand(options) {
211
232
  const postData = JSON.stringify({
212
233
  channel_id,
213
234
  timestamp,
214
- data
235
+ callback_url: resolveCallbackUrl(webhookPayload),
236
+ data,
215
237
  });
216
238
 
217
239
  // Parse the forward URL or use localhost defaults
@@ -232,7 +254,8 @@ async function listenCommand(options) {
232
254
  'Content-Length': Buffer.byteLength(postData),
233
255
  'X-ModelRiver-Signature': signature || '',
234
256
  'X-ModelRiver-Timestamp': timestamp.toString(),
235
- 'X-ModelRiver-Webhook-Id': webhookPayload.webhook_id || ''
257
+ 'X-ModelRiver-Webhook-Id': webhookPayload.webhook_id || '',
258
+ 'X-ModelRiver-CLI-Internal': '1',
236
259
  }
237
260
  };
238
261
 
@@ -23,6 +23,17 @@ async function triggerCommand(options) {
23
23
  messages: [{ role: 'user', content: message || 'Test from CLI' }]
24
24
  };
25
25
 
26
+ // Allow --workflow with --payload when payload omits workflow
27
+ if (workflow && !requestPayload.workflow) {
28
+ requestPayload.workflow = workflow;
29
+ }
30
+
31
+ if (!requestPayload.workflow) {
32
+ spinner.fail('Workflow required');
33
+ Logger.error('Provide --workflow or include "workflow" in --payload JSON');
34
+ process.exit(1);
35
+ }
36
+
26
37
  const response = await apiClient.createAsyncRequest(requestPayload);
27
38
 
28
39
  spinner.succeed('Async request created');
@@ -61,6 +72,7 @@ async function triggerCommand(options) {
61
72
  } else {
62
73
  Logger.info('\n> Use --webhook-url to automatically receive responses via webhook');
63
74
  Logger.info('> Or use "modelriver websocket" to connect and receive responses');
75
+ Logger.info('> For event-driven workflows, complete with: modelriver callback --channel-id <id> --data \'{...}\'');
64
76
  }
65
77
 
66
78
  } catch (error) {
@@ -3,6 +3,11 @@ const WebSocketClient = require('../lib/websocket-client');
3
3
  const config = require('../lib/config');
4
4
  const Logger = require('../utils/logger');
5
5
  const Formatter = require('../utils/formatter');
6
+ const {
7
+ buildCallbackHint,
8
+ isFinalWebSocketStatus,
9
+ isIntermediateWebSocketStatus,
10
+ } = require('../utils/webhook-payload');
6
11
 
7
12
  /**
8
13
  * Normalize WebSocket URL for production environments
@@ -74,9 +79,20 @@ async function websocketCommand(options) {
74
79
  messages: [{ role: 'user', content: message || 'Test from CLI' }]
75
80
  };
76
81
 
82
+ // Allow --workflow with --payload when payload omits workflow
83
+ if (workflow && !requestPayload.workflow) {
84
+ requestPayload.workflow = workflow;
85
+ }
86
+
87
+ if (!requestPayload.workflow) {
88
+ spinner.fail('Workflow required');
89
+ Logger.error('Provide --workflow or include "workflow" in --payload JSON');
90
+ process.exit(1);
91
+ }
92
+
77
93
  if (verbose) {
78
94
  Logger.info(`\n> API URL: ${apiClient.apiUrl}`);
79
- Logger.info(`> Workflow: ${workflow}`);
95
+ Logger.info(`> Workflow: ${requestPayload.workflow}`);
80
96
  Logger.info(`> Payload: ${JSON.stringify(requestPayload, null, 2)}`);
81
97
  }
82
98
 
@@ -194,21 +210,59 @@ async function websocketCommand(options) {
194
210
 
195
211
  // Set up response listener BEFORE waiting (to catch responses that arrive quickly)
196
212
  let responseReceived = false;
213
+ let sawIntermediateEvent = false;
197
214
 
198
215
  // Timeout after 5 minutes
199
216
  const timeoutId = setTimeout(() => {
200
217
  if (!responseReceived) {
201
218
  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');
219
+ if (sawIntermediateEvent) {
220
+ Logger.error(' Event-driven flow is waiting for a backend callback.');
221
+ Logger.error(` Complete it with: modelriver callback --channel-id ${channelIdToUse} --data '{"summary":"..."}'`);
222
+ } else {
223
+ Logger.error(' Possible causes:');
224
+ Logger.error(' 1. Workflow is still processing (try waiting longer)');
225
+ Logger.error(' 2. Workflow failed on the server (check server logs)');
226
+ Logger.error(' 3. Server did not broadcast the response');
227
+ }
206
228
  wsClient.close();
207
229
  process.exit(1);
208
230
  }
209
231
  }, 5 * 60 * 1000);
210
232
 
211
233
  wsClient.onResponse((payload) => {
234
+ const status = payload?.status;
235
+
236
+ if (isIntermediateWebSocketStatus(status)) {
237
+ sawIntermediateEvent = true;
238
+ Logger.warning(`\n⏳ Event-driven update (${status})`);
239
+ Logger.info(`> Channel ID: ${payload.channel_id || channelIdToUse}`);
240
+ if (payload.event_name) {
241
+ Logger.info(`> Event: ${payload.event_name}`);
242
+ }
243
+ if (payload.message) {
244
+ Logger.info(`> Message: ${payload.message}`);
245
+ }
246
+
247
+ const callbackUrl = `${apiClient.apiUrl}${apiClient.apiPath}/callback/${channelIdToUse}`;
248
+ const hint = buildCallbackHint(callbackUrl, channelIdToUse, apiClient.apiKey);
249
+ if (hint) {
250
+ Logger.warning(`\n${hint}\n`);
251
+ }
252
+
253
+ if (verbose) {
254
+ Logger.info(`Intermediate payload: ${Formatter.json(payload)}`);
255
+ }
256
+ return;
257
+ }
258
+
259
+ if (!isFinalWebSocketStatus(status) && status) {
260
+ if (verbose) {
261
+ Logger.info(`Received non-final status "${status}", waiting for completion...`);
262
+ }
263
+ return;
264
+ }
265
+
212
266
  responseReceived = true;
213
267
  clearTimeout(timeoutId);
214
268
  Logger.success('\n✅ Response received:\n');
package/src/index.js CHANGED
@@ -6,6 +6,7 @@ const { listenCommand } = require('./commands/listen');
6
6
  const { forwardCommand } = require('./commands/forward');
7
7
  const { websocketCommand } = require('./commands/websocket');
8
8
  const { triggerCommand } = require('./commands/trigger');
9
+ const { callbackCommand } = require('./commands/callback');
9
10
  const { testWebhookCommand } = require('./commands/test-webhook');
10
11
  const { listWebhooks, verifyWebhook } = require('./commands/webhook');
11
12
  const config = require('./lib/config');
@@ -48,7 +49,7 @@ program
48
49
  listenCommand({
49
50
  ...options,
50
51
  apiKey: options.apiKey || program.opts().apiKey,
51
- apiUrl: options.apiUrl || program.opts().apiUrl
52
+ apiUrl: options.apiUrl || program.opts().apiUrl || config.getApiUrl(),
52
53
  });
53
54
  });
54
55
 
@@ -77,11 +78,29 @@ program
77
78
  websocketCommand({
78
79
  ...options,
79
80
  apiKey: options.apiKey || program.opts().apiKey,
80
- apiUrl: options.apiUrl || program.opts().apiUrl,
81
+ apiUrl: options.apiUrl || program.opts().apiUrl || config.getApiUrl(),
81
82
  projectId: options.projectId || program.opts().projectId
82
83
  });
83
84
  });
84
85
 
86
+ // Callback command - complete event-driven flows
87
+ program
88
+ .command('callback')
89
+ .description('Send an event-driven callback to complete a pending channel')
90
+ .option('-c, --channel-id <id>', 'Channel ID from trigger/listen output')
91
+ .option('--callback-url <url>', 'Full callback URL from webhook payload')
92
+ .option('-d, --data <json>', 'Callback data object JSON')
93
+ .option('-P, --payload <json>', 'Full callback payload JSON')
94
+ .option('--task-id <id>', 'Optional task ID')
95
+ .option('--metadata <json>', 'Optional metadata object JSON')
96
+ .action((options) => {
97
+ callbackCommand({
98
+ ...options,
99
+ apiKey: options.apiKey || program.opts().apiKey,
100
+ apiUrl: options.apiUrl || program.opts().apiUrl || config.getApiUrl(),
101
+ });
102
+ });
103
+
85
104
  // Test Webhook command
86
105
  program
87
106
  .command('test-webhook')
@@ -97,7 +116,7 @@ program
97
116
  testWebhookCommand({
98
117
  ...options,
99
118
  apiKey: options.apiKey || program.opts().apiKey,
100
- apiUrl: options.apiUrl || program.opts().apiUrl
119
+ apiUrl: options.apiUrl || program.opts().apiUrl || config.getApiUrl(),
101
120
  });
102
121
  });
103
122
 
@@ -115,7 +134,7 @@ program
115
134
  triggerCommand({
116
135
  ...options,
117
136
  apiKey: options.apiKey || program.opts().apiKey,
118
- apiUrl: options.apiUrl || program.opts().apiUrl
137
+ apiUrl: options.apiUrl || program.opts().apiUrl || config.getApiUrl()
119
138
  });
120
139
  });
121
140
 
@@ -132,7 +151,7 @@ webhookCmd
132
151
  listWebhooks({
133
152
  ...options,
134
153
  apiKey: options.apiKey || program.opts().apiKey,
135
- apiUrl: options.apiUrl || program.opts().apiUrl
154
+ apiUrl: options.apiUrl || program.opts().apiUrl || config.getApiUrl(),
136
155
  });
137
156
  });
138
157
 
@@ -1,6 +1,13 @@
1
1
  const axios = require('axios');
2
2
  const config = require('./config');
3
3
 
4
+ function createAuthHeaders(apiKey) {
5
+ return {
6
+ Authorization: `Bearer ${apiKey}`,
7
+ 'Content-Type': 'application/json',
8
+ };
9
+ }
10
+
4
11
  class ApiClient {
5
12
  constructor(apiKey = null, apiUrl = null) {
6
13
  this.apiKey = apiKey || config.getApiKey();
@@ -166,6 +173,21 @@ class ApiClient {
166
173
  }
167
174
  }
168
175
  }
176
+
177
+ /**
178
+ * Send an event-driven callback to complete a pending channel.
179
+ * @param {string} callbackUrl - Full callback URL from webhook payload
180
+ * @param {object} body - Callback payload { data?, task_id?, metadata? }
181
+ * @returns {Promise<object>} - Axios-like response { status, data }
182
+ */
183
+ async sendCallback(callbackUrl, body) {
184
+ const response = await axios.post(callbackUrl, body, {
185
+ headers: createAuthHeaders(this.apiKey),
186
+ validateStatus: (status) => status < 500,
187
+ });
188
+
189
+ return response;
190
+ }
169
191
  }
170
192
 
171
193
  module.exports = ApiClient;
@@ -88,7 +88,7 @@ class WebSocketClient {
88
88
  }
89
89
 
90
90
  const joinRef = '1';
91
-
91
+
92
92
  const joinMsg = JSON.stringify({
93
93
  topic: channelName,
94
94
  event: 'phx_join',
@@ -110,7 +110,7 @@ class WebSocketClient {
110
110
  resolved = true;
111
111
  // Remove this listener - handleMessage() will continue processing other messages
112
112
  this.ws.removeListener('message', handleJoinResponse);
113
-
113
+
114
114
  if (msg.payload.status === 'ok') {
115
115
  this.channel = channelName;
116
116
  resolve(msg.payload);
@@ -126,7 +126,7 @@ class WebSocketClient {
126
126
  // Add listener for join response
127
127
  // The handleMessage() listener in connect() will also be called, but won't match
128
128
  this.ws.on('message', handleJoinResponse);
129
-
129
+
130
130
  this.ws.send(joinMsg);
131
131
 
132
132
  // Timeout after 10 seconds
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Helpers for standard vs event-driven webhook payloads.
3
+ */
4
+
5
+ function isEventDrivenWebhook(data) {
6
+ return Boolean(data && data.type === 'task.ai_generated');
7
+ }
8
+
9
+ function resolveCallbackUrl(webhookPayload) {
10
+ if (!webhookPayload) {
11
+ return null;
12
+ }
13
+
14
+ const { data, callback_url: topLevelCallbackUrl } = webhookPayload;
15
+
16
+ return topLevelCallbackUrl || data?.callback_url || null;
17
+ }
18
+
19
+ function getWebhookStatusLabel(data) {
20
+ if (!data) {
21
+ return 'N/A';
22
+ }
23
+
24
+ if (isEventDrivenWebhook(data)) {
25
+ return 'ai_generated (callback required)';
26
+ }
27
+
28
+ return data.status || 'N/A';
29
+ }
30
+
31
+ function getCustomerData(data) {
32
+ if (!data || typeof data !== 'object') {
33
+ return null;
34
+ }
35
+
36
+ const customerData = data.customer_data || data.ai_response?.customer_data;
37
+ if (!customerData || typeof customerData !== 'object') {
38
+ return null;
39
+ }
40
+
41
+ return Object.keys(customerData).length > 0 ? customerData : null;
42
+ }
43
+
44
+ function buildCallbackHint(callbackUrl, channelId, apiKey) {
45
+ if (!callbackUrl && !channelId) {
46
+ return null;
47
+ }
48
+
49
+ const keyHint = apiKey
50
+ ? `${apiKey.substring(0, 12)}...`
51
+ : '$MODELRIVER_API_KEY';
52
+
53
+ const lines = [
54
+ '> Event-driven webhook — complete the flow with a callback:',
55
+ ];
56
+
57
+ if (channelId) {
58
+ lines.push(
59
+ `> modelriver callback --channel-id ${channelId} --data '{"summary":"..."}'`
60
+ );
61
+ }
62
+
63
+ if (callbackUrl) {
64
+ if (channelId) {
65
+ lines.push('> Or:');
66
+ }
67
+ lines.push(
68
+ `> modelriver callback --callback-url "${callbackUrl}" --data '{"summary":"..."}'`
69
+ );
70
+ lines.push(
71
+ `> curl -X POST "${callbackUrl}" \\`,
72
+ `> -H "Authorization: Bearer ${keyHint}" \\`,
73
+ `> -H "Content-Type: application/json" \\`,
74
+ `> -d '{"data":{"enriched":true},"task_id":"your-task-id"}'`
75
+ );
76
+ }
77
+
78
+ return lines.join('\n');
79
+ }
80
+
81
+ function printWebhookDetails(webhookPayload, options = {}) {
82
+ const {
83
+ Logger,
84
+ Formatter,
85
+ WebhookVerifier,
86
+ webhookSecret,
87
+ signature,
88
+ timestamp,
89
+ apiKey,
90
+ label = 'Webhook received',
91
+ } = options;
92
+
93
+ const { channel_id, data } = webhookPayload;
94
+ const callbackUrl = resolveCallbackUrl(webhookPayload);
95
+ const timestampStr = Formatter.timestamp(new Date());
96
+
97
+ Logger.log(`\n[${timestampStr}] ${label}:`);
98
+ Logger.log(` Channel ID: ${channel_id || 'N/A'}`);
99
+ Logger.log(` Status: ${getWebhookStatusLabel(data)}`);
100
+
101
+ if (isEventDrivenWebhook(data)) {
102
+ Logger.log(` Type: ${data.type}`);
103
+ Logger.log(` Event: ${data.event || 'N/A'}`);
104
+
105
+ if (callbackUrl) {
106
+ Logger.log(` Callback URL: ${callbackUrl}`);
107
+ }
108
+
109
+ const customerData = getCustomerData(data);
110
+ if (customerData) {
111
+ Logger.log(` Customer data: ${Formatter.json(customerData)}`);
112
+ }
113
+
114
+ if (data.ai_response) {
115
+ Logger.log(` AI response: ${Formatter.json(data.ai_response)}`);
116
+ }
117
+
118
+ const hint = buildCallbackHint(callbackUrl, channel_id, apiKey);
119
+ if (hint) {
120
+ Logger.warning(`\n${hint}\n`);
121
+ }
122
+ } else {
123
+ if (signature && webhookSecret && data) {
124
+ const isValid = WebhookVerifier.verify(
125
+ signature,
126
+ data,
127
+ webhookSecret,
128
+ String(timestamp)
129
+ );
130
+ Logger.log(` Signature: ${isValid ? '✓ Valid' : '✗ Invalid'}`);
131
+ }
132
+
133
+ if (data?.data) {
134
+ Logger.log(` Data: ${Formatter.json(data.data)}`);
135
+ }
136
+
137
+ if (data?.meta) {
138
+ Logger.log(` Meta: ${Formatter.json(data.meta)}`);
139
+ }
140
+ }
141
+ }
142
+
143
+ function isIntermediateWebSocketStatus(status) {
144
+ return status === 'ai_generated' || status === 'step_received' || status === 'pending';
145
+ }
146
+
147
+ function isFinalWebSocketStatus(status) {
148
+ return status === 'completed' || status === 'success' || status === 'error';
149
+ }
150
+
151
+ module.exports = {
152
+ isEventDrivenWebhook,
153
+ resolveCallbackUrl,
154
+ getCustomerData,
155
+ getWebhookStatusLabel,
156
+ buildCallbackHint,
157
+ printWebhookDetails,
158
+ isIntermediateWebSocketStatus,
159
+ isFinalWebSocketStatus,
160
+ };
@@ -1,101 +0,0 @@
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
- });
@@ -1,98 +0,0 @@
1
- // We need to test the Config class, but it's exported as a singleton instance.
2
- // We'll test the behavior through environment variables.
3
-
4
- // Mock fs to prevent reading from actual ~/.modelriver/config.json
5
- jest.mock('fs', () => ({
6
- existsSync: jest.fn(() => false),
7
- readFileSync: jest.fn(() => '{}'),
8
- writeFileSync: jest.fn(),
9
- mkdirSync: jest.fn(),
10
- }));
11
-
12
- describe('Config', () => {
13
- const originalEnv = process.env;
14
-
15
- beforeEach(() => {
16
- // Reset modules to get fresh Config instance
17
- jest.resetModules();
18
- // Re-apply the mock after resetModules
19
- jest.doMock('fs', () => ({
20
- existsSync: jest.fn(() => false),
21
- readFileSync: jest.fn(() => '{}'),
22
- writeFileSync: jest.fn(),
23
- mkdirSync: jest.fn(),
24
- }));
25
- process.env = { ...originalEnv };
26
- // Clear any config-related env vars
27
- delete process.env.MODELRIVER_API_KEY;
28
- delete process.env.MODELRIVER_API_URL;
29
- });
30
-
31
- afterAll(() => {
32
- process.env = originalEnv;
33
- });
34
-
35
- describe('getApiKey', () => {
36
- it('should read API key from MODELRIVER_API_KEY env var', () => {
37
- process.env.MODELRIVER_API_KEY = 'mr_live_test123';
38
- const config = require('./config');
39
-
40
- expect(config.getApiKey()).toBe('mr_live_test123');
41
- });
42
-
43
- it('should return undefined when API key not set', () => {
44
- delete process.env.MODELRIVER_API_KEY;
45
- const config = require('./config');
46
-
47
- expect(config.getApiKey()).toBeFalsy();
48
- });
49
- });
50
-
51
- describe('getApiUrl', () => {
52
- it('should return default URL when not set', () => {
53
- delete process.env.MODELRIVER_API_URL;
54
- const config = require('./config');
55
-
56
- expect(config.getApiUrl()).toBe('https://api.modelriver.com');
57
- });
58
-
59
- it('should read API URL from MODELRIVER_API_URL env var', () => {
60
- process.env.MODELRIVER_API_URL = 'http://localhost:4000';
61
- const config = require('./config');
62
-
63
- expect(config.getApiUrl()).toBe('http://localhost:4000');
64
- });
65
- });
66
-
67
- describe('getWebSocketUrl', () => {
68
- it('should convert https:// to wss://', () => {
69
- process.env.MODELRIVER_API_URL = 'https://api.modelriver.com';
70
- const config = require('./config');
71
-
72
- expect(config.getWebSocketUrl()).toBe('wss://api.modelriver.com');
73
- });
74
-
75
- it('should convert http:// to ws://', () => {
76
- process.env.MODELRIVER_API_URL = 'http://localhost:4000';
77
- const config = require('./config');
78
-
79
- expect(config.getWebSocketUrl()).toBe('ws://localhost:4000');
80
- });
81
- });
82
-
83
- describe('get', () => {
84
- it('should prioritize env vars over defaults', () => {
85
- process.env.MODELRIVER_CUSTOM_KEY = 'env-value';
86
- const config = require('./config');
87
-
88
- expect(config.get('custom-key', 'default-value')).toBe('env-value');
89
- });
90
-
91
- it('should return default when env var not set', () => {
92
- delete process.env.MODELRIVER_MISSING_KEY;
93
- const config = require('./config');
94
-
95
- expect(config.get('missing-key', 'fallback')).toBe('fallback');
96
- });
97
- });
98
- });
@@ -1,90 +0,0 @@
1
- const WebhookVerifier = require('./webhook-verifier');
2
-
3
- describe('WebhookVerifier', () => {
4
- const secret = 'test-secret-key-12345';
5
- const timestamp = '1704825600';
6
- const payload = { status: 'success', name: 'Test User' };
7
-
8
- describe('generateSignature', () => {
9
- it('should generate a consistent HMAC-SHA256 signature', () => {
10
- const sig1 = WebhookVerifier.generateSignature(payload, secret, timestamp);
11
- const sig2 = WebhookVerifier.generateSignature(payload, secret, timestamp);
12
-
13
- expect(sig1).toBe(sig2);
14
- expect(typeof sig1).toBe('string');
15
- expect(sig1.length).toBe(64); // SHA256 hex is 64 chars
16
- });
17
-
18
- it('should produce different signatures for different payloads', () => {
19
- const sig1 = WebhookVerifier.generateSignature(payload, secret, timestamp);
20
- const sig2 = WebhookVerifier.generateSignature({ different: 'data' }, secret, timestamp);
21
-
22
- expect(sig1).not.toBe(sig2);
23
- });
24
-
25
- it('should produce different signatures for different secrets', () => {
26
- const sig1 = WebhookVerifier.generateSignature(payload, secret, timestamp);
27
- const sig2 = WebhookVerifier.generateSignature(payload, 'different-secret', timestamp);
28
-
29
- expect(sig1).not.toBe(sig2);
30
- });
31
-
32
- it('should produce different signatures for different timestamps', () => {
33
- const sig1 = WebhookVerifier.generateSignature(payload, secret, timestamp);
34
- const sig2 = WebhookVerifier.generateSignature(payload, secret, '1704825601');
35
-
36
- expect(sig1).not.toBe(sig2);
37
- });
38
- });
39
-
40
- describe('verify', () => {
41
- it('should return true for valid signatures', () => {
42
- const signature = WebhookVerifier.generateSignature(payload, secret, timestamp);
43
- const isValid = WebhookVerifier.verify(signature, payload, secret, timestamp);
44
-
45
- expect(isValid).toBe(true);
46
- });
47
-
48
- it('should return false for tampered payloads', () => {
49
- const signature = WebhookVerifier.generateSignature(payload, secret, timestamp);
50
- const tamperedPayload = { ...payload, name: 'Hacker' };
51
- const isValid = WebhookVerifier.verify(signature, tamperedPayload, secret, timestamp);
52
-
53
- expect(isValid).toBe(false);
54
- });
55
-
56
- it('should return false for wrong secret', () => {
57
- const signature = WebhookVerifier.generateSignature(payload, secret, timestamp);
58
- const isValid = WebhookVerifier.verify(signature, payload, 'wrong-secret', timestamp);
59
-
60
- expect(isValid).toBe(false);
61
- });
62
-
63
- it('should return false for wrong timestamp', () => {
64
- const signature = WebhookVerifier.generateSignature(payload, secret, timestamp);
65
- const isValid = WebhookVerifier.verify(signature, payload, secret, 'wrong-timestamp');
66
-
67
- expect(isValid).toBe(false);
68
- });
69
- });
70
-
71
- describe('secureCompare', () => {
72
- it('should return true for equal strings', () => {
73
- expect(WebhookVerifier.secureCompare('abc123', 'abc123')).toBe(true);
74
- });
75
-
76
- it('should return false for different strings', () => {
77
- expect(WebhookVerifier.secureCompare('abc123', 'xyz789')).toBe(false);
78
- });
79
-
80
- it('should return false for different length strings', () => {
81
- expect(WebhookVerifier.secureCompare('short', 'muchlongerstring')).toBe(false);
82
- });
83
-
84
- it('should return false for non-string inputs', () => {
85
- expect(WebhookVerifier.secureCompare(null, 'string')).toBe(false);
86
- expect(WebhookVerifier.secureCompare('string', undefined)).toBe(false);
87
- expect(WebhookVerifier.secureCompare(123, 456)).toBe(false);
88
- });
89
- });
90
- });
@@ -1,84 +0,0 @@
1
- const Formatter = require('./formatter');
2
-
3
- describe('Formatter', () => {
4
- describe('json', () => {
5
- it('should format objects with default 2-space indentation', () => {
6
- const obj = { name: 'test', value: 123 };
7
- const result = Formatter.json(obj);
8
-
9
- expect(result).toBe('{\n "name": "test",\n "value": 123\n}');
10
- });
11
-
12
- it('should format with custom indentation', () => {
13
- const obj = { key: 'value' };
14
- const result = Formatter.json(obj, 4);
15
-
16
- expect(result).toBe('{\n "key": "value"\n}');
17
- });
18
-
19
- it('should handle arrays', () => {
20
- const arr = [1, 2, 3];
21
- const result = Formatter.json(arr);
22
-
23
- expect(result).toBe('[\n 1,\n 2,\n 3\n]');
24
- });
25
- });
26
-
27
- describe('timestamp', () => {
28
- it('should return ISO format string', () => {
29
- const date = new Date('2024-01-09T12:00:00Z');
30
- const result = Formatter.timestamp(date);
31
-
32
- expect(result).toBe('2024-01-09T12:00:00.000Z');
33
- });
34
-
35
- it('should use current date when no argument provided', () => {
36
- const result = Formatter.timestamp();
37
-
38
- expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
39
- });
40
- });
41
-
42
- describe('formatDuration', () => {
43
- it('should format milliseconds', () => {
44
- expect(Formatter.formatDuration(500)).toBe('500ms');
45
- expect(Formatter.formatDuration(999)).toBe('999ms');
46
- });
47
-
48
- it('should format seconds', () => {
49
- expect(Formatter.formatDuration(1000)).toBe('1.0s');
50
- expect(Formatter.formatDuration(5500)).toBe('5.5s');
51
- expect(Formatter.formatDuration(59999)).toBe('60.0s');
52
- });
53
-
54
- it('should format minutes', () => {
55
- expect(Formatter.formatDuration(60000)).toBe('1.0m');
56
- expect(Formatter.formatDuration(90000)).toBe('1.5m');
57
- expect(Formatter.formatDuration(300000)).toBe('5.0m');
58
- });
59
- });
60
-
61
- describe('table', () => {
62
- it('should format a simple table', () => {
63
- const headers = ['Name', 'Age'];
64
- const rows = [
65
- ['Alice', 30],
66
- ['Bob', 25]
67
- ];
68
- const result = Formatter.table(headers, rows);
69
-
70
- expect(result).toContain('Name');
71
- expect(result).toContain('Age');
72
- expect(result).toContain('Alice');
73
- expect(result).toContain('Bob');
74
- });
75
-
76
- it('should handle empty cells', () => {
77
- const headers = ['A', 'B'];
78
- const rows = [['value', null]];
79
- const result = Formatter.table(headers, rows);
80
-
81
- expect(result).toContain('value');
82
- });
83
- });
84
- });
@@ -1,144 +0,0 @@
1
- // Tests for URL helper utilities
2
- const { normalizeWebhookUrl, extractPort, isValidApiKeyFormat, parseForwardUrl } = require('./url-helpers');
3
-
4
- describe('URL Helpers', () => {
5
- describe('normalizeWebhookUrl', () => {
6
- it('should add /webhook/modelriver to base URL', () => {
7
- expect(normalizeWebhookUrl('http://localhost:4000'))
8
- .toBe('http://localhost:4000/webhook/modelriver');
9
- });
10
-
11
- it('should add /modelriver to URL ending with /webhook', () => {
12
- expect(normalizeWebhookUrl('http://localhost:4000/webhook'))
13
- .toBe('http://localhost:4000/webhook/modelriver');
14
- });
15
-
16
- it('should not modify URL already ending with /webhook/modelriver', () => {
17
- expect(normalizeWebhookUrl('http://localhost:4000/webhook/modelriver'))
18
- .toBe('http://localhost:4000/webhook/modelriver');
19
- });
20
-
21
- it('should handle trailing slashes', () => {
22
- expect(normalizeWebhookUrl('http://localhost:4000/'))
23
- .toBe('http://localhost:4000/webhook/modelriver');
24
- });
25
-
26
- it('should handle URLs with port', () => {
27
- expect(normalizeWebhookUrl('http://localhost:3001'))
28
- .toBe('http://localhost:3001/webhook/modelriver');
29
- });
30
-
31
- it('should handle https URLs', () => {
32
- expect(normalizeWebhookUrl('https://myserver.com'))
33
- .toBe('https://myserver.com/webhook/modelriver');
34
- });
35
-
36
- it('should return empty for falsy input', () => {
37
- expect(normalizeWebhookUrl('')).toBeFalsy();
38
- expect(normalizeWebhookUrl(null)).toBeFalsy();
39
- expect(normalizeWebhookUrl(undefined)).toBeFalsy();
40
- });
41
- });
42
-
43
- describe('extractPort', () => {
44
- it('should extract port from URL with explicit port', () => {
45
- expect(extractPort('http://localhost:4000')).toBe(4000);
46
- expect(extractPort('http://localhost:3001/webhook')).toBe(3001);
47
- });
48
-
49
- it('should return 80 for http without port', () => {
50
- expect(extractPort('http://example.com')).toBe(80);
51
- });
52
-
53
- it('should return 443 for https without port', () => {
54
- expect(extractPort('https://example.com')).toBe(443);
55
- });
56
-
57
- it('should return null for invalid URLs', () => {
58
- expect(extractPort('not-a-url')).toBeNull();
59
- expect(extractPort('')).toBeNull();
60
- });
61
- });
62
-
63
- describe('isValidApiKeyFormat', () => {
64
- it('should accept mr_live_ prefixed keys', () => {
65
- expect(isValidApiKeyFormat('mr_live_abc123xyz')).toBe(true);
66
- });
67
-
68
- it('should accept mr_test_ prefixed keys', () => {
69
- expect(isValidApiKeyFormat('mr_test_abc123xyz')).toBe(true);
70
- });
71
-
72
- it('should reject keys without valid prefix', () => {
73
- expect(isValidApiKeyFormat('invalid_key')).toBe(false);
74
- expect(isValidApiKeyFormat('mr_invalid_key')).toBe(false);
75
- expect(isValidApiKeyFormat('abc123')).toBe(false);
76
- });
77
-
78
- it('should reject empty or falsy keys', () => {
79
- expect(isValidApiKeyFormat('')).toBe(false);
80
- expect(isValidApiKeyFormat(null)).toBe(false);
81
- expect(isValidApiKeyFormat(undefined)).toBe(false);
82
- });
83
- });
84
-
85
- describe('parseForwardUrl', () => {
86
- it('should parse localhost URL with port', () => {
87
- const result = parseForwardUrl('http://localhost:4000/webhook/modelriver');
88
- expect(result).toEqual({
89
- hostname: 'localhost',
90
- port: 4000,
91
- path: '/webhook/modelriver',
92
- protocol: 'http'
93
- });
94
- });
95
-
96
- it('should parse external URL with port', () => {
97
- const result = parseForwardUrl('http://myserver.com:3001/webhook/modelriver');
98
- expect(result).toEqual({
99
- hostname: 'myserver.com',
100
- port: 3001,
101
- path: '/webhook/modelriver',
102
- protocol: 'http'
103
- });
104
- });
105
-
106
- it('should default to port 80 for http without explicit port', () => {
107
- const result = parseForwardUrl('http://example.com/webhook/modelriver');
108
- expect(result.port).toBe(80);
109
- expect(result.hostname).toBe('example.com');
110
- });
111
-
112
- it('should default to port 443 for https without explicit port', () => {
113
- const result = parseForwardUrl('https://example.com/webhook/modelriver');
114
- expect(result.port).toBe(443);
115
- expect(result.protocol).toBe('https');
116
- });
117
-
118
- it('should handle URLs with different paths', () => {
119
- const result = parseForwardUrl('http://localhost:5000/custom/path');
120
- expect(result.path).toBe('/custom/path');
121
- });
122
-
123
- it('should return null for empty or falsy input', () => {
124
- expect(parseForwardUrl('')).toBeNull();
125
- expect(parseForwardUrl(null)).toBeNull();
126
- expect(parseForwardUrl(undefined)).toBeNull();
127
- });
128
-
129
- it('should handle URLs without protocol by defaulting to http', () => {
130
- const result = parseForwardUrl('myserver:4000/webhook/modelriver');
131
- expect(result).toEqual({
132
- hostname: 'myserver',
133
- port: 4000,
134
- path: '/webhook/modelriver',
135
- protocol: 'http'
136
- });
137
- });
138
-
139
- it('should return null for invalid URLs', () => {
140
- expect(parseForwardUrl('http://invalid url with spaces')).toBeNull();
141
- expect(parseForwardUrl('just text with spaces')).toBeNull();
142
- });
143
- });
144
- });