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:
parent
cda197adf8
commit
31b6544c57
9 changed files with 236 additions and 3 deletions
|
|
@ -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'), [
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue