GenerateNodeValidator and the Generate UI now respect ContentType::minMediaCount, and content-type listings share accept/min flags via toListingArray(). Co-authored-by: Cursor <cursoragent@cursor.com>
226 lines
11 KiB
PHP
226 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Actions\Automation\Node\RunGenerateNode;
|
|
use App\Enums\Ai\GeneratorFormat;
|
|
use App\Enums\UserWorkspace\Role;
|
|
use App\Models\Automation;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
|
|
beforeEach(function () {
|
|
$this->user = User::factory()->create();
|
|
$this->workspace = Workspace::factory()->create(['account_id' => $this->user->account_id]);
|
|
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
|
$this->workspace->members()->attach($this->user->id, ['role' => Role::Admin->value]);
|
|
$this->user->refresh();
|
|
});
|
|
|
|
it('rejects saving a generate node whose slide count exceeds a content type limit', function () {
|
|
$automation = Automation::factory()->for($this->workspace)->create();
|
|
|
|
$this->actingAs($this->user)
|
|
->putJson(route('app.automations.update', $automation->id), [
|
|
'nodes' => [
|
|
['id' => 'n1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule', 'cron' => '0 9 * * *']],
|
|
['id' => 'n2', 'type' => 'generate', 'position' => ['x' => 1, 'y' => 0], 'data' => [
|
|
'accounts' => [['social_account_id' => 'acc-1', 'content_type' => 'pinterest_carousel']],
|
|
'prompt_template' => 'hi',
|
|
'image_source' => 'none',
|
|
'target_slide_count' => 8,
|
|
]],
|
|
],
|
|
'connections' => [['id' => 'e1', 'source' => 'n1', 'target' => 'n2']],
|
|
])
|
|
->assertStatus(422)
|
|
->assertJsonValidationErrors(['nodes.1.data.accounts']);
|
|
});
|
|
|
|
it('rejects a generate node that targets Pinterest carousel below the minimum slide count', function () {
|
|
$automation = Automation::factory()->for($this->workspace)->create();
|
|
|
|
$this->actingAs($this->user)
|
|
->putJson(route('app.automations.update', $automation->id), [
|
|
'nodes' => [
|
|
['id' => 'n1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule', 'cron' => '0 9 * * *']],
|
|
['id' => 'n2', 'type' => 'generate', 'position' => ['x' => 1, 'y' => 0], 'data' => [
|
|
'accounts' => [['social_account_id' => 'acc-1', 'content_type' => 'pinterest_carousel', 'meta' => ['board_id' => 'board-1']]],
|
|
'prompt_template' => 'hi',
|
|
'target_slide_count' => 1,
|
|
]],
|
|
],
|
|
'connections' => [['id' => 'e1', 'source' => 'n1', 'target' => 'n2']],
|
|
])
|
|
->assertStatus(422)
|
|
->assertJsonValidationErrors(['nodes.1.data.accounts']);
|
|
});
|
|
|
|
it('allows saving a generate node within the content type limit', function () {
|
|
$automation = Automation::factory()->for($this->workspace)->create();
|
|
|
|
$this->actingAs($this->user)
|
|
->put(route('app.automations.update', $automation->id), [
|
|
'nodes' => [
|
|
['id' => 'n1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule', 'cron' => '0 9 * * *']],
|
|
['id' => 'n2', 'type' => 'generate', 'position' => ['x' => 1, 'y' => 0], 'data' => [
|
|
'accounts' => [['social_account_id' => 'acc-1', 'content_type' => 'pinterest_carousel']],
|
|
'prompt_template' => 'hi',
|
|
'image_source' => 'none',
|
|
'target_slide_count' => 4,
|
|
]],
|
|
],
|
|
'connections' => [['id' => 'e1', 'source' => 'n1', 'target' => 'n2']],
|
|
])
|
|
->assertRedirect();
|
|
|
|
expect($automation->fresh()->nodes)->toHaveCount(2);
|
|
});
|
|
|
|
it('rejects a generate node that targets Pinterest with zero images', function () {
|
|
$automation = Automation::factory()->for($this->workspace)->create();
|
|
|
|
$this->actingAs($this->user)
|
|
->putJson(route('app.automations.update', $automation->id), [
|
|
'nodes' => [
|
|
['id' => 'n1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule', 'cron' => '0 9 * * *']],
|
|
['id' => 'n2', 'type' => 'generate', 'position' => ['x' => 1, 'y' => 0], 'data' => [
|
|
'accounts' => [['social_account_id' => 'acc-1', 'content_type' => 'pinterest_pin', 'meta' => ['board_id' => 'board-1']]],
|
|
'prompt_template' => 'hi',
|
|
'target_slide_count' => 0,
|
|
]],
|
|
],
|
|
'connections' => [['id' => 'e1', 'source' => 'n1', 'target' => 'n2']],
|
|
])
|
|
->assertStatus(422)
|
|
->assertJsonValidationErrors(['nodes.1.data.accounts']);
|
|
});
|
|
|
|
it('rejects a generate node that targets a video-only Pinterest format', function () {
|
|
$automation = Automation::factory()->for($this->workspace)->create();
|
|
|
|
$this->actingAs($this->user)
|
|
->putJson(route('app.automations.update', $automation->id), [
|
|
'nodes' => [
|
|
['id' => 'n1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule', 'cron' => '0 9 * * *']],
|
|
['id' => 'n2', 'type' => 'generate', 'position' => ['x' => 1, 'y' => 0], 'data' => [
|
|
'accounts' => [['social_account_id' => 'acc-1', 'content_type' => 'pinterest_video_pin', 'meta' => ['board_id' => 'board-1']]],
|
|
'prompt_template' => 'hi',
|
|
'target_slide_count' => 1,
|
|
]],
|
|
],
|
|
'connections' => [['id' => 'e1', 'source' => 'n1', 'target' => 'n2']],
|
|
])
|
|
->assertStatus(422)
|
|
->assertJsonValidationErrors(['nodes.1.data.accounts']);
|
|
});
|
|
|
|
it('allows saving a Pinterest pin generate node with one image', function () {
|
|
$automation = Automation::factory()->for($this->workspace)->create();
|
|
|
|
$this->actingAs($this->user)
|
|
->put(route('app.automations.update', $automation->id), [
|
|
'nodes' => [
|
|
['id' => 'n1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule', 'cron' => '0 9 * * *']],
|
|
['id' => 'n2', 'type' => 'generate', 'position' => ['x' => 1, 'y' => 0], 'data' => [
|
|
'accounts' => [['social_account_id' => 'acc-1', 'content_type' => 'pinterest_pin', 'meta' => ['board_id' => 'board-1']]],
|
|
'prompt_template' => 'hi',
|
|
'target_slide_count' => 1,
|
|
'style' => 'tweet_card',
|
|
]],
|
|
],
|
|
'connections' => [['id' => 'e1', 'source' => 'n1', 'target' => 'n2']],
|
|
])
|
|
->assertRedirect()
|
|
->assertSessionHasNoErrors();
|
|
|
|
expect($automation->fresh()->nodes)->toHaveCount(2);
|
|
});
|
|
|
|
it('persists the brand toggles on the generate node', function () {
|
|
$automation = Automation::factory()->for($this->workspace)->create();
|
|
|
|
$this->actingAs($this->user)
|
|
->put(route('app.automations.update', $automation->id), [
|
|
'nodes' => [
|
|
['id' => 'n1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule', 'cron' => '0 9 * * *']],
|
|
['id' => 'n2', 'type' => 'generate', 'position' => ['x' => 1, 'y' => 0], 'data' => [
|
|
'accounts' => [['social_account_id' => 'acc-1', 'content_type' => 'pinterest_carousel']],
|
|
'prompt_template' => 'hi',
|
|
'target_slide_count' => 4,
|
|
'use_brand_voice' => false,
|
|
'use_brand_visuals' => false,
|
|
]],
|
|
],
|
|
'connections' => [['id' => 'e1', 'source' => 'n1', 'target' => 'n2']],
|
|
])
|
|
->assertRedirect();
|
|
|
|
$generate = collect($automation->fresh()->nodes)->firstWhere('id', 'n2');
|
|
expect($generate['data']['target_slide_count'])->toBe(4);
|
|
expect($generate['data']['use_brand_voice'])->toBeFalse();
|
|
expect($generate['data']['use_brand_visuals'])->toBeFalse();
|
|
});
|
|
|
|
it('saves a generate node using the exact frontend payload (no image_source field)', function () {
|
|
$automation = Automation::factory()->for($this->workspace)->create();
|
|
|
|
$this->actingAs($this->user)
|
|
->put(route('app.automations.update', $automation->id), [
|
|
'nodes' => [
|
|
['id' => 'n1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule', 'cron' => '0 9 * * *']],
|
|
['id' => 'n2', 'type' => 'generate', 'position' => ['x' => 1, 'y' => 0], 'data' => [
|
|
'accounts' => [['social_account_id' => 'acc-1', 'content_type' => 'instagram_feed', 'meta' => []]],
|
|
'prompt_template' => 'hi',
|
|
'target_slide_count' => 5,
|
|
]],
|
|
],
|
|
'connections' => [['id' => 'e1', 'source' => 'n1', 'target' => 'n2']],
|
|
])
|
|
->assertRedirect()
|
|
->assertSessionHasNoErrors();
|
|
|
|
expect($automation->fresh()->nodes)->toHaveCount(2);
|
|
});
|
|
|
|
it('refuses to activate an automation whose generate node requests images for an image-less format', function () {
|
|
$automation = Automation::factory()->for($this->workspace)->withScheduleTrigger()->create();
|
|
$automation->update([
|
|
'nodes' => array_merge($automation->nodes, [
|
|
['id' => 'n2', 'type' => 'generate', 'position' => ['x' => 1, 'y' => 1], 'data' => [
|
|
'accounts' => [['social_account_id' => 'acc-1', 'content_type' => 'tiktok_video']],
|
|
'target_slide_count' => 1,
|
|
]],
|
|
]),
|
|
'connections' => [['id' => 'e1', 'source' => 'trigger_1', 'target' => 'n2']],
|
|
]);
|
|
|
|
$this->actingAs($this->user)
|
|
->postJson(route('app.automations.activate', $automation->id))
|
|
->assertStatus(422);
|
|
|
|
expect($automation->fresh()->status->value)->not->toBe('active');
|
|
});
|
|
|
|
it('caps the carousel slide count at the global max and the per-content-type limit', function () {
|
|
$node = app(RunGenerateNode::class);
|
|
|
|
// tiktok_photo allows 35, but AI generation is capped at 10.
|
|
expect($node->deriveFormat([['content_type' => 'tiktok_photo']], ['target_slide_count' => 15]))
|
|
->toBe(['format' => GeneratorFormat::Carousel, 'slide_count' => 10]);
|
|
|
|
// pinterest_carousel allows only 5.
|
|
expect($node->deriveFormat([['content_type' => 'pinterest_carousel']], ['target_slide_count' => 8]))
|
|
->toBe(['format' => GeneratorFormat::Carousel, 'slide_count' => 5]);
|
|
});
|
|
|
|
it('recognises facebook_post as multi-image capable (rules-derived, not a hardcoded list)', function () {
|
|
$node = app(RunGenerateNode::class);
|
|
|
|
expect($node->deriveFormat([['content_type' => 'facebook_post']], ['target_slide_count' => 6]))
|
|
->toBe(['format' => GeneratorFormat::Carousel, 'slide_count' => 6]);
|
|
|
|
// A single-image (video) format never becomes a carousel.
|
|
expect($node->deriveFormat([['content_type' => 'tiktok_video']], ['target_slide_count' => 5]))
|
|
->toBe(['format' => GeneratorFormat::Single, 'slide_count' => 1]);
|
|
});
|