trypost/tests/Feature/Automation/Tree/ContentPipelineTreesTest.php
Paulo Castellano 4efaa0bf99 Harden automations module: full-post generation, reliable runs, editor UX
Generation
- Generate node now produces the full post (text + AI image + carousel)
  via a shared PostImagePipeline extracted from StreamPostCreation
- Generate config UI mirrors the /posts/create wizard (carousel slide
  count, include-image toggle); drop the decorative format/unsplash keys

Flow correctness
- RSS/HTTP nodes expose named has-items (default) and no-items output
  handles, labeled and colored like the Condition node
- AdvanceAutomationRun records a no_matching_edge terminal instead of
  completing silently; "0 new items" feedback in the test panel
- Manual/test runs no longer persist the production dedup watermark

Run reliability
- Pause truly halts in-flight runs (production only; manual test runs
  always run regardless of automation status)
- ProcessAutomationNode::failed() marks the run failed
- automation:recover-stuck-runs and automation:prune-dry-runs commands

Webhook / HTTP
- Branded User-Agent (config-driven) on outbound webhook + http_request
- Webhook fails on invalid JSON instead of silently sending {}
- HTTP custom headers editor; CodeMirror-based CodeEditor for JSON

Editor UX
- Header Test button only opens the panel; the panel has a Run button
  (saves first) and owns the with-real-data toggle
- Clicking a node closes the test panel and opens its config
- Node cards: max-width + truncate so long URLs don't grow the node
2026-06-10 20:45:01 -03:00

252 lines
11 KiB
PHP

<?php
declare(strict_types=1);
use App\Actions\Automation\TriggerItem\EnrollTriggerItem;
use App\Ai\Agents\PostContentGenerator;
use App\Ai\Agents\PostContentHumanizer;
use App\Enums\Automation\Run\Status as RunStatus;
use App\Models\Automation;
use App\Models\AutomationNodeState;
use App\Models\Post;
use App\Models\SocialAccount;
use App\Models\Workspace;
use App\Services\Image\PostImagePipeline;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
afterEach(fn () => Carbon::setTestNow());
/**
* Bind a no-op image pipeline so Generate never touches real image generation.
*/
$fakeImagePipeline = function (): void {
$pipeline = Mockery::mock(PostImagePipeline::class);
$pipeline->shouldReceive('forSingle')->andReturn([]);
$pipeline->shouldReceive('forCarousel')->andReturn([]);
app()->instance(PostImagePipeline::class, $pipeline);
};
$fakeContentAgents = function (string $content = 'Generated post body'): void {
PostContentGenerator::fake([
['content' => $content, 'image_title' => 'Title', 'image_body' => 'Body', 'image_keywords' => ['kw']],
]);
PostContentHumanizer::fake([
['content' => $content, 'image_title' => 'Title', 'image_body' => 'Body'],
]);
};
/**
* Build an RSS feed body with a single item published at the given date.
*/
$rssFeed = function (string $title, string $guid, string $pubDate): string {
return <<<XML
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
<item><title>{$title}</title><link>https://blog.example.com/{$guid}</link><guid>{$guid}</guid><pubDate>{$pubDate}</pubDate></item>
</channel></rss>
XML;
};
it('runs cron → generate → publish to completion and creates a draft post', function () use ($fakeImagePipeline, $fakeContentAgents) {
$fakeImagePipeline();
$fakeContentAgents('Post about cron trigger');
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->for($workspace)->create();
$automation = Automation::factory()->for($workspace)->active()->create([
'nodes' => [
['id' => 't', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule', 'cron' => '0 9 * * *']],
['id' => 'g', 'type' => 'generate', 'position' => ['x' => 1, 'y' => 0], 'data' => [
'accounts' => [
['social_account_id' => (string) $account->id, 'content_type' => 'instagram_feed', 'meta' => []],
],
'prompt_template' => 'Write something',
'include_image' => false,
]],
['id' => 'p', 'type' => 'publish', 'position' => ['x' => 2, 'y' => 0], 'data' => ['mode' => 'draft']],
],
'connections' => [
['id' => 'e1', 'source' => 't', 'target' => 'g'],
['id' => 'e2', 'source' => 'g', 'target' => 'p'],
],
]);
app(EnrollTriggerItem::class)($automation, 'cron-item-1', ['topic' => 'launch']);
$run = $automation->runs()->latest()->first();
expect($run->status)->toBe(RunStatus::Completed);
expect($run->generated_post_id)->not->toBeNull();
$post = Post::find($run->generated_post_id);
expect($post)->not->toBeNull();
expect($post->content)->toBe('Post about cron trigger');
});
it('runs fetch_rss → generate → delay → publish, pausing at the delay then resuming via the command', function () use ($fakeImagePipeline, $fakeContentAgents, $rssFeed) {
Carbon::setTestNow('2026-01-15 10:00:00');
$fakeImagePipeline();
$fakeContentAgents('Post from RSS item');
Http::fake([
'blog.example.com/*' => Http::response($rssFeed('Fresh News', 'fresh-1', 'Wed, 14 Jan 2026 12:00:00 +0000'), 200),
]);
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->for($workspace)->create();
$automation = Automation::factory()->for($workspace)->active()->create([
'nodes' => [
['id' => 't', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule']],
['id' => 'f', 'type' => 'fetch_rss', 'position' => ['x' => 1, 'y' => 0], 'data' => ['feed_url' => 'https://blog.example.com/feed']],
['id' => 'g', 'type' => 'generate', 'position' => ['x' => 2, 'y' => 0], 'data' => [
'accounts' => [
['social_account_id' => (string) $account->id, 'content_type' => 'instagram_feed', 'meta' => []],
],
'prompt_template' => 'Write about {{ fetched.title }}',
'include_image' => false,
]],
['id' => 'd', 'type' => 'delay', 'position' => ['x' => 3, 'y' => 0], 'data' => ['duration' => 2, 'unit' => 'hours']],
['id' => 'p', 'type' => 'publish', 'position' => ['x' => 4, 'y' => 0], 'data' => ['mode' => 'draft']],
],
'connections' => [
['id' => 'e1', 'source' => 't', 'target' => 'f'],
['id' => 'e2', 'source' => 'f', 'source_handle' => 'default', 'target' => 'g'],
['id' => 'e3', 'source' => 'g', 'target' => 'd'],
['id' => 'e4', 'source' => 'd', 'target' => 'p'],
],
]);
// Watermark before the feed item so it counts as new.
AutomationNodeState::create([
'automation_id' => $automation->id,
'node_id' => 'f',
'data' => ['last_item_date' => '2026-01-01T00:00:00+00:00'],
]);
app(EnrollTriggerItem::class)($automation, 'rss-item-1', []);
$run = $automation->runs()->latest()->first();
// The run generated a post then hit the delay and is now waiting.
expect($run->status)->toBe(RunStatus::Waiting);
expect($run->current_node_id)->toBe('d');
expect($run->generated_post_id)->not->toBeNull();
// Move past the delay window and resume.
Carbon::setTestNow('2026-01-15 13:00:00');
$this->artisan('automation:process-delays')->assertSuccessful();
$run->refresh();
expect($run->status)->toBe(RunStatus::Completed);
$post = Post::find($run->generated_post_id);
expect($post)->not->toBeNull();
expect($post->content)->toBe('Post from RSS item');
});
it('runs http_request → condition (true) → webhook down the yes handle', function () {
Http::fake([
'api.example.com/*' => Http::response(['status' => 'active'], 200),
'hooks.example.com/*' => Http::response(['ok' => true], 200),
]);
$workspace = Workspace::factory()->create();
$automation = Automation::factory()->for($workspace)->active()->create([
'nodes' => [
['id' => 't', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule']],
['id' => 'h', 'type' => 'http_request', 'position' => ['x' => 1, 'y' => 0], 'data' => ['url' => 'https://api.example.com/status', 'method' => 'GET']],
['id' => 'c', 'type' => 'condition', 'position' => ['x' => 2, 'y' => 0], 'data' => ['field' => '{{ fetched.status }}', 'operator' => 'equals', 'value' => 'active']],
['id' => 'w', 'type' => 'webhook', 'position' => ['x' => 3, 'y' => 0], 'data' => ['url' => 'https://hooks.example.com/notify', 'method' => 'POST', 'payload_template' => '{"ok":true}']],
['id' => 'e', 'type' => 'end', 'position' => ['x' => 3, 'y' => 1], 'data' => []],
],
'connections' => [
['id' => 'e1', 'source' => 't', 'target' => 'h'],
['id' => 'e2', 'source' => 'h', 'target' => 'c'],
['id' => 'e3', 'source' => 'c', 'source_handle' => 'yes', 'target' => 'w'],
['id' => 'e4', 'source' => 'c', 'source_handle' => 'no', 'target' => 'e'],
],
]);
app(EnrollTriggerItem::class)($automation, 'http-true-1', []);
$run = $automation->runs()->latest()->first();
expect($run->status)->toBe(RunStatus::Completed);
Http::assertSent(fn ($request) => str_contains($request->url(), 'hooks.example.com/notify'));
});
it('runs http_request → condition (false) → end down the no handle without hitting the webhook', function () {
Http::fake([
'api.example.com/*' => Http::response(['status' => 'inactive'], 200),
'hooks.example.com/*' => Http::response(['ok' => true], 200),
]);
$workspace = Workspace::factory()->create();
$automation = Automation::factory()->for($workspace)->active()->create([
'nodes' => [
['id' => 't', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule']],
['id' => 'h', 'type' => 'http_request', 'position' => ['x' => 1, 'y' => 0], 'data' => ['url' => 'https://api.example.com/status', 'method' => 'GET']],
['id' => 'c', 'type' => 'condition', 'position' => ['x' => 2, 'y' => 0], 'data' => ['field' => '{{ fetched.status }}', 'operator' => 'equals', 'value' => 'active']],
['id' => 'w', 'type' => 'webhook', 'position' => ['x' => 3, 'y' => 0], 'data' => ['url' => 'https://hooks.example.com/notify', 'method' => 'POST', 'payload_template' => '{"ok":true}']],
['id' => 'e', 'type' => 'end', 'position' => ['x' => 3, 'y' => 1], 'data' => []],
],
'connections' => [
['id' => 'e1', 'source' => 't', 'target' => 'h'],
['id' => 'e2', 'source' => 'h', 'target' => 'c'],
['id' => 'e3', 'source' => 'c', 'source_handle' => 'yes', 'target' => 'w'],
['id' => 'e4', 'source' => 'c', 'source_handle' => 'no', 'target' => 'e'],
],
]);
app(EnrollTriggerItem::class)($automation, 'http-false-1', []);
$run = $automation->runs()->latest()->first();
expect($run->status)->toBe(RunStatus::Completed);
Http::assertNotSent(fn ($request) => str_contains($request->url(), 'hooks.example.com/notify'));
});
it('runs fetch_rss → (no_items) → end when the feed yields no new items and creates no post', function () use ($rssFeed) {
Carbon::setTestNow('2026-01-15 10:00:00');
Http::fake([
'blog.example.com/*' => Http::response($rssFeed('Stale', 'stale-1', 'Mon, 01 Jan 2024 12:00:00 +0000'), 200),
]);
$workspace = Workspace::factory()->create();
$automation = Automation::factory()->for($workspace)->active()->create([
'nodes' => [
['id' => 't', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule']],
['id' => 'f', 'type' => 'fetch_rss', 'position' => ['x' => 1, 'y' => 0], 'data' => ['feed_url' => 'https://blog.example.com/feed']],
['id' => 'g', 'type' => 'generate', 'position' => ['x' => 2, 'y' => 0], 'data' => ['prompt_template' => 'x', 'include_image' => false]],
['id' => 'e', 'type' => 'end', 'position' => ['x' => 2, 'y' => 1], 'data' => []],
],
'connections' => [
['id' => 'e1', 'source' => 't', 'target' => 'f'],
['id' => 'e2', 'source' => 'f', 'source_handle' => 'default', 'target' => 'g'],
['id' => 'e3', 'source' => 'f', 'source_handle' => 'no_items', 'target' => 'e'],
],
]);
// Watermark newer than the feed's only item so zero items are new.
AutomationNodeState::create([
'automation_id' => $automation->id,
'node_id' => 'f',
'data' => ['last_item_date' => '2025-12-01T00:00:00+00:00'],
]);
app(EnrollTriggerItem::class)($automation, 'rss-empty-1', []);
$run = $automation->runs()->latest()->first();
expect($run->status)->toBe(RunStatus::Completed);
expect($run->generated_post_id)->toBeNull();
expect(Post::count())->toBe(0);
});