- Add HasSocialHttpClient trait with 429 rate limit retry (3 attempts, 5s delay) - Integrate trait into all 10 publishers (YouTube uses Google SDK) - Add inline token refresh retry in PublishToSocialPlatform job - Add per-platform Horizon queues via Platform::queue() and Platform::allQueues() - Add RefreshExpiringTokens hourly command for proactive token refresh - Fix token leaks: redact response bodies in all Log::error calls - Fix token leaks: remove $response->body() from exception messages - Fix ConnectionVerifier: redact all refresh error logs - Fix null checks on API response IDs (Instagram, Threads, Pinterest, Facebook) - Fix PublishPost::failed() to mark post as failed - Fix StoreChunkedMediaRequest: validate max 1GB total size - Fix scheduled_at validation: string → date - Fix StoreMediaRequest: images max 10MB, videos max 1GB, only MP4 video
192 lines
6.5 KiB
PHP
192 lines
6.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Enums\PostPlatform\ContentType;
|
|
use App\Enums\SocialAccount\Platform;
|
|
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\YouTubePublisher;
|
|
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()->youtube()->create([
|
|
'workspace_id' => $this->workspace->id,
|
|
'platform_user_id' => 'UC_channel_123',
|
|
'username' => 'mychannel',
|
|
'token_expires_at' => now()->addDays(7),
|
|
'meta' => [
|
|
'channel_id' => 'UC_channel_123',
|
|
'google_user_id' => 'google_user_123',
|
|
],
|
|
]);
|
|
|
|
$this->post = Post::factory()->create([
|
|
'workspace_id' => $this->workspace->id,
|
|
'user_id' => $this->user->id,
|
|
]);
|
|
|
|
$this->postPlatform = PostPlatform::factory()->youtube()->create([
|
|
'post_id' => $this->post->id,
|
|
'social_account_id' => $this->socialAccount->id,
|
|
'platform' => Platform::YouTube,
|
|
'content_type' => ContentType::YouTubeShort,
|
|
'content' => 'Check out this YouTube Short!',
|
|
]);
|
|
|
|
$this->publisher = new YouTubePublisher;
|
|
});
|
|
|
|
test('youtube publisher throws exception when no media', function () {
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
->toThrow(Exception::class, 'YouTube Shorts requires a video to publish.');
|
|
});
|
|
|
|
test('youtube publisher throws exception for non-video content', function () {
|
|
$this->postPlatform->media()->create([
|
|
'collection' => 'default',
|
|
'type' => 'image',
|
|
'path' => 'media/2026-01/image.jpg',
|
|
'original_filename' => 'image.jpg',
|
|
'mime_type' => 'image/jpeg',
|
|
'size' => 512000,
|
|
'order' => 0,
|
|
]);
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
->toThrow(Exception::class, 'YouTube Shorts only supports video content.');
|
|
});
|
|
|
|
test('youtube publisher refreshes token when expired', function () {
|
|
$this->socialAccount->update(['token_expires_at' => now()->subHour()]);
|
|
|
|
$this->postPlatform->media()->create([
|
|
'collection' => 'default',
|
|
'type' => 'video',
|
|
'path' => 'media/2026-01/test-video.mp4',
|
|
'original_filename' => 'test-video.mp4',
|
|
'mime_type' => 'video/mp4',
|
|
'size' => 1024000,
|
|
'order' => 0,
|
|
]);
|
|
|
|
Http::fake([
|
|
'https://oauth2.googleapis.com/token' => Http::response([
|
|
'access_token' => 'new-access-token',
|
|
'refresh_token' => 'new-refresh-token',
|
|
'expires_in' => 3600,
|
|
], 200),
|
|
'*' => Http::response(['error' => ['message' => 'Test']], 400),
|
|
]);
|
|
|
|
try {
|
|
$this->publisher->publish($this->postPlatform);
|
|
} catch (Exception $e) {
|
|
// Expected to fail on upload, but token should be refreshed
|
|
}
|
|
|
|
Http::assertSent(function ($request) {
|
|
return str_contains($request->url(), 'oauth2.googleapis.com/token');
|
|
});
|
|
|
|
$this->socialAccount->refresh();
|
|
expect($this->socialAccount->access_token)->toBe('new-access-token');
|
|
});
|
|
|
|
test('youtube publisher throws exception when no refresh token available', function () {
|
|
$this->socialAccount->update([
|
|
'token_expires_at' => now()->subHour(),
|
|
'refresh_token' => null,
|
|
]);
|
|
|
|
$this->postPlatform->media()->create([
|
|
'collection' => 'default',
|
|
'type' => 'video',
|
|
'path' => 'media/2026-01/test-video.mp4',
|
|
'original_filename' => 'test-video.mp4',
|
|
'mime_type' => 'video/mp4',
|
|
'size' => 1024000,
|
|
'order' => 0,
|
|
]);
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
->toThrow(TokenExpiredException::class, 'No refresh token available for YouTube account');
|
|
});
|
|
|
|
test('youtube publisher throws exception on api init error', function () {
|
|
$this->postPlatform->media()->create([
|
|
'collection' => 'default',
|
|
'type' => 'video',
|
|
'path' => 'media/2026-01/test-video.mp4',
|
|
'original_filename' => 'test-video.mp4',
|
|
'mime_type' => 'video/mp4',
|
|
'size' => 1024000,
|
|
'order' => 0,
|
|
]);
|
|
|
|
Http::fake([
|
|
'https://www.googleapis.com/upload/youtube/v3/videos*' => Http::response([
|
|
'error' => [
|
|
'message' => 'Invalid request',
|
|
],
|
|
], 400),
|
|
]);
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
->toThrow(Exception::class);
|
|
});
|
|
|
|
// Note: Testing token expiration on auth error would require mocking file_get_contents
|
|
// which is used to fetch video content. The token refresh test above covers the token
|
|
// expiration handling. Full integration tests should cover the 401 error scenario.
|
|
|
|
test('youtube publisher throws exception with null content', function () {
|
|
$this->postPlatform->update(['content' => null]);
|
|
|
|
$this->postPlatform->media()->create([
|
|
'collection' => 'default',
|
|
'type' => 'video',
|
|
'path' => 'media/2026-01/test-video.mp4',
|
|
'original_filename' => 'test.mp4',
|
|
'mime_type' => 'video/mp4',
|
|
'size' => 1234567,
|
|
'order' => 0,
|
|
]);
|
|
|
|
expect(fn () => $this->publisher->publish($this->postPlatform))
|
|
->toThrow(Exception::class, 'YouTube Shorts require a title');
|
|
});
|
|
|
|
test('youtube publisher builds correct title with shorts tag', function () {
|
|
$publisher = new YouTubePublisher;
|
|
$reflection = new ReflectionClass($publisher);
|
|
$method = $reflection->getMethod('buildTitle');
|
|
$method->setAccessible(true);
|
|
|
|
// Short content: appends #Shorts
|
|
$title = $method->invoke($publisher, 'My awesome short video');
|
|
expect($title)->toBe('My awesome short video #Shorts');
|
|
|
|
// Long content: truncates to leave room for #Shorts tag (100 chars max)
|
|
$longContent = str_repeat('A', 200);
|
|
$title = $method->invoke($publisher, $longContent);
|
|
expect(strlen($title))->toBeLessThanOrEqual(100);
|
|
expect($title)->toEndWith(' #Shorts');
|
|
|
|
// Multi-line content: only uses first line before period
|
|
$multiLine = "First sentence. Second part.\nSecond line";
|
|
$title = $method->invoke($publisher, $multiLine);
|
|
expect($title)->toBe('First sentence #Shorts');
|
|
|
|
// Newline-separated: stops at newline
|
|
$newlineContent = "Title line\nMore content here";
|
|
$title = $method->invoke($publisher, $newlineContent);
|
|
expect($title)->toBe('Title line #Shorts');
|
|
});
|