fix(ai-create): sync the prompt limit across front and back

The counter added earlier drifted from the backend in two ways: it counted
UTF-16 code units over the raw (untrimmed) value, while the backend measures
Unicode characters (mb_strlen) over the trimmed value that is actually sent —
so emoji or trailing whitespace could falsely turn the counter red and block
the button. The 2000 limit was also copied into three places, and the wizard's
frontend `>= 3` minimum had no backend counterpart.

- Add App\Support\AiPromptRules as the single source of truth for the prompt
  bounds; both StartPostCreationRequest and GeneratePostContentRequest use it.
- Add min:3 to the create wizard endpoint so front and back agree (the editor's
  generate-content flow keeps `required` — it has no counter to mirror).
- Count code points over the trimmed value in AiPostWizard so the counter and
  the submit gate match what the backend validates, matching AltTextDialog.
- Cover min/max/boundary in PostAiCreateTest.
This commit is contained in:
Paulo Castellano 2026-07-17 11:03:41 -03:00
parent a9628b7e8d
commit 3dbb8e6f7e
5 changed files with 84 additions and 7 deletions

View file

@ -4,6 +4,7 @@
namespace App\Http\Requests\App\Ai;
use App\Support\AiPromptRules;
use Illuminate\Foundation\Http\FormRequest;
class GeneratePostContentRequest extends FormRequest
@ -19,7 +20,7 @@ public function authorize(): bool
public function rules(): array
{
return [
'prompt' => ['required', 'string', 'max:2000'],
'prompt' => ['required', 'string', 'max:'.AiPromptRules::PROMPT_MAX_LENGTH],
'current_content' => ['nullable', 'string', 'max:10000'],
];
}

View file

@ -6,6 +6,7 @@
use App\Enums\Ai\ContentStyle;
use App\Enums\PostPlatform\ContentType;
use App\Support\AiPromptRules;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
@ -33,7 +34,7 @@ public function rules(): array
],
'social_account_id' => ['nullable', 'uuid'],
'image_count' => ['nullable', 'integer', 'min:0', 'max:10'],
'prompt' => ['required', 'string', 'max:2000'],
'prompt' => AiPromptRules::promptRule(),
'date' => ['nullable', 'date_format:Y-m-d'],
'template' => ['sometimes', 'string', Rule::enum(ContentStyle::class)],
];

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Support;
/**
* Single source of truth for the user-supplied AI generation prompt length,
* shared by the create wizard (StartPostCreationRequest) and the editor's
* content generation (GeneratePostContentRequest). The frontend counter in
* resources/js/components/posts/create/AiPostWizard.vue mirrors these bounds.
*/
class AiPromptRules
{
/**
* Minimum prompt length (characters) for the create wizard.
*/
public const PROMPT_MIN_LENGTH = 3;
/**
* Maximum prompt length (characters); mirrored by the frontend counter.
*/
public const PROMPT_MAX_LENGTH = 2000;
/**
* Validation rules for the create wizard's generation prompt.
*
* @return array<int, string>
*/
public static function promptRule(): array
{
return ['required', 'string', 'min:'.self::PROMPT_MIN_LENGTH, 'max:'.self::PROMPT_MAX_LENGTH];
}
}

View file

@ -63,7 +63,7 @@ const selectedAccountId = ref<string | null>(null);
const includeImages = ref(true);
const imageCount = ref(2);
const promptText = ref('');
// Mirrors the backend validation (`prompt` max:2000) so the limit is visible in the UI.
const PROMPT_MIN = 3;
const PROMPT_MAX = 2000;
const submitting = ref(false);
@ -159,11 +159,13 @@ const submittedImageCount = computed(() => {
return 0;
});
const promptLength = computed(() => [...promptText.value.trim()].length);
const canSubmit = computed(() =>
selectedFormat.value !== null &&
selectedAccountId.value !== null &&
promptText.value.trim().length >= 3 &&
promptText.value.length <= PROMPT_MAX,
promptLength.value >= PROMPT_MIN &&
promptLength.value <= PROMPT_MAX,
);
// Auto-pick the only account when format has exactly one match.
@ -363,9 +365,9 @@ const startGeneration = async () => {
/>
<p
class="text-right text-xs tabular-nums"
:class="promptText.length > PROMPT_MAX ? 'font-semibold text-destructive' : 'text-muted-foreground'"
:class="promptLength > PROMPT_MAX ? 'font-semibold text-destructive' : 'text-muted-foreground'"
>
{{ promptText.length }}/{{ PROMPT_MAX }}
{{ promptLength }}/{{ PROMPT_MAX }}
</p>
</div>

View file

@ -8,6 +8,7 @@
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use App\Support\AiPromptRules;
use Illuminate\Support\Facades\Bus;
use Symfony\Component\HttpFoundation\Response;
@ -32,6 +33,44 @@
->assertJsonValidationErrors(['prompt']);
});
test('start rejects a prompt shorter than the minimum length', function () {
Bus::fake();
$this->actingAs($this->user)
->postJson(route('app.posts.ai.create'), ['prompt' => 'hi', 'format' => 'x_post'])
->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY)
->assertJsonValidationErrors(['prompt']);
Bus::assertNotDispatched(StreamPostCreation::class);
});
test('start rejects a prompt longer than the maximum length', function () {
Bus::fake();
$this->actingAs($this->user)
->postJson(route('app.posts.ai.create'), [
'prompt' => str_repeat('a', AiPromptRules::PROMPT_MAX_LENGTH + 1),
'format' => 'x_post',
])
->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY)
->assertJsonValidationErrors(['prompt']);
Bus::assertNotDispatched(StreamPostCreation::class);
});
test('start accepts a prompt at the maximum length', function () {
Bus::fake();
$this->actingAs($this->user)
->postJson(route('app.posts.ai.create'), [
'prompt' => str_repeat('a', AiPromptRules::PROMPT_MAX_LENGTH),
'format' => 'x_post',
])
->assertStatus(Response::HTTP_ACCEPTED);
Bus::assertDispatched(StreamPostCreation::class);
});
test('start validates format is required', function () {
Bus::fake();