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');
|
|
|
|
|
});
|
feat(channels): add Discord as a social channel
Connect a Discord server via OAuth (bot authorization) and schedule/publish
messages to its channels, with mentions and rich embeds.
- Connect: custom Socialite Discord provider (bot scope) maps the authorized
guild to a SocialAccount; throws if no server was authorized.
- Publish: DiscordPublisher posts via the global bot token, validates the chosen
channel belongs to the connected guild (anti cross-guild), optimizes media,
builds allowed_mentions only from explicit mention chips (no accidental pings),
and renders rich embeds.
- Compose: per-post channel picker (live lookup), mention autocomplete and an
embed editor, gated by a required-channel compliance rule; Discord post preview.
- Enum/config/content-type wiring, ConnectionVerifier health check, throttled
lookup endpoints, i18n (en/es/pt-BR), and tests.
Operators must create a Discord application and set DISCORD_CLIENT_ID,
DISCORD_CLIENT_SECRET, DISCORD_BOT_TOKEN and DISCORD_CLIENT_REDIRECT.
2026-06-16 17:44:00 +00:00
|
|
|
|
|
|
|
|
test('publishing a discord post without a channel is rejected', function () {
|
|
|
|
|
$account = SocialAccount::factory()->discord()->create(['workspace_id' => $this->workspace->id]);
|
|
|
|
|
$postPlatform = PostPlatform::factory()->create([
|
|
|
|
|
'post_id' => $this->post->id,
|
|
|
|
|
'social_account_id' => $account->id,
|
|
|
|
|
'platform' => Platform::Discord,
|
|
|
|
|
'content_type' => ContentType::DiscordMessage,
|
|
|
|
|
'meta' => [],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->actingAs($this->user)
|
|
|
|
|
->put(route('app.posts.update', $this->post), [
|
|
|
|
|
'status' => Status::Publishing->value,
|
|
|
|
|
'platforms' => [[
|
|
|
|
|
'id' => $postPlatform->id,
|
|
|
|
|
'content_type' => ContentType::DiscordMessage->value,
|
|
|
|
|
'meta' => [],
|
|
|
|
|
]],
|
|
|
|
|
])
|
|
|
|
|
->assertSessionHasErrors('platforms.0.meta.channel_id');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('saving a discord draft without a channel is allowed', function () {
|
|
|
|
|
$account = SocialAccount::factory()->discord()->create(['workspace_id' => $this->workspace->id]);
|
|
|
|
|
$postPlatform = PostPlatform::factory()->create([
|
|
|
|
|
'post_id' => $this->post->id,
|
|
|
|
|
'social_account_id' => $account->id,
|
|
|
|
|
'platform' => Platform::Discord,
|
|
|
|
|
'content_type' => ContentType::DiscordMessage,
|
|
|
|
|
'meta' => [],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->actingAs($this->user)
|
|
|
|
|
->put(route('app.posts.update', $this->post), [
|
|
|
|
|
'status' => Status::Draft->value,
|
|
|
|
|
'platforms' => [[
|
|
|
|
|
'id' => $postPlatform->id,
|
|
|
|
|
'content_type' => ContentType::DiscordMessage->value,
|
|
|
|
|
'meta' => [],
|
|
|
|
|
]],
|
|
|
|
|
])
|
|
|
|
|
->assertSessionDoesntHaveErrors('platforms.0.meta.channel_id');
|
|
|
|
|
});
|
Add optional Pinterest pin title and destination link (#232)
* Add optional Pinterest pin title, description, and link.
Expose title/description/link across web, API, and MCP; seed description from caption into meta on save, and publish description only from meta.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Expand Pinterest title/description/link test coverage.
Cover web draft persistence and validation bounds, API/MCP update merge and seed, and publisher payload fields on video and carousel pins.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Simplify Pinterest: description is post content again.
Keep optional title and link in meta/settings only. Remove the separate description textarea, seed logic, and meta.description path so Pinterest follows the shared caption pattern.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Refactor Pinterest meta handling and validation.
- Update CreatePost and UpdatePost actions to filter out null values from meta fields.
- Introduce a new method in PinterestPublisher to resolve board IDs, ensuring required fields are validated.
- Enhance PinterestSettings component to manage title and link inputs, including validation for HTTP URLs.
- Update PostPlatformMetaRules to enforce URL validation for Pinterest links.
- Add tests for clearing Pinterest title and link, and for rejecting invalid links during scheduling.
This refactor improves the handling of Pinterest metadata and enhances user experience by ensuring proper validation and error handling.
* Add validation messages and attributes for Pinterest meta fields
- Introduced custom validation messages and friendly attribute names for Pinterest link and title fields in PostPlatformMetaRules.
- Updated StorePostRequest, UpdatePostRequest, and related tools to utilize these new messages and attributes.
- Enhanced tests to assert correct error messages for invalid Pinterest links and title length constraints.
This update improves user feedback during post creation and editing, ensuring clarity in validation errors.
* Remove click.prevent directive from Pinterest link in PinterestPreview component.
This change simplifies the link behavior, allowing default click actions to occur, which may enhance user interaction with the Pinterest link.
* Update validation error messages for Pinterest meta fields in tests
- Refined the assertions in PostApiPlatformMetaTest to include localized validation messages for Pinterest title and link fields.
- Ensured that error messages reflect the updated validation rules, enhancing clarity for users during post creation and editing.
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 21:15:50 +00:00
|
|
|
|
|
|
|
|
test('saving a pinterest draft persists title and link meta', 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' => ['board_id' => 'board-1'],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->actingAs($this->user)
|
|
|
|
|
->put(route('app.posts.update', $this->post), [
|
|
|
|
|
'status' => Status::Draft->value,
|
|
|
|
|
'content' => 'Shared caption',
|
|
|
|
|
'platforms' => [[
|
|
|
|
|
'id' => $pinterestPlatform->id,
|
|
|
|
|
'content_type' => ContentType::PinterestPin->value,
|
|
|
|
|
'meta' => [
|
|
|
|
|
'board_id' => 'board-1',
|
|
|
|
|
'title' => 'Pin Title',
|
|
|
|
|
'link' => 'https://example.com/product',
|
|
|
|
|
],
|
|
|
|
|
]],
|
|
|
|
|
])
|
|
|
|
|
->assertSessionDoesntHaveErrors();
|
|
|
|
|
|
|
|
|
|
$meta = $pinterestPlatform->fresh()->meta;
|
|
|
|
|
|
|
|
|
|
expect(data_get($meta, 'title'))->toBe('Pin Title')
|
|
|
|
|
->and(data_get($meta, 'link'))->toBe('https://example.com/product')
|
|
|
|
|
->and(array_key_exists('description', $meta))->toBeFalse();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('clearing pinterest title and link removes the meta keys', 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' => [
|
|
|
|
|
'board_id' => 'board-1',
|
|
|
|
|
'title' => 'Keep me gone',
|
|
|
|
|
'link' => 'https://example.com/gone',
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$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' => [
|
|
|
|
|
'board_id' => 'board-1',
|
|
|
|
|
'title' => null,
|
|
|
|
|
'link' => null,
|
|
|
|
|
],
|
|
|
|
|
]],
|
|
|
|
|
])
|
|
|
|
|
->assertSessionDoesntHaveErrors();
|
|
|
|
|
|
|
|
|
|
$meta = $pinterestPlatform->fresh()->meta;
|
|
|
|
|
|
|
|
|
|
expect(data_get($meta, 'board_id'))->toBe('board-1')
|
|
|
|
|
->and(array_key_exists('title', $meta))->toBeFalse()
|
|
|
|
|
->and(array_key_exists('link', $meta))->toBeFalse();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('pinterest rejects invalid link when scheduling', 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' => ['board_id' => 'board-1'],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$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',
|
|
|
|
|
],
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
$this->actingAs($this->user)
|
|
|
|
|
->put(route('app.posts.update', $this->post), [
|
|
|
|
|
'status' => Status::Scheduled->value,
|
|
|
|
|
'scheduled_at' => now()->addHour()->toIso8601String(),
|
|
|
|
|
'content' => 'Ready to schedule',
|
|
|
|
|
'media' => $mediaPayload,
|
|
|
|
|
'platforms' => [[
|
|
|
|
|
'id' => $pinterestPlatform->id,
|
|
|
|
|
'content_type' => ContentType::PinterestPin->value,
|
|
|
|
|
'meta' => [
|
|
|
|
|
'board_id' => 'board-1',
|
|
|
|
|
'link' => 'not-a-url',
|
|
|
|
|
],
|
|
|
|
|
]],
|
|
|
|
|
])
|
|
|
|
|
->assertSessionHasErrors([
|
|
|
|
|
'platforms.0.meta.link' => __('posts.form.pinterest.link_invalid'),
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('pinterest meta title and link validation bounds are enforced', 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' => [],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$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' => [
|
|
|
|
|
'title' => str_repeat('t', 101),
|
|
|
|
|
'link' => 'not-a-url',
|
|
|
|
|
],
|
|
|
|
|
]],
|
|
|
|
|
])
|
|
|
|
|
->assertSessionHasErrors([
|
|
|
|
|
'platforms.0.meta.title' => __('posts.form.pinterest.title_max'),
|
|
|
|
|
'platforms.0.meta.link' => __('posts.form.pinterest.link_invalid'),
|
|
|
|
|
]);
|
|
|
|
|
});
|