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:
parent
a9628b7e8d
commit
3dbb8e6f7e
5 changed files with 84 additions and 7 deletions
|
|
@ -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'],
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
];
|
||||
|
|
|
|||
34
app/Support/AiPromptRules.php
Normal file
34
app/Support/AiPromptRules.php
Normal 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];
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue