Cover node-run error paths and align webhook failure handling

Wrap the webhook HTTP send in a try/catch so a connection error returns
a clean failed result (reason: request_failed) instead of bubbling up as
a job failure — matching the HTTP request node.

Add tests for the gaps in node-run coverage:
- HTTP request: basic auth, PUT/PATCH/DELETE, non-2xx responses,
  connection exceptions, and an items_path that doesn't resolve to a list.
- Webhook: every HTTP method, header expression resolution, and the new
  connection-failure path.
- Fetch RSS: non-2xx feed responses, malformed XML, items without a
  publish date (skipped), and the link fallback when an item has no guid.
- Delay: unknown unit throws.
- Publish: dry runs don't publish or queue.
This commit is contained in:
Paulo Castellano 2026-06-13 09:56:18 -03:00
parent cda197adf8
commit 31b6544c57
9 changed files with 236 additions and 3 deletions

View file

@ -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'), [

View file

@ -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.',

View file

@ -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.',

View file

@ -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.',

View file

@ -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);
});

View file

@ -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'
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
<item><title>Dated</title><link>https://1.1.1.1/d</link><guid>d</guid><pubDate>Sun, 01 Jun 2025 12:00:00 +0000</pubDate></item>
<item><title>NoDate</title><link>https://1.1.1.1/n</link><guid>n</guid></item>
</channel></rss>
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'
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
<item><title>LinkKey</title><link>https://1.1.1.1/only-link</link><pubDate>Sun, 01 Jun 2025 12:00:00 +0000</pubDate></item>
</channel></rss>
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');
});

View file

@ -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)]);

View file

@ -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);
});

View file

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