diff --git a/app/Enums/PostPlatform/ContentType.php b/app/Enums/PostPlatform/ContentType.php index 0c525df9..02a27e9b 100644 --- a/app/Enums/PostPlatform/ContentType.php +++ b/app/Enums/PostPlatform/ContentType.php @@ -216,6 +216,20 @@ public function supportsImage(): bool }; } + /** + * Whether a single post may carry images and a video together. Most + * targets accept only one or the other; this is conservative and lists + * only the types we've confirmed reject mixed media (e.g. a Bluesky post + * embed is `images` XOR `video`, never both). + */ + public function supportsMixedMedia(): bool + { + return match ($this) { + self::BlueskyPost => false, + default => true, + }; + } + /** * Whether this content type carries a text caption visible to viewers. * Stories are image-overlay only — viewers don't see a separate caption. diff --git a/app/Rules/ContentTypeCompatibleWithMedia.php b/app/Rules/ContentTypeCompatibleWithMedia.php index d0c8df5f..2fd2e5f1 100644 --- a/app/Rules/ContentTypeCompatibleWithMedia.php +++ b/app/Rules/ContentTypeCompatibleWithMedia.php @@ -61,6 +61,10 @@ public function validate(string $attribute, mixed $value, Closure $fail): void if ($hasVideo && ! $contentType->supportsVideo()) { $fail("{$contentType->label()} does not support videos."); } + + if ($hasImage && $hasVideo && ! $contentType->supportsMixedMedia()) { + $fail("{$contentType->label()} can't combine an image and a video in the same post."); + } } /** diff --git a/app/Services/Social/BlueskyLexicon.php b/app/Services/Social/BlueskyLexicon.php index 048f87ae..0fbc70be 100644 --- a/app/Services/Social/BlueskyLexicon.php +++ b/app/Services/Social/BlueskyLexicon.php @@ -21,6 +21,12 @@ final class BlueskyLexicon public const REFRESH_SESSION = 'com.atproto.server.refreshSession'; + public const GET_SERVICE_AUTH = 'com.atproto.server.getServiceAuth'; + + public const VIDEO_UPLOAD = 'app.bsky.video.uploadVideo'; + + public const VIDEO_GET_JOB_STATUS = 'app.bsky.video.getJobStatus'; + public const FEED_POST = 'app.bsky.feed.post'; public const GET_POSTS = 'app.bsky.feed.getPosts'; @@ -29,6 +35,8 @@ final class BlueskyLexicon public const EMBED_IMAGES = 'app.bsky.embed.images'; + public const EMBED_VIDEO = 'app.bsky.embed.video'; + public const FACET_LINK = 'app.bsky.richtext.facet#link'; public const FACET_MENTION = 'app.bsky.richtext.facet#mention'; diff --git a/app/Services/Social/BlueskyPublisher.php b/app/Services/Social/BlueskyPublisher.php index cc998718..95c63fd3 100644 --- a/app/Services/Social/BlueskyPublisher.php +++ b/app/Services/Social/BlueskyPublisher.php @@ -10,6 +10,7 @@ use App\Models\SocialAccount; use App\Services\Media\MediaOptimizer; use App\Services\Social\Concerns\HasSocialHttpClient; +use Carbon\CarbonInterface; use Exception; use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; @@ -20,6 +21,22 @@ class BlueskyPublisher { use HasSocialHttpClient; + /** Seconds allowed for a remote media download (large videos need time). */ + private const DOWNLOAD_TIMEOUT = 600; + + /** Re-upload a transiently-failing transcode this many times before giving up. */ + private const VIDEO_UPLOAD_ATTEMPTS = 3; + + /** Poll getJobStatus up to this many times (× the configured interval) before timing out. */ + private const VIDEO_POLL_MAX_ATTEMPTS = 150; + + /** Wall-clock budget (seconds) for the whole upload+poll+retry flow, kept under the 600s job timeout so a stuck transcode degrades to text instead of being killed mid-flight. */ + private const VIDEO_PROCESSING_BUDGET = 420; + + private const JOB_STATE_COMPLETED = 'JOB_STATE_COMPLETED'; + + private const JOB_STATE_FAILED = 'JOB_STATE_FAILED'; + public function publish(PostPlatform $postPlatform): array { $this->validateContentLength($postPlatform); @@ -60,6 +77,23 @@ public function publish(PostPlatform $postPlatform): array } } + // A post carries either images or a single video, never both. Only look + // for a video when no image embed was built (mirrors the official client). + if ($embed === null) { + $video = $medias->first(fn ($media) => $media->isVideo()); + + if ($video) { + $videoBlob = $this->uploadVideo($account, $service, $video->url, $video->mime_type); + + if ($videoBlob) { + $embed = [ + '$type' => BlueskyLexicon::EMBED_VIDEO, + 'video' => $videoBlob, + ]; + } + } + } + // Parse facets (links, mentions, hashtags) from text $text = $content ?? ''; $facets = $this->parseFacets($text); @@ -109,27 +143,16 @@ public function publish(PostPlatform $postPlatform): array private function uploadBlob(SocialAccount $account, string $service, string $url, string $mimeType): ?array { - $tempFile = tempnam(sys_get_temp_dir(), 'bsky_blob_'); + $tempFile = $this->downloadToTempFile($url, 'bsky_blob_'); + + if ($tempFile === null) { + return null; + } try { - $downloadResponse = Http::withOptions(['sink' => $tempFile])->timeout(600)->get($url); - - if ($downloadResponse->failed()) { - throw new Exception('Failed to download media: HTTP '.$downloadResponse->status()); - } - - $fileSize = filesize($tempFile); - - if ($fileSize === false || $fileSize === 0) { - Log::error('Bluesky failed to download media', ['url' => $url]); - - return null; - } - - // Optimize images for Bluesky's 1MB limit + // Optimize images for Bluesky's 1MB limit (GIFs are passed through untouched). if (str_starts_with($mimeType, 'image/') && ! str_starts_with($mimeType, 'image/gif')) { - $optimizer = app(MediaOptimizer::class); - $optimizedPath = $optimizer->optimizeImage($tempFile, Platform::Bluesky); + $optimizedPath = app(MediaOptimizer::class)->optimizeImage($tempFile, Platform::Bluesky); @unlink($tempFile); $tempFile = $optimizedPath; $mimeType = 'image/jpeg'; @@ -137,6 +160,12 @@ private function uploadBlob(SocialAccount $account, string $service, string $url $stream = fopen($tempFile, 'r'); + if ($stream === false) { + Log::error('Bluesky could not open media file for upload', ['file' => $tempFile]); + + return null; + } + $response = $this->socialHttp()->withToken($account->access_token) ->withHeaders(['Content-Type' => $mimeType]) ->withBody($stream, $mimeType) @@ -156,7 +185,7 @@ private function uploadBlob(SocialAccount $account, string $service, string $url } return data_get($response->json(), 'blob'); - } catch (Exception $e) { + } catch (Throwable $e) { Log::error('Bluesky blob upload exception', [ 'error' => $e->getMessage(), 'url' => $url, @@ -168,6 +197,357 @@ private function uploadBlob(SocialAccount $account, string $service, string $url } } + /** + * Download a remote media file to a temp file. Returns the temp path, or + * null (after cleaning up) if the temp file can't be created, the download + * fails, or the downloaded file is empty. + */ + private function downloadToTempFile(string $url, string $prefix): ?string + { + $tempFile = tempnam(sys_get_temp_dir(), $prefix); + + if ($tempFile === false) { + Log::error('Bluesky could not create temp file for download', ['url' => $url]); + + return null; + } + + try { + $response = Http::withOptions(['sink' => $tempFile])->timeout(self::DOWNLOAD_TIMEOUT)->get($url); + + if ($response->failed()) { + throw new Exception('HTTP '.$response->status()); + } + + $size = filesize($tempFile); + + if ($size === false || $size === 0) { + throw new Exception('downloaded file is empty'); + } + + return $tempFile; + } catch (Throwable $e) { + Log::error('Bluesky media download failed', ['url' => $url, 'error' => $e->getMessage()]); + @unlink($tempFile); + + return null; + } + } + + /** + * Upload a video to Bluesky and return the processed blob for embedding. + * + * Unlike images, video does not go to the PDS via uploadBlob. It is sent to + * the separate video service (video.bsky.app), which transcodes it and + * stores the resulting blob on the account's PDS. The flow is: + * 1. resolve the account's real PDS host (for the upload-token audience), + * 2. mint two service-auth tokens — one for the video service to write + * the blob back to the PDS (uploadBlob), one to poll job status, + * 3. POST the bytes to app.bsky.video.uploadVideo, + * 4. poll app.bsky.video.getJobStatus until the blob is ready, + * retrying the whole upload a few times on a transient transcode failure. + * + * Returns null on any failure so the post still publishes as text rather + * than crashing the whole job (mirrors uploadBlob()). + */ + private function uploadVideo(SocialAccount $account, string $service, string $url, ?string $mimeType): ?array + { + $tempFile = $this->downloadToTempFile($url, 'bsky_video_'); + + if ($tempFile === null) { + return null; + } + + try { + $fileSize = filesize($tempFile); + + // Bluesky caps videos at 100MB; skip oversized files rather than + // burning an upload that the service will reject. + if ($fileSize > (int) config('trypost.platforms.bluesky.video_max_bytes')) { + Log::error('Bluesky video exceeds size limit', ['url' => $url, 'size' => $fileSize]); + + return null; + } + + $pds = $this->resolvePdsEndpoint($account, $service); + $pdsHost = parse_url($pds, PHP_URL_HOST); + + if (! is_string($pdsHost) || $pdsHost === '') { + Log::error('Bluesky could not resolve PDS host for video upload', ['did' => $account->platform_user_id]); + + return null; + } + + // Two service-auth tokens, minted once (valid 30 min) and reused + // across retries: + // - upload: lets the video service write the blob back to the + // user's PDS, so its audience is the PDS itself; + // - status: lets us poll the video service for the transcode job. + $uploadToken = $this->getServiceAuth($account, $pds, "did:web:{$pdsHost}", BlueskyLexicon::UPLOAD_BLOB); + $statusToken = $this->getServiceAuth($account, $pds, (string) config('trypost.platforms.bluesky.video_service_did'), BlueskyLexicon::VIDEO_GET_JOB_STATUS); + + if ($uploadToken === null || $statusToken === null) { + return null; + } + + // Bound the whole upload+poll+retry flow to a wall-clock budget that + // stays under the queue job timeout: a stuck transcode must give up + // and let the post publish as text, not run the worker to its + // timeout (which would drop the post entirely on a $tries=1 job). + $deadline = now()->addSeconds(self::VIDEO_PROCESSING_BUDGET); + + // The transcoder occasionally fails a job transiently + // (JOB_STATE_FAILED "Failed to process video") even for valid input. + // Re-uploading starts a fresh job, so retry until the budget runs out. + for ($attempt = 1; $attempt <= self::VIDEO_UPLOAD_ATTEMPTS && now()->lessThan($deadline); $attempt++) { + $blob = $this->attemptVideoUpload($account, $uploadToken, $statusToken, $tempFile, $mimeType, $deadline); + + if ($blob !== null) { + return $blob; + } + + if ($attempt < self::VIDEO_UPLOAD_ATTEMPTS) { + Log::warning('Bluesky video upload attempt failed, retrying', [ + 'attempt' => $attempt, + 'url' => $url, + ]); + } + } + + return null; + } catch (Throwable $e) { + Log::error('Bluesky video upload exception', [ + 'error' => $e->getMessage(), + 'url' => $url, + ]); + + return null; + } finally { + @unlink($tempFile); + } + } + + /** + * A single upload-and-poll attempt against the video service. Returns the + * processed blob, or null if this attempt failed (so the caller can retry). + */ + private function attemptVideoUpload(SocialAccount $account, string $uploadToken, string $statusToken, string $tempFile, ?string $mimeType, CarbonInterface $deadline): ?array + { + $stream = fopen($tempFile, 'r'); + + if ($stream === false) { + Log::error('Bluesky could not open video file for upload', ['file' => $tempFile]); + + return null; + } + + [$contentType, $extension] = $this->videoUploadFormat($mimeType); + $videoService = (string) config('trypost.platforms.bluesky.video_service'); + $name = bin2hex(random_bytes(8)).'.'.$extension; + $uploadUrl = "{$videoService}/xrpc/".BlueskyLexicon::VIDEO_UPLOAD + .'?did='.rawurlencode($account->platform_user_id).'&name='.rawurlencode($name); + + $response = $this->socialHttp()->withToken($uploadToken) + ->withHeaders(['Content-Type' => $contentType]) + ->withBody($stream, $contentType) + ->post($uploadUrl); + + if (is_resource($stream)) { + fclose($stream); + } + + // A re-upload of identical bytes returns 409 with the already-finished + // job, whose blob we can embed directly. + if ($response->failed() && $response->status() !== 409) { + Log::error('Bluesky video upload failed', [ + 'status' => $response->status(), + 'body' => $this->redactResponseBody($response->body()), + ]); + + return null; + } + + $jobStatus = $this->unwrapJobStatus($response->json()); + + if ($blob = data_get($jobStatus, 'blob')) { + return $blob; + } + + $jobId = data_get($jobStatus, 'jobId'); + + if (! is_string($jobId) || $jobId === '') { + Log::error('Bluesky video upload returned no jobId', [ + 'body' => $this->redactResponseBody($response->body()), + ]); + + return null; + } + + return $this->pollVideoJob($statusToken, $jobId, $deadline); + } + + /** + * Poll the video service until the transcode job finishes, then return its + * blob. Returns null if the job fails or never completes within the shared + * deadline. The status token is minted by the caller and reused per poll. + */ + private function pollVideoJob(string $statusToken, string $jobId, CarbonInterface $deadline): ?array + { + $statusUrl = (string) config('trypost.platforms.bluesky.video_service').'/xrpc/'.BlueskyLexicon::VIDEO_GET_JOB_STATUS; + $intervalSeconds = (int) config('trypost.platforms.bluesky.video_poll_seconds'); + + // Processing usually finishes within seconds. State is checked before + // sleeping so an already-complete job returns at once. The attempt cap + // and the wall-clock deadline both bound the loop. + for ($attempt = 0; $attempt < self::VIDEO_POLL_MAX_ATTEMPTS && now()->lessThan($deadline); $attempt++) { + $response = $this->socialHttp()->withToken($statusToken) + ->get($statusUrl, ['jobId' => $jobId]); + + // Bail on a hard error (e.g. expired/invalid token) instead of + // sleeping to the timeout and masking the real failure. + if ($response->failed()) { + Log::error('Bluesky getJobStatus failed', [ + 'jobId' => $jobId, + 'status' => $response->status(), + 'body' => $this->redactResponseBody($response->body()), + ]); + + return null; + } + + $jobStatus = $this->unwrapJobStatus($response->json()); + $state = data_get($jobStatus, 'state'); + + if ($state === self::JOB_STATE_COMPLETED && ($blob = data_get($jobStatus, 'blob'))) { + return $blob; + } + + if ($state === self::JOB_STATE_FAILED) { + Log::error('Bluesky video processing failed', [ + 'jobId' => $jobId, + 'message' => data_get($jobStatus, 'message'), + ]); + + return null; + } + + sleep($intervalSeconds); + } + + Log::error('Bluesky video processing timed out', ['jobId' => $jobId]); + + return null; + } + + /** + * uploadVideo returns the job status at the top level, while getJobStatus + * wraps it under a `jobStatus` key. Fall back to the top level only when the + * key is absent (null), not when it's present-but-empty. + */ + private function unwrapJobStatus(mixed $body): mixed + { + return data_get($body, 'jobStatus') ?? $body; + } + + /** + * Map a video mime type to the [Content-Type, file extension] the upload + * should carry. Bluesky accepts mp4, mpeg, webm and mov; anything else + * (or an unknown mime) is sent as mp4 and left to the transcoder. + * + * @return array{0: string, 1: string} + */ + private function videoUploadFormat(?string $mimeType): array + { + return match ($mimeType) { + 'video/mpeg' => ['video/mpeg', 'mpeg'], + 'video/webm' => ['video/webm', 'webm'], + 'video/quicktime' => ['video/quicktime', 'mov'], + default => ['video/mp4', 'mp4'], + }; + } + + /** + * Mint a short-lived service-auth token (com.atproto.server.getServiceAuth) + * scoped to a single audience + method, used to authorize the video service. + */ + private function getServiceAuth(SocialAccount $account, string $pds, string $aud, string $lxm): ?string + { + try { + $response = $this->socialHttp()->withToken($account->access_token) + ->get("{$pds}/xrpc/".BlueskyLexicon::GET_SERVICE_AUTH, [ + 'aud' => $aud, + 'lxm' => $lxm, + 'exp' => now()->addMinutes(30)->timestamp, + ]); + + if ($response->failed()) { + Log::error('Bluesky getServiceAuth failed', [ + 'status' => $response->status(), + 'lxm' => $lxm, + 'body' => $this->redactResponseBody($response->body()), + ]); + + return null; + } + + $token = data_get($response->json(), 'token'); + + return is_string($token) && $token !== '' ? $token : null; + } catch (Throwable $e) { + Log::error('Bluesky getServiceAuth exception', ['error' => $e->getMessage(), 'lxm' => $lxm]); + + return null; + } + } + + /** + * Resolve the account's real PDS service endpoint from its DID document. + * Bluesky entryway accounts store the entryway URL as `service`, but video + * service-auth must be scoped to the actual PDS host (e.g. *.host.bsky.network). + * Falls back to the stored service URL when the DID document is unavailable. + */ + private function resolvePdsEndpoint(SocialAccount $account, string $service): string + { + $did = (string) $account->platform_user_id; + + try { + $docUrl = null; + if (str_starts_with($did, 'did:plc:')) { + $directory = (string) config('trypost.platforms.bluesky.plc_directory'); + $docUrl = "{$directory}/".rawurlencode($did); + } elseif (str_starts_with($did, 'did:web:')) { + // Per the did:web spec, colon-separated segments map to a host + // plus optional path; a bare host uses /.well-known/did.json. + $segments = array_map('rawurldecode', explode(':', substr($did, strlen('did:web:')))); + $host = array_shift($segments); + $path = $segments === [] ? '/.well-known/did.json' : '/'.implode('/', $segments).'/did.json'; + $docUrl = "https://{$host}{$path}"; + } + + if ($docUrl !== null) { + $response = $this->socialHttp()->get($docUrl); + + if ($response->successful()) { + $services = data_get($response->json(), 'service', []); + foreach ($services as $entry) { + $type = data_get($entry, 'type'); + $id = data_get($entry, 'id'); + if ($type === 'AtprotoPersonalDataServer' || $id === '#atproto_pds') { + $endpoint = data_get($entry, 'serviceEndpoint'); + if (is_string($endpoint) && $endpoint !== '') { + return $endpoint; + } + } + } + } + } + } catch (Throwable $e) { + Log::warning('Bluesky PDS resolution failed', ['did' => $did, 'error' => $e->getMessage()]); + } + + return $service; + } + private function parseFacets(string $text): array { $facets = []; diff --git a/config/trypost.php b/config/trypost.php index 16bac3f5..761be98f 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -147,6 +147,16 @@ 'default_service' => env('BLUESKY_DEFAULT_SERVICE', 'https://bsky.social'), // Web client where published posts are viewed (profile/post URLs). 'web_app' => env('BLUESKY_WEB_APP', 'https://bsky.app'), + // Video upload service (separate from the PDS). Videos are processed + // here, then the resulting blob is embedded in the post record. + 'video_service' => env('BLUESKY_VIDEO_SERVICE', 'https://video.bsky.app'), + 'video_service_did' => env('BLUESKY_VIDEO_SERVICE_DID', 'did:web:video.bsky.app'), + // Seconds between transcode job-status polls. + 'video_poll_seconds' => env('BLUESKY_VIDEO_POLL_SECONDS', 2), + // Bluesky rejects videos larger than 100 MB; skip oversized files early. + 'video_max_bytes' => env('BLUESKY_VIDEO_MAX_BYTES', 100 * 1024 * 1024), + // PLC directory, used to resolve an account's real PDS host from its DID. + 'plc_directory' => env('BLUESKY_PLC_DIRECTORY', 'https://plc.directory'), ], 'mastodon' => [ 'enabled' => env('MASTODON_ENABLED', true), diff --git a/lang/en/posts.php b/lang/en/posts.php index 09e66b58..d3f3f7dd 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -342,6 +342,7 @@ 'too_few_files' => 'Add at least :min files for this format.', 'no_videos' => 'Only images are allowed for this format.', 'no_images' => 'Only videos are allowed for this format.', + 'no_mixed_media' => "Images and videos can't be combined in the same post.", 'no_gifs' => 'GIFs are not supported here.', 'video_too_large' => 'Video exceeds the size limit for this platform.', 'video_too_long' => 'Video must be under :seconds seconds for this format.', diff --git a/lang/es/posts.php b/lang/es/posts.php index cbe0df58..c52e8895 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -342,6 +342,7 @@ 'too_few_files' => 'Agrega al menos :min archivos para este formato.', 'no_videos' => 'Solo se permiten imágenes en este formato.', 'no_images' => 'Solo se permiten videos en este formato.', + 'no_mixed_media' => 'No se pueden combinar imágenes y videos en la misma publicación.', 'no_gifs' => 'Los GIFs no son compatibles aquí.', 'video_too_large' => 'El video supera el límite de tamaño de esta plataforma.', 'video_too_long' => 'El video debe durar menos de :seconds segundos en este formato.', diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index ade99dd0..3b580dcb 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -342,6 +342,7 @@ 'too_few_files' => 'Adicione pelo menos :min arquivos para este formato.', 'no_videos' => 'Apenas imagens são permitidas neste formato.', 'no_images' => 'Apenas vídeos são permitidos neste formato.', + 'no_mixed_media' => 'Não é possível combinar imagem e vídeo na mesma publicação.', 'no_gifs' => 'GIFs não são suportados aqui.', 'video_too_large' => 'Vídeo excede o limite de tamanho desta plataforma.', 'video_too_long' => 'Vídeo deve ter menos de :seconds segundos neste formato.', diff --git a/resources/js/composables/useMediaRules.ts b/resources/js/composables/useMediaRules.ts index 8c3b08e9..aa5624d5 100644 --- a/resources/js/composables/useMediaRules.ts +++ b/resources/js/composables/useMediaRules.ts @@ -7,6 +7,7 @@ export interface MediaRules { acceptVideos: boolean; requiresMedia: boolean; acceptsGif: boolean; + forbidsMixedMedia?: boolean; maxImageBytes?: number; maxVideoBytes?: number; maxVideoDurationSec?: number; @@ -131,10 +132,11 @@ const CONTENT_TYPE_RULES: Record = { maxImageBytes: 8 * MB, maxVideoBytes: 1 * GB, maxVideoDurationSec: 5 * 60, }, - // Bluesky — accepts GIF; tight image size (auto-resized by backend) + // Bluesky — accepts GIF; tight image size (auto-resized by backend). + // The embed is images XOR video, so the two can't be combined in one post. bluesky_post: { maxFiles: 4, acceptImages: true, acceptVideos: true, requiresMedia: false, - acceptsGif: true, + acceptsGif: true, forbidsMixedMedia: true, maxVideoBytes: 100 * MB, maxVideoDurationSec: 60, }, diff --git a/resources/js/composables/usePostCompliance.ts b/resources/js/composables/usePostCompliance.ts index 9a855247..fed0cfed 100644 --- a/resources/js/composables/usePostCompliance.ts +++ b/resources/js/composables/usePostCompliance.ts @@ -99,6 +99,7 @@ export const getMediaIncompatibilityReason = ( if (rules.requiresMedia && total === 0) return trans('posts.edit.compliance.requires_media'); if (!rules.acceptVideos && videos.length > 0) return trans('posts.edit.compliance.no_videos'); if (!rules.acceptImages && images.length > 0) return trans('posts.edit.compliance.no_images'); + if (rules.forbidsMixedMedia && videos.length > 0 && images.length > 0) return trans('posts.edit.compliance.no_mixed_media'); if (!rules.acceptsGif && gifs.length > 0) return trans('posts.edit.compliance.no_gifs'); if (total > rules.maxFiles) return trans('posts.edit.compliance.too_many_files', { max: String(rules.maxFiles) }); if (rules.minFiles && total < rules.minFiles) return trans('posts.edit.compliance.too_few_files', { min: String(rules.minFiles) }); diff --git a/tests/Feature/Services/Social/BlueskyPublisherTest.php b/tests/Feature/Services/Social/BlueskyPublisherTest.php index 76c648ad..6bd662c6 100644 --- a/tests/Feature/Services/Social/BlueskyPublisherTest.php +++ b/tests/Feature/Services/Social/BlueskyPublisherTest.php @@ -594,3 +594,767 @@ && count($request['record']['embed']['images'] ?? []) === 4; }); }); + +/** + * Attach a single video to the post under test (mp4 by default). + */ +function attachBlueskyVideo(Post $post, string $mimeType = 'video/mp4'): void +{ + $post->update([ + 'media' => [[ + 'id' => 'test-video-id', + 'path' => 'media/2026-01/test-video.mp4', + 'url' => 'https://example.com/media/2026-01/test-video.mp4', + 'mime_type' => $mimeType, + 'original_filename' => 'test.mp4', + ]], + ]); +} + +/** + * Fake the Bluesky video pipeline. Order matters: getServiceAuth must be + * matched before uploadBlob because its query carries lxm=...uploadBlob. + */ +function fakeBlueskyVideoPipeline(string $jobState = 'JOB_STATE_COMPLETED', bool $blobOnComplete = true): void +{ + // Poll without sleeping so multi-poll paths stay instant under test. + config(['trypost.platforms.bluesky.video_poll_seconds' => 0]); + + Http::fake(function ($request) use ($jobState, $blobOnComplete) { + $url = $request->url(); + + if (str_contains($url, 'plc.directory')) { + return Http::response([ + 'service' => [[ + 'id' => '#atproto_pds', + 'type' => 'AtprotoPersonalDataServer', + 'serviceEndpoint' => 'https://pds.example.host', + ]], + ], 200); + } + + if (str_contains($url, 'getServiceAuth')) { + return Http::response(['token' => 'service-auth-token'], 200); + } + + if (str_contains($url, 'app.bsky.video.uploadVideo')) { + // uploadVideo returns the jobStatus object directly (not wrapped). + return Http::response(['jobId' => 'job-123', 'did' => 'did:plc:testuser123', 'state' => 'JOB_STATE_CREATED'], 200); + } + + if (str_contains($url, 'app.bsky.video.getJobStatus')) { + $status = ['jobId' => 'job-123', 'state' => $jobState]; + if ($jobState === 'JOB_STATE_COMPLETED' && $blobOnComplete) { + $status['blob'] = ['$type' => 'blob', 'ref' => ['$link' => 'bafvideo123'], 'mimeType' => 'video/mp4', 'size' => 2048]; + } + if ($jobState === 'JOB_STATE_FAILED') { + $status['message'] = 'processing error'; + } + + return Http::response(['jobStatus' => $status], 200); + } + + if (str_contains($url, 'createRecord')) { + return Http::response([ + 'uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3vid123xyz', + 'cid' => 'bafyreivid123', + ], 200); + } + + return Http::response(str_repeat('v', 2048), 200); // video download + }); +} + +test('bluesky publisher uploads a video and embeds it', function () { + attachBlueskyVideo($this->post); + + fakeBlueskyVideoPipeline(); + + $this->publisher->publish($this->postPlatform); + + Http::assertSent(function ($request) { + if (! str_contains($request->url(), 'createRecord')) { + return false; + } + + $embed = $request['record']['embed'] ?? null; + + return $embed + && $embed['$type'] === 'app.bsky.embed.video' + && data_get($embed, 'video.ref.$link') === 'bafvideo123'; + }); + + // The video goes to the video service, never to the PDS uploadBlob endpoint. + Http::assertSent(fn ($request) => str_contains($request->url(), 'app.bsky.video.uploadVideo')); +}); + +test('bluesky publisher scopes the upload service-auth to the resolved PDS host', function () { + attachBlueskyVideo($this->post); + + fakeBlueskyVideoPipeline(); + + $this->publisher->publish($this->postPlatform); + + // Audience for the upload token must be the real PDS host from the DID doc, + // not the bsky.social entryway the account was connected through. + Http::assertSent(function ($request) { + return str_contains($request->url(), 'getServiceAuth') + && str_contains($request->url(), 'aud=did%3Aweb%3Apds.example.host') + && str_contains($request->url(), 'lxm=com.atproto.repo.uploadBlob'); + }); +}); + +test('bluesky publisher publishes text-only when video processing fails', function () { + $this->post->update([ + 'media' => [[ + 'id' => 'test-video-id', + 'path' => 'media/2026-01/test-video.mp4', + 'url' => 'https://example.com/media/2026-01/test-video.mp4', + 'mime_type' => 'video/mp4', + 'original_filename' => 'test.mp4', + ]], + ]); + + fakeBlueskyVideoPipeline('JOB_STATE_FAILED'); + + $this->publisher->publish($this->postPlatform); + + // A failed transcode must not crash the job; the post still goes out as text. + Http::assertSent(function ($request) { + return str_contains($request->url(), 'createRecord') + && ! isset($request['record']['embed']); + }); +}); + +test('bluesky publisher retries a transient video transcode failure', function () { + attachBlueskyVideo($this->post); + config(['trypost.platforms.bluesky.video_poll_seconds' => 0]); + + $jobCalls = 0; + Http::fake(function ($request) use (&$jobCalls) { + $url = $request->url(); + + if (str_contains($url, 'plc.directory')) { + return Http::response(['service' => [[ + 'id' => '#atproto_pds', + 'type' => 'AtprotoPersonalDataServer', + 'serviceEndpoint' => 'https://pds.example.host', + ]]], 200); + } + if (str_contains($url, 'getServiceAuth')) { + return Http::response(['token' => 'service-auth-token'], 200); + } + if (str_contains($url, 'app.bsky.video.uploadVideo')) { + return Http::response(['jobId' => 'job-123', 'state' => 'JOB_STATE_CREATED'], 200); + } + if (str_contains($url, 'app.bsky.video.getJobStatus')) { + $jobCalls++; + // First attempt's job fails transiently; the retry succeeds. + if ($jobCalls === 1) { + return Http::response(['jobStatus' => ['jobId' => 'job-123', 'state' => 'JOB_STATE_FAILED', 'message' => 'transient']], 200); + } + + return Http::response(['jobStatus' => [ + 'jobId' => 'job-123', 'state' => 'JOB_STATE_COMPLETED', + 'blob' => ['$type' => 'blob', 'ref' => ['$link' => 'bafretry456'], 'mimeType' => 'video/mp4', 'size' => 2048], + ]], 200); + } + if (str_contains($url, 'createRecord')) { + return Http::response(['uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3retry', 'cid' => 'bafretry'], 200); + } + + return Http::response(str_repeat('v', 2048), 200); + }); + + $this->publisher->publish($this->postPlatform); + + expect($jobCalls)->toBeGreaterThanOrEqual(2); + Http::assertSent(function ($request) { + if (! str_contains($request->url(), 'createRecord')) { + return false; + } + $embed = $request['record']['embed'] ?? null; + + return $embed + && $embed['$type'] === 'app.bsky.embed.video' + && data_get($embed, 'video.ref.$link') === 'bafretry456'; + }); +}); + +test('bluesky publisher skips an oversized video and publishes text-only', function () { + attachBlueskyVideo($this->post); + config(['trypost.platforms.bluesky.video_max_bytes' => 1024]); + + Http::fake(function ($request) { + if (str_contains($request->url(), 'createRecord')) { + return Http::response(['uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3big', 'cid' => 'bafbig'], 200); + } + + // Video download is 2 KB — over the 1 KB cap set above. + return Http::response(str_repeat('v', 2048), 200); + }); + + $this->publisher->publish($this->postPlatform); + + // Oversized video is dropped before any upload; the post still goes out as text. + Http::assertSent(fn ($request) => str_contains($request->url(), 'createRecord') && ! isset($request['record']['embed'])); + Http::assertNotSent(fn ($request) => str_contains($request->url(), 'app.bsky.video.uploadVideo')); +}); + +test('bluesky publisher publishes text-only when the video download fails', function () { + attachBlueskyVideo($this->post); + + Http::fake(function ($request) { + if (str_contains($request->url(), 'createRecord')) { + return Http::response(['uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3dl', 'cid' => 'bafdl'], 200); + } + + // The CDN download 404s — no video, no service-auth, just text. + return Http::response('Not Found', 404); + }); + + $this->publisher->publish($this->postPlatform); + + Http::assertSent(fn ($request) => str_contains($request->url(), 'createRecord') && ! isset($request['record']['embed'])); + Http::assertNotSent(fn ($request) => str_contains($request->url(), 'getServiceAuth')); +}); + +test('bluesky publisher publishes text-only when service-auth minting fails', function () { + attachBlueskyVideo($this->post); + + Http::fake(function ($request) { + $url = $request->url(); + + if (str_contains($url, 'plc.directory')) { + return Http::response(['service' => [['id' => '#atproto_pds', 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => 'https://pds.example.host']]], 200); + } + if (str_contains($url, 'getServiceAuth')) { + return Http::response(['error' => 'AuthRequired'], 400); + } + if (str_contains($url, 'createRecord')) { + return Http::response(['uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3auth', 'cid' => 'bafauth'], 200); + } + + return Http::response(str_repeat('v', 2048), 200); + }); + + $this->publisher->publish($this->postPlatform); + + // Without a service-auth token the upload can't proceed; degrade to text. + Http::assertSent(fn ($request) => str_contains($request->url(), 'createRecord') && ! isset($request['record']['embed'])); + Http::assertNotSent(fn ($request) => str_contains($request->url(), 'app.bsky.video.uploadVideo')); +}); + +test('bluesky publisher embeds the existing blob when upload returns 409 already-exists', function () { + attachBlueskyVideo($this->post); + + Http::fake(function ($request) { + $url = $request->url(); + + if (str_contains($url, 'plc.directory')) { + return Http::response(['service' => [['id' => '#atproto_pds', 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => 'https://pds.example.host']]], 200); + } + if (str_contains($url, 'getServiceAuth')) { + return Http::response(['token' => 'service-auth-token'], 200); + } + if (str_contains($url, 'app.bsky.video.uploadVideo')) { + // Re-upload of identical bytes: 409 carrying the already-finished job. + return Http::response([ + 'jobId' => 'job-dup', 'state' => 'JOB_STATE_COMPLETED', + 'blob' => ['$type' => 'blob', 'ref' => ['$link' => 'bafdup789'], 'mimeType' => 'video/mp4', 'size' => 2048], + ], 409); + } + if (str_contains($url, 'createRecord')) { + return Http::response(['uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3dup', 'cid' => 'bafdup'], 200); + } + + return Http::response(str_repeat('v', 2048), 200); + }); + + $this->publisher->publish($this->postPlatform); + + Http::assertSent(function ($request) { + if (! str_contains($request->url(), 'createRecord')) { + return false; + } + $embed = $request['record']['embed'] ?? null; + + return $embed + && $embed['$type'] === 'app.bsky.embed.video' + && data_get($embed, 'video.ref.$link') === 'bafdup789'; + }); + + // The blob came straight from the 409 body; no job polling needed. Match the + // endpoint path, not the bare NSID — the status-token getServiceAuth request + // also carries `lxm=app.bsky.video.getJobStatus` in its query string. + Http::assertNotSent(fn ($request) => str_contains($request->url(), 'xrpc/app.bsky.video.getJobStatus')); +}); + +test('bluesky publisher publishes text-only when upload returns no job id', function () { + attachBlueskyVideo($this->post); + config(['trypost.platforms.bluesky.video_poll_seconds' => 0]); + + Http::fake(function ($request) { + $url = $request->url(); + + if (str_contains($url, 'plc.directory')) { + return Http::response(['service' => [['id' => '#atproto_pds', 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => 'https://pds.example.host']]], 200); + } + if (str_contains($url, 'getServiceAuth')) { + return Http::response(['token' => 'service-auth-token'], 200); + } + if (str_contains($url, 'app.bsky.video.uploadVideo')) { + return Http::response([], 200); // neither blob nor jobId + } + if (str_contains($url, 'createRecord')) { + return Http::response(['uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3nojob', 'cid' => 'bafnojob'], 200); + } + + return Http::response(str_repeat('v', 2048), 200); + }); + + $this->publisher->publish($this->postPlatform); + + Http::assertSent(fn ($request) => str_contains($request->url(), 'createRecord') && ! isset($request['record']['embed'])); +}); + +test('bluesky publisher publishes text-only when getJobStatus errors', function () { + attachBlueskyVideo($this->post); + config(['trypost.platforms.bluesky.video_poll_seconds' => 0]); + + Http::fake(function ($request) { + $url = $request->url(); + + if (str_contains($url, 'plc.directory')) { + return Http::response(['service' => [['id' => '#atproto_pds', 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => 'https://pds.example.host']]], 200); + } + if (str_contains($url, 'getServiceAuth')) { + return Http::response(['token' => 'service-auth-token'], 200); + } + if (str_contains($url, 'app.bsky.video.uploadVideo')) { + return Http::response(['jobId' => 'job-err', 'state' => 'JOB_STATE_CREATED'], 200); + } + if (str_contains($url, 'app.bsky.video.getJobStatus')) { + return Http::response(['error' => 'InternalServerError'], 500); + } + if (str_contains($url, 'createRecord')) { + return Http::response(['uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3joberr', 'cid' => 'bafjoberr'], 200); + } + + return Http::response(str_repeat('v', 2048), 200); + }); + + $this->publisher->publish($this->postPlatform); + + // A failing getJobStatus bails immediately rather than sleeping to timeout. + Http::assertSent(fn ($request) => str_contains($request->url(), 'xrpc/app.bsky.video.getJobStatus')); + Http::assertSent(fn ($request) => str_contains($request->url(), 'createRecord') && ! isset($request['record']['embed'])); +}); + +test('bluesky publisher retries the upload up to three times before giving up', function () { + attachBlueskyVideo($this->post); + config(['trypost.platforms.bluesky.video_poll_seconds' => 0]); + + $uploads = 0; + Http::fake(function ($request) use (&$uploads) { + $url = $request->url(); + + if (str_contains($url, 'plc.directory')) { + return Http::response(['service' => [['id' => '#atproto_pds', 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => 'https://pds.example.host']]], 200); + } + if (str_contains($url, 'getServiceAuth')) { + return Http::response(['token' => 'service-auth-token'], 200); + } + if (str_contains($url, 'app.bsky.video.uploadVideo')) { + $uploads++; + + return Http::response(['jobId' => "job-{$uploads}", 'state' => 'JOB_STATE_CREATED'], 200); + } + if (str_contains($url, 'app.bsky.video.getJobStatus')) { + return Http::response(['jobStatus' => ['jobId' => 'job', 'state' => 'JOB_STATE_FAILED', 'message' => 'permanent']], 200); + } + if (str_contains($url, 'createRecord')) { + return Http::response(['uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3exhaust', 'cid' => 'bafex'], 200); + } + + return Http::response(str_repeat('v', 2048), 200); + }); + + $this->publisher->publish($this->postPlatform); + + // Every attempt fails permanently → exactly VIDEO_UPLOAD_ATTEMPTS uploads, then text-only. + expect($uploads)->toBe(3); + Http::assertSent(fn ($request) => str_contains($request->url(), 'createRecord') && ! isset($request['record']['embed'])); +}); + +test('bluesky publisher times out and publishes text-only when the job never completes', function () { + attachBlueskyVideo($this->post); + config(['trypost.platforms.bluesky.video_poll_seconds' => 0]); + + Http::fake(function ($request) { + $url = $request->url(); + + if (str_contains($url, 'plc.directory')) { + return Http::response(['service' => [['id' => '#atproto_pds', 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => 'https://pds.example.host']]], 200); + } + if (str_contains($url, 'getServiceAuth')) { + return Http::response(['token' => 'service-auth-token'], 200); + } + if (str_contains($url, 'app.bsky.video.uploadVideo')) { + return Http::response(['jobId' => 'job-stuck', 'state' => 'JOB_STATE_CREATED'], 200); + } + if (str_contains($url, 'app.bsky.video.getJobStatus')) { + // Never reaches a terminal state — exercises the poll timeout. + return Http::response(['jobStatus' => ['jobId' => 'job-stuck', 'state' => 'JOB_STATE_RUNNING']], 200); + } + if (str_contains($url, 'createRecord')) { + return Http::response(['uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3stuck', 'cid' => 'bafstuck'], 200); + } + + return Http::response(str_repeat('v', 2048), 200); + }); + + $this->publisher->publish($this->postPlatform); + + Http::assertSent(fn ($request) => str_contains($request->url(), 'createRecord') && ! isset($request['record']['embed'])); +}); + +test('bluesky publisher falls back to the entryway when the DID document is unavailable', function () { + attachBlueskyVideo($this->post); + + Http::fake(function ($request) { + $url = $request->url(); + + if (str_contains($url, 'plc.directory')) { + return Http::response(['error' => 'NotFound'], 500); // DID doc unavailable + } + if (str_contains($url, 'getServiceAuth')) { + return Http::response(['token' => 'service-auth-token'], 200); + } + if (str_contains($url, 'app.bsky.video.uploadVideo')) { + return Http::response(['jobId' => 'job-fb', 'state' => 'JOB_STATE_COMPLETED', 'blob' => ['$type' => 'blob', 'ref' => ['$link' => 'baffb'], 'mimeType' => 'video/mp4', 'size' => 2048]], 200); + } + if (str_contains($url, 'createRecord')) { + return Http::response(['uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3fb', 'cid' => 'baffb'], 200); + } + + return Http::response(str_repeat('v', 2048), 200); + }); + + $this->publisher->publish($this->postPlatform); + + // With no DID doc, the upload-token audience falls back to the entryway host (bsky.social). + Http::assertSent(function ($request) { + return str_contains($request->url(), 'getServiceAuth') + && str_contains($request->url(), 'aud=did%3Aweb%3Absky.social') + && str_contains($request->url(), 'lxm=com.atproto.repo.uploadBlob'); + }); +}); + +test('bluesky publisher resolves the PDS from a did:web document', function () { + $this->socialAccount->update(['platform_user_id' => 'did:web:example.com']); + attachBlueskyVideo($this->post); + + Http::fake(function ($request) { + $url = $request->url(); + + if (str_contains($url, 'example.com/.well-known/did.json')) { + return Http::response(['service' => [['id' => '#atproto_pds', 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => 'https://pds.example.host']]], 200); + } + if (str_contains($url, 'getServiceAuth')) { + return Http::response(['token' => 'service-auth-token'], 200); + } + if (str_contains($url, 'app.bsky.video.uploadVideo')) { + return Http::response(['jobId' => 'job-web', 'state' => 'JOB_STATE_COMPLETED', 'blob' => ['$type' => 'blob', 'ref' => ['$link' => 'bafweb'], 'mimeType' => 'video/mp4', 'size' => 2048]], 200); + } + if (str_contains($url, 'createRecord')) { + return Http::response(['uri' => 'at://did:web:example.com/app.bsky.feed.post/3web', 'cid' => 'bafweb'], 200); + } + + return Http::response(str_repeat('v', 2048), 200); + }); + + $this->publisher->publish($this->postPlatform); + + Http::assertSent(fn ($request) => str_contains($request->url(), 'example.com/.well-known/did.json')); + Http::assertSent(function ($request) { + return str_contains($request->url(), 'getServiceAuth') + && str_contains($request->url(), 'aud=did%3Aweb%3Apds.example.host') + && str_contains($request->url(), 'lxm=com.atproto.repo.uploadBlob'); + }); +}); + +test('bluesky publisher builds the did:web document url from colon path segments', function () { + $this->socialAccount->update(['platform_user_id' => 'did:web:example.com:user:alice']); + attachBlueskyVideo($this->post); + + Http::fake(function ($request) { + $url = $request->url(); + + if (str_contains($url, 'example.com/user/alice/did.json')) { + return Http::response(['service' => [['id' => '#atproto_pds', 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => 'https://pds.example.host']]], 200); + } + if (str_contains($url, 'getServiceAuth')) { + return Http::response(['token' => 'service-auth-token'], 200); + } + if (str_contains($url, 'app.bsky.video.uploadVideo')) { + return Http::response(['jobId' => 'job-seg', 'state' => 'JOB_STATE_COMPLETED', 'blob' => ['$type' => 'blob', 'ref' => ['$link' => 'bafseg'], 'mimeType' => 'video/mp4', 'size' => 2048]], 200); + } + if (str_contains($url, 'createRecord')) { + return Http::response(['uri' => 'at://x/app.bsky.feed.post/3seg', 'cid' => 'bafseg'], 200); + } + + return Http::response(str_repeat('v', 2048), 200); + }); + + $this->publisher->publish($this->postPlatform); + + // did:web:example.com:user:alice → https://example.com/user/alice/did.json + Http::assertSent(fn ($request) => str_contains($request->url(), 'example.com/user/alice/did.json')); +}); + +test('bluesky publisher scopes the status service-auth to the video service', function () { + attachBlueskyVideo($this->post); + + fakeBlueskyVideoPipeline(); + + $this->publisher->publish($this->postPlatform); + + // The getJobStatus token is minted for the video service DID, not the PDS. + Http::assertSent(function ($request) { + return str_contains($request->url(), 'getServiceAuth') + && str_contains($request->url(), 'aud=did%3Aweb%3Avideo.bsky.app') + && str_contains($request->url(), 'lxm=app.bsky.video.getJobStatus'); + }); +}); + +test('bluesky publisher picks the PDS entry even when other services come first', function () { + attachBlueskyVideo($this->post); + config(['trypost.platforms.bluesky.video_poll_seconds' => 0]); + + Http::fake(function ($request) { + $url = $request->url(); + + if (str_contains($url, 'plc.directory')) { + // A labeler service is listed before the PDS; resolution must skip it. + return Http::response(['service' => [ + ['id' => '#atproto_labeler', 'type' => 'AtprotoLabeler', 'serviceEndpoint' => 'https://labeler.example'], + ['id' => '#atproto_pds', 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => 'https://real-pds.example.host'], + ]], 200); + } + if (str_contains($url, 'getServiceAuth')) { + return Http::response(['token' => 'service-auth-token'], 200); + } + if (str_contains($url, 'app.bsky.video.uploadVideo')) { + return Http::response(['jobId' => 'job-pds', 'state' => 'JOB_STATE_COMPLETED', 'blob' => ['$type' => 'blob', 'ref' => ['$link' => 'bafpds'], 'mimeType' => 'video/mp4', 'size' => 2048]], 200); + } + if (str_contains($url, 'createRecord')) { + return Http::response(['uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3pds', 'cid' => 'bafpds'], 200); + } + + return Http::response(str_repeat('v', 2048), 200); + }); + + $this->publisher->publish($this->postPlatform); + + // The upload-token audience is the PDS host, not the labeler listed first. + Http::assertSent(function ($request) { + return str_contains($request->url(), 'getServiceAuth') + && str_contains($request->url(), 'aud=did%3Aweb%3Areal-pds.example.host') + && str_contains($request->url(), 'lxm=com.atproto.repo.uploadBlob'); + }); +}); + +test('bluesky publisher uploads a webm video with the correct content type', function () { + attachBlueskyVideo($this->post, 'video/webm'); + + fakeBlueskyVideoPipeline(); + + $this->publisher->publish($this->postPlatform); + + // Bluesky accepts webm natively; send it as webm, not mislabeled mp4. + Http::assertSent(function ($request) { + return str_contains($request->url(), 'xrpc/app.bsky.video.uploadVideo') + && str_contains($request->url(), '.webm') + && $request->hasHeader('Content-Type', 'video/webm'); + }); +}); + +test('bluesky publisher uploads a mov video with the quicktime content type', function () { + attachBlueskyVideo($this->post, 'video/quicktime'); + + fakeBlueskyVideoPipeline(); + + $this->publisher->publish($this->postPlatform); + + Http::assertSent(function ($request) { + return str_contains($request->url(), 'xrpc/app.bsky.video.uploadVideo') + && str_contains($request->url(), '.mov') + && $request->hasHeader('Content-Type', 'video/quicktime'); + }); +}); + +test('bluesky publisher sends an unsupported video format as mp4', function () { + attachBlueskyVideo($this->post, 'video/x-matroska'); // .mkv is not one of Bluesky's four formats + + fakeBlueskyVideoPipeline(); + + $this->publisher->publish($this->postPlatform); + + // Unknown formats fall back to mp4 and let the transcoder decide. + Http::assertSent(function ($request) { + return str_contains($request->url(), 'xrpc/app.bsky.video.uploadVideo') + && str_contains($request->url(), '.mp4') + && $request->hasHeader('Content-Type', 'video/mp4'); + }); +}); + +test('bluesky publisher polls when a 409 carries an in-flight job without a blob', function () { + attachBlueskyVideo($this->post); + + Http::fake(function ($request) { + $url = $request->url(); + + if (str_contains($url, 'plc.directory')) { + return Http::response(['service' => [['id' => '#atproto_pds', 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => 'https://pds.example.host']]], 200); + } + if (str_contains($url, 'getServiceAuth')) { + return Http::response(['token' => 'service-auth-token'], 200); + } + if (str_contains($url, 'app.bsky.video.uploadVideo')) { + // 409 already-exists, but the job is still processing (no blob yet) — must poll. + return Http::response(['jobId' => 'job-409', 'state' => 'JOB_STATE_CREATED'], 409); + } + if (str_contains($url, 'app.bsky.video.getJobStatus')) { + return Http::response(['jobStatus' => ['jobId' => 'job-409', 'state' => 'JOB_STATE_COMPLETED', 'blob' => ['$type' => 'blob', 'ref' => ['$link' => 'baf409poll'], 'mimeType' => 'video/mp4', 'size' => 2048]]], 200); + } + if (str_contains($url, 'createRecord')) { + return Http::response(['uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3p409', 'cid' => 'bafp409'], 200); + } + + return Http::response(str_repeat('v', 2048), 200); + }); + + $this->publisher->publish($this->postPlatform); + + // The 409 had no blob, so the job is polled and the completed blob is embedded. + Http::assertSent(fn ($request) => str_contains($request->url(), 'xrpc/app.bsky.video.getJobStatus')); + Http::assertSent(function ($request) { + if (! str_contains($request->url(), 'createRecord')) { + return false; + } + $embed = $request['record']['embed'] ?? null; + + return $embed && $embed['$type'] === 'app.bsky.embed.video' + && data_get($embed, 'video.ref.$link') === 'baf409poll'; + }); +}); + +test('bluesky publisher embeds images and skips the video when a post carries both', function () { + $this->post->update([ + 'media' => [ + ['id' => 'img', 'path' => 'media/2026-01/test.jpg', 'url' => 'https://example.com/test.jpg', 'mime_type' => 'image/jpeg', 'original_filename' => 'test.jpg'], + ['id' => 'vid', 'path' => 'media/2026-01/test.mp4', 'url' => 'https://example.com/test.mp4', 'mime_type' => 'video/mp4', 'original_filename' => 'test.mp4'], + ], + ]); + + $this->mock(MediaOptimizer::class) + ->shouldReceive('optimizeImage') + ->andReturnUsing(fn () => tap(tempnam(sys_get_temp_dir(), 'bsky_test_'), fn ($f) => file_put_contents($f, str_repeat('x', 1024)))); + + Http::fake(function ($request) { + $url = $request->url(); + + if (str_contains($url, 'uploadBlob')) { + return Http::response(['blob' => ['$type' => 'blob', 'ref' => ['$link' => 'bafimg'], 'mimeType' => 'image/jpeg', 'size' => 1024]], 200); + } + if (str_contains($url, 'createRecord')) { + return Http::response(['uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3both', 'cid' => 'bafboth'], 200); + } + + return Http::response(str_repeat('x', 1024), 200); + }); + + $this->publisher->publish($this->postPlatform); + + // Images win; the embed is images and the video service is never touched. + Http::assertSent(function ($request) { + if (! str_contains($request->url(), 'createRecord')) { + return false; + } + + return ($request['record']['embed']['$type'] ?? null) === 'app.bsky.embed.images'; + }); + Http::assertNotSent(fn ($request) => str_contains($request->url(), 'xrpc/app.bsky.video.uploadVideo')); +}); + +test('bluesky publisher publishes text-only when only the status token fails', function () { + attachBlueskyVideo($this->post); + + Http::fake(function ($request) { + $url = $request->url(); + + if (str_contains($url, 'plc.directory')) { + return Http::response(['service' => [['id' => '#atproto_pds', 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => 'https://pds.example.host']]], 200); + } + if (str_contains($url, 'getServiceAuth')) { + // Upload token mints fine; only the getJobStatus token fails. + return str_contains($url, 'lxm=app.bsky.video.getJobStatus') + ? Http::response(['error' => 'AuthRequired'], 400) + : Http::response(['token' => 'upload-token'], 200); + } + if (str_contains($url, 'createRecord')) { + return Http::response(['uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3stok', 'cid' => 'bafstok'], 200); + } + + return Http::response(str_repeat('v', 2048), 200); + }); + + $this->publisher->publish($this->postPlatform); + + // Without the status token the upload can't be polled, so we never upload — degrade to text. + Http::assertSent(fn ($request) => str_contains($request->url(), 'createRecord') && ! isset($request['record']['embed'])); + Http::assertNotSent(fn ($request) => str_contains($request->url(), 'xrpc/app.bsky.video.uploadVideo')); +}); + +test('bluesky publisher uploads an mpeg video with the correct content type', function () { + attachBlueskyVideo($this->post, 'video/mpeg'); + + fakeBlueskyVideoPipeline(); + + $this->publisher->publish($this->postPlatform); + + Http::assertSent(function ($request) { + return str_contains($request->url(), 'xrpc/app.bsky.video.uploadVideo') + && str_contains($request->url(), '.mpeg') + && $request->hasHeader('Content-Type', 'video/mpeg'); + }); +}); + +test('bluesky publisher uploads a gif without optimizing it', function () { + $this->post->update([ + 'media' => [['id' => 'gif', 'path' => 'media/2026-01/test.gif', 'url' => 'https://example.com/test.gif', 'mime_type' => 'image/gif', 'original_filename' => 'test.gif']], + ]); + + // GIFs are passed through untouched — the optimizer must not run. + $this->mock(MediaOptimizer::class)->shouldReceive('optimizeImage')->never(); + + Http::fake(function ($request) { + $url = $request->url(); + + if (str_contains($url, 'uploadBlob')) { + return Http::response(['blob' => ['$type' => 'blob', 'ref' => ['$link' => 'bafgif'], 'mimeType' => 'image/gif', 'size' => 1024]], 200); + } + if (str_contains($url, 'createRecord')) { + return Http::response(['uri' => 'at://did:plc:testuser123/app.bsky.feed.post/3gif', 'cid' => 'bafgif'], 200); + } + + return Http::response(str_repeat('g', 1024), 200); + }); + + $this->publisher->publish($this->postPlatform); + + // The GIF is uploaded as-is with its original content type. + Http::assertSent(fn ($request) => str_contains($request->url(), 'uploadBlob') + && $request->hasHeader('Content-Type', 'image/gif')); +}); diff --git a/tests/Unit/Enums/ContentTypeTest.php b/tests/Unit/Enums/ContentTypeTest.php index 7a771504..4928fc42 100644 --- a/tests/Unit/Enums/ContentTypeTest.php +++ b/tests/Unit/Enums/ContentTypeTest.php @@ -86,6 +86,14 @@ expect(ContentType::YouTubeShort->supportsImage())->toBeFalse(); }); +test('content type supports mixed media correctly', function () { + // A Bluesky embed is images XOR video, so it can't carry both. + expect(ContentType::BlueskyPost->supportsMixedMedia())->toBeFalse(); + // Multi-attachment messengers can mix an image and a video. + expect(ContentType::DiscordMessage->supportsMixedMedia())->toBeTrue(); + expect(ContentType::TelegramPost->supportsMixedMedia())->toBeTrue(); +}); + test('content type requires media correctly', function () { expect(ContentType::InstagramReel->requiresMedia())->toBeTrue(); expect(ContentType::TikTokVideo->requiresMedia())->toBeTrue(); diff --git a/tests/Unit/Rules/ContentTypeCompatibleWithMediaTest.php b/tests/Unit/Rules/ContentTypeCompatibleWithMediaTest.php index 81fd06a0..2c4dfa20 100644 --- a/tests/Unit/Rules/ContentTypeCompatibleWithMediaTest.php +++ b/tests/Unit/Rules/ContentTypeCompatibleWithMediaTest.php @@ -94,6 +94,48 @@ function runMediaRule(string $contentType, array $media): array expect($errors)->toHaveCount(1); }); +test('bluesky rejects an image and a video in the same post', function () { + $media = [ + ['type' => MediaType::Image->value, 'mime_type' => 'image/jpeg'], + ['type' => MediaType::Video->value, 'mime_type' => 'video/mp4'], + ]; + + $errors = runMediaRule(ContentType::BlueskyPost->value, $media); + + expect($errors)->toHaveCount(1); + expect($errors[0])->toContain("can't combine an image and a video"); +}); + +test('bluesky still accepts an image-only or video-only post', function () { + $image = [['type' => MediaType::Image->value, 'mime_type' => 'image/jpeg']]; + $video = [['type' => MediaType::Video->value, 'mime_type' => 'video/mp4']]; + + expect(runMediaRule(ContentType::BlueskyPost->value, $image))->toBe([]); + expect(runMediaRule(ContentType::BlueskyPost->value, $video))->toBe([]); +}); + +test('bluesky rejects an animated gif combined with a video', function () { + // A GIF counts as an image, so gif + video is still mixed media. + $media = [ + ['type' => MediaType::Image->value, 'mime_type' => 'image/gif'], + ['type' => MediaType::Video->value, 'mime_type' => 'video/mp4'], + ]; + + $errors = runMediaRule(ContentType::BlueskyPost->value, $media); + + expect($errors)->toHaveCount(1); + expect($errors[0])->toContain("can't combine an image and a video"); +}); + +test('a mixed-media content type accepts an image and a video together', function () { + $media = [ + ['type' => MediaType::Image->value, 'mime_type' => 'image/jpeg'], + ['type' => MediaType::Video->value, 'mime_type' => 'video/mp4'], + ]; + + expect(runMediaRule(ContentType::DiscordMessage->value, $media))->toBe([]); +}); + test('does nothing for invalid content type values', function () { expect(runMediaRule('not_a_real_content_type', []))->toBe([]); });