trypost/tests/Feature/Automation/GenerateNodeValidationTest.php
Paulo Castellano a09b1f45c2 Structure brand voice and make generated copy platform-aware
Replace free-text brand_tone/brand_voice_notes with a single structured
brand_voice_traits JSON column backed by the BrandVoiceTrait enum, exposed
as choice-chip pills in the brand settings UI and autofillable from a site.
Brand voice and visuals become per-automation toggles on the Generate node.

Unify the image controls into one 0-10 picker (0 = text-only, 1 = single,
2+ = carousel) and feed the generator the most restrictive selected network
so copy fits every platform. Pass that same platform context through the
humanizer pass — extracted into a shared ResolvesPlatformCopyBudget trait —
so the rewrite can no longer drift past the character cap the generator
respected, in both the automation and manual creation flows.

Persist the trigger node's schedule editor fields on save (they were
silently dropped by validated() for lacking validation rules).
2026-06-12 17:09:39 -03:00

147 lines
6.6 KiB
PHP

<?php
declare(strict_types=1);
use App\Actions\Automation\Node\RunGenerateNode;
use App\Enums\UserWorkspace\Role;
use App\Models\Automation;
use App\Models\User;
use App\Models\Workspace;
beforeEach(function () {
$this->workspace = Workspace::factory()->create();
$this->user = User::factory()->create([
'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('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('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' => 'carousel', 'slide_count' => 10]);
// pinterest_carousel allows only 5.
expect($node->deriveFormat([['content_type' => 'pinterest_carousel']], ['target_slide_count' => 8]))
->toBe(['format' => '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' => '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' => 'single', 'slide_count' => 1]);
});