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.
224 lines
8 KiB
PHP
224 lines
8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Enums\Post\Status;
|
|
use App\Enums\PostPlatform\ContentType;
|
|
use App\Enums\SocialAccount\Platform;
|
|
use App\Enums\UserWorkspace\Role;
|
|
use App\Models\Post;
|
|
use App\Models\PostPlatform;
|
|
use App\Models\SocialAccount;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
|
|
beforeEach(function () {
|
|
$this->user = User::factory()->create();
|
|
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
|
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
|
|
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
|
|
|
$this->post = Post::factory()->create([
|
|
'workspace_id' => $this->workspace->id,
|
|
'user_id' => $this->user->id,
|
|
]);
|
|
|
|
// Media payload used by tests that need to satisfy ContentTypeCompatibleWithMedia.
|
|
$this->mediaPayload = [
|
|
[
|
|
'id' => 'test-media-video',
|
|
'path' => 'media/2026-01/test-video.mp4',
|
|
'url' => 'https://example.com/media/2026-01/test-video.mp4',
|
|
'type' => 'video',
|
|
'mime_type' => 'video/mp4',
|
|
'original_filename' => 'test-video.mp4',
|
|
],
|
|
];
|
|
$this->socialAccount = SocialAccount::factory()->create([
|
|
'workspace_id' => $this->workspace->id,
|
|
'platform' => Platform::TikTok,
|
|
]);
|
|
$this->postPlatform = PostPlatform::factory()->tiktok()->create([
|
|
'post_id' => $this->post->id,
|
|
'social_account_id' => $this->socialAccount->id,
|
|
// Override factory default so we control privacy_level per test.
|
|
'meta' => [],
|
|
]);
|
|
});
|
|
|
|
test('publishing a tiktok post without privacy_level is rejected', function () {
|
|
$response = $this->actingAs($this->user)
|
|
->put(route('app.posts.update', $this->post), [
|
|
'status' => Status::Publishing->value,
|
|
'media' => $this->mediaPayload,
|
|
'platforms' => [
|
|
[
|
|
'id' => $this->postPlatform->id,
|
|
'content_type' => ContentType::TikTokVideo->value,
|
|
'meta' => [],
|
|
],
|
|
],
|
|
]);
|
|
|
|
$response->assertSessionHasErrors('platforms.0.meta.privacy_level');
|
|
});
|
|
|
|
test('publishing a tiktok post with privacy_level passes privacy_level validation', function () {
|
|
$response = $this->actingAs($this->user)
|
|
->put(route('app.posts.update', $this->post), [
|
|
'status' => Status::Publishing->value,
|
|
'media' => $this->mediaPayload,
|
|
'platforms' => [
|
|
[
|
|
'id' => $this->postPlatform->id,
|
|
'content_type' => ContentType::TikTokVideo->value,
|
|
'meta' => ['privacy_level' => 'SELF_ONLY'],
|
|
],
|
|
],
|
|
]);
|
|
|
|
$response->assertSessionDoesntHaveErrors(['platforms.0.meta.privacy_level']);
|
|
});
|
|
|
|
test('saving a tiktok post as draft without privacy_level skips the privacy_level rule', function () {
|
|
$response = $this->actingAs($this->user)
|
|
->put(route('app.posts.update', $this->post), [
|
|
'status' => Status::Draft->value,
|
|
'platforms' => [
|
|
[
|
|
'id' => $this->postPlatform->id,
|
|
'content_type' => ContentType::TikTokVideo->value,
|
|
'meta' => [],
|
|
],
|
|
],
|
|
]);
|
|
|
|
$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');
|
|
});
|