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
This commit is contained in:
parent
af4d83190e
commit
4efaa0bf99
40 changed files with 1898 additions and 231 deletions
|
|
@ -30,6 +30,8 @@
|
|||
*/
|
||||
class RunFetchRssNode
|
||||
{
|
||||
private const ITEM_HANDLE = 'default';
|
||||
|
||||
public function __invoke(AutomationRun $run, array $config): NodeRunResult
|
||||
{
|
||||
$feedUrl = (string) data_get($config, 'feed_url', '');
|
||||
|
|
@ -68,7 +70,7 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
|
|||
|
||||
[$newItems, $newestSeen] = $this->collectNewItems($xml, $watermark);
|
||||
|
||||
if ($state !== null && $newestSeen !== null) {
|
||||
if ($state !== null && $newestSeen !== null && ! $run->is_manual) {
|
||||
$state->update(['data' => array_merge($state->data ?? [], [
|
||||
'last_item_date' => $newestSeen->toIso8601String(),
|
||||
])]);
|
||||
|
|
@ -167,7 +169,7 @@ private function spawnSiblings(AutomationRun $parent, string $fetchNodeId, array
|
|||
private function findNextNodeId(AutomationRun $run, string $fromNodeId): ?string
|
||||
{
|
||||
$connection = collect($run->automation->connections ?? [])
|
||||
->first(fn ($c) => $c['source'] === $fromNodeId && ($c['source_handle'] ?? 'default') === 'default');
|
||||
->first(fn ($c) => $c['source'] === $fromNodeId && ($c['source_handle'] ?? self::ITEM_HANDLE) === self::ITEM_HANDLE);
|
||||
|
||||
return $connection['target'] ?? null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
use App\Models\Workspace;
|
||||
use App\Services\Ai\RecordAiUsage;
|
||||
use App\Services\Automation\ExpressionResolver;
|
||||
use App\Services\Image\PostImagePipeline;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class RunGenerateNode
|
||||
|
|
@ -100,6 +101,18 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
|
|||
];
|
||||
}
|
||||
|
||||
$includeImage = (bool) data_get($config, 'include_image', true);
|
||||
|
||||
$brandAccount = $platforms !== []
|
||||
? $activeAccounts->get(data_get($platforms[0], 'social_account_id'))
|
||||
: null;
|
||||
|
||||
$contentType = $platforms !== []
|
||||
? ContentType::tryFrom((string) data_get($platforms[0], 'content_type'))
|
||||
: null;
|
||||
|
||||
$imageCount = $this->intendedImageCount($format, $slideCount, $includeImage, $structured, $brandAccount);
|
||||
|
||||
// Dry runs do the AI work (so the user sees a real generation) but
|
||||
// never persist a Post. Downstream nodes (Publish) read `is_dry_run`
|
||||
// and skip their persistence too.
|
||||
|
|
@ -109,13 +122,24 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
|
|||
'post_id' => null,
|
||||
'content' => $content,
|
||||
'dry_run' => true,
|
||||
'image_count' => $imageCount,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$media = [];
|
||||
|
||||
if ($brandAccount) {
|
||||
if ($format === 'carousel') {
|
||||
$media = app(PostImagePipeline::class)->forCarousel($workspace, $brandAccount, $structured, $contentType);
|
||||
} elseif ($includeImage) {
|
||||
$media = app(PostImagePipeline::class)->forSingle($workspace, $brandAccount, $structured, $contentType);
|
||||
}
|
||||
}
|
||||
|
||||
$post = CreatePost::execute($workspace, $user, [
|
||||
'content' => $content,
|
||||
'media' => [],
|
||||
'media' => $media,
|
||||
'platforms' => $platforms,
|
||||
]);
|
||||
|
||||
|
|
@ -239,6 +263,28 @@ public function deriveFormat(array $accountsConfig, array $config): array
|
|||
return ['format' => 'single', 'slide_count' => 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of images that would be attached for the resolved format. Used as
|
||||
* the dry-run indicator and mirrors the non-dry image generation branches:
|
||||
* one per slide for carousels, one for single posts when images are enabled.
|
||||
*
|
||||
* @param array<string, mixed> $structured
|
||||
*/
|
||||
private function intendedImageCount(string $format, int $slideCount, bool $includeImage, array $structured, ?SocialAccount $brandAccount): int
|
||||
{
|
||||
if (! $brandAccount) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($format === 'carousel') {
|
||||
$slides = data_get($structured, 'slides', []);
|
||||
|
||||
return is_array($slides) ? count($slides) : $slideCount;
|
||||
}
|
||||
|
||||
return $includeImage ? 1 : 0;
|
||||
}
|
||||
|
||||
private function resolveUser(AutomationRun $run): User
|
||||
{
|
||||
if ($run->automation->user_id) {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@
|
|||
*/
|
||||
class RunHttpRequestNode
|
||||
{
|
||||
private const ITEM_HANDLE = 'default';
|
||||
|
||||
public function __construct(private ExpressionResolver $resolver) {}
|
||||
|
||||
public function __invoke(AutomationRun $run, array $config): NodeRunResult
|
||||
|
|
@ -130,7 +132,7 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
|
|||
$newItems[] = array_merge($item, ['_key' => $key]);
|
||||
}
|
||||
|
||||
if ($useWatermark && $newestSeen !== null && $state !== null) {
|
||||
if ($useWatermark && $newestSeen !== null && $state !== null && ! $run->is_manual) {
|
||||
$state->update(['data' => array_merge($state->data ?? [], [
|
||||
'last_item_date' => $newestSeen->toIso8601String(),
|
||||
])]);
|
||||
|
|
@ -194,7 +196,7 @@ private function buildRequest(array $config, array $context): PendingRequest
|
|||
$request = $request->withHeaders($headers);
|
||||
}
|
||||
|
||||
return $request;
|
||||
return $request->withUserAgent(config('trypost.user_agent'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -252,7 +254,7 @@ private function spawnSiblings(AutomationRun $parent, string $fetchNodeId, array
|
|||
private function findNextNodeId(AutomationRun $run, string $fromNodeId): ?string
|
||||
{
|
||||
$connection = collect($run->automation->connections ?? [])
|
||||
->first(fn ($c) => $c['source'] === $fromNodeId && ($c['source_handle'] ?? 'default') === 'default');
|
||||
->first(fn ($c) => $c['source'] === $fromNodeId && ($c['source_handle'] ?? self::ITEM_HANDLE) === self::ITEM_HANDLE);
|
||||
|
||||
return $connection['target'] ?? null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,9 +24,24 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
|
|||
}
|
||||
|
||||
$payloadJson = $this->resolver->resolve($config['payload_template'] ?? '{}', $run->context ?? []);
|
||||
$payload = json_decode($payloadJson, true) ?? [];
|
||||
$trimmedPayload = trim($payloadJson);
|
||||
|
||||
if ($trimmedPayload !== '' && $trimmedPayload !== 'null') {
|
||||
$decoded = json_decode($payloadJson, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
return NodeRunResult::failed(__('automations.errors.webhook_invalid_payload_json'), [
|
||||
'reason' => 'invalid_payload_json',
|
||||
]);
|
||||
}
|
||||
|
||||
$payload = $decoded ?? [];
|
||||
} else {
|
||||
$payload = [];
|
||||
}
|
||||
|
||||
$response = Http::withHeaders($headers)
|
||||
->withUserAgent(config('trypost.user_agent'))
|
||||
->send($method, $url, ['json' => $payload]);
|
||||
|
||||
if ($response->serverError()) {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@ public function __invoke(AutomationRun $run, string $fromNodeId, string $handle
|
|||
'status' => Status::Completed,
|
||||
'finished_at' => now(),
|
||||
'current_node_id' => null,
|
||||
'error' => [
|
||||
'reason' => 'no_matching_edge',
|
||||
'handle' => $handle,
|
||||
'node_id' => $fromNodeId,
|
||||
],
|
||||
]);
|
||||
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -20,8 +20,9 @@
|
|||
*
|
||||
* When `$withRealData` is false (the default), the run is marked `is_dry_run`
|
||||
* so side-effectful nodes (publish, generate, watermark advancement, sibling
|
||||
* spawning) short-circuit. The run row is auto-deleted after reaching a
|
||||
* terminal state — dry tests intentionally leave no trace.
|
||||
* spawning) short-circuit. Dry-run rows are kept briefly so the editor test
|
||||
* panel can show the completed result, then reaped by the scheduled
|
||||
* `automation:prune-dry-runs` command once past its grace window.
|
||||
*/
|
||||
class TestAutomation
|
||||
{
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
use App\Actions\Automation\Run\AdvanceAutomationRun;
|
||||
use App\Enums\Automation\Run\Status;
|
||||
use App\Enums\Automation\Status as AutomationStatus;
|
||||
use App\Models\AutomationRun;
|
||||
use Illuminate\Console\Attributes\Description;
|
||||
use Illuminate\Console\Attributes\Signature;
|
||||
|
|
@ -21,6 +22,9 @@ public function handle(AdvanceAutomationRun $advance): int
|
|||
AutomationRun::query()
|
||||
->where('status', Status::Waiting)
|
||||
->where('next_action_at', '<=', now())
|
||||
->where(fn ($query) => $query
|
||||
->where('is_manual', true)
|
||||
->orWhereHas('automation', fn ($inner) => $inner->where('status', AutomationStatus::Active)))
|
||||
->lockForUpdate()
|
||||
->chunkById(50, function ($runs) use ($advance) {
|
||||
DB::transaction(function () use ($runs, $advance) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands\Automation;
|
||||
|
||||
use App\Models\AutomationRun;
|
||||
use Illuminate\Console\Attributes\Description;
|
||||
use Illuminate\Console\Attributes\Signature;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
#[Signature('automation:prune-dry-runs')]
|
||||
#[Description('Delete terminal dry-run automation runs older than the test-panel grace window')]
|
||||
class PruneDryRunAutomationRuns extends Command
|
||||
{
|
||||
private const GRACE_MINUTES = 10;
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$count = AutomationRun::query()
|
||||
->where('is_dry_run', true)
|
||||
->whereNotNull('finished_at')
|
||||
->where('finished_at', '<=', now()->subMinutes(self::GRACE_MINUTES))
|
||||
->delete();
|
||||
|
||||
$this->info("Pruned {$count} dry-run automation runs.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands\Automation;
|
||||
|
||||
use App\Enums\Automation\Run\Status;
|
||||
use App\Models\AutomationRun;
|
||||
use Illuminate\Console\Attributes\Description;
|
||||
use Illuminate\Console\Attributes\Signature;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
#[Signature('automation:recover-stuck-runs')]
|
||||
#[Description('Fail automation runs stuck running or pending for more than 1 hour')]
|
||||
class RecoverStuckAutomationRuns extends Command
|
||||
{
|
||||
public function handle(): int
|
||||
{
|
||||
$count = AutomationRun::query()
|
||||
->whereIn('status', [Status::Running, Status::Pending])
|
||||
->where('updated_at', '<=', now()->subHour())
|
||||
->update([
|
||||
'status' => Status::Failed,
|
||||
'error' => ['reason' => 'stuck'],
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
|
||||
$this->info("Recovered {$count} stuck automation runs.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -7,8 +7,6 @@
|
|||
use App\Actions\Post\CreatePost;
|
||||
use App\Ai\Agents\PostContentGenerator;
|
||||
use App\Ai\Agents\PostContentHumanizer;
|
||||
use App\Enums\Media\Source;
|
||||
use App\Enums\Media\Type as MediaType;
|
||||
use App\Enums\Notification\Channel as NotificationChannel;
|
||||
use App\Enums\Notification\Type as NotificationType;
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
|
|
@ -18,17 +16,14 @@
|
|||
use App\Models\SocialAccount;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Ai\AiImageClient;
|
||||
use App\Services\Ai\RecordAiUsage;
|
||||
use App\Services\Image\BrandColorMapper;
|
||||
use App\Services\Image\TemplateImageGenerator;
|
||||
use App\Services\Image\PostImagePipeline;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class StreamPostCreation implements ShouldQueue
|
||||
{
|
||||
|
|
@ -101,32 +96,6 @@ public function handle(): void
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the structured generator output through the humanizer pass and merge
|
||||
* the humanized text fields back over the original structure (preserving
|
||||
* image_keywords and slide order/count). Failures are logged and the
|
||||
* original structure is returned so generation never breaks because of the
|
||||
* polish step.
|
||||
*
|
||||
* @param array<string, mixed> $structured
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
/**
|
||||
* Look up the AI image dimensions for the current format. Falls back to
|
||||
* the generator's defaults (4:5 portrait) if the format string isn't a
|
||||
* known ContentType case.
|
||||
*
|
||||
* @return array{width: int, height: int}
|
||||
*/
|
||||
private function dimensionsForFormat(): array
|
||||
{
|
||||
$type = $this->resolvedContentType();
|
||||
|
||||
return $type
|
||||
? $type->aiImageDimensions()
|
||||
: ['width' => TemplateImageGenerator::DEFAULT_WIDTH, 'height' => TemplateImageGenerator::DEFAULT_HEIGHT];
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored content type for the requested generation format. The carousel
|
||||
* generation format is persisted as an Instagram feed post.
|
||||
|
|
@ -140,6 +109,16 @@ private function resolvedContentType(): ?ContentType
|
|||
return ContentType::tryFrom($this->format);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the structured generator output through the humanizer pass and merge
|
||||
* the humanized text fields back over the original structure (preserving
|
||||
* image_keywords and slide order/count). Failures are logged and the
|
||||
* original structure is returned so generation never breaks because of the
|
||||
* polish step.
|
||||
*
|
||||
* @param array<string, mixed> $structured
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function humanize(Workspace $workspace, array $structured, string $format): array
|
||||
{
|
||||
try {
|
||||
|
|
@ -207,29 +186,16 @@ private function humanize(Workspace $workspace, array $structured, string $forma
|
|||
private function handleCarousel(Workspace $workspace, ?SocialAccount $socialAccount, array $structured): void
|
||||
{
|
||||
$caption = (string) data_get($structured, 'caption', '');
|
||||
$slides = data_get($structured, 'slides', []);
|
||||
|
||||
$media = [];
|
||||
|
||||
if ($socialAccount) {
|
||||
$generator = new TemplateImageGenerator(new BrandColorMapper, new AiImageClient);
|
||||
['width' => $width, 'height' => $height] = $this->dimensionsForFormat();
|
||||
|
||||
foreach ($slides as $slide) {
|
||||
$rendered = $generator->render(
|
||||
workspace: $workspace,
|
||||
socialAccount: $socialAccount,
|
||||
title: data_get($slide, 'title', ''),
|
||||
body: data_get($slide, 'body', ''),
|
||||
imageKeywords: data_get($slide, 'image_keywords', []),
|
||||
width: $width,
|
||||
height: $height,
|
||||
);
|
||||
|
||||
if ($rendered) {
|
||||
$media[] = $this->buildAiMediaItem($workspace, $rendered);
|
||||
}
|
||||
}
|
||||
$media = app(PostImagePipeline::class)->forCarousel(
|
||||
workspace: $workspace,
|
||||
account: $socialAccount,
|
||||
structured: $structured,
|
||||
contentType: $this->resolvedContentType(),
|
||||
);
|
||||
}
|
||||
|
||||
$post = $this->createPost($workspace, $caption, $media, $socialAccount);
|
||||
|
|
@ -246,29 +212,16 @@ private function handleSingle(Workspace $workspace, ?SocialAccount $socialAccoun
|
|||
$supportsCaption = $contentType?->supportsCaption() ?? true;
|
||||
|
||||
$rawContent = (string) data_get($structured, 'content', data_get($structured, 'text', ''));
|
||||
$imageTitle = (string) data_get($structured, 'image_title', '');
|
||||
$imageBody = (string) data_get($structured, 'image_body', '');
|
||||
$keywords = data_get($structured, 'image_keywords', []);
|
||||
|
||||
$media = [];
|
||||
|
||||
if ($this->imageCount > 0 && $socialAccount) {
|
||||
$generator = new TemplateImageGenerator(new BrandColorMapper, new AiImageClient);
|
||||
['width' => $width, 'height' => $height] = $this->dimensionsForFormat();
|
||||
|
||||
$rendered = $generator->render(
|
||||
$media = app(PostImagePipeline::class)->forSingle(
|
||||
workspace: $workspace,
|
||||
socialAccount: $socialAccount,
|
||||
title: $imageTitle,
|
||||
body: $imageBody,
|
||||
imageKeywords: $keywords,
|
||||
width: $width,
|
||||
height: $height,
|
||||
account: $socialAccount,
|
||||
structured: $structured,
|
||||
contentType: $this->resolvedContentType(),
|
||||
);
|
||||
|
||||
if ($rendered) {
|
||||
$media[] = $this->buildAiMediaItem($workspace, $rendered);
|
||||
}
|
||||
}
|
||||
|
||||
$caption = $supportsCaption ? $rawContent : '';
|
||||
|
|
@ -345,31 +298,4 @@ private function aspectRatioFor(ContentType $type): ?string
|
|||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{path: string, source_meta: array<string, mixed>} $rendered
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildAiMediaItem(Workspace $workspace, array $rendered): array
|
||||
{
|
||||
$media = $workspace->media()->create([
|
||||
'collection' => 'ai-generated',
|
||||
'type' => MediaType::Image,
|
||||
'path' => $rendered['path'],
|
||||
'original_filename' => basename($rendered['path']),
|
||||
'mime_type' => 'image/webp',
|
||||
'size' => Storage::size($rendered['path']),
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
return [
|
||||
'id' => $media->id,
|
||||
'path' => $media->path,
|
||||
'url' => $media->url,
|
||||
'type' => 'image',
|
||||
'mime_type' => 'image/webp',
|
||||
'source' => Source::Ai->value,
|
||||
'source_meta' => $rendered['source_meta'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
use App\Enums\Automation\Node\Type as NodeType;
|
||||
use App\Enums\Automation\NodeRun\Status as NodeRunStatus;
|
||||
use App\Enums\Automation\Run\Status as RunStatus;
|
||||
use App\Enums\Automation\Status as AutomationStatus;
|
||||
use App\Models\AutomationNodeRun;
|
||||
use App\Models\AutomationRun;
|
||||
use Illuminate\Bus\Queueable;
|
||||
|
|
@ -48,6 +49,10 @@ public function handle(AdvanceAutomationRun $advance): void
|
|||
return;
|
||||
}
|
||||
|
||||
if (! $this->run->is_manual && $this->run->automation->status !== AutomationStatus::Active) {
|
||||
return;
|
||||
}
|
||||
|
||||
$node = collect($this->run->automation->nodes ?? [])->firstWhere('id', $this->nodeId);
|
||||
|
||||
if ($node === null) {
|
||||
|
|
@ -116,6 +121,21 @@ public function handle(AdvanceAutomationRun $advance): void
|
|||
$advance($this->run, $this->nodeId, $result->nextHandle);
|
||||
}
|
||||
|
||||
public function failed(?Throwable $e): void
|
||||
{
|
||||
$this->run->refresh();
|
||||
|
||||
if (in_array($this->run->status, [RunStatus::Completed, RunStatus::Failed], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->run->update([
|
||||
'status' => RunStatus::Failed,
|
||||
'error' => ['message' => $e?->getMessage() ?? 'job failed', 'node_id' => $this->nodeId],
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function executeNode(NodeType $type, array $config): NodeRunResult
|
||||
{
|
||||
$handler = match ($type) {
|
||||
|
|
|
|||
120
app/Services/Image/PostImagePipeline.php
Normal file
120
app/Services/Image/PostImagePipeline.php
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Image;
|
||||
|
||||
use App\Enums\Media\Source;
|
||||
use App\Enums\Media\Type as MediaType;
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class PostImagePipeline
|
||||
{
|
||||
public function __construct(
|
||||
private TemplateImageGenerator $generator,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Render the single AI image for a structured generator output. Returns a
|
||||
* one-element media-item array when an image is produced, or an empty array
|
||||
* when the generator renders nothing.
|
||||
*
|
||||
* @param array<string, mixed> $structured
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function forSingle(Workspace $workspace, SocialAccount $account, array $structured, ?ContentType $contentType): array
|
||||
{
|
||||
['width' => $width, 'height' => $height] = $this->dimensionsForContentType($contentType);
|
||||
|
||||
$rendered = $this->generator->render(
|
||||
workspace: $workspace,
|
||||
socialAccount: $account,
|
||||
title: (string) data_get($structured, 'image_title', ''),
|
||||
body: (string) data_get($structured, 'image_body', ''),
|
||||
imageKeywords: data_get($structured, 'image_keywords', []),
|
||||
width: $width,
|
||||
height: $height,
|
||||
);
|
||||
|
||||
if (! $rendered) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [$this->buildAiMediaItem($workspace, $rendered)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one AI image per slide in the structured carousel output. Slides
|
||||
* that render nothing are skipped.
|
||||
*
|
||||
* @param array<string, mixed> $structured
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function forCarousel(Workspace $workspace, SocialAccount $account, array $structured, ?ContentType $contentType): array
|
||||
{
|
||||
['width' => $width, 'height' => $height] = $this->dimensionsForContentType($contentType);
|
||||
|
||||
$media = [];
|
||||
|
||||
foreach (data_get($structured, 'slides', []) as $slide) {
|
||||
$rendered = $this->generator->render(
|
||||
workspace: $workspace,
|
||||
socialAccount: $account,
|
||||
title: (string) data_get($slide, 'title', ''),
|
||||
body: (string) data_get($slide, 'body', ''),
|
||||
imageKeywords: data_get($slide, 'image_keywords', []),
|
||||
width: $width,
|
||||
height: $height,
|
||||
);
|
||||
|
||||
if ($rendered) {
|
||||
$media[] = $this->buildAiMediaItem($workspace, $rendered);
|
||||
}
|
||||
}
|
||||
|
||||
return $media;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the AI image dimensions for the given content type, falling back
|
||||
* to the generator defaults (4:5 portrait) when no content type is known.
|
||||
*
|
||||
* @return array{width: int, height: int}
|
||||
*/
|
||||
private function dimensionsForContentType(?ContentType $contentType): array
|
||||
{
|
||||
return $contentType
|
||||
? $contentType->aiImageDimensions()
|
||||
: ['width' => TemplateImageGenerator::DEFAULT_WIDTH, 'height' => TemplateImageGenerator::DEFAULT_HEIGHT];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{path: string, source_meta: array<string, mixed>} $rendered
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildAiMediaItem(Workspace $workspace, array $rendered): array
|
||||
{
|
||||
$media = $workspace->media()->create([
|
||||
'collection' => 'ai-generated',
|
||||
'type' => MediaType::Image,
|
||||
'path' => $rendered['path'],
|
||||
'original_filename' => basename($rendered['path']),
|
||||
'mime_type' => 'image/webp',
|
||||
'size' => Storage::size($rendered['path']),
|
||||
'order' => 0,
|
||||
]);
|
||||
|
||||
return [
|
||||
'id' => $media->id,
|
||||
'path' => $media->path,
|
||||
'url' => $media->url,
|
||||
'type' => 'image',
|
||||
'mime_type' => 'image/webp',
|
||||
'source' => Source::Ai->value,
|
||||
'source_meta' => $rendered['source_meta'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -59,6 +59,19 @@
|
|||
|
|
||||
*/
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Outbound User-Agent
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Branded User-Agent applied to outbound HTTP from automation nodes
|
||||
| (webhook + http_request) so recipients know the request came from
|
||||
| TryPost.it. Self-hosters can override it.
|
||||
|
|
||||
*/
|
||||
|
||||
'user_agent' => env('TRYPOST_USER_AGENT', 'TryPost.it/1.0 (+https://trypost.it)'),
|
||||
|
||||
'google_auth_enabled' => env('GOOGLE_AUTH_ENABLED', false),
|
||||
|
||||
'github_auth_enabled' => env('GITHUB_AUTH_ENABLED', false),
|
||||
|
|
|
|||
|
|
@ -31,8 +31,11 @@
|
|||
'node_input' => 'Input',
|
||||
'node_output' => 'Output',
|
||||
'node_error' => 'Error',
|
||||
'no_new_items' => 'No new items — nothing downstream ran.',
|
||||
'error_starting' => 'Could not start the test run.',
|
||||
'with_real_data' => 'With real data',
|
||||
'run' => 'Run test',
|
||||
'idle_hint' => 'Hit Run test to execute the automation end-to-end.',
|
||||
'real_data_hint' => 'This test will publish posts, advance polling watermarks, and trigger external side effects.',
|
||||
'dry_badge' => 'Dry run',
|
||||
],
|
||||
|
|
@ -90,10 +93,15 @@
|
|||
'end_summary' => 'Stops the automation here',
|
||||
'fetch_rss' => 'Fetch RSS',
|
||||
'http_request' => 'HTTP Request',
|
||||
'handles' => [
|
||||
'items' => 'has items',
|
||||
'no_items' => 'no items',
|
||||
],
|
||||
],
|
||||
|
||||
'config' => [
|
||||
'select_placeholder' => 'Select…',
|
||||
'invalid_json' => 'This isn’t valid JSON yet.',
|
||||
|
||||
'trigger' => [
|
||||
'type' => 'Trigger type',
|
||||
|
|
@ -146,14 +154,10 @@
|
|||
'generate' => [
|
||||
'social_accounts' => 'Social accounts',
|
||||
'social_accounts_empty' => 'No connected social accounts. Connect one first.',
|
||||
'target_slide_count' => 'Slides to generate (for carousel-capable platforms)',
|
||||
'target_slide_count' => 'Slides to generate',
|
||||
'prompt_template' => 'Prompt template',
|
||||
'image_source' => 'Image source',
|
||||
'image_sources' => [
|
||||
'ai' => 'AI generated',
|
||||
'unsplash' => 'Unsplash',
|
||||
'none' => 'No image',
|
||||
],
|
||||
'include_image' => 'Include image',
|
||||
'include_image_hint' => 'Generate an AI image for this post',
|
||||
],
|
||||
'delay' => [
|
||||
'duration' => 'Duration',
|
||||
|
|
@ -216,6 +220,10 @@
|
|||
'api_key_header' => 'Header name',
|
||||
'api_key_value' => 'API key',
|
||||
'body_template' => 'Body template (JSON)',
|
||||
'headers' => 'Headers',
|
||||
'header_name' => 'Header name',
|
||||
'header_value' => 'Value',
|
||||
'add_header' => 'Add header',
|
||||
'polling_section' => 'Polling (optional)',
|
||||
'polling_hint' => 'Leave blank to use the whole response as a single payload. Fill in to extract an array of items and spawn one run per item.',
|
||||
'items_path' => 'Items path',
|
||||
|
|
@ -244,6 +252,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_invalid_payload_json' => 'The payload template is not valid JSON.',
|
||||
'node_no_longer_exists' => 'Node :node_id no longer exists in the automation.',
|
||||
'no_trigger_connection' => 'No node connected to the Trigger node.',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -31,8 +31,11 @@
|
|||
'node_input' => 'Entrada',
|
||||
'node_output' => 'Salida',
|
||||
'node_error' => 'Error',
|
||||
'no_new_items' => 'Sin elementos nuevos — no se ejecutó nada después.',
|
||||
'error_starting' => 'No se pudo iniciar la ejecución de prueba.',
|
||||
'with_real_data' => 'Con datos reales',
|
||||
'run' => 'Ejecutar prueba',
|
||||
'idle_hint' => 'Pulsa Ejecutar prueba para correr la automatización de principio a fin.',
|
||||
'real_data_hint' => 'Esta prueba publicará posts, avanzará marcadores de polling y disparará efectos secundarios externos.',
|
||||
'dry_badge' => 'Prueba seca',
|
||||
],
|
||||
|
|
@ -90,10 +93,15 @@
|
|||
'end_summary' => 'Termina la automatización aquí',
|
||||
'fetch_rss' => 'Obtener RSS',
|
||||
'http_request' => 'Petición HTTP',
|
||||
'handles' => [
|
||||
'items' => 'con elementos',
|
||||
'no_items' => 'sin elementos',
|
||||
],
|
||||
],
|
||||
|
||||
'config' => [
|
||||
'select_placeholder' => 'Selecciona…',
|
||||
'invalid_json' => 'Esto aún no es un JSON válido.',
|
||||
|
||||
'trigger' => [
|
||||
'type' => 'Tipo de disparador',
|
||||
|
|
@ -146,14 +154,10 @@
|
|||
'generate' => [
|
||||
'social_accounts' => 'Cuentas sociales',
|
||||
'social_accounts_empty' => 'Sin cuentas sociales conectadas. Conecta una primero.',
|
||||
'target_slide_count' => 'Diapositivas a generar (para plataformas con carrusel)',
|
||||
'target_slide_count' => 'Diapositivas a generar',
|
||||
'prompt_template' => 'Plantilla de prompt',
|
||||
'image_source' => 'Fuente de imagen',
|
||||
'image_sources' => [
|
||||
'ai' => 'Generada con IA',
|
||||
'unsplash' => 'Unsplash',
|
||||
'none' => 'Sin imagen',
|
||||
],
|
||||
'include_image' => 'Incluir imagen',
|
||||
'include_image_hint' => 'Generar una imagen con IA para esta publicación',
|
||||
],
|
||||
'delay' => [
|
||||
'duration' => 'Duración',
|
||||
|
|
@ -216,6 +220,10 @@
|
|||
'api_key_header' => 'Nombre del header',
|
||||
'api_key_value' => 'API key',
|
||||
'body_template' => 'Plantilla del body (JSON)',
|
||||
'headers' => 'Headers',
|
||||
'header_name' => 'Nombre del header',
|
||||
'header_value' => 'Valor',
|
||||
'add_header' => 'Agregar header',
|
||||
'polling_section' => 'Polling (opcional)',
|
||||
'polling_hint' => 'Deja vacío para usar la respuesta completa como un solo payload. Rellena para extraer un array de ítems y disparar un run por ítem.',
|
||||
'items_path' => 'Ruta de ítems',
|
||||
|
|
@ -244,6 +252,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_invalid_payload_json' => 'La plantilla de payload no es un JSON válido.',
|
||||
'node_no_longer_exists' => 'El nodo :node_id ya no existe en la automatización.',
|
||||
'no_trigger_connection' => 'Ningún nodo está conectado al nodo disparador.',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -31,8 +31,11 @@
|
|||
'node_input' => 'Entrada',
|
||||
'node_output' => 'Saída',
|
||||
'node_error' => 'Erro',
|
||||
'no_new_items' => 'Nenhum item novo — nada foi executado adiante.',
|
||||
'error_starting' => 'Não foi possível iniciar a execução de teste.',
|
||||
'with_real_data' => 'Com dados reais',
|
||||
'run' => 'Rodar teste',
|
||||
'idle_hint' => 'Clique em Rodar teste para executar a automação de ponta a ponta.',
|
||||
'real_data_hint' => 'Este teste vai publicar posts, avançar watermarks e disparar efeitos colaterais externos.',
|
||||
'dry_badge' => 'Teste seco',
|
||||
],
|
||||
|
|
@ -90,10 +93,15 @@
|
|||
'end_summary' => 'Encerra a automação aqui',
|
||||
'fetch_rss' => 'Buscar RSS',
|
||||
'http_request' => 'Requisição HTTP',
|
||||
'handles' => [
|
||||
'items' => 'tem itens',
|
||||
'no_items' => 'sem itens',
|
||||
],
|
||||
],
|
||||
|
||||
'config' => [
|
||||
'select_placeholder' => 'Selecione…',
|
||||
'invalid_json' => 'Isto ainda não é um JSON válido.',
|
||||
|
||||
'trigger' => [
|
||||
'type' => 'Tipo de trigger',
|
||||
|
|
@ -146,14 +154,10 @@
|
|||
'generate' => [
|
||||
'social_accounts' => 'Contas sociais',
|
||||
'social_accounts_empty' => 'Nenhuma conta social conectada. Conecte uma primeiro.',
|
||||
'target_slide_count' => 'Slides a gerar (para plataformas com carrossel)',
|
||||
'target_slide_count' => 'Slides a gerar',
|
||||
'prompt_template' => 'Template do prompt',
|
||||
'image_source' => 'Origem da imagem',
|
||||
'image_sources' => [
|
||||
'ai' => 'Gerada por IA',
|
||||
'unsplash' => 'Unsplash',
|
||||
'none' => 'Sem imagem',
|
||||
],
|
||||
'include_image' => 'Incluir imagem',
|
||||
'include_image_hint' => 'Gerar uma imagem com IA para esta publicação',
|
||||
],
|
||||
'delay' => [
|
||||
'duration' => 'Duração',
|
||||
|
|
@ -216,6 +220,10 @@
|
|||
'api_key_header' => 'Nome do header',
|
||||
'api_key_value' => 'API key',
|
||||
'body_template' => 'Template do body (JSON)',
|
||||
'headers' => 'Headers',
|
||||
'header_name' => 'Nome do header',
|
||||
'header_value' => 'Valor',
|
||||
'add_header' => 'Adicionar header',
|
||||
'polling_section' => 'Polling (opcional)',
|
||||
'polling_hint' => 'Deixe vazio para usar a resposta inteira como payload único. Preencha para extrair um array de itens e disparar um run por item.',
|
||||
'items_path' => 'Caminho dos itens',
|
||||
|
|
@ -244,6 +252,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_invalid_payload_json' => 'O template do payload não é um JSON válido.',
|
||||
'node_no_longer_exists' => 'O nó :node_id não existe mais nesta automação.',
|
||||
'no_trigger_connection' => 'Nenhum nó conectado ao nó de trigger.',
|
||||
],
|
||||
|
|
|
|||
170
package-lock.json
generated
170
package-lock.json
generated
|
|
@ -5,6 +5,10 @@
|
|||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/state": "^6.5.4",
|
||||
"@codemirror/view": "^6.39.17",
|
||||
"@inertiajs/vue3": "^3.0.0",
|
||||
"@tabler/icons-vue": "^3.36.1",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
|
|
@ -16,6 +20,7 @@
|
|||
"axios": "^1.13.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"dayjs": "^1.11.19",
|
||||
"embla-carousel-vue": "^8.6.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
|
|
@ -214,6 +219,97 @@
|
|||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/autocomplete": {
|
||||
"version": "6.20.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
|
||||
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/commands": {
|
||||
"version": "6.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz",
|
||||
"integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/view": "^6.27.0",
|
||||
"@lezer/common": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-json": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
|
||||
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@lezer/json": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/language": {
|
||||
"version": "6.12.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
|
||||
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0",
|
||||
"style-mod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lint": {
|
||||
"version": "6.9.7",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
|
||||
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.42.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/search": {
|
||||
"version": "6.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.0.tgz",
|
||||
"integrity": "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.37.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/state": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
|
||||
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.43.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.1.tgz",
|
||||
"integrity": "sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"crelt": "^1.0.6",
|
||||
"style-mod": "^4.1.0",
|
||||
"w3c-keyname": "^2.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
|
||||
|
|
@ -1188,6 +1284,41 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/common": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
|
||||
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/highlight": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
|
||||
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/json": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
|
||||
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/lr": {
|
||||
"version": "1.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
|
||||
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/geojson-rewind": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz",
|
||||
|
|
@ -1259,6 +1390,12 @@
|
|||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@marijn/find-cluster-break": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
||||
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
||||
|
|
@ -4333,6 +4470,21 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/codemirror": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
|
||||
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/commands": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/lint": "^6.0.0",
|
||||
"@codemirror/search": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
|
|
@ -4452,6 +4604,12 @@
|
|||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
|
|
@ -9141,6 +9299,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/style-mod": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
|
||||
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stylis": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
|
||||
|
|
@ -9947,6 +10111,12 @@
|
|||
"typescript": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/web-vitals": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.2.0.tgz",
|
||||
|
|
|
|||
|
|
@ -38,6 +38,10 @@
|
|||
"vue-tsc": "^2.2.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/state": "^6.5.4",
|
||||
"@codemirror/view": "^6.39.17",
|
||||
"@inertiajs/vue3": "^3.0.0",
|
||||
"@tabler/icons-vue": "^3.36.1",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
|
|
@ -49,6 +53,7 @@
|
|||
"axios": "^1.13.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"dayjs": "^1.11.19",
|
||||
"embla-carousel-vue": "^8.6.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
|
|
|
|||
|
|
@ -171,6 +171,7 @@ .trypost-container {
|
|||
.automation-node {
|
||||
position: relative;
|
||||
min-width: 230px;
|
||||
max-width: 260px;
|
||||
background: var(--card);
|
||||
border: 2px solid var(--foreground);
|
||||
border-radius: 14px;
|
||||
|
|
@ -185,6 +186,7 @@ .automation-node:hover {
|
|||
|
||||
.automation-node--wide {
|
||||
min-width: 260px;
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
.automation-node.is-selected {
|
||||
|
|
@ -233,11 +235,15 @@ .automation-node--accent-slate .automation-node__header { background: #f1f5f9;
|
|||
.automation-node--accent-zinc .automation-node__header { background: #f4f4f5; }
|
||||
|
||||
.automation-node__title {
|
||||
min-width: 0;
|
||||
font-weight: 700;
|
||||
font-size: 0.875rem;
|
||||
color: var(--foreground);
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.005em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.automation-node__summary {
|
||||
|
|
|
|||
159
resources/js/components/CodeEditor.vue
Normal file
159
resources/js/components/CodeEditor.vue
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
<script setup lang="ts">
|
||||
import { indentWithTab } from '@codemirror/commands';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { EditorView, keymap, placeholder as placeholderExt } from '@codemirror/view';
|
||||
import { basicSetup } from 'codemirror';
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import debounce from '@/debounce';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string;
|
||||
language?: 'json';
|
||||
readOnly?: boolean;
|
||||
placeholder?: string;
|
||||
}>(),
|
||||
{
|
||||
language: 'json',
|
||||
readOnly: false,
|
||||
placeholder: '',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string];
|
||||
}>();
|
||||
|
||||
const editorContainer = ref<HTMLElement>();
|
||||
let view: EditorView | null = null;
|
||||
|
||||
const debouncedEmit = debounce((value: string) => {
|
||||
emit('update:modelValue', value);
|
||||
}, 250);
|
||||
|
||||
const languageExtension = () => {
|
||||
switch (props.language) {
|
||||
case 'json':
|
||||
default:
|
||||
return json();
|
||||
}
|
||||
};
|
||||
|
||||
const lightTheme = EditorView.theme({
|
||||
'&': {
|
||||
height: '100%',
|
||||
fontSize: '13px',
|
||||
color: 'var(--foreground)',
|
||||
backgroundColor: 'var(--card)',
|
||||
border: '2px solid var(--foreground)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
'&.cm-focused': {
|
||||
outline: '2px solid var(--ring)',
|
||||
outlineOffset: '0px',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
overflow: 'auto',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
lineHeight: '1.6',
|
||||
},
|
||||
'.cm-content': {
|
||||
caretColor: 'var(--foreground)',
|
||||
padding: '8px 0',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: 'var(--muted)',
|
||||
color: 'var(--muted-foreground)',
|
||||
border: 'none',
|
||||
borderRight: '2px solid color-mix(in srgb, var(--foreground) 15%, transparent)',
|
||||
},
|
||||
'.cm-activeLine': {
|
||||
backgroundColor: 'color-mix(in srgb, var(--foreground) 4%, transparent)',
|
||||
},
|
||||
'.cm-activeLineGutter': {
|
||||
backgroundColor: 'color-mix(in srgb, var(--foreground) 8%, transparent)',
|
||||
},
|
||||
'.cm-selectionBackground, &.cm-focused .cm-selectionBackground, .cm-content ::selection':
|
||||
{
|
||||
backgroundColor: 'color-mix(in srgb, var(--ring) 20%, transparent)',
|
||||
},
|
||||
'.cm-cursor, .cm-dropCursor': {
|
||||
borderLeftColor: 'var(--foreground)',
|
||||
},
|
||||
'.cm-placeholder': {
|
||||
color: 'var(--muted-foreground)',
|
||||
},
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (!editorContainer.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateListener = EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
debouncedEmit(update.state.doc.toString());
|
||||
}
|
||||
});
|
||||
|
||||
const extensions = [
|
||||
basicSetup,
|
||||
keymap.of([indentWithTab]),
|
||||
languageExtension(),
|
||||
EditorView.lineWrapping,
|
||||
lightTheme,
|
||||
updateListener,
|
||||
];
|
||||
|
||||
if (props.placeholder) {
|
||||
extensions.push(placeholderExt(props.placeholder));
|
||||
}
|
||||
|
||||
if (props.readOnly) {
|
||||
extensions.push(EditorState.readOnly.of(true));
|
||||
extensions.push(EditorView.editable.of(false));
|
||||
}
|
||||
|
||||
view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: props.modelValue ?? '',
|
||||
extensions,
|
||||
}),
|
||||
parent: editorContainer.value,
|
||||
});
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = view.state.doc.toString();
|
||||
|
||||
if ((value ?? '') !== current) {
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: current.length,
|
||||
insert: value ?? '',
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
debouncedEmit.cancel();
|
||||
view?.destroy();
|
||||
view = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="editorContainer" class="code-editor h-full w-full" />
|
||||
</template>
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { IconAlertCircle, IconChevronRight, IconCircleCheck, IconCircleDot, IconLoader2, IconX } from '@tabler/icons-vue';
|
||||
import { IconAlertCircle, IconChevronRight, IconCircleCheck, IconCircleDot, IconInfoCircle, IconLoader2, IconPlayerPlay, IconX } from '@tabler/icons-vue';
|
||||
import { ref } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
|
|
@ -7,6 +7,7 @@ import { trans } from 'laravel-vue-i18n';
|
|||
import JsonViewer from '@/components/JsonViewer.vue';
|
||||
import { useAutomationEcho } from '@/composables/echo/useAutomationEcho';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { test as testAutomation } from '@/routes/app/automations';
|
||||
import { showRun as showRunRoute } from '@/actions/App/Http/Controllers/App/AutomationController';
|
||||
|
||||
|
|
@ -32,11 +33,12 @@ interface Run {
|
|||
is_dry_run: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<{ automationId: string; withRealData?: boolean }>();
|
||||
const props = defineProps<{ automationId: string; beforeRun?: () => Promise<boolean> | boolean }>();
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false });
|
||||
|
||||
const isStarting = ref(false);
|
||||
const realData = ref(false);
|
||||
const run = ref<Run | null>(null);
|
||||
const nodeRuns = ref<NodeRun[]>([]);
|
||||
const activeRunId = ref<string | null>(null);
|
||||
|
|
@ -83,7 +85,7 @@ const start = async () => {
|
|||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement | null)?.content ?? '',
|
||||
},
|
||||
body: JSON.stringify({ with_real_data: props.withRealData ?? false }),
|
||||
body: JSON.stringify({ with_real_data: realData.value }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('start failed');
|
||||
|
|
@ -99,20 +101,15 @@ const start = async () => {
|
|||
}
|
||||
};
|
||||
|
||||
// Parent triggers runs imperatively via the template ref so every click on the
|
||||
// Test button kicks off a fresh execution — including the first one, since the
|
||||
// parent calls `start()` right after toggling the panel open.
|
||||
defineExpose({ start });
|
||||
const runTest = async () => {
|
||||
if (isStarting.value) return;
|
||||
const proceed = await (props.beforeRun?.() ?? true);
|
||||
if (proceed === false) return;
|
||||
await start();
|
||||
};
|
||||
|
||||
const close = () => { open.value = false; };
|
||||
|
||||
const runStatusIcon = (status: string) => {
|
||||
if (status === 'completed') return IconCircleCheck;
|
||||
if (status === 'failed') return IconAlertCircle;
|
||||
if (status === 'running') return IconLoader2;
|
||||
return IconCircleDot;
|
||||
};
|
||||
|
||||
const statusLabel = (status: string): string => {
|
||||
const map: Record<string, string> = {
|
||||
running: trans('automations.test.in_progress'),
|
||||
|
|
@ -129,6 +126,16 @@ const nodeStatusIcon = (status: string) => {
|
|||
if (status === 'running') return IconLoader2;
|
||||
return IconCircleDot;
|
||||
};
|
||||
|
||||
// Fetch nodes (RSS / HTTP) short-circuit via the `no_items` handle with an
|
||||
// output of `{ fetch: { count: 0 } }` when nothing new arrived. Surface that
|
||||
// as an explicit note instead of an uninformative empty JSON blob.
|
||||
const isZeroFetchResult = (nodeRun: NodeRun): boolean => {
|
||||
if (nodeRun.status !== 'completed') return false;
|
||||
if (nodeRun.node_type !== 'fetch_rss' && nodeRun.node_type !== 'http_request') return false;
|
||||
const fetch = nodeRun.output?.fetch as { count?: number } | undefined;
|
||||
return fetch?.count === 0;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -153,28 +160,36 @@ const nodeStatusIcon = (status: string) => {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 rounded-xl border-2 border-foreground bg-card p-3 shadow-[3px_3px_0_var(--foreground)]">
|
||||
<label class="flex cursor-pointer items-center gap-2 text-sm font-semibold text-foreground/70">
|
||||
<Checkbox v-model="realData" :disabled="isStarting" />
|
||||
{{ $t('automations.test.with_real_data') }}
|
||||
</label>
|
||||
<Button size="sm" :disabled="isStarting" @click="runTest">
|
||||
<IconLoader2 v-if="isStarting" class="size-4 animate-spin" />
|
||||
<IconPlayerPlay v-else class="size-4" />
|
||||
{{ $t('automations.test.run') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="run === null && !isStarting"
|
||||
class="rounded-xl border-2 border-dashed border-foreground/25 bg-card/40 p-8 text-center text-sm font-medium text-foreground/60"
|
||||
>
|
||||
{{ $t('automations.test.idle_hint') }}
|
||||
</div>
|
||||
|
||||
<div v-if="run === null && isStarting" class="flex items-center gap-2.5 text-sm font-medium text-foreground/70">
|
||||
<IconLoader2 class="size-5 animate-spin" />
|
||||
{{ $t('automations.test.starting') }}
|
||||
</div>
|
||||
|
||||
<div v-if="run" class="flex items-center gap-3 rounded-xl border-2 border-foreground bg-card p-4 shadow-[3px_3px_0_var(--foreground)]">
|
||||
<div
|
||||
:class="[
|
||||
'inline-flex size-10 -rotate-3 shrink-0 items-center justify-center rounded-xl border-2 border-foreground shadow-2xs',
|
||||
run.status === 'completed' && 'bg-emerald-200 text-emerald-900',
|
||||
run.status === 'failed' && 'bg-rose-200 text-rose-900',
|
||||
run.status === 'running' && 'bg-amber-200 text-amber-900',
|
||||
!['completed', 'failed', 'running'].includes(run.status) && 'bg-zinc-200 text-zinc-900',
|
||||
]"
|
||||
>
|
||||
<component :is="runStatusIcon(run.status)" :class="['size-5', run.status === 'running' && 'animate-spin']" stroke-width="2.5" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-black uppercase tracking-wider text-foreground/60">{{ $t('automations.test.title') }}</p>
|
||||
<p class="text-base font-bold">{{ statusLabel(run.status) }}</p>
|
||||
<p v-if="run.error" class="mt-1 text-sm text-rose-700">{{ run.error.message }}</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="run && run.status === 'failed' && run.error?.message"
|
||||
class="flex items-start gap-2.5 rounded-xl border-2 border-rose-700 bg-rose-50 p-4 text-sm font-medium text-rose-800"
|
||||
>
|
||||
<IconAlertCircle class="mt-0.5 size-5 shrink-0" stroke-width="2.5" />
|
||||
<span>{{ run.error.message }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="run && nodeRuns.length === 0" class="rounded-xl border-2 border-dashed border-foreground/25 bg-card/40 p-8 text-center text-sm font-medium text-foreground/60">
|
||||
|
|
@ -211,7 +226,15 @@ const nodeStatusIcon = (status: string) => {
|
|||
<span class="font-black uppercase text-xs tracking-wider">{{ $t('automations.test.node_error') }}:</span>
|
||||
<span class="ml-1">{{ nodeRun.error.message }}</span>
|
||||
</p>
|
||||
<details v-if="nodeRun.output" class="group" :class="{ 'mt-3': nodeRun.error }">
|
||||
<p
|
||||
v-if="isZeroFetchResult(nodeRun)"
|
||||
class="flex items-center gap-2 rounded-lg border-2 border-foreground/30 bg-card p-3 text-sm font-medium text-foreground/70"
|
||||
:class="{ 'mt-3': nodeRun.error }"
|
||||
>
|
||||
<IconInfoCircle class="size-5 shrink-0 text-foreground/50" stroke-width="2.5" />
|
||||
{{ $t('automations.test.no_new_items') }}
|
||||
</p>
|
||||
<details v-if="nodeRun.output && !isZeroFetchResult(nodeRun)" class="group" :class="{ 'mt-3': nodeRun.error }">
|
||||
<summary class="flex cursor-pointer items-center gap-1.5 text-xs font-black uppercase tracking-wider text-foreground/60 hover:text-foreground">
|
||||
<IconChevronRight class="size-4 transition-transform group-open:rotate-90" stroke-width="2.5" />
|
||||
{{ $t('automations.test.node_output') }}
|
||||
|
|
|
|||
|
|
@ -9,15 +9,9 @@ import InstagramSettings from '@/components/posts/editor/InstagramSettings.vue';
|
|||
import LinkedInSettings from '@/components/posts/editor/LinkedInSettings.vue';
|
||||
import PinterestSettings from '@/components/posts/editor/PinterestSettings.vue';
|
||||
import TikTokSettings from '@/components/posts/editor/TikTokSettings.vue';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { ContentType } from '@/types/content-type';
|
||||
import { Platform } from '@/types/platform';
|
||||
|
|
@ -50,9 +44,9 @@ interface GenerateAccount {
|
|||
|
||||
interface GenerateConfig {
|
||||
accounts: GenerateAccount[];
|
||||
target_slide_count?: number;
|
||||
target_slide_count: number;
|
||||
prompt_template: string;
|
||||
image_source: 'ai' | 'unsplash' | 'none';
|
||||
include_image: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
@ -142,9 +136,9 @@ const normalizeAccountsFromData = (): GenerateAccount[] => {
|
|||
|
||||
const local = ref<GenerateConfig>({
|
||||
accounts: normalizeAccountsFromData(),
|
||||
target_slide_count: props.data.target_slide_count as number | undefined,
|
||||
target_slide_count: (props.data.target_slide_count as number | undefined) ?? 5,
|
||||
prompt_template: (props.data.prompt_template as string) ?? '',
|
||||
image_source: (props.data.image_source as GenerateConfig['image_source']) ?? 'ai',
|
||||
include_image: (props.data.include_image as boolean | undefined) ?? true,
|
||||
});
|
||||
|
||||
watch(local, (val) => emit('update', val), { deep: true });
|
||||
|
|
@ -308,9 +302,28 @@ const hasCarouselCapableAccount = computed(() =>
|
|||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="hasCarouselCapableAccount">
|
||||
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.generate.target_slide_count') }}</label>
|
||||
<Input type="number" v-model.number="local.target_slide_count" placeholder="5" />
|
||||
<div v-if="hasCarouselCapableAccount" class="space-y-2">
|
||||
<Label class="text-sm font-bold">{{ $t('automations.config.generate.target_slide_count') }}</Label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
v-for="n in [2, 3, 4, 5, 6, 7, 8, 9, 10]"
|
||||
:key="n"
|
||||
type="button"
|
||||
size="icon"
|
||||
:variant="local.target_slide_count === n ? 'default' : 'outline'"
|
||||
@click="local.target_slide_count = n"
|
||||
>
|
||||
{{ n }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex items-start justify-between gap-3">
|
||||
<div class="space-y-0.5">
|
||||
<Label class="text-sm font-bold">{{ $t('automations.config.generate.include_image') }}</Label>
|
||||
<p class="text-xs text-foreground/60">{{ $t('automations.config.generate.include_image_hint') }}</p>
|
||||
</div>
|
||||
<Switch v-model="local.include_image" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
|
@ -318,20 +331,5 @@ const hasCarouselCapableAccount = computed(() =>
|
|||
<Textarea v-model="local.prompt_template" :rows="6" placeholder="Write a social media post about {{ trigger.title }}…" />
|
||||
<InputError :message="errors?.prompt_template" class="mt-1" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.generate.image_source') }}</label>
|
||||
<Select v-model="local.image_source">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue :placeholder="$t('automations.config.select_placeholder')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ai">{{ $t('automations.config.generate.image_sources.ai') }}</SelectItem>
|
||||
<SelectItem value="unsplash">{{ $t('automations.config.generate.image_sources.unsplash') }}</SelectItem>
|
||||
<SelectItem value="none">{{ $t('automations.config.generate.image_sources.none') }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InputError :message="errors?.image_source" class="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { IconPlus, IconTrash } from '@tabler/icons-vue';
|
||||
|
||||
import CodeEditor from '@/components/CodeEditor.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
|
|
@ -10,7 +14,6 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
type AuthType = 'none' | 'bearer' | 'basic' | 'api_key';
|
||||
|
|
@ -23,6 +26,7 @@ interface HttpRequestConfig {
|
|||
auth_username: string;
|
||||
auth_password: string;
|
||||
auth_header_name: string;
|
||||
headers: Record<string, string>;
|
||||
body_template: string;
|
||||
items_path: string;
|
||||
item_key_path: string;
|
||||
|
|
@ -43,16 +47,65 @@ const local = ref<HttpRequestConfig>({
|
|||
auth_username: (props.data.auth_username as string) ?? '',
|
||||
auth_password: (props.data.auth_password as string) ?? '',
|
||||
auth_header_name: (props.data.auth_header_name as string) ?? 'X-API-Key',
|
||||
headers: (props.data.headers as Record<string, string>) ?? {},
|
||||
body_template: (props.data.body_template as string) ?? '',
|
||||
items_path: (props.data.items_path as string) ?? '',
|
||||
item_key_path: (props.data.item_key_path as string) ?? '',
|
||||
item_date_path: (props.data.item_date_path as string) ?? '',
|
||||
});
|
||||
|
||||
interface HeaderRow {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const headerRows = ref<HeaderRow[]>(
|
||||
Object.entries((props.data.headers as Record<string, string>) ?? {}).map(([name, value]) => ({
|
||||
name,
|
||||
value: String(value),
|
||||
})),
|
||||
);
|
||||
|
||||
watch(
|
||||
headerRows,
|
||||
(rows) => {
|
||||
const headers: Record<string, string> = {};
|
||||
rows.forEach((row) => {
|
||||
const name = row.name.trim();
|
||||
if (name !== '') {
|
||||
headers[name] = row.value;
|
||||
}
|
||||
});
|
||||
local.value.headers = headers;
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
const addHeader = (): void => {
|
||||
headerRows.value.push({ name: '', value: '' });
|
||||
};
|
||||
|
||||
const removeHeader = (index: number): void => {
|
||||
headerRows.value.splice(index, 1);
|
||||
};
|
||||
|
||||
watch(local, (val) => emit('update', val), { deep: true });
|
||||
|
||||
const supportsBody = computed(() => ['POST', 'PUT', 'PATCH'].includes(local.value.method));
|
||||
const isPollingMode = computed(() => local.value.items_path.trim() !== '');
|
||||
|
||||
const isBodyJsonInvalid = computed(() => {
|
||||
const value = local.value.body_template.trim();
|
||||
if (value === '') {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
JSON.parse(value);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -129,10 +182,46 @@ const isPollingMode = computed(() => local.value.items_path.trim() !== '');
|
|||
|
||||
<div v-if="supportsBody">
|
||||
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.http_request.body_template') }}</label>
|
||||
<Textarea v-model="local.body_template" :rows="5" placeholder='{"id": "{{ trigger.post.id }}"}' />
|
||||
<div class="h-36">
|
||||
<CodeEditor v-model="local.body_template" language="json" placeholder='{"id": "{{ trigger.post.id }}"}' />
|
||||
</div>
|
||||
<p v-if="isBodyJsonInvalid" class="mt-1 text-xs text-amber-600 dark:text-amber-500">
|
||||
{{ $t('automations.config.invalid_json') }}
|
||||
</p>
|
||||
<InputError :message="errors?.body_template" class="mt-1" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.http_request.headers') }}</label>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(row, index) in headerRows" :key="index" class="flex items-center gap-2">
|
||||
<Input
|
||||
v-model="row.name"
|
||||
class="flex-1"
|
||||
:placeholder="$t('automations.config.http_request.header_name')"
|
||||
/>
|
||||
<Input
|
||||
v-model="row.value"
|
||||
class="flex-1"
|
||||
:placeholder="$t('automations.config.http_request.header_value')"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="shrink-0 text-foreground/60 hover:text-destructive"
|
||||
@click="removeHeader(index)"
|
||||
>
|
||||
<IconTrash class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" class="mt-2" @click="addHeader">
|
||||
<IconPlus class="size-4" />
|
||||
{{ $t('automations.config.http_request.add_header') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="border-t-2 border-foreground/10 pt-4">
|
||||
<p class="mb-2 text-[11px] font-black uppercase tracking-widest text-foreground/60">
|
||||
{{ $t('automations.config.http_request.polling_section') }}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import CodeEditor from '@/components/CodeEditor.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
|
|
@ -10,7 +11,6 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
interface WebhookConfig {
|
||||
url: string;
|
||||
|
|
@ -33,6 +33,19 @@ const local = ref<WebhookConfig>({
|
|||
});
|
||||
|
||||
watch(local, (val) => emit('update', val), { deep: true });
|
||||
|
||||
const isPayloadJsonInvalid = computed(() => {
|
||||
const value = local.value.payload_template.trim();
|
||||
if (value === '') {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
JSON.parse(value);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -62,7 +75,12 @@ watch(local, (val) => emit('update', val), { deep: true });
|
|||
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.webhook.payload_template') }}</label>
|
||||
<Textarea v-model="local.payload_template" :rows="6" placeholder='{"content": "{{ post.content }}"}' />
|
||||
<div class="h-40">
|
||||
<CodeEditor v-model="local.payload_template" language="json" placeholder='{"content": "{{ post.content }}"}' />
|
||||
</div>
|
||||
<p v-if="isPayloadJsonInvalid" class="mt-1 text-xs text-amber-600 dark:text-amber-500">
|
||||
{{ $t('automations.config.invalid_json') }}
|
||||
</p>
|
||||
<InputError :message="errors?.payload_template" class="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -36,6 +36,21 @@ const summary = computed(() => {
|
|||
{{ summary }}
|
||||
</div>
|
||||
<Handle type="target" :position="Position.Left" class="!bg-amber-500" />
|
||||
<Handle type="source" :position="Position.Right" class="!bg-amber-500" />
|
||||
<Handle
|
||||
id="default"
|
||||
type="source"
|
||||
:position="Position.Right"
|
||||
class="!bg-emerald-500"
|
||||
:style="{ top: '35%' }"
|
||||
/>
|
||||
<span class="pointer-events-none absolute left-full top-[35%] z-10 ml-3 -translate-y-1/2 whitespace-nowrap rounded bg-background px-1.5 text-[10px] font-bold uppercase tracking-wider text-emerald-700">{{ $t('automations.nodes.handles.items') }}</span>
|
||||
<Handle
|
||||
id="no_items"
|
||||
type="source"
|
||||
:position="Position.Right"
|
||||
class="!bg-rose-500"
|
||||
:style="{ top: '75%' }"
|
||||
/>
|
||||
<span class="pointer-events-none absolute left-full top-[75%] z-10 ml-3 -translate-y-1/2 whitespace-nowrap rounded bg-background px-1.5 text-[10px] font-bold uppercase tracking-wider text-rose-700">{{ $t('automations.nodes.handles.no_items') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -40,6 +40,21 @@ const summary = computed(() => {
|
|||
{{ summary }}
|
||||
</div>
|
||||
<Handle type="target" :position="Position.Left" class="!bg-slate-500" />
|
||||
<Handle type="source" :position="Position.Right" class="!bg-slate-500" />
|
||||
<Handle
|
||||
id="default"
|
||||
type="source"
|
||||
:position="Position.Right"
|
||||
class="!bg-emerald-500"
|
||||
:style="{ top: '35%' }"
|
||||
/>
|
||||
<span class="pointer-events-none absolute left-full top-[35%] z-10 ml-3 -translate-y-1/2 whitespace-nowrap rounded bg-background px-1.5 text-[10px] font-bold uppercase tracking-wider text-emerald-700">{{ $t('automations.nodes.handles.items') }}</span>
|
||||
<Handle
|
||||
id="no_items"
|
||||
type="source"
|
||||
:position="Position.Right"
|
||||
class="!bg-rose-500"
|
||||
:style="{ top: '75%' }"
|
||||
/>
|
||||
<span class="pointer-events-none absolute left-full top-[75%] z-10 ml-3 -translate-y-1/2 whitespace-nowrap rounded bg-background px-1.5 text-[10px] font-bold uppercase tracking-wider text-rose-700">{{ $t('automations.nodes.handles.no_items') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import {
|
|||
IconWorld,
|
||||
IconX,
|
||||
} from '@tabler/icons-vue';
|
||||
import { computed, markRaw, nextTick, ref, watch } from 'vue';
|
||||
import { computed, markRaw, ref, watch } from 'vue';
|
||||
import {
|
||||
ConnectionMode,
|
||||
MarkerType,
|
||||
|
|
@ -34,7 +34,6 @@ import '@vue-flow/core/dist/theme-default.css';
|
|||
import '@vue-flow/controls/dist/style.css';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import {
|
||||
show as showAutomation,
|
||||
|
|
@ -190,6 +189,7 @@ const {
|
|||
} = useVueFlow();
|
||||
|
||||
onNodeClick(({ node }) => {
|
||||
isTestPanelOpen.value = false;
|
||||
selectedNodeId.value = node.id;
|
||||
});
|
||||
|
||||
|
|
@ -269,7 +269,7 @@ const defaultConfigFor = (type: string): Record<string, unknown> => {
|
|||
schedule_minute: 0,
|
||||
schedule_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
};
|
||||
case NodeType.Generate: return { accounts: [], format: 'single', prompt_template: '', image_source: 'ai' };
|
||||
case NodeType.Generate: return { accounts: [], prompt_template: '', include_image: true, target_slide_count: 5 };
|
||||
case NodeType.Delay: return { duration: 1, unit: 'hours' };
|
||||
case NodeType.Condition: return { field: '', operator: 'contains', value: '' };
|
||||
case NodeType.Publish: return { mode: 'now', scheduled_offset: 60 };
|
||||
|
|
@ -343,15 +343,9 @@ const deleteSelectedNode = () => {
|
|||
|
||||
const isSaving = ref(false);
|
||||
const isTestPanelOpen = ref(false);
|
||||
const testWithRealData = ref(false);
|
||||
const testPanelRef = ref<InstanceType<typeof TestRunPanel> | null>(null);
|
||||
|
||||
const handleTestClick = async () => {
|
||||
const handleTestClick = () => {
|
||||
isTestPanelOpen.value = true;
|
||||
// Wait one tick when opening for the first time so v-if mounts the panel
|
||||
// and the ref resolves before we invoke start().
|
||||
await nextTick();
|
||||
testPanelRef.value?.start();
|
||||
};
|
||||
|
||||
const sanitizeNodes = (list: Node[]) =>
|
||||
|
|
@ -374,27 +368,32 @@ const sanitizeEdges = (list: Edge[]) =>
|
|||
return edge;
|
||||
});
|
||||
|
||||
const save = () => {
|
||||
if (isSaving.value) return;
|
||||
isSaving.value = true;
|
||||
router.put(
|
||||
updateAutomation.url(props.automation.id),
|
||||
{
|
||||
name: name.value.trim() || props.automation.name,
|
||||
nodes: sanitizeNodes(nodes.value),
|
||||
connections: sanitizeEdges(edges.value),
|
||||
},
|
||||
{
|
||||
preserveScroll: true,
|
||||
onFinish: () => { isSaving.value = false; },
|
||||
onSuccess: () => toast.success(trans('automations.form.save_success')),
|
||||
onError: (errors: Record<string, string>) => {
|
||||
const msg = (errors as any).message ?? trans('automations.form.save_error_fallback');
|
||||
toast.error(msg);
|
||||
const save = (): Promise<boolean> =>
|
||||
new Promise((resolve) => {
|
||||
if (isSaving.value) return resolve(false);
|
||||
isSaving.value = true;
|
||||
router.put(
|
||||
updateAutomation.url(props.automation.id),
|
||||
{
|
||||
name: name.value.trim() || props.automation.name,
|
||||
nodes: sanitizeNodes(nodes.value),
|
||||
connections: sanitizeEdges(edges.value),
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
{
|
||||
preserveScroll: true,
|
||||
onFinish: () => { isSaving.value = false; },
|
||||
onSuccess: () => {
|
||||
toast.success(trans('automations.form.save_success'));
|
||||
resolve(true);
|
||||
},
|
||||
onError: (errors: Record<string, string>) => {
|
||||
const msg = (errors as any).message ?? trans('automations.form.save_error_fallback');
|
||||
toast.error(msg);
|
||||
resolve(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const closePanel = () => {
|
||||
selectedNodeId.value = null;
|
||||
|
|
@ -451,10 +450,6 @@ const defaultEdgeOptions = {
|
|||
class="w-72 rounded-md border-2 border-transparent bg-transparent px-3 py-1 text-center text-sm font-semibold text-foreground transition-colors hover:border-foreground/15 focus:border-foreground focus:bg-background focus:outline-none"
|
||||
/>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<label class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-xs font-semibold text-foreground/70 hover:bg-foreground/5">
|
||||
<Checkbox v-model="testWithRealData" />
|
||||
{{ $t('automations.test.with_real_data') }}
|
||||
</label>
|
||||
<Button variant="outline" size="sm" @click="handleTestClick">{{ $t('automations.actions.test') }}</Button>
|
||||
<Button size="sm" @click="save" :disabled="isSaving">{{ $t('automations.actions.save') }}</Button>
|
||||
</div>
|
||||
|
|
@ -522,10 +517,9 @@ const defaultEdgeOptions = {
|
|||
|
||||
<TestRunPanel
|
||||
v-if="isTestPanelOpen"
|
||||
ref="testPanelRef"
|
||||
v-model:open="isTestPanelOpen"
|
||||
:automation-id="automation.id"
|
||||
:with-real-data="testWithRealData"
|
||||
:before-run="save"
|
||||
/>
|
||||
|
||||
<aside
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
|
||||
use App\Console\Commands\Automation\FireScheduleTriggers;
|
||||
use App\Console\Commands\Automation\ProcessAutomationDelays;
|
||||
use App\Console\Commands\Automation\PruneDryRunAutomationRuns;
|
||||
use App\Console\Commands\Automation\RecoverStuckAutomationRuns;
|
||||
use App\Console\Commands\CheckSocialConnections;
|
||||
use App\Console\Commands\ProcessScheduledPosts;
|
||||
use App\Console\Commands\RecoverStuckPosts;
|
||||
|
|
@ -16,3 +18,5 @@
|
|||
Schedule::command(RecoverStuckPosts::class)->everyThirtyMinutes()->withoutOverlapping()->onOneServer();
|
||||
Schedule::command(FireScheduleTriggers::class)->everyMinute()->withoutOverlapping()->onOneServer();
|
||||
Schedule::command(ProcessAutomationDelays::class)->everyMinute()->withoutOverlapping()->onOneServer();
|
||||
Schedule::command(RecoverStuckAutomationRuns::class)->everyFiveMinutes()->withoutOverlapping()->onOneServer();
|
||||
Schedule::command(PruneDryRunAutomationRuns::class)->everyTenMinutes()->withoutOverlapping()->onOneServer();
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
it('wakes runs whose next_action_at is in the past', function () {
|
||||
Queue::fake();
|
||||
|
||||
$automation = Automation::factory()->create([
|
||||
$automation = Automation::factory()->active()->create([
|
||||
'nodes' => [
|
||||
['id' => 't', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => []],
|
||||
['id' => 'g', 'type' => 'generate', 'position' => ['x' => 1, 'y' => 0], 'data' => []],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AutomationNodeRun;
|
||||
use App\Models\AutomationRun;
|
||||
|
||||
it('prunes terminal dry-run rows older than the grace window', function () {
|
||||
$old = AutomationRun::factory()->create(['is_dry_run' => true, 'finished_at' => now()->subHour()]);
|
||||
$recent = AutomationRun::factory()->create(['is_dry_run' => true, 'finished_at' => now()->subMinute()]);
|
||||
$realOld = AutomationRun::factory()->create(['is_dry_run' => false, 'finished_at' => now()->subHour()]);
|
||||
|
||||
$this->artisan('automation:prune-dry-runs')->assertExitCode(0);
|
||||
|
||||
expect(AutomationRun::find($old->id))->toBeNull();
|
||||
expect(AutomationRun::find($recent->id))->not->toBeNull();
|
||||
expect(AutomationRun::find($realOld->id))->not->toBeNull();
|
||||
});
|
||||
|
||||
it('leaves dry-run rows that have not finished yet', function () {
|
||||
$unfinished = AutomationRun::factory()->create(['is_dry_run' => true, 'finished_at' => null, 'updated_at' => now()->subHour()]);
|
||||
|
||||
$this->artisan('automation:prune-dry-runs')->assertExitCode(0);
|
||||
|
||||
expect(AutomationRun::find($unfinished->id))->not->toBeNull();
|
||||
});
|
||||
|
||||
it('deletes the node runs belonging to pruned dry runs', function () {
|
||||
$old = AutomationRun::factory()->create(['is_dry_run' => true, 'finished_at' => now()->subHour()]);
|
||||
$nodeRun = AutomationNodeRun::factory()->create(['run_id' => $old->id]);
|
||||
|
||||
$this->artisan('automation:prune-dry-runs')->assertExitCode(0);
|
||||
|
||||
expect(AutomationRun::find($old->id))->toBeNull();
|
||||
expect(AutomationNodeRun::find($nodeRun->id))->toBeNull();
|
||||
});
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\Automation\Run\Status as RunStatus;
|
||||
use App\Models\AutomationRun;
|
||||
|
||||
it('fails runs stuck running past the threshold', function () {
|
||||
$stuck = AutomationRun::factory()->create(['status' => RunStatus::Running, 'started_at' => now()->subHours(3), 'updated_at' => now()->subHours(3)]);
|
||||
|
||||
$this->artisan('automation:recover-stuck-runs')->assertExitCode(0);
|
||||
|
||||
$stuck->refresh();
|
||||
expect($stuck->status)->toBe(RunStatus::Failed);
|
||||
expect(data_get($stuck->error, 'reason'))->toBe('stuck');
|
||||
expect($stuck->finished_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('fails runs stuck pending past the threshold', function () {
|
||||
$stuck = AutomationRun::factory()->create(['status' => RunStatus::Pending, 'updated_at' => now()->subHours(3)]);
|
||||
|
||||
$this->artisan('automation:recover-stuck-runs')->assertExitCode(0);
|
||||
|
||||
$stuck->refresh();
|
||||
expect($stuck->status)->toBe(RunStatus::Failed);
|
||||
expect(data_get($stuck->error, 'reason'))->toBe('stuck');
|
||||
expect($stuck->finished_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('leaves recent running runs alone', function () {
|
||||
$recent = AutomationRun::factory()->create(['status' => RunStatus::Running, 'started_at' => now()->subMinute(), 'updated_at' => now()->subMinute()]);
|
||||
|
||||
$this->artisan('automation:recover-stuck-runs');
|
||||
|
||||
expect($recent->fresh()->status)->toBe(RunStatus::Running);
|
||||
});
|
||||
|
||||
it('does not touch waiting runs', function () {
|
||||
$waiting = AutomationRun::factory()->create(['status' => RunStatus::Waiting, 'updated_at' => now()->subDay(), 'next_action_at' => now()->addDay()]);
|
||||
|
||||
$this->artisan('automation:recover-stuck-runs');
|
||||
|
||||
expect($waiting->fresh()->status)->toBe(RunStatus::Waiting);
|
||||
});
|
||||
|
|
@ -32,6 +32,15 @@
|
|||
</channel></rss>
|
||||
XML;
|
||||
|
||||
const FETCH_RSS_THREE_NEW = <<<'XML'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"><channel>
|
||||
<item><title>New1</title><link>https://blog.example.com/x</link><guid>x</guid><pubDate>Sun, 01 Jun 2025 12:00:00 +0000</pubDate></item>
|
||||
<item><title>New2</title><link>https://blog.example.com/y</link><guid>y</guid><pubDate>Mon, 02 Jun 2025 12:00:00 +0000</pubDate></item>
|
||||
<item><title>New3</title><link>https://blog.example.com/z</link><guid>z</guid><pubDate>Tue, 03 Jun 2025 12:00:00 +0000</pubDate></item>
|
||||
</channel></rss>
|
||||
XML;
|
||||
|
||||
beforeEach(fn () => Bus::fake());
|
||||
|
||||
it('first execution dispatches nothing and stores watermark from newest item', function () {
|
||||
|
|
@ -100,6 +109,121 @@
|
|||
Bus::assertDispatchedTimes(ProcessAutomationNode::class, 1);
|
||||
});
|
||||
|
||||
it('spawns sibling runs down the item edge for each remaining new item', function () {
|
||||
Carbon::setTestNow('2026-01-15 10:00:00');
|
||||
Http::fake(['blog.example.com/*' => Http::response(FETCH_RSS_THREE_NEW, 200)]);
|
||||
|
||||
$automation = Automation::factory()->active()->create([
|
||||
'nodes' => [
|
||||
['id' => 'trigger_1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule']],
|
||||
['id' => 'fetch_1', 'type' => 'fetch_rss', 'position' => ['x' => 200, 'y' => 0], 'data' => ['feed_url' => 'https://blog.example.com/feed']],
|
||||
['id' => 'generate_1', 'type' => 'generate', 'position' => ['x' => 400, 'y' => 0], 'data' => []],
|
||||
],
|
||||
'connections' => [
|
||||
['id' => 'e1', 'source' => 'trigger_1', 'target' => 'fetch_1'],
|
||||
['id' => 'e2', 'source' => 'fetch_1', 'source_handle' => 'default', 'target' => 'generate_1'],
|
||||
],
|
||||
]);
|
||||
|
||||
// Watermark predates all three items, so every item is "new".
|
||||
AutomationNodeState::create([
|
||||
'automation_id' => $automation->id,
|
||||
'node_id' => 'fetch_1',
|
||||
'data' => ['last_item_date' => '2025-01-01T00:00:00+00:00'],
|
||||
]);
|
||||
|
||||
$run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'fetch_1']);
|
||||
|
||||
$result = app(RunFetchRssNode::class)($run, ['feed_url' => 'https://blog.example.com/feed']);
|
||||
|
||||
// Current run handles item #1 (oldest); siblings handle #2 and #3.
|
||||
expect($result->status)->toBe(NodeRunStatus::Completed);
|
||||
expect($result->output['fetched']['key'])->toBe('x');
|
||||
expect($result->output['fetch']['count'])->toBe(3);
|
||||
expect($result->output['fetch']['spawned'])->toBe(2);
|
||||
|
||||
Bus::assertDispatchedTimes(ProcessAutomationNode::class, 2);
|
||||
Bus::assertDispatched(
|
||||
ProcessAutomationNode::class,
|
||||
fn (ProcessAutomationNode $job) => $job->nodeId === 'generate_1',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not persist the production watermark on a manual real-data test', function () {
|
||||
Carbon::setTestNow('2026-01-15 10:00:00');
|
||||
Http::fake(['blog.example.com/*' => Http::response(FETCH_RSS_MIXED, 200)]);
|
||||
|
||||
$automation = Automation::factory()->active()->create([
|
||||
'nodes' => [
|
||||
['id' => 'trigger_1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule']],
|
||||
['id' => 'fetch_1', 'type' => 'fetch_rss', 'position' => ['x' => 200, 'y' => 0], 'data' => ['feed_url' => 'https://blog.example.com/feed']],
|
||||
['id' => 'end_1', 'type' => 'end', 'position' => ['x' => 400, 'y' => 0], 'data' => []],
|
||||
],
|
||||
'connections' => [
|
||||
['id' => 'e1', 'source' => 'trigger_1', 'target' => 'fetch_1'],
|
||||
['id' => 'e2', 'source' => 'fetch_1', 'target' => 'end_1'],
|
||||
],
|
||||
]);
|
||||
|
||||
AutomationNodeState::create([
|
||||
'automation_id' => $automation->id,
|
||||
'node_id' => 'fetch_1',
|
||||
'data' => ['last_item_date' => '2025-01-01T00:00:00+00:00'],
|
||||
]);
|
||||
|
||||
$run = AutomationRun::factory()->for($automation)->create([
|
||||
'current_node_id' => 'fetch_1',
|
||||
'is_manual' => true,
|
||||
'is_dry_run' => false,
|
||||
]);
|
||||
|
||||
$result = app(RunFetchRssNode::class)($run, ['feed_url' => 'https://blog.example.com/feed']);
|
||||
|
||||
// Reads the production watermark, so it finds the newer items.
|
||||
expect($result->status)->toBe(NodeRunStatus::Completed);
|
||||
expect($result->output['fetch']['count'])->toBeGreaterThan(0);
|
||||
|
||||
// The persisted watermark stays put — a second identical manual test would
|
||||
// surface the same items.
|
||||
$state = AutomationNodeState::for($automation->id, 'fetch_1');
|
||||
expect($state->data['last_item_date'])->toBe('2025-01-01T00:00:00+00:00');
|
||||
});
|
||||
|
||||
it('advances the production watermark on a non-manual real-data run', function () {
|
||||
Carbon::setTestNow('2026-01-15 10:00:00');
|
||||
Http::fake(['blog.example.com/*' => Http::response(FETCH_RSS_MIXED, 200)]);
|
||||
|
||||
$automation = Automation::factory()->active()->create([
|
||||
'nodes' => [
|
||||
['id' => 'trigger_1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule']],
|
||||
['id' => 'fetch_1', 'type' => 'fetch_rss', 'position' => ['x' => 200, 'y' => 0], 'data' => ['feed_url' => 'https://blog.example.com/feed']],
|
||||
['id' => 'end_1', 'type' => 'end', 'position' => ['x' => 400, 'y' => 0], 'data' => []],
|
||||
],
|
||||
'connections' => [
|
||||
['id' => 'e1', 'source' => 'trigger_1', 'target' => 'fetch_1'],
|
||||
['id' => 'e2', 'source' => 'fetch_1', 'target' => 'end_1'],
|
||||
],
|
||||
]);
|
||||
|
||||
AutomationNodeState::create([
|
||||
'automation_id' => $automation->id,
|
||||
'node_id' => 'fetch_1',
|
||||
'data' => ['last_item_date' => '2025-01-01T00:00:00+00:00'],
|
||||
]);
|
||||
|
||||
$run = AutomationRun::factory()->for($automation)->create([
|
||||
'current_node_id' => 'fetch_1',
|
||||
'is_manual' => false,
|
||||
'is_dry_run' => false,
|
||||
]);
|
||||
|
||||
app(RunFetchRssNode::class)($run, ['feed_url' => 'https://blog.example.com/feed']);
|
||||
|
||||
$state = AutomationNodeState::for($automation->id, 'fetch_1');
|
||||
expect(CarbonImmutable::parse($state->data['last_item_date'])->toIso8601String())
|
||||
->toBe(CarbonImmutable::parse('Mon, 15 Jun 2025 12:00:00 +0000')->toIso8601String());
|
||||
});
|
||||
|
||||
it('fails when feed_url is missing', function () {
|
||||
$automation = Automation::factory()->active()->create();
|
||||
$run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'fetch_1']);
|
||||
|
|
|
|||
|
|
@ -7,10 +7,13 @@
|
|||
use App\Ai\Agents\PostContentHumanizer;
|
||||
use App\Enums\Post\Status as PostStatus;
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use App\Models\Automation;
|
||||
use App\Models\AutomationRun;
|
||||
use App\Models\Media;
|
||||
use App\Models\Post;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Image\PostImagePipeline;
|
||||
|
||||
it('creates a draft post and writes generated output to run context', function () {
|
||||
PostContentGenerator::fake([
|
||||
|
|
@ -133,3 +136,168 @@
|
|||
expect($format)->toBe('single');
|
||||
expect($slideCount)->toBe(1);
|
||||
});
|
||||
|
||||
it('attaches a generated image to a single-format post', function () {
|
||||
PostContentGenerator::fake([
|
||||
['content' => 'Single post', 'image_title' => 'Title', 'image_body' => 'Body', 'image_keywords' => ['kw']],
|
||||
]);
|
||||
|
||||
PostContentHumanizer::fake([
|
||||
['content' => 'Single post', 'image_title' => 'Title', 'image_body' => 'Body'],
|
||||
]);
|
||||
|
||||
$workspace = Workspace::factory()->create();
|
||||
$account = SocialAccount::factory()->for($workspace)->create(['platform' => 'x']);
|
||||
|
||||
$mediaItem = ['id' => 1, 'path' => 'ai-images/x.webp', 'url' => 'http://x', 'type' => 'image', 'mime_type' => 'image/webp', 'source' => 'ai', 'source_meta' => []];
|
||||
|
||||
$pipeline = Mockery::mock(PostImagePipeline::class);
|
||||
$pipeline->shouldReceive('forSingle')->once()->andReturn([$mediaItem]);
|
||||
app()->instance(PostImagePipeline::class, $pipeline);
|
||||
|
||||
$automation = Automation::factory()->for($workspace)->create();
|
||||
$run = AutomationRun::factory()->for($automation)->create([
|
||||
'context' => ['trigger' => ['title' => 'test']],
|
||||
]);
|
||||
|
||||
$result = app(RunGenerateNode::class)($run, [
|
||||
'accounts' => [
|
||||
['social_account_id' => (string) $account->id, 'content_type' => ContentType::XPost->value, 'meta' => []],
|
||||
],
|
||||
'prompt_template' => 'Write about {{ trigger.title }}',
|
||||
'include_image' => true,
|
||||
]);
|
||||
|
||||
expect($result->status->value)->toBe('completed');
|
||||
|
||||
$post = Post::find($result->output['generated']['post_id']);
|
||||
expect($post)->not->toBeNull();
|
||||
expect($post->media)->toHaveCount(1);
|
||||
});
|
||||
|
||||
it('attaches one image per slide for carousel', function () {
|
||||
PostContentGenerator::fake([
|
||||
['caption' => 'Carousel caption', 'slides' => [
|
||||
['title' => 'S1', 'body' => 'B1', 'image_keywords' => ['a']],
|
||||
['title' => 'S2', 'body' => 'B2', 'image_keywords' => ['b']],
|
||||
['title' => 'S3', 'body' => 'B3', 'image_keywords' => ['c']],
|
||||
]],
|
||||
]);
|
||||
|
||||
PostContentHumanizer::fake([
|
||||
['caption' => 'Carousel caption', 'slides' => [
|
||||
['title' => 'S1', 'body' => 'B1'],
|
||||
['title' => 'S2', 'body' => 'B2'],
|
||||
['title' => 'S3', 'body' => 'B3'],
|
||||
]],
|
||||
]);
|
||||
|
||||
$workspace = Workspace::factory()->create();
|
||||
$account = SocialAccount::factory()->for($workspace)->create(['platform' => 'instagram']);
|
||||
|
||||
$item = fn (int $id) => ['id' => $id, 'path' => "ai-images/{$id}.webp", 'url' => "http://{$id}", 'type' => 'image', 'mime_type' => 'image/webp', 'source' => 'ai', 'source_meta' => []];
|
||||
|
||||
$pipeline = Mockery::mock(PostImagePipeline::class);
|
||||
$pipeline->shouldReceive('forCarousel')->once()->andReturn([$item(1), $item(2), $item(3)]);
|
||||
app()->instance(PostImagePipeline::class, $pipeline);
|
||||
|
||||
$automation = Automation::factory()->for($workspace)->create();
|
||||
$run = AutomationRun::factory()->for($automation)->create([
|
||||
'context' => ['trigger' => ['title' => 'test']],
|
||||
]);
|
||||
|
||||
$result = app(RunGenerateNode::class)($run, [
|
||||
'accounts' => [
|
||||
['social_account_id' => (string) $account->id, 'content_type' => ContentType::InstagramFeed->value, 'meta' => []],
|
||||
],
|
||||
'prompt_template' => 'Write about {{ trigger.title }}',
|
||||
'target_slide_count' => 3,
|
||||
]);
|
||||
|
||||
expect($result->status->value)->toBe('completed');
|
||||
|
||||
$post = Post::find($result->output['generated']['post_id']);
|
||||
expect($post)->not->toBeNull();
|
||||
expect($post->media)->toHaveCount(3);
|
||||
});
|
||||
|
||||
it('skips images when include_image is false', function () {
|
||||
PostContentGenerator::fake([
|
||||
['content' => 'No image post', 'image_title' => 'Title', 'image_body' => 'Body', 'image_keywords' => ['kw']],
|
||||
]);
|
||||
|
||||
PostContentHumanizer::fake([
|
||||
['content' => 'No image post', 'image_title' => 'Title', 'image_body' => 'Body'],
|
||||
]);
|
||||
|
||||
$workspace = Workspace::factory()->create();
|
||||
$account = SocialAccount::factory()->for($workspace)->create(['platform' => 'x']);
|
||||
|
||||
$pipeline = Mockery::mock(PostImagePipeline::class);
|
||||
$pipeline->shouldNotReceive('forSingle');
|
||||
$pipeline->shouldNotReceive('forCarousel');
|
||||
app()->instance(PostImagePipeline::class, $pipeline);
|
||||
|
||||
$automation = Automation::factory()->for($workspace)->create();
|
||||
$run = AutomationRun::factory()->for($automation)->create([
|
||||
'context' => ['trigger' => ['title' => 'test']],
|
||||
]);
|
||||
|
||||
$result = app(RunGenerateNode::class)($run, [
|
||||
'accounts' => [
|
||||
['social_account_id' => (string) $account->id, 'content_type' => ContentType::XPost->value, 'meta' => []],
|
||||
],
|
||||
'prompt_template' => 'Write about {{ trigger.title }}',
|
||||
'include_image' => false,
|
||||
]);
|
||||
|
||||
expect($result->status->value)->toBe('completed');
|
||||
|
||||
$post = Post::find($result->output['generated']['post_id']);
|
||||
expect($post)->not->toBeNull();
|
||||
expect($post->media)->toHaveCount(0);
|
||||
});
|
||||
|
||||
it('does not generate images or persist on a dry run', function () {
|
||||
PostContentGenerator::fake([
|
||||
['content' => 'Dry run post', 'image_title' => 'Title', 'image_body' => 'Body', 'image_keywords' => ['kw']],
|
||||
]);
|
||||
|
||||
PostContentHumanizer::fake([
|
||||
['content' => 'Dry run post', 'image_title' => 'Title', 'image_body' => 'Body'],
|
||||
]);
|
||||
|
||||
$workspace = Workspace::factory()->create();
|
||||
$account = SocialAccount::factory()->for($workspace)->create(['platform' => 'x']);
|
||||
|
||||
$pipeline = Mockery::mock(PostImagePipeline::class);
|
||||
$pipeline->shouldNotReceive('forSingle');
|
||||
$pipeline->shouldNotReceive('forCarousel');
|
||||
app()->instance(PostImagePipeline::class, $pipeline);
|
||||
|
||||
$automation = Automation::factory()->for($workspace)->create();
|
||||
$run = AutomationRun::factory()->for($automation)->create([
|
||||
'is_dry_run' => true,
|
||||
'context' => ['trigger' => ['title' => 'test']],
|
||||
]);
|
||||
|
||||
$result = app(RunGenerateNode::class)($run, [
|
||||
'accounts' => [
|
||||
['social_account_id' => (string) $account->id, 'content_type' => ContentType::XPost->value, 'meta' => []],
|
||||
],
|
||||
'prompt_template' => 'Write about {{ trigger.title }}',
|
||||
'include_image' => true,
|
||||
]);
|
||||
|
||||
expect($result->status->value)->toBe('completed');
|
||||
expect($result->output['generated']['dry_run'])->toBeTrue();
|
||||
expect($result->output['generated']['content'])->toBe('Dry run post');
|
||||
expect($result->output['generated']['image_count'])->toBe(1);
|
||||
expect($result->output['generated']['post_id'])->toBeNull();
|
||||
|
||||
expect(Post::count())->toBe(0);
|
||||
expect(Media::count())->toBe(0);
|
||||
|
||||
$run->refresh();
|
||||
expect($run->generated_post_id)->toBeNull();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -134,6 +134,38 @@
|
|||
Http::assertSent(fn ($request) => $request->method() === 'POST' && $request['post_id'] === 'post-42');
|
||||
});
|
||||
|
||||
it('sends the branded user-agent header', function () {
|
||||
Http::fake(['api.example.com/*' => 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://api.example.com/me',
|
||||
'method' => 'GET',
|
||||
'auth_type' => 'none',
|
||||
'headers' => ['User-Agent' => 'user-supplied-agent'],
|
||||
]);
|
||||
|
||||
Http::assertSent(fn ($request) => $request->hasHeader('User-Agent', config('trypost.user_agent')));
|
||||
});
|
||||
|
||||
it('sends custom headers configured in the editor', function () {
|
||||
Http::fake(['api.example.com/*' => 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://api.example.com/me',
|
||||
'method' => 'GET',
|
||||
'auth_type' => 'none',
|
||||
'headers' => ['X-Custom' => 'v'],
|
||||
]);
|
||||
|
||||
Http::assertSent(fn ($request) => $request->hasHeader('X-Custom', 'v'));
|
||||
});
|
||||
|
||||
it('fails when url is missing', function () {
|
||||
$automation = Automation::factory()->active()->create();
|
||||
$run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'http_1']);
|
||||
|
|
|
|||
|
|
@ -27,6 +27,23 @@
|
|||
Http::assertSent(fn ($request) => $request['title'] === 'Hello' && $request['post_url'] === 'https://t.it/p/1');
|
||||
});
|
||||
|
||||
it('sends the branded user-agent header', function () {
|
||||
Http::fake([
|
||||
'hooks.example.com/*' => Http::response(['ok' => true], 200),
|
||||
]);
|
||||
|
||||
$run = AutomationRun::factory()->create();
|
||||
|
||||
app(RunWebhookNode::class)($run, [
|
||||
'url' => 'https://hooks.example.com/test',
|
||||
'method' => 'POST',
|
||||
'headers' => ['User-Agent' => 'user-supplied-agent'],
|
||||
'payload_template' => '{}',
|
||||
]);
|
||||
|
||||
Http::assertSent(fn ($request) => $request->hasHeader('User-Agent', config('trypost.user_agent')));
|
||||
});
|
||||
|
||||
it('fails on 5xx response', function () {
|
||||
Http::fake(['hooks.example.com/*' => Http::response('err', 500)]);
|
||||
|
||||
|
|
@ -41,6 +58,22 @@
|
|||
expect($result->status)->toBe(Status::Failed);
|
||||
});
|
||||
|
||||
it('fails on malformed payload json instead of silently sending an empty body', function () {
|
||||
Http::fake(['hooks.example.com/*' => Http::response(['ok' => true], 200)]);
|
||||
|
||||
$run = AutomationRun::factory()->create();
|
||||
|
||||
$result = app(RunWebhookNode::class)($run, [
|
||||
'url' => 'https://hooks.example.com/test',
|
||||
'method' => 'POST',
|
||||
'payload_template' => '{ "a": }',
|
||||
]);
|
||||
|
||||
expect($result->status)->toBe(Status::Failed);
|
||||
expect($result->error['reason'])->toBe('invalid_payload_json');
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('treats 4xx responses as completed (only 5xx fails)', function () {
|
||||
Http::fake(['hooks.example.com/*' => Http::response('not found', 404)]);
|
||||
|
||||
|
|
|
|||
75
tests/Feature/Automation/PauseHaltsRunsTest.php
Normal file
75
tests/Feature/Automation/PauseHaltsRunsTest.php
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Actions\Automation\Run\AdvanceAutomationRun;
|
||||
use App\Enums\Automation\Run\Status as RunStatus;
|
||||
use App\Jobs\Automation\ProcessAutomationNode;
|
||||
use App\Models\Automation;
|
||||
use App\Models\AutomationRun;
|
||||
|
||||
it('does not advance a node when the automation is paused', function () {
|
||||
$automation = Automation::factory()->paused()->create([
|
||||
'nodes' => [['id' => 'a', 'type' => 'end', 'position' => ['x' => 0, 'y' => 0], 'data' => []]],
|
||||
'connections' => [],
|
||||
]);
|
||||
$run = AutomationRun::factory()->for($automation)->create(['status' => RunStatus::Pending]);
|
||||
|
||||
(new ProcessAutomationNode($run, 'a'))->handle(app(AdvanceAutomationRun::class));
|
||||
|
||||
expect($run->fresh()->status)->toBe(RunStatus::Pending);
|
||||
});
|
||||
|
||||
it('does not resume a waiting run while the automation is paused', function () {
|
||||
$automation = Automation::factory()->paused()->create([
|
||||
'nodes' => [
|
||||
['id' => 'delay', 'type' => 'delay', 'position' => ['x' => 0, 'y' => 0], 'data' => ['duration' => 1, 'unit' => 'hours']],
|
||||
['id' => 'end', 'type' => 'end', 'position' => ['x' => 1, 'y' => 0], 'data' => []],
|
||||
],
|
||||
'connections' => [['id' => 'e1', 'source' => 'delay', 'target' => 'end']],
|
||||
]);
|
||||
$run = AutomationRun::factory()->for($automation)->waiting(now()->subMinute())->create(['current_node_id' => 'delay']);
|
||||
|
||||
$this->artisan('automation:process-delays');
|
||||
|
||||
expect($run->fresh()->status)->toBe(RunStatus::Waiting);
|
||||
});
|
||||
|
||||
it('advances normally when the automation is active', function () {
|
||||
$automation = Automation::factory()->active()->create([
|
||||
'nodes' => [['id' => 'a', 'type' => 'end', 'position' => ['x' => 0, 'y' => 0], 'data' => []]],
|
||||
'connections' => [],
|
||||
]);
|
||||
$run = AutomationRun::factory()->for($automation)->create(['status' => RunStatus::Pending]);
|
||||
|
||||
(new ProcessAutomationNode($run, 'a'))->handle(app(AdvanceAutomationRun::class));
|
||||
|
||||
expect($run->fresh()->status)->toBe(RunStatus::Completed);
|
||||
});
|
||||
|
||||
it('advances a manual test run even when the automation is not active', function () {
|
||||
$automation = Automation::factory()->create([
|
||||
'nodes' => [['id' => 'a', 'type' => 'end', 'position' => ['x' => 0, 'y' => 0], 'data' => []]],
|
||||
'connections' => [],
|
||||
]);
|
||||
$run = AutomationRun::factory()->for($automation)->create(['status' => RunStatus::Pending, 'is_manual' => true]);
|
||||
|
||||
(new ProcessAutomationNode($run, 'a'))->handle(app(AdvanceAutomationRun::class));
|
||||
|
||||
expect($run->fresh()->status)->toBe(RunStatus::Completed);
|
||||
});
|
||||
|
||||
it('resumes a waiting manual test run regardless of automation status', function () {
|
||||
$automation = Automation::factory()->paused()->create([
|
||||
'nodes' => [
|
||||
['id' => 'delay', 'type' => 'delay', 'position' => ['x' => 0, 'y' => 0], 'data' => ['duration' => 1, 'unit' => 'hours']],
|
||||
['id' => 'end', 'type' => 'end', 'position' => ['x' => 1, 'y' => 0], 'data' => []],
|
||||
],
|
||||
'connections' => [['id' => 'e1', 'source' => 'delay', 'target' => 'end']],
|
||||
]);
|
||||
$run = AutomationRun::factory()->for($automation)->waiting(now()->subMinute())->create(['current_node_id' => 'delay', 'is_manual' => true]);
|
||||
|
||||
$this->artisan('automation:process-delays');
|
||||
|
||||
expect($run->fresh()->status)->toBe(RunStatus::Completed);
|
||||
});
|
||||
25
tests/Feature/Automation/Run/AdvanceUnmatchedHandleTest.php
Normal file
25
tests/Feature/Automation/Run/AdvanceUnmatchedHandleTest.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Actions\Automation\Run\AdvanceAutomationRun;
|
||||
use App\Enums\Automation\Run\Status as RunStatus;
|
||||
use App\Models\Automation;
|
||||
use App\Models\AutomationRun;
|
||||
|
||||
it('completes with a reason when no edge matches the handle', function () {
|
||||
$automation = Automation::factory()->create([
|
||||
'nodes' => [
|
||||
['id' => 'a', 'type' => 'fetch_rss', 'position' => ['x' => 0, 'y' => 0], 'data' => []],
|
||||
],
|
||||
'connections' => [],
|
||||
]);
|
||||
$run = AutomationRun::factory()->for($automation)->running('a')->create();
|
||||
|
||||
app(AdvanceAutomationRun::class)($run, 'a', 'no_items');
|
||||
|
||||
$run->refresh();
|
||||
expect($run->status)->toBe(RunStatus::Completed);
|
||||
expect(data_get($run->error, 'reason'))->toBe('no_matching_edge');
|
||||
expect(data_get($run->error, 'handle'))->toBe('no_items');
|
||||
});
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\Automation\Run\Status as RunStatus;
|
||||
use App\Jobs\Automation\ProcessAutomationNode;
|
||||
use App\Models\Automation;
|
||||
use App\Models\AutomationRun;
|
||||
|
||||
it('marks the run failed when the job fails', function () {
|
||||
$automation = Automation::factory()->active()->create([
|
||||
'nodes' => [['id' => 'a', 'type' => 'generate', 'position' => ['x' => 0, 'y' => 0], 'data' => []]],
|
||||
'connections' => [],
|
||||
]);
|
||||
$run = AutomationRun::factory()->for($automation)->create(['status' => RunStatus::Running, 'current_node_id' => 'a']);
|
||||
|
||||
(new ProcessAutomationNode($run, 'a'))->failed(new RuntimeException('boom'));
|
||||
|
||||
$run->refresh();
|
||||
expect($run->status)->toBe(RunStatus::Failed);
|
||||
expect(data_get($run->error, 'message'))->toBe('boom');
|
||||
expect($run->finished_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('does not override an already-terminal run', function () {
|
||||
$automation = Automation::factory()->active()->create(['nodes' => [], 'connections' => []]);
|
||||
$run = AutomationRun::factory()->for($automation)->create(['status' => RunStatus::Completed]);
|
||||
|
||||
(new ProcessAutomationNode($run, 'a'))->failed(new RuntimeException('late'));
|
||||
|
||||
expect($run->fresh()->status)->toBe(RunStatus::Completed);
|
||||
});
|
||||
252
tests/Feature/Automation/Tree/ContentPipelineTreesTest.php
Normal file
252
tests/Feature/Automation/Tree/ContentPipelineTreesTest.php
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
<?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);
|
||||
});
|
||||
107
tests/Feature/Image/PostImagePipelineTest.php
Normal file
107
tests/Feature/Image/PostImagePipelineTest.php
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\Media\Source;
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Image\PostImagePipeline;
|
||||
use App\Services\Image\TemplateImageGenerator;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
beforeEach(function () {
|
||||
Storage::fake();
|
||||
|
||||
$this->workspace = Workspace::factory()->create();
|
||||
$this->account = SocialAccount::factory()->instagram()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
]);
|
||||
|
||||
Storage::put('ai-images/x.webp', 'fake-image-bytes');
|
||||
|
||||
$this->rendered = [
|
||||
'path' => 'ai-images/x.webp',
|
||||
'source_meta' => [
|
||||
'keywords' => ['productivity'],
|
||||
'style' => 'cinematic',
|
||||
'language' => 'en',
|
||||
],
|
||||
];
|
||||
});
|
||||
|
||||
test('forSingle returns a media item with the expected shape and persists a Media row', function () {
|
||||
$this->mock(TemplateImageGenerator::class, function ($mock) {
|
||||
$mock->shouldReceive('render')->once()->andReturn($this->rendered);
|
||||
});
|
||||
|
||||
$structured = [
|
||||
'content' => 'A single productivity tip',
|
||||
'image_title' => 'Tip',
|
||||
'image_body' => 'Do less',
|
||||
'image_keywords' => ['productivity'],
|
||||
];
|
||||
|
||||
$pipeline = app(PostImagePipeline::class);
|
||||
|
||||
$media = $pipeline->forSingle($this->workspace, $this->account, $structured, ContentType::InstagramFeed);
|
||||
|
||||
expect($media)->toHaveCount(1);
|
||||
|
||||
$item = $media[0];
|
||||
|
||||
expect($item)->toHaveKeys(['id', 'path', 'url', 'type', 'mime_type', 'source', 'source_meta']);
|
||||
expect($item['path'])->toBe('ai-images/x.webp');
|
||||
expect($item['type'])->toBe('image');
|
||||
expect($item['mime_type'])->toBe('image/webp');
|
||||
expect($item['source'])->toBe(Source::Ai->value);
|
||||
expect($item['source_meta'])->toBe($this->rendered['source_meta']);
|
||||
|
||||
$this->assertDatabaseHas('medias', [
|
||||
'id' => $item['id'],
|
||||
'path' => 'ai-images/x.webp',
|
||||
'collection' => 'ai-generated',
|
||||
]);
|
||||
});
|
||||
|
||||
test('forSingle returns an empty array when the generator renders nothing', function () {
|
||||
$this->mock(TemplateImageGenerator::class, function ($mock) {
|
||||
$mock->shouldReceive('render')->once()->andReturn(null);
|
||||
});
|
||||
|
||||
$structured = [
|
||||
'image_title' => 'Tip',
|
||||
'image_body' => 'Do less',
|
||||
'image_keywords' => [],
|
||||
];
|
||||
|
||||
$pipeline = app(PostImagePipeline::class);
|
||||
|
||||
$media = $pipeline->forSingle($this->workspace, $this->account, $structured, ContentType::InstagramFeed);
|
||||
|
||||
expect($media)->toBe([]);
|
||||
});
|
||||
|
||||
test('forCarousel returns one media item per slide', function () {
|
||||
$this->mock(TemplateImageGenerator::class, function ($mock) {
|
||||
$mock->shouldReceive('render')->times(3)->andReturn($this->rendered);
|
||||
});
|
||||
|
||||
$structured = [
|
||||
'caption' => 'Swipe',
|
||||
'slides' => [
|
||||
['title' => 'Tip 1', 'body' => 'First', 'image_keywords' => ['a']],
|
||||
['title' => 'Tip 2', 'body' => 'Second', 'image_keywords' => ['b']],
|
||||
['title' => 'Tip 3', 'body' => 'Third', 'image_keywords' => ['c']],
|
||||
],
|
||||
];
|
||||
|
||||
$pipeline = app(PostImagePipeline::class);
|
||||
|
||||
$media = $pipeline->forCarousel($this->workspace, $this->account, $structured, ContentType::InstagramFeed);
|
||||
|
||||
expect($media)->toHaveCount(3);
|
||||
expect($media[0])->toHaveKeys(['id', 'path', 'url', 'type', 'mime_type', 'source', 'source_meta']);
|
||||
|
||||
$this->assertDatabaseCount('medias', 3);
|
||||
});
|
||||
Loading…
Reference in a new issue