feat(tiktok): photo carousel support + UX Content Sharing API compliance
## Photo carousel support
- Adds `ContentType::TikTokPhoto` enum case (max 35 photos, 1:1 aspect,
supportsImage true, supportsVideo false) and JS mirror in content-type.ts.
- Variant pill picker (Video / Photo carousel) at the top of TikTokSettings,
mirroring the Instagram pattern. Wired through ScheduleTab to the parent
editor's existing update:platformContentType emit.
- i18n keys for variant_label / variant.video / variant.photo in en/pt-BR/es.
- Publisher: split buildPostInfo into buildVideoPostInfo (uses `title`,
TikTok cap 2200 chars) and buildPhotoPostInfo (uses `description`, cap
4000 chars; omits Duet/Stitch/AIGC since they don't apply). Removed the
no-longer-needed queryCreatorInfo() call from publishVideo/publishPhotos
— its only previous consumer (silent privacy_level fallback) is gone.
## UX Content Sharing API compliance
Per TikTok review feedback citing
https://developers.tiktok.com/doc/content-sharing-guidelines#required_ux_implementation_in_your_app
Point 1 — already satisfied (creator_info fetch + nickname display).
Point 2/4 — Music Usage Confirmation declaration is now always visible
in TikTokSettings; text changes between "Music Usage Confirmation" and
"Branded Content Policy and Music Usage Confirmation" based on toggle
state. Previously the entire `<p>` block was conditional on a brand
toggle being selected, hiding the baseline declaration.
Point 2b — privacy_level may not have a default. UI was already correct;
backend hardened: UpdatePostRequest now requires meta.privacy_level for
tiktok platforms when status is publishing/scheduled (via withValidator);
TikTokPublisher::resolveRequiredPrivacyLevel throws TikTokPublishException
(ContentPolicy category) when missing instead of silently falling back to
the creator's preferred level.
Point 2c — interaction settings now condition on content type:
- Photo posts hide Duet/Stitch (they don't apply per TikTok docs).
- Photo posts hide AIGC (also video-only).
- Video posts hide Auto Add Music (photos-only feature).
- Max-duration warning hidden when not a video post.
Source of truth is the user-selected contentType prop, not inferred
from media — ensures the UI reacts immediately to the variant pill.
Point 3a — publish button stays disabled when Disclose toggle is on
without a sub-selection (already the case via tiktokComplianceValid).
The disabled tooltip now uses the verbatim TikTok-required text "You
need to indicate if your content promotes yourself, a third party, or
both." instead of the generic "Some platform settings are incomplete..."
when the only blocker is TikTok disclosure incompleteness.
Point 3b — SELF_ONLY (Only me) privacy option is no longer filtered out
when Branded Content is checked. It is rendered disabled with a hover
tooltip "Branded content visibility cannot be set to private." plus a
persistent amber warning paragraph below the dropdown. When the user
toggles Branded Content while privacy is SELF_ONLY, the privacy clears
and a vue-sonner warning toast surfaces the change.
## Cross-cutting
- New `resources/js/enums/platform.ts` mirrors the PHP Platform enum,
used in Edit.vue (tiktokComplianceValid + tiktokDisclosureIncomplete)
and ScheduleTab.vue (all selected*Platforms computeds) to replace
string literal comparisons against `'tiktok'` / `'facebook'` / etc.
- PostPlatformFactory tiktok() state defaults meta.privacy_level to
SELF_ONLY so existing test fixtures keep passing under the new
publisher/FormRequest requirements.
## Tests
- New tests/Unit/Enums/PostPlatform/TikTokPhotoContentTypeTest.php
covering the new enum case (4 tests).
- TikTokPublisherTest: added "video uses title not description" and
"throws when meta.privacy_level missing" regression tests; renamed
two existing tests that depended on the removed silent fallback.
- New tests/Feature/UpdatePostRequestTest.php with 3 tests covering
the FormRequest's privacy_level enforcement (publish-rejected,
publish-passes, draft-allowed).
Full Pest suite: 1490 passed, 2 skipped (pre-existing).
2026-05-09 15:47:49 +00:00
|
|
|
<?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']);
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-15 14:51:20 +00:00
|
|
|
test('publishing a pinterest post without board_id is rejected', function () {
|
|
|
|
|
$pinterestAccount = SocialAccount::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'platform' => Platform::Pinterest,
|
|
|
|
|
]);
|
|
|
|
|
$pinterestPlatform = PostPlatform::factory()->pinterest()->create([
|
|
|
|
|
'post_id' => $this->post->id,
|
|
|
|
|
'social_account_id' => $pinterestAccount->id,
|
|
|
|
|
'meta' => [],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$mediaPayload = [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'test-image',
|
|
|
|
|
'path' => 'media/2026-01/pin.jpg',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/pin.jpg',
|
|
|
|
|
'type' => 'image',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => 'pin.jpg',
|
|
|
|
|
],
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
$response = $this->actingAs($this->user)
|
|
|
|
|
->put(route('app.posts.update', $this->post), [
|
|
|
|
|
'status' => Status::Publishing->value,
|
|
|
|
|
'media' => $mediaPayload,
|
|
|
|
|
'platforms' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => $pinterestPlatform->id,
|
|
|
|
|
'content_type' => ContentType::PinterestPin->value,
|
|
|
|
|
'meta' => [],
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response->assertSessionHasErrors('platforms.0.meta.board_id');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('publishing a pinterest post with board_id passes board validation', function () {
|
|
|
|
|
$pinterestAccount = SocialAccount::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'platform' => Platform::Pinterest,
|
|
|
|
|
]);
|
|
|
|
|
$pinterestPlatform = PostPlatform::factory()->pinterest()->create([
|
|
|
|
|
'post_id' => $this->post->id,
|
|
|
|
|
'social_account_id' => $pinterestAccount->id,
|
|
|
|
|
'meta' => [],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$mediaPayload = [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'test-image',
|
|
|
|
|
'path' => 'media/2026-01/pin.jpg',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/pin.jpg',
|
|
|
|
|
'type' => 'image',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => 'pin.jpg',
|
|
|
|
|
],
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
$response = $this->actingAs($this->user)
|
|
|
|
|
->put(route('app.posts.update', $this->post), [
|
|
|
|
|
'status' => Status::Publishing->value,
|
|
|
|
|
'media' => $mediaPayload,
|
|
|
|
|
'platforms' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => $pinterestPlatform->id,
|
|
|
|
|
'content_type' => ContentType::PinterestPin->value,
|
|
|
|
|
'meta' => ['board_id' => '123456789'],
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response->assertSessionDoesntHaveErrors(['platforms.0.meta.board_id']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('scheduling a pinterest post without board_id is rejected', function () {
|
|
|
|
|
$pinterestAccount = SocialAccount::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'platform' => Platform::Pinterest,
|
|
|
|
|
]);
|
|
|
|
|
$pinterestPlatform = PostPlatform::factory()->pinterest()->create([
|
|
|
|
|
'post_id' => $this->post->id,
|
|
|
|
|
'social_account_id' => $pinterestAccount->id,
|
|
|
|
|
'meta' => [],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$mediaPayload = [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'test-image',
|
|
|
|
|
'path' => 'media/2026-01/pin.jpg',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/pin.jpg',
|
|
|
|
|
'type' => 'image',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => 'pin.jpg',
|
|
|
|
|
],
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
$response = $this->actingAs($this->user)
|
|
|
|
|
->put(route('app.posts.update', $this->post), [
|
|
|
|
|
'status' => Status::Scheduled->value,
|
|
|
|
|
'scheduled_at' => now()->addDay()->toIso8601String(),
|
|
|
|
|
'media' => $mediaPayload,
|
|
|
|
|
'platforms' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => $pinterestPlatform->id,
|
|
|
|
|
'content_type' => ContentType::PinterestPin->value,
|
|
|
|
|
'meta' => [],
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response->assertSessionHasErrors('platforms.0.meta.board_id');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('publishing a pinterest carousel without board_id is rejected', function () {
|
|
|
|
|
$pinterestAccount = SocialAccount::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'platform' => Platform::Pinterest,
|
|
|
|
|
]);
|
|
|
|
|
$pinterestPlatform = PostPlatform::factory()->pinterestCarousel()->create([
|
|
|
|
|
'post_id' => $this->post->id,
|
|
|
|
|
'social_account_id' => $pinterestAccount->id,
|
|
|
|
|
'meta' => [],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$mediaPayload = [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'img-1',
|
|
|
|
|
'path' => 'media/2026-01/img1.jpg',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/img1.jpg',
|
|
|
|
|
'type' => 'image',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => 'img1.jpg',
|
|
|
|
|
],
|
|
|
|
|
[
|
|
|
|
|
'id' => 'img-2',
|
|
|
|
|
'path' => 'media/2026-01/img2.jpg',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/img2.jpg',
|
|
|
|
|
'type' => 'image',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => 'img2.jpg',
|
|
|
|
|
],
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
$response = $this->actingAs($this->user)
|
|
|
|
|
->put(route('app.posts.update', $this->post), [
|
|
|
|
|
'status' => Status::Publishing->value,
|
|
|
|
|
'media' => $mediaPayload,
|
|
|
|
|
'platforms' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => $pinterestPlatform->id,
|
|
|
|
|
'content_type' => ContentType::PinterestCarousel->value,
|
|
|
|
|
'meta' => [],
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response->assertSessionHasErrors('platforms.0.meta.board_id');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('publishing a pinterest video pin without board_id is rejected', function () {
|
|
|
|
|
$pinterestAccount = SocialAccount::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'platform' => Platform::Pinterest,
|
|
|
|
|
]);
|
|
|
|
|
$pinterestPlatform = PostPlatform::factory()->pinterestVideoPin()->create([
|
|
|
|
|
'post_id' => $this->post->id,
|
|
|
|
|
'social_account_id' => $pinterestAccount->id,
|
|
|
|
|
'meta' => [],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response = $this->actingAs($this->user)
|
|
|
|
|
->put(route('app.posts.update', $this->post), [
|
|
|
|
|
'status' => Status::Publishing->value,
|
|
|
|
|
'media' => $this->mediaPayload,
|
|
|
|
|
'platforms' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => $pinterestPlatform->id,
|
|
|
|
|
'content_type' => ContentType::PinterestVideoPin->value,
|
|
|
|
|
'meta' => [],
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response->assertSessionHasErrors('platforms.0.meta.board_id');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('pinterest board error does not block other platforms in multi-platform publish', function () {
|
|
|
|
|
$pinterestAccount = SocialAccount::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'platform' => Platform::Pinterest,
|
|
|
|
|
]);
|
|
|
|
|
$pinterestPlatform = PostPlatform::factory()->pinterest()->create([
|
|
|
|
|
'post_id' => $this->post->id,
|
|
|
|
|
'social_account_id' => $pinterestAccount->id,
|
|
|
|
|
'meta' => [],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$linkedinAccount = SocialAccount::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'platform' => Platform::LinkedIn,
|
|
|
|
|
]);
|
|
|
|
|
$linkedinPlatform = PostPlatform::factory()->create([
|
|
|
|
|
'post_id' => $this->post->id,
|
|
|
|
|
'social_account_id' => $linkedinAccount->id,
|
|
|
|
|
'platform' => Platform::LinkedIn,
|
|
|
|
|
'content_type' => ContentType::LinkedInPost,
|
|
|
|
|
'meta' => [],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$mediaPayload = [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'test-image',
|
|
|
|
|
'path' => 'media/2026-01/pin.jpg',
|
|
|
|
|
'url' => 'https://example.com/media/2026-01/pin.jpg',
|
|
|
|
|
'type' => 'image',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
'original_filename' => 'pin.jpg',
|
|
|
|
|
],
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
$response = $this->actingAs($this->user)
|
|
|
|
|
->put(route('app.posts.update', $this->post), [
|
|
|
|
|
'status' => Status::Publishing->value,
|
|
|
|
|
'media' => $mediaPayload,
|
|
|
|
|
'platforms' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => $pinterestPlatform->id,
|
|
|
|
|
'content_type' => ContentType::PinterestPin->value,
|
|
|
|
|
'meta' => [],
|
|
|
|
|
],
|
|
|
|
|
[
|
|
|
|
|
'id' => $linkedinPlatform->id,
|
|
|
|
|
'content_type' => ContentType::LinkedInPost->value,
|
|
|
|
|
'meta' => [],
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response->assertSessionHasErrors('platforms.0.meta.board_id');
|
|
|
|
|
$response->assertSessionDoesntHaveErrors(['platforms.1.meta.board_id']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('saving a pinterest post as draft without board_id skips the board rule', function () {
|
|
|
|
|
$pinterestAccount = SocialAccount::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'platform' => Platform::Pinterest,
|
|
|
|
|
]);
|
|
|
|
|
$pinterestPlatform = PostPlatform::factory()->pinterest()->create([
|
|
|
|
|
'post_id' => $this->post->id,
|
|
|
|
|
'social_account_id' => $pinterestAccount->id,
|
|
|
|
|
'meta' => [],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response = $this->actingAs($this->user)
|
|
|
|
|
->put(route('app.posts.update', $this->post), [
|
|
|
|
|
'status' => Status::Draft->value,
|
|
|
|
|
'platforms' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => $pinterestPlatform->id,
|
|
|
|
|
'content_type' => ContentType::PinterestPin->value,
|
|
|
|
|
'meta' => [],
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response->assertSessionDoesntHaveErrors(['platforms.0.meta.board_id']);
|
|
|
|
|
});
|
|
|
|
|
|
feat(tiktok): photo carousel support + UX Content Sharing API compliance
## Photo carousel support
- Adds `ContentType::TikTokPhoto` enum case (max 35 photos, 1:1 aspect,
supportsImage true, supportsVideo false) and JS mirror in content-type.ts.
- Variant pill picker (Video / Photo carousel) at the top of TikTokSettings,
mirroring the Instagram pattern. Wired through ScheduleTab to the parent
editor's existing update:platformContentType emit.
- i18n keys for variant_label / variant.video / variant.photo in en/pt-BR/es.
- Publisher: split buildPostInfo into buildVideoPostInfo (uses `title`,
TikTok cap 2200 chars) and buildPhotoPostInfo (uses `description`, cap
4000 chars; omits Duet/Stitch/AIGC since they don't apply). Removed the
no-longer-needed queryCreatorInfo() call from publishVideo/publishPhotos
— its only previous consumer (silent privacy_level fallback) is gone.
## UX Content Sharing API compliance
Per TikTok review feedback citing
https://developers.tiktok.com/doc/content-sharing-guidelines#required_ux_implementation_in_your_app
Point 1 — already satisfied (creator_info fetch + nickname display).
Point 2/4 — Music Usage Confirmation declaration is now always visible
in TikTokSettings; text changes between "Music Usage Confirmation" and
"Branded Content Policy and Music Usage Confirmation" based on toggle
state. Previously the entire `<p>` block was conditional on a brand
toggle being selected, hiding the baseline declaration.
Point 2b — privacy_level may not have a default. UI was already correct;
backend hardened: UpdatePostRequest now requires meta.privacy_level for
tiktok platforms when status is publishing/scheduled (via withValidator);
TikTokPublisher::resolveRequiredPrivacyLevel throws TikTokPublishException
(ContentPolicy category) when missing instead of silently falling back to
the creator's preferred level.
Point 2c — interaction settings now condition on content type:
- Photo posts hide Duet/Stitch (they don't apply per TikTok docs).
- Photo posts hide AIGC (also video-only).
- Video posts hide Auto Add Music (photos-only feature).
- Max-duration warning hidden when not a video post.
Source of truth is the user-selected contentType prop, not inferred
from media — ensures the UI reacts immediately to the variant pill.
Point 3a — publish button stays disabled when Disclose toggle is on
without a sub-selection (already the case via tiktokComplianceValid).
The disabled tooltip now uses the verbatim TikTok-required text "You
need to indicate if your content promotes yourself, a third party, or
both." instead of the generic "Some platform settings are incomplete..."
when the only blocker is TikTok disclosure incompleteness.
Point 3b — SELF_ONLY (Only me) privacy option is no longer filtered out
when Branded Content is checked. It is rendered disabled with a hover
tooltip "Branded content visibility cannot be set to private." plus a
persistent amber warning paragraph below the dropdown. When the user
toggles Branded Content while privacy is SELF_ONLY, the privacy clears
and a vue-sonner warning toast surfaces the change.
## Cross-cutting
- New `resources/js/enums/platform.ts` mirrors the PHP Platform enum,
used in Edit.vue (tiktokComplianceValid + tiktokDisclosureIncomplete)
and ScheduleTab.vue (all selected*Platforms computeds) to replace
string literal comparisons against `'tiktok'` / `'facebook'` / etc.
- PostPlatformFactory tiktok() state defaults meta.privacy_level to
SELF_ONLY so existing test fixtures keep passing under the new
publisher/FormRequest requirements.
## Tests
- New tests/Unit/Enums/PostPlatform/TikTokPhotoContentTypeTest.php
covering the new enum case (4 tests).
- TikTokPublisherTest: added "video uses title not description" and
"throws when meta.privacy_level missing" regression tests; renamed
two existing tests that depended on the removed silent fallback.
- New tests/Feature/UpdatePostRequestTest.php with 3 tests covering
the FormRequest's privacy_level enforcement (publish-rejected,
publish-passes, draft-allowed).
Full Pest suite: 1490 passed, 2 skipped (pre-existing).
2026-05-09 15:47:49 +00:00
|
|
|
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']);
|
|
|
|
|
});
|
2026-05-11 22:39:41 +00:00
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
});
|
2026-05-21 19:27:55 +00:00
|
|
|
|
|
|
|
|
test('draft save accepts media source metadata for ai regeneration', function () {
|
|
|
|
|
$payload = [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'media-ai-keep-meta',
|
|
|
|
|
'path' => 'ai-images/generated.webp',
|
|
|
|
|
'url' => 'https://example.com/ai-images/generated.webp',
|
|
|
|
|
'type' => 'image',
|
|
|
|
|
'mime_type' => 'image/webp',
|
|
|
|
|
'source' => 'ai',
|
|
|
|
|
'source_meta' => [
|
|
|
|
|
'title' => 'Fix ECP typo',
|
|
|
|
|
'body' => 'Body copy',
|
|
|
|
|
'keywords' => ['marketing', 'automation'],
|
|
|
|
|
'width' => 1080,
|
|
|
|
|
'height' => 1350,
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
$response = $this->actingAs($this->user)
|
|
|
|
|
->put(route('app.posts.update', $this->post), [
|
|
|
|
|
'status' => Status::Draft->value,
|
|
|
|
|
'media' => $payload,
|
|
|
|
|
'platforms' => [],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response->assertSessionDoesntHaveErrors();
|
|
|
|
|
|
|
|
|
|
$this->post->refresh();
|
|
|
|
|
expect(data_get($this->post->media, '0.source'))->toBe('ai');
|
|
|
|
|
expect(data_get($this->post->media, '0.source_meta.title'))->toBe('Fix ECP typo');
|
|
|
|
|
});
|
2026-06-04 20:08:57 +00:00
|
|
|
|
|
|
|
|
test('instagram_carousel is rejected as a content_type — carousel is a feed post with multiple images', function () {
|
|
|
|
|
$response = $this->actingAs($this->user)
|
|
|
|
|
->put(route('app.posts.update', $this->post), [
|
|
|
|
|
'status' => Status::Draft->value,
|
|
|
|
|
'platforms' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => $this->postPlatform->id,
|
|
|
|
|
'content_type' => 'instagram_carousel',
|
|
|
|
|
'meta' => [],
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response->assertSessionHasErrors('platforms.0.content_type');
|
|
|
|
|
});
|