fix(posts): block scheduling when content exceeds any platform's char limit
Threads posts over 500 chars were saved + scheduled successfully and only failed inside the publish job. The frontend already showed the 537|500 badge but `canSchedule` ignored content length, so Schedule and Post Now stayed enabled. Backend `UpdatePostRequest` only capped at 63206 (Facebook's max), not per-platform. - Add `Platform::contentOverflow()` as the single source of truth and reuse it from `HasSocialHttpClient::validateContentLength` (publish-time). - New `ContentFitsPlatformLimits` rule applied to the `content` field on `App\\UpdatePostRequest`, `Api\\UpdatePostRequest`, and `Api\\StorePostRequest` via `Rule::when(...)` so drafts are not blocked. - Rule dedupes per platform (two Threads accounts -> one error) and reports the platform label, hard cap, and overage via i18n. - Edit.vue feeds `contentLengthOverflows` into `canSchedule` and lists each offending platform in `postActionTooltip` using the existing `getPlatformLabel` resolver.
This commit is contained in:
parent
479011b0b3
commit
953be22b5b
13 changed files with 475 additions and 12 deletions
|
|
@ -122,6 +122,19 @@ public function maxContentLength(): int
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of characters the given content exceeds this platform's hard cap,
|
||||
* or null when it fits. Single source of truth for content-length checks —
|
||||
* used both at schedule/publish-validation time and at publish time itself
|
||||
* so the two paths can never drift apart.
|
||||
*/
|
||||
public function contentOverflow(string $content): ?int
|
||||
{
|
||||
$over = mb_strlen($content) - $this->maxContentLength();
|
||||
|
||||
return $over > 0 ? $over : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recommended target length (in characters) for AI-generated posts. This
|
||||
* is the engagement sweet spot — much shorter than the platform's hard
|
||||
|
|
|
|||
|
|
@ -5,8 +5,12 @@
|
|||
namespace App\Http\Requests\Api\Post;
|
||||
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Rules\ContentFitsPlatformLimits;
|
||||
use App\Rules\ContentTypeMatchesPlatform;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StorePostRequest extends FormRequest
|
||||
|
|
@ -21,7 +25,15 @@ public function rules(): array
|
|||
$workspaceId = $this->user()->currentWorkspace->id;
|
||||
|
||||
return [
|
||||
'content' => ['nullable', 'string', 'max:63206'],
|
||||
'content' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:63206',
|
||||
Rule::when(
|
||||
$this->filled('scheduled_at'),
|
||||
[new ContentFitsPlatformLimits($this->resolveSelectedPlatforms($workspaceId))]
|
||||
),
|
||||
],
|
||||
'media' => ['sometimes', 'array'],
|
||||
'platforms' => ['required', 'array', 'min:1'],
|
||||
'platforms.*.social_account_id' => [
|
||||
|
|
@ -45,4 +57,20 @@ public function rules(): array
|
|||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int|string, Platform>
|
||||
*/
|
||||
private function resolveSelectedPlatforms(string $workspaceId): Collection
|
||||
{
|
||||
$accountIds = collect($this->input('platforms', []))->pluck('social_account_id')->filter()->all();
|
||||
if (empty($accountIds)) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return SocialAccount::query()
|
||||
->where('workspace_id', $workspaceId)
|
||||
->whereIn('id', $accountIds)
|
||||
->pluck('platform', 'id');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,9 +6,13 @@
|
|||
|
||||
use App\Enums\Post\Status;
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostPlatform;
|
||||
use App\Rules\ContentFitsPlatformLimits;
|
||||
use App\Rules\ContentTypeMatchesPostPlatform;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdatePostRequest extends FormRequest
|
||||
|
|
@ -20,9 +24,23 @@ public function authorize(): bool
|
|||
|
||||
public function rules(): array
|
||||
{
|
||||
$enforcesPlatformLimits = in_array(
|
||||
$this->input('status'),
|
||||
[Status::Scheduled->value, Status::Publishing->value],
|
||||
true,
|
||||
);
|
||||
|
||||
return [
|
||||
'status' => ['required', 'string', Rule::in([Status::Draft->value, Status::Scheduled->value, Status::Publishing->value])],
|
||||
'content' => ['nullable', 'string', 'max:63206'],
|
||||
'content' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:63206',
|
||||
Rule::when(
|
||||
$enforcesPlatformLimits,
|
||||
[new ContentFitsPlatformLimits($this->resolveSelectedPlatforms())]
|
||||
),
|
||||
],
|
||||
'media' => ['sometimes', 'array'],
|
||||
'platforms' => ['sometimes', 'array'],
|
||||
'platforms.*.id' => ['required', 'uuid', Rule::exists('post_platforms', 'id')->where('post_id', $this->route('post') instanceof Post ? $this->route('post')->id : $this->route('post'))],
|
||||
|
|
@ -45,4 +63,23 @@ public function rules(): array
|
|||
'label_ids.*' => ['uuid', Rule::exists('workspace_labels', 'id')->where('workspace_id', $this->user()->currentWorkspace->id)],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int|string, Platform>
|
||||
*/
|
||||
private function resolveSelectedPlatforms(): Collection
|
||||
{
|
||||
$ids = collect($this->input('platforms', []))->pluck('id')->filter()->all();
|
||||
if (empty($ids)) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$post = $this->route('post');
|
||||
$postId = $post instanceof Post ? $post->id : $post;
|
||||
|
||||
return PostPlatform::query()
|
||||
->where('post_id', $postId)
|
||||
->whereIn('id', $ids)
|
||||
->pluck('platform', 'id');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,10 @@
|
|||
use App\Enums\Post\Status;
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Rules\ContentFitsPlatformLimits;
|
||||
use App\Rules\ContentTypeCompatibleWithMedia;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
|
|
@ -29,7 +31,15 @@ public function rules(): array
|
|||
|
||||
return [
|
||||
'status' => ['required', 'string', Rule::in([Status::Draft->value, Status::Scheduled->value, Status::Publishing->value])],
|
||||
'content' => ['nullable', 'string', 'max:63206'],
|
||||
'content' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:63206',
|
||||
Rule::when(
|
||||
$enforcesMediaCompatibility,
|
||||
[new ContentFitsPlatformLimits($this->resolveSelectedPlatforms())]
|
||||
),
|
||||
],
|
||||
'media' => ['sometimes', 'array'],
|
||||
'media.*.id' => ['required', 'string'],
|
||||
'media.*.path' => ['required', 'string', 'max:500'],
|
||||
|
|
@ -108,4 +118,20 @@ private function isPublishingOrScheduling(): bool
|
|||
true,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int|string, Platform>
|
||||
*/
|
||||
private function resolveSelectedPlatforms(): Collection
|
||||
{
|
||||
$ids = collect($this->input('platforms', []))->pluck('id')->filter()->all();
|
||||
if (empty($ids)) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return $this->route('post')
|
||||
->postPlatforms()
|
||||
->whereIn('id', $ids)
|
||||
->pluck('platform', 'id');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
56
app/Rules/ContentFitsPlatformLimits.php
Normal file
56
app/Rules/ContentFitsPlatformLimits.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Translation\PotentiallyTranslatedString;
|
||||
|
||||
/**
|
||||
* Fails the content field once per platform whose hard `maxContentLength()` is
|
||||
* exceeded by the submitted text. Pre-resolve the platforms the post is bound
|
||||
* to (App: from `post_platforms.id`; API store: from `social_accounts.id`) and
|
||||
* pass them in — keeps the rule decoupled from the FormRequest payload shape.
|
||||
*/
|
||||
class ContentFitsPlatformLimits implements ValidationRule
|
||||
{
|
||||
/**
|
||||
* @param Collection<int|string, Platform> $platforms
|
||||
*/
|
||||
public function __construct(private Collection $platforms) {}
|
||||
|
||||
/**
|
||||
* @param Closure(string, ?string=): PotentiallyTranslatedString $fail
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
$content = (string) $value;
|
||||
$reported = [];
|
||||
|
||||
foreach ($this->platforms as $platform) {
|
||||
if (! $platform instanceof Platform) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($reported[$platform->value])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$over = $platform->contentOverflow($content);
|
||||
if ($over === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$reported[$platform->value] = true;
|
||||
$fail(trans('posts.form.content_exceeds_platform', [
|
||||
'platform' => $platform->label(),
|
||||
'limit' => $platform->maxContentLength(),
|
||||
'over' => $over,
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -14,14 +14,18 @@ trait HasSocialHttpClient
|
|||
{
|
||||
protected function validateContentLength(PostPlatform $postPlatform): void
|
||||
{
|
||||
$maxLength = $postPlatform->platform->maxContentLength();
|
||||
$contentLength = mb_strlen($postPlatform->post->content ?? '');
|
||||
$content = $postPlatform->post->content ?? '';
|
||||
|
||||
if ($contentLength > $maxLength) {
|
||||
throw new \Exception(
|
||||
"Content exceeds {$postPlatform->platform->label()} limit of {$maxLength} characters ({$contentLength} provided)."
|
||||
);
|
||||
if ($postPlatform->platform->contentOverflow($content) === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$maxLength = $postPlatform->platform->maxContentLength();
|
||||
$contentLength = mb_strlen($content);
|
||||
|
||||
throw new \Exception(
|
||||
"Content exceeds {$postPlatform->platform->label()} limit of {$maxLength} characters ({$contentLength} provided)."
|
||||
);
|
||||
}
|
||||
|
||||
protected function refreshTokenWithLock(SocialAccount $account, callable $refreshFn): void
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@
|
|||
'drag_to_reorder' => 'Drag to reorder',
|
||||
'caption' => 'Caption',
|
||||
'write_caption' => 'Write your caption...',
|
||||
'content_exceeds_platform' => ':platform: too long by :over chars (max :limit).',
|
||||
'tiktok' => [
|
||||
'settings' => 'TikTok Settings',
|
||||
'variant_label' => 'Post type',
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@
|
|||
'drag_to_reorder' => 'Arrastra para reordenar',
|
||||
'caption' => 'Descripción',
|
||||
'write_caption' => 'Escribe tu descripción...',
|
||||
'content_exceeds_platform' => ':platform: demasiado largo por :over caracteres (máx :limit).',
|
||||
'tiktok' => [
|
||||
'settings' => 'Configuración de TikTok',
|
||||
'variant_label' => 'Tipo de publicación',
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@
|
|||
'drag_to_reorder' => 'Arraste para reordenar',
|
||||
'caption' => 'Legenda',
|
||||
'write_caption' => 'Escreva sua legenda...',
|
||||
'content_exceeds_platform' => ':platform: longo demais por :over caracteres (máx :limit).',
|
||||
'tiktok' => [
|
||||
'settings' => 'Configurações do TikTok',
|
||||
'variant_label' => 'Tipo de publicação',
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
|
|||
import { usePostEcho } from '@/composables/echo/usePostEcho';
|
||||
import { getMediaItemIssue } from '@/composables/useMedia';
|
||||
import { getMediaRulesForContentType } from '@/composables/useMediaRules';
|
||||
import { getPlatformLabel } from '@/composables/usePlatformLogo';
|
||||
import dayjs from '@/dayjs';
|
||||
import debounce from '@/debounce';
|
||||
import { Platform } from '@/enums/platform';
|
||||
|
|
@ -279,18 +280,34 @@ const tiktokComplianceValid = computed(() => {
|
|||
});
|
||||
});
|
||||
|
||||
const contentLengthOverflows = computed(() => {
|
||||
const len = content.value.length;
|
||||
return platformLimits.value
|
||||
.filter((p) => len > p.maxLength)
|
||||
.map((p) => ({ platform: p.platform, limit: p.maxLength, over: len - p.maxLength }));
|
||||
});
|
||||
|
||||
const canSchedule = computed(
|
||||
() => mediaCompliancePerPlatformValid.value && tiktokComplianceValid.value,
|
||||
() => mediaCompliancePerPlatformValid.value
|
||||
&& tiktokComplianceValid.value
|
||||
&& contentLengthOverflows.value.length === 0,
|
||||
);
|
||||
|
||||
const postActionTooltip = computed(() => {
|
||||
if (canSchedule.value) return '';
|
||||
|
||||
// Collect platform-specific media compatibility issues.
|
||||
const reasons = post.value.post_platforms
|
||||
const mediaReasons = post.value.post_platforms
|
||||
.filter((pp) => selectedPlatformIds.value.includes(pp.id) && platformIssues.value[pp.id])
|
||||
.map((pp) => `${pp.platform_name ?? pp.platform}: ${platformIssues.value[pp.id]}`);
|
||||
|
||||
const lengthReasons = contentLengthOverflows.value.map((overflow) => trans('posts.form.content_exceeds_platform', {
|
||||
platform: getPlatformLabel(overflow.platform),
|
||||
limit: String(overflow.limit),
|
||||
over: String(overflow.over),
|
||||
}));
|
||||
|
||||
const reasons = [...mediaReasons, ...lengthReasons];
|
||||
|
||||
if (reasons.length > 0) return reasons.join('\n');
|
||||
|
||||
// No media issues — the only remaining blocker is TikTok compliance.
|
||||
|
|
|
|||
|
|
@ -307,6 +307,96 @@
|
|||
->assertJsonValidationErrors(['platforms.0.content_type']);
|
||||
});
|
||||
|
||||
it('rejects scheduling an over-limit threads post via the api store', function () {
|
||||
$threadsAccount = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::Threads,
|
||||
]);
|
||||
|
||||
$response = $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
|
||||
->postJson(route('api.posts.store'), [
|
||||
'content' => str_repeat('a', 537),
|
||||
'scheduled_at' => now()->addDay()->toIso8601String(),
|
||||
'platforms' => [
|
||||
['social_account_id' => $threadsAccount->id, 'content_type' => ContentType::ThreadsPost->value],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable()->assertJsonValidationErrors(['content']);
|
||||
expect($response->json('errors.content.0'))
|
||||
->toContain('Threads')
|
||||
->toContain('500')
|
||||
->toContain('37');
|
||||
});
|
||||
|
||||
it('accepts creating an over-limit draft (no scheduled_at) via the api store', function () {
|
||||
$threadsAccount = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::Threads,
|
||||
]);
|
||||
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
|
||||
->postJson(route('api.posts.store'), [
|
||||
'content' => str_repeat('a', 1000),
|
||||
'platforms' => [
|
||||
['social_account_id' => $threadsAccount->id, 'content_type' => ContentType::ThreadsPost->value],
|
||||
],
|
||||
])
|
||||
->assertCreated();
|
||||
});
|
||||
|
||||
it('rejects scheduling an over-limit threads post via the api update', function () {
|
||||
$post = Post::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
$threadsAccount = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::Threads,
|
||||
]);
|
||||
$threadsPlatform = PostPlatform::factory()->threads()->create([
|
||||
'post_id' => $post->id,
|
||||
'social_account_id' => $threadsAccount->id,
|
||||
]);
|
||||
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
|
||||
->putJson(route('api.posts.update', $post), [
|
||||
'status' => PostStatus::Scheduled->value,
|
||||
'content' => str_repeat('a', 600),
|
||||
'scheduled_at' => now()->addDay()->toIso8601String(),
|
||||
'platforms' => [
|
||||
['id' => $threadsPlatform->id, 'content_type' => ContentType::ThreadsPost->value],
|
||||
],
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['content']);
|
||||
});
|
||||
|
||||
it('saving an over-limit threads post as draft via api skips the content-length check', function () {
|
||||
$post = Post::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
$threadsAccount = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::Threads,
|
||||
]);
|
||||
$threadsPlatform = PostPlatform::factory()->threads()->create([
|
||||
'post_id' => $post->id,
|
||||
'social_account_id' => $threadsAccount->id,
|
||||
]);
|
||||
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
|
||||
->putJson(route('api.posts.update', $post), [
|
||||
'status' => PostStatus::Draft->value,
|
||||
'content' => str_repeat('a', 1000),
|
||||
'platforms' => [
|
||||
['id' => $threadsPlatform->id, 'content_type' => ContentType::ThreadsPost->value],
|
||||
],
|
||||
])
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
it('rejects creating a post when content_type does not match the social account platform', function () {
|
||||
// x_post on a LinkedIn account — ContentTypeMatchesPlatform should reject.
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
|
||||
|
|
|
|||
|
|
@ -95,3 +95,130 @@
|
|||
|
||||
$response->assertSessionDoesntHaveErrors(['platforms.0.meta.privacy_level']);
|
||||
});
|
||||
|
||||
test('scheduling a threads post over 500 chars is rejected with the platform name', function () {
|
||||
$threadsAccount = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::Threads,
|
||||
]);
|
||||
$threadsPlatform = PostPlatform::factory()->threads()->create([
|
||||
'post_id' => $this->post->id,
|
||||
'social_account_id' => $threadsAccount->id,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->put(route('app.posts.update', $this->post), [
|
||||
'status' => Status::Scheduled->value,
|
||||
'content' => str_repeat('a', 537),
|
||||
'scheduled_at' => now()->addDay()->toIso8601String(),
|
||||
'platforms' => [
|
||||
[
|
||||
'id' => $threadsPlatform->id,
|
||||
'content_type' => ContentType::ThreadsPost->value,
|
||||
'meta' => [],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('content');
|
||||
expect(session('errors')->get('content')[0])
|
||||
->toContain('Threads')
|
||||
->toContain('500')
|
||||
->toContain('37'); // over by 37
|
||||
});
|
||||
|
||||
test('scheduling a threads post within 500 chars passes content-length validation', function () {
|
||||
$threadsAccount = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::Threads,
|
||||
]);
|
||||
$threadsPlatform = PostPlatform::factory()->threads()->create([
|
||||
'post_id' => $this->post->id,
|
||||
'social_account_id' => $threadsAccount->id,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->put(route('app.posts.update', $this->post), [
|
||||
'status' => Status::Scheduled->value,
|
||||
'content' => str_repeat('a', 500),
|
||||
'scheduled_at' => now()->addDay()->toIso8601String(),
|
||||
'platforms' => [
|
||||
[
|
||||
'id' => $threadsPlatform->id,
|
||||
'content_type' => ContentType::ThreadsPost->value,
|
||||
'meta' => [],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertSessionDoesntHaveErrors('content');
|
||||
});
|
||||
|
||||
test('saving an over-limit threads post as draft skips the content-length rule', function () {
|
||||
$threadsAccount = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::Threads,
|
||||
]);
|
||||
$threadsPlatform = PostPlatform::factory()->threads()->create([
|
||||
'post_id' => $this->post->id,
|
||||
'social_account_id' => $threadsAccount->id,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->put(route('app.posts.update', $this->post), [
|
||||
'status' => Status::Draft->value,
|
||||
'content' => str_repeat('a', 1000),
|
||||
'platforms' => [
|
||||
[
|
||||
'id' => $threadsPlatform->id,
|
||||
'content_type' => ContentType::ThreadsPost->value,
|
||||
'meta' => [],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertSessionDoesntHaveErrors('content');
|
||||
});
|
||||
|
||||
test('scheduling across multiple platforms enforces the strictest content-length cap', function () {
|
||||
$facebookAccount = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::Facebook,
|
||||
]);
|
||||
$facebookPlatform = PostPlatform::factory()->facebook()->create([
|
||||
'post_id' => $this->post->id,
|
||||
'social_account_id' => $facebookAccount->id,
|
||||
]);
|
||||
|
||||
$threadsAccount = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::Threads,
|
||||
]);
|
||||
$threadsPlatform = PostPlatform::factory()->threads()->create([
|
||||
'post_id' => $this->post->id,
|
||||
'social_account_id' => $threadsAccount->id,
|
||||
]);
|
||||
|
||||
// 600 chars: fine for Facebook (63206 cap), over for Threads (500 cap).
|
||||
$response = $this->actingAs($this->user)
|
||||
->put(route('app.posts.update', $this->post), [
|
||||
'status' => Status::Scheduled->value,
|
||||
'content' => str_repeat('a', 600),
|
||||
'scheduled_at' => now()->addDay()->toIso8601String(),
|
||||
'platforms' => [
|
||||
[
|
||||
'id' => $facebookPlatform->id,
|
||||
'content_type' => ContentType::FacebookPost->value,
|
||||
'meta' => [],
|
||||
],
|
||||
[
|
||||
'id' => $threadsPlatform->id,
|
||||
'content_type' => ContentType::ThreadsPost->value,
|
||||
'meta' => [],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('content');
|
||||
expect(session('errors')->get('content')[0])->toContain('Threads');
|
||||
});
|
||||
|
|
|
|||
62
tests/Unit/Rules/ContentFitsPlatformLimitsTest.php
Normal file
62
tests/Unit/Rules/ContentFitsPlatformLimitsTest.php
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Rules\ContentFitsPlatformLimits;
|
||||
|
||||
function runFitsRule(string $content, array $platforms): array
|
||||
{
|
||||
$errors = [];
|
||||
$rule = new ContentFitsPlatformLimits(collect($platforms));
|
||||
|
||||
$rule->validate('content', $content, function (string $message) use (&$errors): void {
|
||||
$errors[] = $message;
|
||||
});
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
test('passes when content fits every platform cap', function () {
|
||||
$errors = runFitsRule(str_repeat('a', 280), [Platform::X, Platform::Threads, Platform::Facebook]);
|
||||
|
||||
expect($errors)->toBe([]);
|
||||
});
|
||||
|
||||
test('fails with the platform label, limit and overage when content exceeds a single platform', function () {
|
||||
$errors = runFitsRule(str_repeat('a', 537), [Platform::Threads]);
|
||||
|
||||
expect($errors)->toHaveCount(1);
|
||||
expect($errors[0])
|
||||
->toContain('Threads')
|
||||
->toContain('500')
|
||||
->toContain('37');
|
||||
});
|
||||
|
||||
test('emits one error per overflowing platform in a multi-platform set', function () {
|
||||
// 320 chars: fine for Threads (500), over for X (280) and Bluesky (300).
|
||||
$errors = runFitsRule(str_repeat('a', 320), [Platform::X, Platform::Bluesky, Platform::Threads]);
|
||||
|
||||
expect($errors)->toHaveCount(2);
|
||||
expect($errors[0])->toContain('X');
|
||||
expect($errors[1])->toContain('Bluesky');
|
||||
});
|
||||
|
||||
test('deduplicates errors when the same platform appears twice in the collection', function () {
|
||||
// Two Threads accounts selected, content 600 chars — should still produce ONE error.
|
||||
$errors = runFitsRule(str_repeat('a', 600), [Platform::Threads, Platform::Threads]);
|
||||
|
||||
expect($errors)->toHaveCount(1);
|
||||
});
|
||||
|
||||
test('passes for an empty platforms collection', function () {
|
||||
$errors = runFitsRule(str_repeat('a', 10_000), []);
|
||||
|
||||
expect($errors)->toBe([]);
|
||||
});
|
||||
|
||||
test('treats null content as an empty string and passes', function () {
|
||||
$errors = runFitsRule('', [Platform::Threads, Platform::X]);
|
||||
|
||||
expect($errors)->toBe([]);
|
||||
});
|
||||
Loading…
Reference in a new issue