diff --git a/app/Actions/Automation/Node/RunWebhookNode.php b/app/Actions/Automation/Node/RunWebhookNode.php
index 33dd316e..a2b38000 100644
--- a/app/Actions/Automation/Node/RunWebhookNode.php
+++ b/app/Actions/Automation/Node/RunWebhookNode.php
@@ -10,6 +10,7 @@
use App\Services\Brand\SafeHttpFetcher;
use Illuminate\Support\Facades\Http;
use RuntimeException;
+use Throwable;
class RunWebhookNode
{
@@ -64,9 +65,16 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
]);
}
- $response = Http::withHeaders($headers)
- ->withUserAgent(config('trypost.user_agent'))
- ->send($method, $url, ['json' => $payload]);
+ try {
+ $response = Http::withHeaders($headers)
+ ->withUserAgent(config('trypost.user_agent'))
+ ->send($method, $url, ['json' => $payload]);
+ } catch (Throwable $e) {
+ return NodeRunResult::failed(__('automations.errors.webhook_request_failed'), [
+ 'reason' => 'request_failed',
+ 'message' => $e->getMessage(),
+ ]);
+ }
if ($response->serverError()) {
return NodeRunResult::failed(__('automations.errors.webhook_server_error'), [
diff --git a/lang/en/automations.php b/lang/en/automations.php
index 6c28f6f0..c8340e44 100644
--- a/lang/en/automations.php
+++ b/lang/en/automations.php
@@ -379,6 +379,7 @@
'only_failed_can_retry' => 'Only failed runs can be retried.',
'no_generated_post' => 'No generated post found on run.',
'webhook_server_error' => 'Webhook server error.',
+ 'webhook_request_failed' => 'The webhook request could not be completed.',
'webhook_invalid_payload_json' => 'The payload template is not valid JSON.',
'url_not_allowed' => 'The request URL points to a private or unreachable address and was blocked.',
'node_no_longer_exists' => 'Node :node_id no longer exists in the automation.',
diff --git a/lang/es/automations.php b/lang/es/automations.php
index aab61867..fa683cb5 100644
--- a/lang/es/automations.php
+++ b/lang/es/automations.php
@@ -379,6 +379,7 @@
'only_failed_can_retry' => 'Solo se pueden reintentar ejecuciones fallidas.',
'no_generated_post' => 'No se encontró un post generado en la ejecución.',
'webhook_server_error' => 'Error del servidor del webhook.',
+ 'webhook_request_failed' => 'No se pudo completar la solicitud del webhook.',
'webhook_invalid_payload_json' => 'La plantilla de payload no es un JSON válido.',
'url_not_allowed' => 'La URL de la petición apunta a una dirección privada o inaccesible y fue bloqueada.',
'node_no_longer_exists' => 'El nodo :node_id ya no existe en la automatización.',
diff --git a/lang/pt-BR/automations.php b/lang/pt-BR/automations.php
index 71f3926f..fb0e9fd7 100644
--- a/lang/pt-BR/automations.php
+++ b/lang/pt-BR/automations.php
@@ -379,6 +379,7 @@
'only_failed_can_retry' => 'Apenas execuções que falharam podem ser repetidas.',
'no_generated_post' => 'Nenhum post gerado encontrado para esta execução.',
'webhook_server_error' => 'Erro no servidor do webhook.',
+ 'webhook_request_failed' => 'Não foi possível completar a requisição do webhook.',
'webhook_invalid_payload_json' => 'O template do payload não é um JSON válido.',
'url_not_allowed' => 'A URL da requisição aponta para um endereço privado ou inacessível e foi bloqueada.',
'node_no_longer_exists' => 'O nó :node_id não existe mais nesta automação.',
diff --git a/tests/Feature/Automation/Node/DelayNodeTest.php b/tests/Feature/Automation/Node/DelayNodeTest.php
index 6234fc71..ea05aaf0 100644
--- a/tests/Feature/Automation/Node/DelayNodeTest.php
+++ b/tests/Feature/Automation/Node/DelayNodeTest.php
@@ -26,3 +26,10 @@
expect(now()->diffInHours($handler($run, ['duration' => 1, 'unit' => 'days'])->sleepUntil))
->toBeGreaterThanOrEqual(23);
});
+
+it('throws on an unknown delay unit', function () {
+ $run = AutomationRun::factory()->create();
+
+ expect(fn () => app(RunDelayNode::class)($run, ['duration' => 5, 'unit' => 'weeks']))
+ ->toThrow(InvalidArgumentException::class);
+});
diff --git a/tests/Feature/Automation/Node/FetchRssNodeTest.php b/tests/Feature/Automation/Node/FetchRssNodeTest.php
index 023a7c84..48ae91f2 100644
--- a/tests/Feature/Automation/Node/FetchRssNodeTest.php
+++ b/tests/Feature/Automation/Node/FetchRssNodeTest.php
@@ -267,3 +267,75 @@
expect($result->status)->toBe(NodeRunStatus::Failed);
});
+
+it('fails when the feed request returns a non-2xx status', function () {
+ Http::fake(['1.1.1.1/*' => Http::response('upstream down', 500)]);
+
+ $automation = Automation::factory()->active()->create();
+ $run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'fetch_1']);
+
+ $result = app(RunFetchRssNode::class)($run, ['feed_url' => 'https://1.1.1.1/feed.xml']);
+
+ expect($result->status)->toBe(NodeRunStatus::Failed);
+ expect($result->error['status'])->toBe(500);
+});
+
+it('fails on a malformed RSS feed', function () {
+ Http::fake(['1.1.1.1/*' => Http::response('this is not xml at all', 200)]);
+
+ $automation = Automation::factory()->active()->create();
+ $run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'fetch_1']);
+
+ $result = app(RunFetchRssNode::class)($run, ['feed_url' => 'https://1.1.1.1/feed.xml']);
+
+ expect($result->status)->toBe(NodeRunStatus::Failed);
+});
+
+it('skips items without a publish date', function () {
+ Carbon::setTestNow('2026-01-15 10:00:00');
+ $feed = <<<'XML'
+
+
+ - Datedhttps://1.1.1.1/ddSun, 01 Jun 2025 12:00:00 +0000
+ - NoDatehttps://1.1.1.1/nn
+
+ XML;
+ Http::fake(['1.1.1.1/*' => Http::response($feed, 200)]);
+
+ $automation = Automation::factory()->active()->create();
+ AutomationNodeState::create([
+ 'automation_id' => $automation->id,
+ 'node_id' => 'fetch_1',
+ 'data' => ['last_item_date' => '2025-02-01T12:00:00+00:00'],
+ ]);
+ $run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'fetch_1']);
+
+ $result = app(RunFetchRssNode::class)($run, ['feed_url' => 'https://1.1.1.1/feed']);
+
+ // Only the dated item passes; the dateless one is ignored.
+ expect($result->output['fetch']['count'])->toBe(1);
+ expect($result->output['fetched']['key'])->toBe('d');
+});
+
+it('falls back to the link as the dedup key when an item has no guid', function () {
+ Carbon::setTestNow('2026-01-15 10:00:00');
+ $feed = <<<'XML'
+
+
+ - LinkKeyhttps://1.1.1.1/only-linkSun, 01 Jun 2025 12:00:00 +0000
+
+ XML;
+ Http::fake(['1.1.1.1/*' => Http::response($feed, 200)]);
+
+ $automation = Automation::factory()->active()->create();
+ AutomationNodeState::create([
+ 'automation_id' => $automation->id,
+ 'node_id' => 'fetch_1',
+ 'data' => ['last_item_date' => '2025-02-01T12:00:00+00:00'],
+ ]);
+ $run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'fetch_1']);
+
+ $result = app(RunFetchRssNode::class)($run, ['feed_url' => 'https://1.1.1.1/feed']);
+
+ expect($result->output['fetched']['key'])->toBe('https://1.1.1.1/only-link');
+});
diff --git a/tests/Feature/Automation/Node/HttpRequestNodeTest.php b/tests/Feature/Automation/Node/HttpRequestNodeTest.php
index 56ed5930..4f5a8188 100644
--- a/tests/Feature/Automation/Node/HttpRequestNodeTest.php
+++ b/tests/Feature/Automation/Node/HttpRequestNodeTest.php
@@ -8,6 +8,7 @@
use App\Models\Automation;
use App\Models\AutomationNodeState;
use App\Models\AutomationRun;
+use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Crypt;
@@ -163,6 +164,87 @@
Http::assertSent(fn ($request) => $request->hasHeader('X-Custom-Key', 'plain-text-key'));
});
+it('sends basic auth with a decrypted password', function () {
+ Http::fake(['1.1.1.1/*' => Http::response(['ok' => true], 200)]);
+
+ $automation = Automation::factory()->active()->create();
+ $run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'http_1']);
+
+ app(RunHttpRequestNode::class)($run, [
+ 'url' => 'https://1.1.1.1/me',
+ 'method' => 'GET',
+ 'auth_type' => 'basic',
+ 'auth_username' => 'user',
+ 'auth_password' => Crypt::encryptString('pass'),
+ ]);
+
+ Http::assertSent(fn ($request) => $request->hasHeader('Authorization', 'Basic '.base64_encode('user:pass')));
+});
+
+it('supports PUT, PATCH and DELETE methods', function (string $method) {
+ Http::fake(['1.1.1.1/*' => Http::response(['ok' => true], 200)]);
+
+ $automation = Automation::factory()->active()->create();
+ $run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'http_1']);
+
+ $result = app(RunHttpRequestNode::class)($run, [
+ 'url' => 'https://1.1.1.1/resource',
+ 'method' => $method,
+ 'auth_type' => 'none',
+ ]);
+
+ expect($result->status)->toBe(NodeRunStatus::Completed);
+ Http::assertSent(fn ($request) => $request->method() === $method);
+})->with(['PUT', 'PATCH', 'DELETE']);
+
+it('fails on a non-2xx response', function (int $status) {
+ Http::fake(['1.1.1.1/*' => Http::response('nope', $status)]);
+
+ $automation = Automation::factory()->active()->create();
+ $run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'http_1']);
+
+ $result = app(RunHttpRequestNode::class)($run, [
+ 'url' => 'https://1.1.1.1/resource',
+ 'method' => 'GET',
+ 'auth_type' => 'none',
+ ]);
+
+ expect($result->status)->toBe(NodeRunStatus::Failed);
+ expect($result->error['status'])->toBe($status);
+})->with([404, 500]);
+
+it('fails when the request throws a connection exception', function () {
+ Http::fake(fn () => throw new ConnectionException('connection timed out'));
+
+ $automation = Automation::factory()->active()->create();
+ $run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'http_1']);
+
+ $result = app(RunHttpRequestNode::class)($run, [
+ 'url' => 'https://1.1.1.1/resource',
+ 'method' => 'GET',
+ 'auth_type' => 'none',
+ ]);
+
+ expect($result->status)->toBe(NodeRunStatus::Failed);
+ expect($result->error['message'])->toContain('connection timed out');
+});
+
+it('fails when items_path does not resolve to an array', function () {
+ Http::fake(['1.1.1.1/*' => Http::response(['data' => 'not-a-list'], 200)]);
+
+ $automation = Automation::factory()->active()->create();
+ $run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'http_1']);
+
+ $result = app(RunHttpRequestNode::class)($run, [
+ 'url' => 'https://1.1.1.1/resource',
+ 'method' => 'GET',
+ 'auth_type' => 'none',
+ 'items_path' => 'data',
+ ]);
+
+ expect($result->status)->toBe(NodeRunStatus::Failed);
+});
+
it('posts a body rendered from the template with run context', function () {
Http::fake(['1.1.1.1/*' => Http::response(['ok' => true], 200)]);
diff --git a/tests/Feature/Automation/Node/PublishNodeTest.php b/tests/Feature/Automation/Node/PublishNodeTest.php
index 73a5d57b..63852011 100644
--- a/tests/Feature/Automation/Node/PublishNodeTest.php
+++ b/tests/Feature/Automation/Node/PublishNodeTest.php
@@ -49,3 +49,17 @@
expect($post->fresh()->status)->toBe(PostStatus::Publishing);
Queue::assertPushed(PublishPost::class);
});
+
+it('does not publish or change the post on a dry run', function () {
+ Queue::fake();
+
+ $post = Post::factory()->create(['status' => PostStatus::Draft]);
+ $run = AutomationRun::factory()->create(['generated_post_id' => $post->id, 'is_dry_run' => true]);
+
+ $result = app(RunPublishNode::class)($run, ['mode' => 'now']);
+
+ expect($result->status->value)->toBe('completed');
+ expect($result->output['publish']['dry_run'])->toBeTrue();
+ expect($post->fresh()->status)->toBe(PostStatus::Draft);
+ Queue::assertNotPushed(PublishPost::class);
+});
diff --git a/tests/Feature/Automation/Node/WebhookNodeTest.php b/tests/Feature/Automation/Node/WebhookNodeTest.php
index 080a2a44..1d247ffb 100644
--- a/tests/Feature/Automation/Node/WebhookNodeTest.php
+++ b/tests/Feature/Automation/Node/WebhookNodeTest.php
@@ -5,6 +5,7 @@
use App\Actions\Automation\Node\RunWebhookNode;
use App\Enums\Automation\NodeRun\Status;
use App\Models\AutomationRun;
+use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
it('posts interpolated payload to the configured url', function () {
@@ -153,3 +154,49 @@
expect($result->status->value)->toBe('completed');
expect($result->output['webhook']['status'])->toBe(404);
});
+
+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');
+});