2026-05-24 12:17:19 +00:00
|
|
|
<?php
|
|
|
|
|
|
2026-06-10 20:26:50 +00:00
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
2026-05-24 12:17:19 +00:00
|
|
|
use App\Actions\Automation\Node\RunWebhookNode;
|
|
|
|
|
use App\Enums\Automation\NodeRun\Status;
|
|
|
|
|
use App\Models\AutomationRun;
|
2026-06-13 12:56:18 +00:00
|
|
|
use Illuminate\Http\Client\ConnectionException;
|
2026-05-24 12:17:19 +00:00
|
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
|
|
|
|
|
|
it('posts interpolated payload to the configured url', function () {
|
|
|
|
|
Http::fake([
|
2026-06-11 18:47:29 +00:00
|
|
|
'1.1.1.1/*' => Http::response(['ok' => true], 200),
|
2026-05-24 12:17:19 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$run = AutomationRun::factory()->create([
|
|
|
|
|
'context' => ['trigger' => ['title' => 'Hello'], 'generated' => ['post_url' => 'https://t.it/p/1']],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = app(RunWebhookNode::class)($run, [
|
2026-06-11 18:47:29 +00:00
|
|
|
'url' => 'https://1.1.1.1/test',
|
2026-05-24 12:17:19 +00:00
|
|
|
'method' => 'POST',
|
|
|
|
|
'headers' => ['X-Source' => 'TryPost'],
|
|
|
|
|
'payload_template' => '{"title":"{{ trigger.title }}","post_url":"{{ generated.post_url }}"}',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect($result->status)->toBe(Status::Completed);
|
|
|
|
|
Http::assertSent(fn ($request) => $request['title'] === 'Hello' && $request['post_url'] === 'https://t.it/p/1');
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-10 23:45:01 +00:00
|
|
|
it('sends the branded user-agent header', function () {
|
|
|
|
|
Http::fake([
|
2026-06-11 18:47:29 +00:00
|
|
|
'1.1.1.1/*' => Http::response(['ok' => true], 200),
|
2026-06-10 23:45:01 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$run = AutomationRun::factory()->create();
|
|
|
|
|
|
|
|
|
|
app(RunWebhookNode::class)($run, [
|
2026-06-11 18:47:29 +00:00
|
|
|
'url' => 'https://1.1.1.1/test',
|
2026-06-10 23:45:01 +00:00
|
|
|
'method' => 'POST',
|
|
|
|
|
'headers' => ['User-Agent' => 'user-supplied-agent'],
|
|
|
|
|
'payload_template' => '{}',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Http::assertSent(fn ($request) => $request->hasHeader('User-Agent', config('trypost.user_agent')));
|
|
|
|
|
});
|
|
|
|
|
|
Add expression autocomplete, side-panel editor, and richer HTTP fetch
Automations editor:
- {{ }} expression autocomplete in CodeMirror, scoped to the braces and
graph-aware (suggests only what upstream nodes provide + variables + now);
migrate the Generate prompt to CodeMirror so it shares the same completions
- Expandable editors: an expand button slides out a side-by-side panel
(matching the sidebar card), with a minimize control; the inline field
collapses to a hint while editing in the panel
- Hover-revealed editor toolbar (expand/copy) with styled tooltips so the
buttons no longer obscure the text while reading
- Beta badge on the Automations sidebar item
- Delete a single connection with Backspace/Delete (edge selection)
- Re-key node config so switching between same-type nodes refreshes the form
HTTP fetch node — cover every JSON response shape:
- Top-level array, object map (items_path=*), array of primitives, and NDJSON
- Key-based dedup via item_key_path (seen-set, FIFO-capped) for feeds without
dates; first poll records a baseline and emits nothing (date path too)
Fan-out test visibility:
- root_run_id links every forked branch back to the run that started a test,
so the test panel aggregates all branches instead of one
Fix a few pre-existing type issues (ScheduleData import, padded minute,
optional created_at).
2026-06-12 14:31:58 +00:00
|
|
|
it('escapes special characters in templated payload values so the JSON stays valid', function () {
|
|
|
|
|
Http::fake(['1.1.1.1/*' => Http::response(['ok' => true], 200)]);
|
|
|
|
|
|
|
|
|
|
$run = AutomationRun::factory()->create([
|
|
|
|
|
'context' => ['fetched' => ['title' => 'He said "hi" & bye']],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$result = app(RunWebhookNode::class)($run, [
|
|
|
|
|
'url' => 'https://1.1.1.1/hook',
|
|
|
|
|
'method' => 'POST',
|
|
|
|
|
'payload_template' => '{"title":"{{ fetched.title }}"}',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect($result->status)->toBe(Status::Completed);
|
|
|
|
|
Http::assertSent(fn ($request) => $request['title'] === 'He said "hi" & bye');
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-24 12:17:19 +00:00
|
|
|
it('fails on 5xx response', function () {
|
2026-06-11 18:47:29 +00:00
|
|
|
Http::fake(['1.1.1.1/*' => Http::response('err', 500)]);
|
2026-05-24 12:17:19 +00:00
|
|
|
|
|
|
|
|
$run = AutomationRun::factory()->create();
|
|
|
|
|
|
|
|
|
|
$result = app(RunWebhookNode::class)($run, [
|
2026-06-11 18:47:29 +00:00
|
|
|
'url' => 'https://1.1.1.1/test',
|
2026-05-24 12:17:19 +00:00
|
|
|
'method' => 'POST',
|
|
|
|
|
'payload_template' => '{}',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect($result->status)->toBe(Status::Failed);
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-10 23:45:01 +00:00
|
|
|
it('fails on malformed payload json instead of silently sending an empty body', function () {
|
2026-06-11 18:47:29 +00:00
|
|
|
Http::fake(['1.1.1.1/*' => Http::response(['ok' => true], 200)]);
|
2026-06-10 23:45:01 +00:00
|
|
|
|
|
|
|
|
$run = AutomationRun::factory()->create();
|
|
|
|
|
|
|
|
|
|
$result = app(RunWebhookNode::class)($run, [
|
2026-06-11 18:47:29 +00:00
|
|
|
'url' => 'https://1.1.1.1/test',
|
2026-06-10 23:45:01 +00:00
|
|
|
'method' => 'POST',
|
|
|
|
|
'payload_template' => '{ "a": }',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect($result->status)->toBe(Status::Failed);
|
|
|
|
|
expect($result->error['reason'])->toBe('invalid_payload_json');
|
|
|
|
|
Http::assertNothingSent();
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-11 18:47:29 +00:00
|
|
|
it('does not fire a real request on a dry run', function () {
|
|
|
|
|
Http::fake(['1.1.1.1/*' => Http::response(['ok' => true], 200)]);
|
|
|
|
|
|
|
|
|
|
$run = AutomationRun::factory()->create(['is_dry_run' => true]);
|
|
|
|
|
|
|
|
|
|
$result = app(RunWebhookNode::class)($run, [
|
|
|
|
|
'url' => 'https://1.1.1.1/test',
|
|
|
|
|
'method' => 'POST',
|
|
|
|
|
'payload_template' => '{"a":1}',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect($result->status)->toBe(Status::Completed);
|
|
|
|
|
expect($result->output['webhook']['dry_run'])->toBeTrue();
|
|
|
|
|
Http::assertNothingSent();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('still validates payload json on a dry run', function () {
|
|
|
|
|
Http::fake(['1.1.1.1/*' => Http::response(['ok' => true], 200)]);
|
|
|
|
|
|
|
|
|
|
$run = AutomationRun::factory()->create(['is_dry_run' => true]);
|
|
|
|
|
|
|
|
|
|
$result = app(RunWebhookNode::class)($run, [
|
|
|
|
|
'url' => 'https://1.1.1.1/test',
|
|
|
|
|
'method' => 'POST',
|
|
|
|
|
'payload_template' => '{ "a": }',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect($result->status)->toBe(Status::Failed);
|
|
|
|
|
expect($result->error['reason'])->toBe('invalid_payload_json');
|
|
|
|
|
Http::assertNothingSent();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('blocks a request to a private or reserved address', function () {
|
|
|
|
|
Http::fake();
|
|
|
|
|
|
|
|
|
|
$run = AutomationRun::factory()->create();
|
|
|
|
|
|
|
|
|
|
$result = app(RunWebhookNode::class)($run, [
|
|
|
|
|
'url' => 'http://169.254.169.254/latest/meta-data/',
|
|
|
|
|
'method' => 'POST',
|
|
|
|
|
'payload_template' => '{}',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect($result->status)->toBe(Status::Failed);
|
|
|
|
|
expect($result->error['reason'])->toBe('url_not_allowed');
|
|
|
|
|
Http::assertNothingSent();
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-24 12:17:19 +00:00
|
|
|
it('treats 4xx responses as completed (only 5xx fails)', function () {
|
2026-06-11 18:47:29 +00:00
|
|
|
Http::fake(['1.1.1.1/*' => Http::response('not found', 404)]);
|
2026-05-24 12:17:19 +00:00
|
|
|
|
|
|
|
|
$run = AutomationRun::factory()->create();
|
|
|
|
|
|
|
|
|
|
$result = app(RunWebhookNode::class)($run, [
|
2026-06-11 18:47:29 +00:00
|
|
|
'url' => 'https://1.1.1.1/test',
|
2026-05-24 12:17:19 +00:00
|
|
|
'method' => 'POST',
|
|
|
|
|
'payload_template' => '{}',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect($result->status->value)->toBe('completed');
|
|
|
|
|
expect($result->output['webhook']['status'])->toBe(404);
|
|
|
|
|
});
|
2026-06-13 12:56:18 +00:00
|
|
|
|
|
|
|
|
it('sends the configured HTTP method', function (string $method) {
|
|
|
|
|
Http::fake(['1.1.1.1/*' => Http::response(['ok' => true], 200)]);
|
|
|
|
|
|
|
|
|
|
$run = AutomationRun::factory()->create();
|
|
|
|
|
|
|
|
|
|
$result = app(RunWebhookNode::class)($run, [
|
|
|
|
|
'url' => 'https://1.1.1.1/hook',
|
|
|
|
|
'method' => $method,
|
|
|
|
|
'payload_template' => '{}',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect($result->status)->toBe(Status::Completed);
|
|
|
|
|
Http::assertSent(fn ($request) => $request->method() === $method);
|
|
|
|
|
})->with(['PUT', 'PATCH', 'DELETE', 'GET']);
|
|
|
|
|
|
|
|
|
|
it('resolves expressions in custom header values', function () {
|
|
|
|
|
Http::fake(['1.1.1.1/*' => Http::response(['ok' => true], 200)]);
|
|
|
|
|
|
|
|
|
|
$run = AutomationRun::factory()->create(['context' => ['trigger' => ['title' => 'tok-123']]]);
|
|
|
|
|
|
|
|
|
|
app(RunWebhookNode::class)($run, [
|
|
|
|
|
'url' => 'https://1.1.1.1/hook',
|
|
|
|
|
'method' => 'POST',
|
|
|
|
|
'headers' => ['X-Token' => '{{ trigger.title }}'],
|
|
|
|
|
'payload_template' => '{}',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Http::assertSent(fn ($request) => $request->hasHeader('X-Token', 'tok-123'));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('fails cleanly when the request throws a connection exception', function () {
|
|
|
|
|
Http::fake(fn () => throw new ConnectionException('connection timed out'));
|
|
|
|
|
|
|
|
|
|
$run = AutomationRun::factory()->create();
|
|
|
|
|
|
|
|
|
|
$result = app(RunWebhookNode::class)($run, [
|
|
|
|
|
'url' => 'https://1.1.1.1/hook',
|
|
|
|
|
'method' => 'POST',
|
|
|
|
|
'payload_template' => '{}',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
expect($result->status)->toBe(Status::Failed);
|
|
|
|
|
expect($result->error['reason'])->toBe('request_failed');
|
|
|
|
|
expect($result->error['message'])->toContain('connection timed out');
|
|
|
|
|
});
|