trypost/tests/Feature/Services/Social/TikTokPublisherTest.php
Paulo Castellano d349e499b5 test: move publisher tests from Unit to Feature
These tests use Http::fake, model factories, and DB — by Laravel/Pest
convention that's a feature test, not a unit test. Moving them to the
Feature suite to match the convention.

No code changes. Tests still pass: 1493 passed, 2 skipped, 0 failed.
2026-05-12 19:02:51 -03:00

692 lines
24 KiB
PHP

<?php
declare(strict_types=1);
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\TikTokPublishException;
use App\Exceptions\TokenExpiredException;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Social\TikTokPublisher;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->socialAccount = SocialAccount::factory()->tiktok()->create([
'workspace_id' => $this->workspace->id,
'platform_user_id' => 'tiktok123',
'username' => 'tiktoker',
'token_expires_at' => now()->addDays(1),
]);
$this->post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'content' => 'Check out this TikTok video!',
]);
$this->postPlatform = PostPlatform::factory()->tiktok()->create([
'post_id' => $this->post->id,
'social_account_id' => $this->socialAccount->id,
'platform' => Platform::TikTok,
'content_type' => ContentType::TikTokVideo,
]);
$this->publisher = new TikTokPublisher;
});
test('tiktok publisher throws exception when no media', function () {
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(Exception::class, 'TikTok requires media (video or photos) to publish.');
});
test('tiktok publisher can publish video', function () {
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/test-video.mp4',
'url' => 'https://example.com/media/2026-01/test-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'test-video.mp4',
],
],
]);
Http::fake([
'https://open.tiktokapis.com/v2/post/publish/video/init/' => Http::response([
'data' => ['publish_id' => 'pub_123'],
], 200),
'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([
'data' => [
'status' => 'PUBLISH_COMPLETE',
'publish_id' => 'pub_123',
],
], 200),
]);
$result = $this->publisher->publish($this->postPlatform);
expect($result)->toHaveKey('id');
expect($result)->toHaveKey('url');
expect($result['id'])->toBe('pub_123');
expect($result['url'])->toContain('tiktok.com/@tiktoker');
Http::assertSent(function ($request) {
return str_contains($request->url(), '/post/publish/video/init/');
});
});
test('tiktok publisher can publish photos', function () {
$this->post->update([
'media' => [
[
'id' => 'test-media-image',
'path' => 'media/2026-01/image1.jpg',
'url' => 'https://example.com/media/2026-01/image1.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'image1.jpg',
],
],
]);
Http::fake([
'https://open.tiktokapis.com/v2/post/publish/content/init/' => Http::response([
'data' => ['publish_id' => 'pub_photo_123'],
], 200),
'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([
'data' => [
'status' => 'PUBLISH_COMPLETE',
'publish_id' => 'pub_photo_123',
],
], 200),
]);
$result = $this->publisher->publish($this->postPlatform);
expect($result)->toHaveKey('id');
expect($result['id'])->toBe('pub_photo_123');
Http::assertSent(function ($request) {
if (! str_contains($request->url(), '/post/publish/content/init/')) {
return false;
}
$body = json_decode($request->body(), true);
return data_get($body, 'post_info.description') === 'Check out this TikTok video!'
&& ! isset($body['post_info']['title']);
});
});
test('tiktok publisher throws exception on api error', function () {
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/test-video.mp4',
'url' => 'https://example.com/media/2026-01/test-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'test-video.mp4',
],
],
]);
Http::fake([
'https://open.tiktokapis.com/v2/post/publish/video/init/' => Http::response([
'error' => [
'code' => 'invalid_request',
'message' => 'Invalid request',
],
], 400),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(Exception::class);
});
test('tiktok publisher throws token expired exception on auth error', function () {
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/test-video.mp4',
'url' => 'https://example.com/media/2026-01/test-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'test-video.mp4',
],
],
]);
Http::fake([
'https://open.tiktokapis.com/v2/post/publish/video/init/' => Http::response([
'error' => [
'code' => 'access_token_invalid',
'message' => 'Access token is invalid',
],
], 401),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(TokenExpiredException::class);
});
test('tiktok publisher refreshes token when expired', function () {
$this->socialAccount->update(['token_expires_at' => now()->subHour()]);
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/test-video.mp4',
'url' => 'https://example.com/media/2026-01/test-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'test-video.mp4',
],
],
]);
Http::fake([
'https://open.tiktokapis.com/v2/oauth/token/' => Http::response([
'access_token' => 'new-access-token',
'refresh_token' => 'new-refresh-token',
'expires_in' => 86400,
], 200),
'https://open.tiktokapis.com/v2/post/publish/video/init/' => Http::response([
'data' => ['publish_id' => 'pub_123'],
], 200),
'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([
'data' => ['status' => 'PUBLISH_COMPLETE'],
], 200),
]);
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
return str_contains($request->url(), 'oauth/token');
});
$this->socialAccount->refresh();
expect($this->socialAccount->access_token)->toBe('new-access-token');
});
test('tiktok publisher throws exception when no refresh token available', function () {
$this->socialAccount->update([
'token_expires_at' => now()->subHour(),
'refresh_token' => null,
]);
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/test-video.mp4',
'url' => 'https://example.com/media/2026-01/test-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'test-video.mp4',
],
],
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(TokenExpiredException::class, 'No refresh token available for TikTok account');
});
test('tiktok publisher throws TokenExpiredException when refresh_token is rejected', function () {
$this->socialAccount->update(['token_expires_at' => now()->subHour()]);
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/test-video.mp4',
'url' => 'https://example.com/media/2026-01/test-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'test-video.mp4',
],
],
]);
Http::fake([
'*/oauth/token/' => Http::response([
'error' => ['code' => 'invalid_grant', 'message' => 'Refresh token expired'],
], 400),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(TokenExpiredException::class, 'Refresh token expired');
});
test('tiktok publisher throws exception for unsupported media type', function () {
$this->post->update([
'media' => [
[
'id' => 'test-media-doc',
'path' => 'media/2026-01/doc.pdf',
'url' => 'https://example.com/media/2026-01/doc.pdf',
'mime_type' => 'application/pdf',
'original_filename' => 'doc.pdf',
],
],
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(Exception::class, 'TikTok only supports video or image content.');
});
test('tiktok publisher builds correct profile url when username present', function () {
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/test-video.mp4',
'url' => 'https://example.com/media/2026-01/test-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'test-video.mp4',
],
],
]);
Http::fake([
'https://open.tiktokapis.com/v2/post/publish/video/init/' => Http::response([
'data' => ['publish_id' => 'pub_123'],
], 200),
'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([
'data' => ['status' => 'PUBLISH_COMPLETE'],
], 200),
]);
$result = $this->publisher->publish($this->postPlatform);
expect($result['url'])->toBe('https://www.tiktok.com/@tiktoker');
});
test('tiktok publisher returns null url when username missing', function () {
$this->socialAccount->update(['username' => null]);
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/test-video.mp4',
'url' => 'https://example.com/media/2026-01/test-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'test-video.mp4',
],
],
]);
Http::fake([
'https://open.tiktokapis.com/v2/post/publish/video/init/' => Http::response([
'data' => ['publish_id' => 'pub_123'],
], 200),
'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([
'data' => ['status' => 'PUBLISH_COMPLETE'],
], 200),
]);
$result = $this->publisher->publish($this->postPlatform);
expect($result['url'])->toBeNull();
});
test('tiktok publisher publishes with user-selected privacy level even when creator info query fails', function () {
// User has explicitly selected SELF_ONLY in meta. creator_info failure must not block publishing.
$this->postPlatform->update(['meta' => ['privacy_level' => 'SELF_ONLY']]);
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/test-video.mp4',
'url' => 'https://example.com/media/2026-01/test-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'test-video.mp4',
],
],
]);
Http::fake([
// creator_info/query returns 500 — should not affect publishing since user picked privacy_level.
'https://open.tiktokapis.com/v2/post/publish/creator_info/query/' => Http::response([
'error' => ['code' => 'internal_error', 'message' => 'Internal server error'],
], 500),
'https://open.tiktokapis.com/v2/post/publish/video/init/' => Http::response([
'data' => ['publish_id' => 'pub_fallback_123'],
], 200),
'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([
'data' => [
'status' => 'PUBLISH_COMPLETE',
'publish_id' => 'pub_fallback_123',
],
], 200),
]);
$result = $this->publisher->publish($this->postPlatform);
expect($result)->toHaveKey('id');
expect($result['id'])->toBe('pub_fallback_123');
// Assert SELF_ONLY (user's explicit pick) was used in the video init payload
Http::assertSent(function ($request) {
if (! str_contains($request->url(), '/post/publish/video/init/')) {
return false;
}
$body = json_decode($request->body(), true);
return data_get($body, 'post_info.privacy_level') === 'SELF_ONLY';
});
});
test('tiktok publisher throws exception when publish fails', function () {
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/test-video.mp4',
'url' => 'https://example.com/media/2026-01/test-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'test-video.mp4',
],
],
]);
Http::fake([
'https://open.tiktokapis.com/v2/post/publish/video/init/' => Http::response([
'data' => ['publish_id' => 'pub_123'],
], 200),
'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([
'data' => [
'status' => 'FAILED',
'fail_reason' => 'video_rejected',
],
], 200),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(TikTokPublishException::class);
});
test('tiktok publisher sends meta settings in video publish request', function () {
$this->postPlatform->update([
'meta' => [
'privacy_level' => 'PUBLIC_TO_EVERYONE',
'allow_comments' => true,
'allow_duet' => false,
'allow_stitch' => true,
'is_aigc' => true,
'brand_content_toggle' => true,
'brand_organic_toggle' => false,
],
]);
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/test-video.mp4',
'url' => 'https://example.com/media/2026-01/test-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'test-video.mp4',
],
],
]);
Http::fake([
'https://open.tiktokapis.com/v2/post/publish/creator_info/query/' => Http::response([
'data' => [
'privacy_level_options' => ['PUBLIC_TO_EVERYONE', 'SELF_ONLY'],
],
], 200),
'https://open.tiktokapis.com/v2/post/publish/video/init/' => Http::response([
'data' => ['publish_id' => 'pub_meta_123'],
], 200),
'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([
'data' => ['status' => 'PUBLISH_COMPLETE'],
], 200),
]);
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
if (! str_contains($request->url(), '/post/publish/video/init/')) {
return false;
}
$body = json_decode($request->body(), true);
$postInfo = data_get($body, 'post_info');
return $postInfo['privacy_level'] === 'PUBLIC_TO_EVERYONE'
&& $postInfo['disable_comment'] === false
&& $postInfo['disable_duet'] === true
&& $postInfo['disable_stitch'] === false
&& $postInfo['is_aigc'] === true
&& $postInfo['brand_content_toggle'] === true
&& ! isset($postInfo['brand_organic_toggle']);
});
});
test('tiktok publisher sends auto_add_music for photo posts', function () {
$this->postPlatform->update([
'meta' => [
'privacy_level' => 'SELF_ONLY',
'allow_comments' => true,
'auto_add_music' => true,
],
]);
$this->post->update([
'media' => [
[
'id' => 'test-media-photo',
'path' => 'media/2026-01/photo.jpg',
'url' => 'https://example.com/media/2026-01/photo.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'photo.jpg',
],
],
]);
Http::fake([
'https://open.tiktokapis.com/v2/post/publish/creator_info/query/' => Http::response([
'data' => ['privacy_level_options' => ['SELF_ONLY']],
], 200),
'https://open.tiktokapis.com/v2/post/publish/content/init/' => Http::response([
'data' => ['publish_id' => 'pub_music_123'],
], 200),
'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([
'data' => ['status' => 'PUBLISH_COMPLETE'],
], 200),
]);
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
if (! str_contains($request->url(), '/post/publish/content/init/')) {
return false;
}
$body = json_decode($request->body(), true);
$postInfo = data_get($body, 'post_info');
return $postInfo['auto_add_music'] === true
&& ! isset($postInfo['disable_duet'])
&& ! isset($postInfo['disable_stitch'])
&& ! isset($postInfo['is_aigc'])
&& ! isset($postInfo['title'])
&& isset($postInfo['description']);
});
});
test('tiktok publisher does not send auto_add_music for video posts', function () {
$this->postPlatform->update([
'meta' => [
'privacy_level' => 'SELF_ONLY',
'auto_add_music' => true,
],
]);
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/video.mp4',
'url' => 'https://example.com/media/2026-01/video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'video.mp4',
],
],
]);
Http::fake([
'https://open.tiktokapis.com/v2/post/publish/creator_info/query/' => Http::response([
'data' => ['privacy_level_options' => ['SELF_ONLY']],
], 200),
'https://open.tiktokapis.com/v2/post/publish/video/init/' => Http::response([
'data' => ['publish_id' => 'pub_vid_123'],
], 200),
'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([
'data' => ['status' => 'PUBLISH_COMPLETE'],
], 200),
]);
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
if (! str_contains($request->url(), '/post/publish/video/init/')) {
return false;
}
$body = json_decode($request->body(), true);
$postInfo = data_get($body, 'post_info');
return ! isset($postInfo['auto_add_music']);
});
});
test('tiktok publisher uses default settings when only privacy_level is set', function () {
// Only privacy_level is set (required); all other meta keys absent — exercise default toggles.
$this->postPlatform->update(['meta' => ['privacy_level' => 'PUBLIC_TO_EVERYONE']]);
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/test-video.mp4',
'url' => 'https://example.com/media/2026-01/test-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'test-video.mp4',
],
],
]);
Http::fake([
'https://open.tiktokapis.com/v2/post/publish/creator_info/query/' => Http::response([
'data' => [
'privacy_level_options' => ['PUBLIC_TO_EVERYONE', 'FOLLOWER_OF_CREATOR', 'SELF_ONLY'],
],
], 200),
'https://open.tiktokapis.com/v2/post/publish/video/init/' => Http::response([
'data' => ['publish_id' => 'pub_default_123'],
], 200),
'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([
'data' => ['status' => 'PUBLISH_COMPLETE'],
], 200),
]);
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
if (! str_contains($request->url(), '/post/publish/video/init/')) {
return false;
}
$body = json_decode($request->body(), true);
$postInfo = data_get($body, 'post_info');
// privacy_level passes through; toggles use safe defaults (duet/stitch off, comments on).
return $postInfo['privacy_level'] === 'PUBLIC_TO_EVERYONE'
&& $postInfo['disable_comment'] === false
&& $postInfo['disable_duet'] === true
&& $postInfo['disable_stitch'] === true
&& ! isset($postInfo['is_aigc'])
&& ! isset($postInfo['brand_content_toggle']);
});
});
test('tiktok publisher sends video caption in title field, never description', function () {
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/test-video.mp4',
'url' => 'https://example.com/media/2026-01/test-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'test-video.mp4',
],
],
'content' => 'My video caption',
]);
$this->postPlatform->update(['meta' => ['privacy_level' => 'SELF_ONLY']]);
Http::fake([
'https://open.tiktokapis.com/v2/post/publish/creator_info/query/' => Http::response([
'data' => [
'privacy_level_options' => ['SELF_ONLY'],
],
], 200),
'https://open.tiktokapis.com/v2/post/publish/video/init/' => Http::response([
'data' => ['publish_id' => 'pub_video_123'],
], 200),
'https://open.tiktokapis.com/v2/post/publish/status/fetch/' => Http::response([
'data' => ['status' => 'PUBLISH_COMPLETE', 'publish_id' => 'pub_video_123'],
], 200),
]);
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
if (! str_contains($request->url(), '/post/publish/video/init/')) {
return false;
}
$body = json_decode($request->body(), true);
return data_get($body, 'post_info.title') === 'My video caption'
&& ! isset($body['post_info']['description']);
});
});
test('tiktok publisher throws when meta.privacy_level is missing and user did not pick', function () {
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/test-video.mp4',
'url' => 'https://example.com/media/2026-01/test-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'test-video.mp4',
],
],
]);
// Explicitly clear privacy_level from meta (simulate UI never set it).
$this->postPlatform->update(['meta' => []]);
Http::fake([
// creator_info returns a healthy response — fallback would have silently picked PUBLIC_TO_EVERYONE.
'https://open.tiktokapis.com/v2/post/publish/creator_info/query/' => Http::response([
'data' => [
'creator_nickname' => 'test',
'creator_username' => 'test',
'privacy_level_options' => ['PUBLIC_TO_EVERYONE', 'SELF_ONLY'],
'comment_disabled' => false,
'duet_disabled' => false,
'stitch_disabled' => false,
'max_video_post_duration_sec' => 300,
],
], 200),
]);
expect(fn () => $this->publisher->publish($this->postPlatform))
->toThrow(TikTokPublishException::class);
});