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'); +});