refactor: use data_get in MediaOptimizer for consistency
This commit is contained in:
parent
4ddb4d941a
commit
30bfb2a890
12 changed files with 1200 additions and 4 deletions
|
|
@ -44,7 +44,7 @@ ## Skills Activation
|
|||
- `cashier-stripe-development` — Handles Laravel Cashier Stripe integration including subscriptions, webhooks, Stripe Checkout, invoices, charges, refunds, trials, coupons, metered billing, and payment failure handling. Triggered when a user mentions Cashier, Billable, IncompletePayment, stripe_id, newSubscription, Stripe subscriptions, or billing. Also applies when setting up webhooks, handling SCA/3DS payment failures, testing with Stripe test cards, or troubleshooting incomplete subscriptions, CSRF webhook errors, or migration publish issues.
|
||||
- `laravel-best-practices` — Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns.
|
||||
- `configuring-horizon` — Use this skill whenever the user mentions Horizon by name in a Laravel context. Covers the full Horizon lifecycle: installing Horizon (horizon:install, Sail setup), configuring config/horizon.php (supervisor blocks, queue assignments, balancing strategies, minProcesses/maxProcesses), fixing the dashboard (authorization via Gate::define viewHorizon, blank metrics, horizon:snapshot scheduling), and troubleshooting production issues (worker crashes, timeout chain ordering, LongWaitDetected notifications, waits config). Also covers job tagging and silencing. Do not use for generic Laravel queues without Horizon, SQS or database drivers, standalone Redis setup, Linux supervisord, Telescope, or job batching.
|
||||
- `mcp-development` — Use this skill for Laravel MCP development only. Trigger when creating or editing MCP tools, resources, prompts, or servers in Laravel projects. Covers: artisan make:mcp-\* generators, mcp:inspector, routes/ai.php, Tool/Resource/Prompt classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, and MCP debugging. Do not use for non-Laravel MCP projects or generic AI features without MCP.
|
||||
- `mcp-development` — Use this skill for Laravel MCP development only. Trigger when creating or editing MCP tools, resources, prompts, or servers in Laravel projects. Covers: artisan make:mcp-* generators, mcp:inspector, routes/ai.php, Tool/Resource/Prompt classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, and MCP debugging. Do not use for non-Laravel MCP projects or generic AI features without MCP.
|
||||
- `socialite-development` — Manages OAuth social authentication with Laravel Socialite. Activate when adding social login providers; configuring OAuth redirect/callback flows; retrieving authenticated user details; customizing scopes or parameters; setting up community providers; testing with Socialite fakes; or when the user mentions social login, OAuth, Socialite, or third-party authentication.
|
||||
- `wayfinder-development` — Use this skill for Laravel Wayfinder which auto-generates typed functions for Laravel controllers and routes. ALWAYS use this skill when frontend code needs to call backend routes or controller actions. Trigger when: connecting any React/Vue/Svelte/Inertia frontend to Laravel controllers, routes, building end-to-end features with both frontend and backend, wiring up forms or links to backend endpoints, fixing route-related TypeScript errors, importing from @/actions or @/routes, or running wayfinder:generate. Use Wayfinder route functions instead of hardcoded URLs. Covers: wayfinder() vite plugin, .url()/.get()/.post()/.form(), query params, route model binding, tree-shaking. Do not use for backend-only task
|
||||
- `pest-testing` — Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code.
|
||||
|
|
@ -116,7 +116,7 @@ ## Tinker
|
|||
|
||||
- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code.
|
||||
- Always use single quotes to prevent shell expansion: `php artisan tinker --execute 'Your::code();'`
|
||||
- Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'`
|
||||
- Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'`
|
||||
|
||||
=== php rules ===
|
||||
|
||||
|
|
@ -222,7 +222,6 @@ ## Pest
|
|||
# Inertia + Vue
|
||||
|
||||
Vue components must have a single root element.
|
||||
|
||||
- IMPORTANT: Activate `inertia-vue-development` when working with Inertia Vue client-side patterns.
|
||||
|
||||
</laravel-boost-guidelines>
|
||||
|
|
|
|||
77
app/Exceptions/Social/FacebookPublishException.php
Normal file
77
app/Exceptions/Social/FacebookPublishException.php
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Social;
|
||||
|
||||
use App\Exceptions\TokenExpiredException;
|
||||
use Illuminate\Http\Client\Response;
|
||||
|
||||
class FacebookPublishException extends SocialPublishException
|
||||
{
|
||||
public static function fromApiResponse(mixed $response): static
|
||||
{
|
||||
/** @var Response $response */
|
||||
$body = $response->json();
|
||||
$rawResponse = $response->body();
|
||||
|
||||
$errorType = data_get($body, 'error.type');
|
||||
$errorCode = data_get($body, 'error.code');
|
||||
$errorSubcode = data_get($body, 'error.error_subcode');
|
||||
$errorMessage = data_get($body, 'error.message', 'An unknown Facebook error occurred.');
|
||||
|
||||
$tokenSubcodes = [458, 459, 460, 463, 464, 467];
|
||||
|
||||
if ($errorType === 'OAuthException' || $errorCode === 190 || in_array($errorSubcode, $tokenSubcodes, true)) {
|
||||
throw new TokenExpiredException(
|
||||
message: $errorMessage,
|
||||
platformErrorCode: $errorCode !== null ? (string) $errorCode : null,
|
||||
);
|
||||
}
|
||||
|
||||
[$message, $category] = match ($errorCode) {
|
||||
6000 => ['Problem with file. Try with another file.', ErrorCategory::MediaFormat],
|
||||
1363042 => ['No permission to upload video here.', ErrorCategory::Permission],
|
||||
1363023 => ['Video exceeds 2GB maximum size.', ErrorCategory::MediaFormat],
|
||||
1363022 => ['Video below 1KB minimum size.', ErrorCategory::MediaFormat],
|
||||
1363030 => ['Upload timed out. Please try again.', ErrorCategory::ServerError],
|
||||
1363019 => ['Problem uploading video. Please try again.', ErrorCategory::ServerError],
|
||||
1363031 => ['Unsupported file format.', ErrorCategory::MediaFormat],
|
||||
1363032 => ['File is not a valid video.', ErrorCategory::MediaFormat],
|
||||
1363024 => ['Unsupported video format.', ErrorCategory::MediaFormat],
|
||||
1363025 => ['Video is too short (minimum 1 second).', ErrorCategory::MediaFormat],
|
||||
1363026 => ['Video is too long (maximum 40 minutes).', ErrorCategory::MediaFormat],
|
||||
1363033 => ['Upload interrupted. Please try again.', ErrorCategory::ServerError],
|
||||
1363037 => ['Invalid upload offset.', ErrorCategory::ServerError],
|
||||
1363020 => ['No video file selected.', ErrorCategory::MediaFormat],
|
||||
1363045 => ['Upload size mismatch.', ErrorCategory::ServerError],
|
||||
1363041 => ['Upload session expired. Please try again.', ErrorCategory::ServerError],
|
||||
1363021 => ['Problem during video upload. Please try again.', ErrorCategory::ServerError],
|
||||
1363005 => ['No permission to edit this video.', ErrorCategory::Permission],
|
||||
1363047 => ['Reel encoding issue. Please try a different video.', ErrorCategory::MediaFormat],
|
||||
1609008 => ['Video format not supported for Reels.', ErrorCategory::MediaFormat],
|
||||
1609010 => ['Reel encoding requirements not met.', ErrorCategory::MediaFormat],
|
||||
1366046 => ['Reels require a video.', ErrorCategory::ContentPolicy],
|
||||
2061006 => ['Video is too short for this format.', ErrorCategory::MediaFormat],
|
||||
1390008 => ['Caption is too long.', ErrorCategory::ContentPolicy],
|
||||
1346003 => ['Thumbnail is incompatible.', ErrorCategory::ContentPolicy],
|
||||
1349125 => ['Rate limit exceeded. Try again later.', ErrorCategory::RateLimit],
|
||||
4 => ['Too many API calls. Please try again later.', ErrorCategory::RateLimit],
|
||||
17 => ['User call limit reached.', ErrorCategory::RateLimit],
|
||||
506 => ['Duplicate post detected. Please modify content.', ErrorCategory::ContentPolicy],
|
||||
default => [$errorMessage, ErrorCategory::Unknown],
|
||||
};
|
||||
|
||||
return new static(
|
||||
userMessage: $message,
|
||||
category: $category,
|
||||
platformErrorCode: $errorCode !== null ? (string) $errorCode : null,
|
||||
rawResponse: $rawResponse,
|
||||
);
|
||||
}
|
||||
|
||||
public function platform(): string
|
||||
{
|
||||
return 'facebook';
|
||||
}
|
||||
}
|
||||
82
app/Exceptions/Social/TikTokPublishException.php
Normal file
82
app/Exceptions/Social/TikTokPublishException.php
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Social;
|
||||
|
||||
use App\Exceptions\TokenExpiredException;
|
||||
use Illuminate\Http\Client\Response;
|
||||
|
||||
class TikTokPublishException extends SocialPublishException
|
||||
{
|
||||
public static function fromApiResponse(mixed $response): static
|
||||
{
|
||||
/** @var Response $response */
|
||||
$body = $response->json();
|
||||
$rawResponse = $response->body();
|
||||
|
||||
$errorCode = data_get($body, 'error.code');
|
||||
$errorMessage = data_get($body, 'error.message', 'An unknown TikTok error occurred.');
|
||||
|
||||
if ($errorCode === 'access_token_invalid') {
|
||||
throw new TokenExpiredException(
|
||||
message: $errorMessage,
|
||||
platformErrorCode: $errorCode,
|
||||
);
|
||||
}
|
||||
|
||||
[$message, $category] = match ($errorCode) {
|
||||
'scope_not_authorized' => ['Missing required permissions. Please reconnect with all scopes.', ErrorCategory::Permission],
|
||||
'scope_permission_missed' => ['Additional permissions required. Please reconnect.', ErrorCategory::Permission],
|
||||
'rate_limit_exceeded' => ['TikTok rate limit exceeded. Please try again later.', ErrorCategory::RateLimit],
|
||||
'invalid_file_upload' => ['File does not meet API specifications.', ErrorCategory::MediaFormat],
|
||||
'invalid_params' => ['Invalid request parameters.', ErrorCategory::MediaFormat],
|
||||
'internal_error' => ['TikTok server error. Please try again later.', ErrorCategory::ServerError],
|
||||
'reached_active_user_cap' => ['Daily active user quota reached.', ErrorCategory::RateLimit],
|
||||
'unaudited_client_can_only_post_to_private_accounts' => ['App not approved for public posting.', ErrorCategory::Permission],
|
||||
'url_ownership_unverified' => ['Domain ownership not verified.', ErrorCategory::Permission],
|
||||
'privacy_level_option_mismatch' => ['Privacy level not available for this account.', ErrorCategory::Permission],
|
||||
'app_version_check_failed' => ['TikTok app update required.', ErrorCategory::Permission],
|
||||
default => [$errorMessage, ErrorCategory::Unknown],
|
||||
};
|
||||
|
||||
return new static(
|
||||
userMessage: $message,
|
||||
category: $category,
|
||||
platformErrorCode: $errorCode !== null ? (string) $errorCode : null,
|
||||
rawResponse: $rawResponse,
|
||||
);
|
||||
}
|
||||
|
||||
public static function fromFailReason(string $failReason, ?string $rawResponse = null): static
|
||||
{
|
||||
[$message, $category] = match ($failReason) {
|
||||
'file_format_check_failed' => ['Unsupported media format.', ErrorCategory::MediaFormat],
|
||||
'duration_check_failed' => ['Video duration is not within allowed limits.', ErrorCategory::MediaFormat],
|
||||
'frame_rate_check_failed' => ['Video frame rate is not supported.', ErrorCategory::MediaFormat],
|
||||
'picture_size_check_failed' => ['Image dimensions exceed limits.', ErrorCategory::MediaFormat],
|
||||
'video_pull_failed' => ['Failed to download video from URL.', ErrorCategory::ServerError],
|
||||
'photo_pull_failed' => ['Failed to download photo from URL.', ErrorCategory::ServerError],
|
||||
'publish_cancelled' => ['Publishing was cancelled.', ErrorCategory::ContentPolicy],
|
||||
'auth_removed' => ['App access was revoked during processing.', ErrorCategory::Permission],
|
||||
'spam_risk_too_many_posts' => ['Daily posting limit reached. Try again tomorrow.', ErrorCategory::RateLimit],
|
||||
'spam_risk_user_banned_from_posting' => ['Account is banned from posting.', ErrorCategory::ContentPolicy],
|
||||
'spam_risk_text' => ['TikTok detected spam in the description.', ErrorCategory::ContentPolicy],
|
||||
'spam_risk' => ['Publishing request flagged as high-risk.', ErrorCategory::ContentPolicy],
|
||||
'internal' => ['TikTok server error. Please try again.', ErrorCategory::ServerError],
|
||||
default => [$failReason, ErrorCategory::Unknown],
|
||||
};
|
||||
|
||||
return new static(
|
||||
userMessage: $message,
|
||||
category: $category,
|
||||
platformErrorCode: $failReason,
|
||||
rawResponse: $rawResponse,
|
||||
);
|
||||
}
|
||||
|
||||
public function platform(): string
|
||||
{
|
||||
return 'tiktok';
|
||||
}
|
||||
}
|
||||
89
app/Exceptions/Social/YouTubePublishException.php
Normal file
89
app/Exceptions/Social/YouTubePublishException.php
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Social;
|
||||
|
||||
use App\Exceptions\TokenExpiredException;
|
||||
use Google\Service\Exception as GoogleServiceException;
|
||||
use Illuminate\Http\Client\Response;
|
||||
|
||||
class YouTubePublishException extends SocialPublishException
|
||||
{
|
||||
public static function fromApiResponse(mixed $response): static
|
||||
{
|
||||
/** @var Response $response */
|
||||
$body = $response->json();
|
||||
$rawResponse = $response->body();
|
||||
|
||||
$reason = data_get($body, 'error.errors.0.reason');
|
||||
|
||||
[$message, $category] = self::mapReasonToMessageAndCategory(
|
||||
reason: $reason,
|
||||
fallbackMessage: data_get($body, 'error.message', 'An unknown YouTube error occurred.'),
|
||||
);
|
||||
|
||||
return new static(
|
||||
userMessage: $message,
|
||||
category: $category,
|
||||
platformErrorCode: $reason,
|
||||
rawResponse: $rawResponse,
|
||||
);
|
||||
}
|
||||
|
||||
public static function fromGoogleException(GoogleServiceException $e): static
|
||||
{
|
||||
$errors = $e->getErrors();
|
||||
$reason = data_get($errors, '0.reason');
|
||||
$rawMessage = data_get($errors, '0.message', $e->getMessage());
|
||||
|
||||
if ($e->getCode() === 401 || in_array($reason, ['authError', 'unauthorized'], true)) {
|
||||
throw new TokenExpiredException(
|
||||
message: $rawMessage,
|
||||
platformErrorCode: $reason,
|
||||
);
|
||||
}
|
||||
|
||||
[$message, $category] = self::mapReasonToMessageAndCategory(
|
||||
reason: $reason,
|
||||
fallbackMessage: $rawMessage,
|
||||
);
|
||||
|
||||
return new static(
|
||||
userMessage: $message,
|
||||
category: $category,
|
||||
platformErrorCode: $reason,
|
||||
rawResponse: $e->getMessage(),
|
||||
);
|
||||
}
|
||||
|
||||
public function platform(): string
|
||||
{
|
||||
return 'youtube';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{string, ErrorCategory}
|
||||
*/
|
||||
private static function mapReasonToMessageAndCategory(?string $reason, string $fallbackMessage): array
|
||||
{
|
||||
return match ($reason) {
|
||||
'invalidTitle' => ['Video title is invalid or empty.', ErrorCategory::ContentPolicy],
|
||||
'invalidDescription' => ['Video description is invalid.', ErrorCategory::ContentPolicy],
|
||||
'invalidTags' => ['Video tags are invalid.', ErrorCategory::ContentPolicy],
|
||||
'invalidCategoryId' => ['Video category is invalid.', ErrorCategory::ContentPolicy],
|
||||
'invalidVideoMetadata' => ['Video metadata is invalid. Title and category are required.', ErrorCategory::ContentPolicy],
|
||||
'invalidPublishAt' => ['Scheduled publishing time is invalid.', ErrorCategory::ContentPolicy],
|
||||
'invalidRecordingDetails' => ['Recording details are invalid.', ErrorCategory::ContentPolicy],
|
||||
'invalidVideoGameRating' => ['Video game rating is invalid.', ErrorCategory::ContentPolicy],
|
||||
'invalidFilename' => ['Video filename is invalid.', ErrorCategory::MediaFormat],
|
||||
'mediaBodyRequired' => ['Video file is missing from the request.', ErrorCategory::MediaFormat],
|
||||
'failedPrecondition' => ['Thumbnail too large or account not verified.', ErrorCategory::MediaFormat],
|
||||
'uploadLimitExceeded' => ['Daily upload limit reached. Try again tomorrow.', ErrorCategory::RateLimit],
|
||||
'forbidden' => ["You don't have permission to upload to this channel.", ErrorCategory::Permission],
|
||||
'forbiddenLicenseSetting' => ['Invalid video license setting.', ErrorCategory::Permission],
|
||||
'forbiddenPrivacySetting' => ['Invalid video privacy setting.', ErrorCategory::Permission],
|
||||
default => [$fallbackMessage, ErrorCategory::Unknown],
|
||||
};
|
||||
}
|
||||
}
|
||||
116
app/Services/Media/MediaOptimizer.php
Normal file
116
app/Services/Media/MediaOptimizer.php
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Media;
|
||||
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use Intervention\Image\Drivers\Gd\Driver;
|
||||
use Intervention\Image\ImageManager;
|
||||
|
||||
class MediaOptimizer
|
||||
{
|
||||
private ImageManager $manager;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->manager = new ImageManager(Driver::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize an image for a specific platform.
|
||||
* Returns path to optimized temp file (caller must clean up).
|
||||
*/
|
||||
public function optimizeImage(string $filePath, Platform $platform): string
|
||||
{
|
||||
$config = $this->getImageConfig($platform);
|
||||
$image = $this->manager->decodePath($filePath);
|
||||
|
||||
$maxWidth = data_get($config, 'max_width');
|
||||
$maxSize = data_get($config, 'max_size');
|
||||
$format = data_get($config, 'format');
|
||||
$quality = data_get($config, 'quality');
|
||||
|
||||
// Resize if needed (maintain aspect ratio, never upscale)
|
||||
if ($maxWidth && $image->width() > $maxWidth) {
|
||||
$image->scaleDown(width: $maxWidth);
|
||||
}
|
||||
|
||||
// Encode to target format
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'media_opt_');
|
||||
$encoded = $image->encodeUsingMediaType($format, quality: $quality);
|
||||
file_put_contents($tempFile, (string) $encoded);
|
||||
|
||||
// Reduce quality iteratively if file still too large
|
||||
while (filesize($tempFile) > $maxSize && $quality > 30) {
|
||||
$quality -= 10;
|
||||
$encoded = $image->encodeUsingMediaType($format, quality: $quality);
|
||||
file_put_contents($tempFile, (string) $encoded);
|
||||
}
|
||||
|
||||
return $tempFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{max_width: int, max_size: int, format: string, quality: int}
|
||||
*/
|
||||
private function getImageConfig(Platform $platform): array
|
||||
{
|
||||
return match ($platform) {
|
||||
Platform::Instagram, Platform::Threads => [
|
||||
'max_width' => 1440,
|
||||
'max_size' => 8 * 1024 * 1024,
|
||||
'format' => 'image/jpeg',
|
||||
'quality' => 90,
|
||||
],
|
||||
Platform::Facebook => [
|
||||
'max_width' => 2048,
|
||||
'max_size' => 4 * 1024 * 1024,
|
||||
'format' => 'image/jpeg',
|
||||
'quality' => 90,
|
||||
],
|
||||
Platform::X => [
|
||||
'max_width' => 2048,
|
||||
'max_size' => 5 * 1024 * 1024,
|
||||
'format' => 'image/jpeg',
|
||||
'quality' => 90,
|
||||
],
|
||||
Platform::TikTok => [
|
||||
'max_width' => 1080,
|
||||
'max_size' => 20 * 1024 * 1024,
|
||||
'format' => 'image/jpeg',
|
||||
'quality' => 95,
|
||||
],
|
||||
Platform::LinkedIn, Platform::LinkedInPage => [
|
||||
'max_width' => 2048,
|
||||
'max_size' => 10 * 1024 * 1024,
|
||||
'format' => 'image/jpeg',
|
||||
'quality' => 90,
|
||||
],
|
||||
Platform::Pinterest => [
|
||||
'max_width' => 1000,
|
||||
'max_size' => 20 * 1024 * 1024,
|
||||
'format' => 'image/jpeg',
|
||||
'quality' => 90,
|
||||
],
|
||||
Platform::Bluesky => [
|
||||
'max_width' => 2048,
|
||||
'max_size' => 976 * 1024,
|
||||
'format' => 'image/jpeg',
|
||||
'quality' => 85,
|
||||
],
|
||||
Platform::Mastodon => [
|
||||
'max_width' => 2048,
|
||||
'max_size' => 10 * 1024 * 1024,
|
||||
'format' => 'image/jpeg',
|
||||
'quality' => 90,
|
||||
],
|
||||
Platform::YouTube => [
|
||||
'max_width' => 1920,
|
||||
'max_size' => 2 * 1024 * 1024,
|
||||
'format' => 'image/jpeg',
|
||||
'quality' => 90,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -36,6 +36,7 @@
|
|||
"php": "^8.2",
|
||||
"google/apiclient": "^2.19",
|
||||
"inertiajs/inertia-laravel": "^3.0",
|
||||
"intervention/image": "^4.0",
|
||||
"laravel/ai": "^0.4.2",
|
||||
"laravel/boost": "^2.0",
|
||||
"laravel/cashier": "^16.2",
|
||||
|
|
|
|||
146
composer.lock
generated
146
composer.lock
generated
|
|
@ -4,7 +4,7 @@
|
|||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "2f1a57707f17ee68dc1090c641f8d753",
|
||||
"content-hash": "99d4e3356781b4df8847d654815dd664",
|
||||
"packages": [
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
|
|
@ -1696,6 +1696,150 @@
|
|||
},
|
||||
"time": "2026-03-25T21:07:46+00:00"
|
||||
},
|
||||
{
|
||||
"name": "intervention/gif",
|
||||
"version": "5.0.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Intervention/gif.git",
|
||||
"reference": "d856f59205aec768059d837148d755c079cdb94a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Intervention/gif/zipball/d856f59205aec768059d837148d755c079cdb94a",
|
||||
"reference": "d856f59205aec768059d837148d755c079cdb94a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan": "^2.1",
|
||||
"phpunit/phpunit": "^12.0",
|
||||
"slevomat/coding-standard": "~8.0",
|
||||
"squizlabs/php_codesniffer": "^4"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Intervention\\Gif\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Oliver Vogel",
|
||||
"email": "oliver@intervention.io",
|
||||
"homepage": "https://intervention.io/"
|
||||
}
|
||||
],
|
||||
"description": "PHP GIF Encoder/Decoder",
|
||||
"homepage": "https://github.com/intervention/gif",
|
||||
"keywords": [
|
||||
"animation",
|
||||
"gd",
|
||||
"gif",
|
||||
"image"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Intervention/gif/issues",
|
||||
"source": "https://github.com/Intervention/gif/tree/5.0.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://paypal.me/interventionio",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/Intervention",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://ko-fi.com/interventionphp",
|
||||
"type": "ko_fi"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-21T05:08:17+00:00"
|
||||
},
|
||||
{
|
||||
"name": "intervention/image",
|
||||
"version": "4.0.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Intervention/image.git",
|
||||
"reference": "66865f150576ab36e72d1096e8e108c072d40fff"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Intervention/image/zipball/66865f150576ab36e72d1096e8e108c072d40fff",
|
||||
"reference": "66865f150576ab36e72d1096e8e108c072d40fff",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-mbstring": "*",
|
||||
"intervention/gif": "^5",
|
||||
"php": "^8.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "^1.6",
|
||||
"phpstan/phpstan": "^2.1",
|
||||
"phpunit/phpunit": "^12.0",
|
||||
"slevomat/coding-standard": "~8.0",
|
||||
"squizlabs/php_codesniffer": "^4"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-exif": "Recommended to be able to read EXIF data properly."
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Intervention\\Image\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Oliver Vogel",
|
||||
"email": "oliver@intervention.io",
|
||||
"homepage": "https://intervention.io"
|
||||
}
|
||||
],
|
||||
"description": "PHP Image Processing",
|
||||
"homepage": "https://image.intervention.io",
|
||||
"keywords": [
|
||||
"gd",
|
||||
"image",
|
||||
"imagick",
|
||||
"resize",
|
||||
"thumbnail",
|
||||
"watermark"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Intervention/image/issues",
|
||||
"source": "https://github.com/Intervention/image/tree/4.0.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://paypal.me/interventionio",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/Intervention",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://ko-fi.com/interventionphp",
|
||||
"type": "ko_fi"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-28T07:02:39+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/ai",
|
||||
"version": "v0.4.2",
|
||||
|
|
|
|||
178
tests/Feature/Middleware/AuthenticateMcpTokenTest.php
Normal file
178
tests/Feature/Middleware/AuthenticateMcpTokenTest.php
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Http\Middleware\Mcp\AuthenticateMcpToken;
|
||||
use App\Models\ApiToken;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* @return array{token: ApiToken, plain_token: string, workspace: Workspace, user: User}
|
||||
*/
|
||||
function createMcpToken(array $overrides = []): array
|
||||
{
|
||||
$plainToken = 'tp_'.Str::random(48);
|
||||
|
||||
$user = data_get($overrides, 'user') ?? User::factory()->create();
|
||||
$workspace = data_get($overrides, 'workspace') ?? Workspace::factory()->create(['user_id' => $user->id]);
|
||||
$workspace->members()->syncWithoutDetaching([$user->id => ['role' => Role::Owner->value]]);
|
||||
|
||||
$apiToken = ApiToken::factory()->create([
|
||||
'workspace_id' => $workspace->id,
|
||||
'token_lookup' => substr($plainToken, 3, 16),
|
||||
'token_hash' => Hash::make($plainToken),
|
||||
...collect($overrides)->except(['user', 'workspace'])->toArray(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'token' => $apiToken,
|
||||
'plain_token' => $plainToken,
|
||||
'workspace' => $workspace,
|
||||
'user' => $user,
|
||||
];
|
||||
}
|
||||
|
||||
function callMiddleware(string $bearerToken = ''): JsonResponse|Response
|
||||
{
|
||||
$request = Request::create('/mcp/trypost', 'GET');
|
||||
if ($bearerToken) {
|
||||
$request->headers->set('Authorization', "Bearer {$bearerToken}");
|
||||
}
|
||||
|
||||
$middleware = new AuthenticateMcpToken;
|
||||
|
||||
return $middleware->handle($request, fn () => response()->json(['ok' => true]));
|
||||
}
|
||||
|
||||
test('returns 401 without token', function () {
|
||||
$response = callMiddleware();
|
||||
|
||||
expect($response->getStatusCode())->toBe(Response::HTTP_UNAUTHORIZED);
|
||||
expect(json_decode($response->getContent(), true))->toMatchArray(['message' => 'Missing API key.']);
|
||||
});
|
||||
|
||||
test('returns 401 with invalid token format', function () {
|
||||
$response = callMiddleware('invalid-token');
|
||||
|
||||
expect($response->getStatusCode())->toBe(Response::HTTP_UNAUTHORIZED);
|
||||
expect(json_decode($response->getContent(), true))->toMatchArray(['message' => 'Invalid API key.']);
|
||||
});
|
||||
|
||||
test('returns 401 with token that does not start with tp_', function () {
|
||||
$response = callMiddleware('xx_'.Str::random(48));
|
||||
|
||||
expect($response->getStatusCode())->toBe(Response::HTTP_UNAUTHORIZED);
|
||||
});
|
||||
|
||||
test('returns 401 with wrong token length', function () {
|
||||
$response = callMiddleware('tp_short');
|
||||
|
||||
expect($response->getStatusCode())->toBe(Response::HTTP_UNAUTHORIZED);
|
||||
});
|
||||
|
||||
test('returns 401 with wrong token', function () {
|
||||
createMcpToken();
|
||||
|
||||
$response = callMiddleware('tp_'.Str::random(48));
|
||||
|
||||
expect($response->getStatusCode())->toBe(Response::HTTP_UNAUTHORIZED);
|
||||
expect(json_decode($response->getContent(), true))->toMatchArray(['message' => 'Invalid API key.']);
|
||||
});
|
||||
|
||||
test('returns 401 with expired token', function () {
|
||||
$result = createMcpToken();
|
||||
$result['token']->update(['expires_at' => now()->subDay()]);
|
||||
|
||||
$response = callMiddleware($result['plain_token']);
|
||||
|
||||
expect($response->getStatusCode())->toBe(Response::HTTP_UNAUTHORIZED);
|
||||
expect(json_decode($response->getContent(), true))->toMatchArray(['message' => 'API key has expired.']);
|
||||
});
|
||||
|
||||
test('authenticates with valid token', function () {
|
||||
$result = createMcpToken();
|
||||
|
||||
$response = callMiddleware($result['plain_token']);
|
||||
|
||||
expect($response->getStatusCode())->toBe(Response::HTTP_OK);
|
||||
expect(Auth::id())->toBe($result['user']->id);
|
||||
});
|
||||
|
||||
test('sets current workspace on authenticated user', function () {
|
||||
$result = createMcpToken();
|
||||
|
||||
callMiddleware($result['plain_token']);
|
||||
|
||||
expect(Auth::user()->current_workspace_id)->toBe($result['workspace']->id);
|
||||
});
|
||||
|
||||
test('updates last_used_at on successful auth', function () {
|
||||
$this->freezeTime();
|
||||
$result = createMcpToken();
|
||||
|
||||
expect($result['token']->last_used_at)->toBeNull();
|
||||
|
||||
callMiddleware($result['plain_token']);
|
||||
|
||||
expect($result['token']->fresh()->last_used_at->toDateTimeString())->toBe(now()->toDateTimeString());
|
||||
});
|
||||
|
||||
test('returns 402 when owner has no subscription', function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
$result = createMcpToken();
|
||||
|
||||
$response = callMiddleware($result['plain_token']);
|
||||
|
||||
expect($response->getStatusCode())->toBe(Response::HTTP_PAYMENT_REQUIRED);
|
||||
expect(json_decode($response->getContent(), true))->toMatchArray(['message' => 'Active subscription required.']);
|
||||
});
|
||||
|
||||
test('allows access when owner has active subscription', function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
$result = createMcpToken();
|
||||
|
||||
$result['user']->subscriptions()->create([
|
||||
'type' => User::SUBSCRIPTION_NAME,
|
||||
'stripe_id' => 'sub_test',
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_123',
|
||||
]);
|
||||
|
||||
$response = callMiddleware($result['plain_token']);
|
||||
|
||||
expect($response->getStatusCode())->toBe(Response::HTTP_OK);
|
||||
});
|
||||
|
||||
test('allows access when owner is on trial', function () {
|
||||
config(['trypost.self_hosted' => false]);
|
||||
$result = createMcpToken();
|
||||
|
||||
$result['user']->subscriptions()->create([
|
||||
'type' => User::SUBSCRIPTION_NAME,
|
||||
'stripe_id' => 'sub_trial',
|
||||
'stripe_status' => 'trialing',
|
||||
'stripe_price' => 'price_123',
|
||||
'trial_ends_at' => now()->addDays(7),
|
||||
]);
|
||||
|
||||
$response = callMiddleware($result['plain_token']);
|
||||
|
||||
expect($response->getStatusCode())->toBe(Response::HTTP_OK);
|
||||
});
|
||||
|
||||
test('skips subscription check in self-hosted mode', function () {
|
||||
config(['trypost.self_hosted' => true]);
|
||||
$result = createMcpToken();
|
||||
|
||||
$response = callMiddleware($result['plain_token']);
|
||||
|
||||
expect($response->getStatusCode())->toBe(Response::HTTP_OK);
|
||||
});
|
||||
138
tests/Unit/Exceptions/Social/FacebookPublishExceptionTest.php
Normal file
138
tests/Unit/Exceptions/Social/FacebookPublishExceptionTest.php
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Exceptions\Social\ErrorCategory;
|
||||
use App\Exceptions\Social\FacebookPublishException;
|
||||
use App\Exceptions\TokenExpiredException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
test('code 1363031 maps to MediaFormat category', function () {
|
||||
$response = Http::response([
|
||||
'error' => [
|
||||
'message' => 'Unsupported file format.',
|
||||
'type' => 'FacebookApiException',
|
||||
'code' => 1363031,
|
||||
],
|
||||
], 400);
|
||||
|
||||
$fakeResponse = Http::fake(['*' => $response])->post('https://graph.facebook.com/test');
|
||||
|
||||
$exception = FacebookPublishException::fromApiResponse($fakeResponse);
|
||||
|
||||
expect($exception)
|
||||
->toBeInstanceOf(FacebookPublishException::class)
|
||||
->and($exception->userMessage)->toBe('Unsupported file format.')
|
||||
->and($exception->category)->toBe(ErrorCategory::MediaFormat);
|
||||
});
|
||||
|
||||
test('code 190 throws TokenExpiredException', function () {
|
||||
$response = Http::response([
|
||||
'error' => [
|
||||
'message' => 'Invalid OAuth access token.',
|
||||
'type' => 'FacebookApiException',
|
||||
'code' => 190,
|
||||
],
|
||||
], 400);
|
||||
|
||||
$fakeResponse = Http::fake(['*' => $response])->post('https://graph.facebook.com/test');
|
||||
|
||||
FacebookPublishException::fromApiResponse($fakeResponse);
|
||||
})->throws(TokenExpiredException::class);
|
||||
|
||||
test('OAuthException type throws TokenExpiredException', function () {
|
||||
$response = Http::response([
|
||||
'error' => [
|
||||
'message' => 'Invalid OAuth access token.',
|
||||
'type' => 'OAuthException',
|
||||
'code' => 400,
|
||||
],
|
||||
], 400);
|
||||
|
||||
$fakeResponse = Http::fake(['*' => $response])->post('https://graph.facebook.com/test');
|
||||
|
||||
FacebookPublishException::fromApiResponse($fakeResponse);
|
||||
})->throws(TokenExpiredException::class);
|
||||
|
||||
test('code 4 maps to RateLimit category', function () {
|
||||
$response = Http::response([
|
||||
'error' => [
|
||||
'message' => 'Too many API calls.',
|
||||
'type' => 'FacebookApiException',
|
||||
'code' => 4,
|
||||
],
|
||||
], 400);
|
||||
|
||||
$fakeResponse = Http::fake(['*' => $response])->post('https://graph.facebook.com/test');
|
||||
|
||||
$exception = FacebookPublishException::fromApiResponse($fakeResponse);
|
||||
|
||||
expect($exception->category)->toBe(ErrorCategory::RateLimit)
|
||||
->and($exception->userMessage)->toBe('Too many API calls. Please try again later.');
|
||||
});
|
||||
|
||||
test('code 1363042 maps to Permission category', function () {
|
||||
$response = Http::response([
|
||||
'error' => [
|
||||
'message' => 'No permission to upload video here.',
|
||||
'type' => 'FacebookApiException',
|
||||
'code' => 1363042,
|
||||
],
|
||||
], 400);
|
||||
|
||||
$fakeResponse = Http::fake(['*' => $response])->post('https://graph.facebook.com/test');
|
||||
|
||||
$exception = FacebookPublishException::fromApiResponse($fakeResponse);
|
||||
|
||||
expect($exception->category)->toBe(ErrorCategory::Permission)
|
||||
->and($exception->userMessage)->toBe('No permission to upload video here.');
|
||||
});
|
||||
|
||||
test('code 506 maps to ContentPolicy category', function () {
|
||||
$response = Http::response([
|
||||
'error' => [
|
||||
'message' => 'Duplicate post.',
|
||||
'type' => 'FacebookApiException',
|
||||
'code' => 506,
|
||||
],
|
||||
], 400);
|
||||
|
||||
$fakeResponse = Http::fake(['*' => $response])->post('https://graph.facebook.com/test');
|
||||
|
||||
$exception = FacebookPublishException::fromApiResponse($fakeResponse);
|
||||
|
||||
expect($exception->category)->toBe(ErrorCategory::ContentPolicy)
|
||||
->and($exception->userMessage)->toBe('Duplicate post detected. Please modify content.');
|
||||
});
|
||||
|
||||
test('unknown code maps to Unknown category with error message', function () {
|
||||
$response = Http::response([
|
||||
'error' => [
|
||||
'message' => 'An unexpected error occurred.',
|
||||
'type' => 'FacebookApiException',
|
||||
'code' => 9999999,
|
||||
],
|
||||
], 400);
|
||||
|
||||
$fakeResponse = Http::fake(['*' => $response])->post('https://graph.facebook.com/test');
|
||||
|
||||
$exception = FacebookPublishException::fromApiResponse($fakeResponse);
|
||||
|
||||
expect($exception->category)->toBe(ErrorCategory::Unknown)
|
||||
->and($exception->userMessage)->toBe('An unexpected error occurred.');
|
||||
});
|
||||
|
||||
test('subcode 463 throws TokenExpiredException', function () {
|
||||
$response = Http::response([
|
||||
'error' => [
|
||||
'message' => 'Session expired.',
|
||||
'type' => 'FacebookApiException',
|
||||
'code' => 102,
|
||||
'error_subcode' => 463,
|
||||
],
|
||||
], 400);
|
||||
|
||||
$fakeResponse = Http::fake(['*' => $response])->post('https://graph.facebook.com/test');
|
||||
|
||||
FacebookPublishException::fromApiResponse($fakeResponse);
|
||||
})->throws(TokenExpiredException::class);
|
||||
114
tests/Unit/Exceptions/Social/TikTokPublishExceptionTest.php
Normal file
114
tests/Unit/Exceptions/Social/TikTokPublishExceptionTest.php
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Exceptions\Social\ErrorCategory;
|
||||
use App\Exceptions\Social\TikTokPublishException;
|
||||
use App\Exceptions\TokenExpiredException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
test('HTTP error rate_limit_exceeded maps to RateLimit category', function () {
|
||||
$response = Http::response([
|
||||
'error' => [
|
||||
'code' => 'rate_limit_exceeded',
|
||||
'message' => 'Rate limit hit.',
|
||||
'log_id' => 'abc123',
|
||||
],
|
||||
], 429);
|
||||
|
||||
$fakeResponse = Http::fake(['*' => $response])->post('https://open.tiktokapis.com/test');
|
||||
|
||||
$exception = TikTokPublishException::fromApiResponse($fakeResponse);
|
||||
|
||||
expect($exception)
|
||||
->toBeInstanceOf(TikTokPublishException::class)
|
||||
->and($exception->category)->toBe(ErrorCategory::RateLimit)
|
||||
->and($exception->userMessage)->toBe('TikTok rate limit exceeded. Please try again later.')
|
||||
->and($exception->platformErrorCode)->toBe('rate_limit_exceeded');
|
||||
});
|
||||
|
||||
test('HTTP error access_token_invalid throws TokenExpiredException', function () {
|
||||
$response = Http::response([
|
||||
'error' => [
|
||||
'code' => 'access_token_invalid',
|
||||
'message' => 'The access token is invalid.',
|
||||
'log_id' => 'xyz789',
|
||||
],
|
||||
], 401);
|
||||
|
||||
$fakeResponse = Http::fake(['*' => $response])->post('https://open.tiktokapis.com/test');
|
||||
|
||||
TikTokPublishException::fromApiResponse($fakeResponse);
|
||||
})->throws(TokenExpiredException::class);
|
||||
|
||||
test('HTTP error invalid_file_upload maps to MediaFormat category', function () {
|
||||
$response = Http::response([
|
||||
'error' => [
|
||||
'code' => 'invalid_file_upload',
|
||||
'message' => 'File does not meet specifications.',
|
||||
'log_id' => 'def456',
|
||||
],
|
||||
], 400);
|
||||
|
||||
$fakeResponse = Http::fake(['*' => $response])->post('https://open.tiktokapis.com/test');
|
||||
|
||||
$exception = TikTokPublishException::fromApiResponse($fakeResponse);
|
||||
|
||||
expect($exception->category)->toBe(ErrorCategory::MediaFormat)
|
||||
->and($exception->userMessage)->toBe('File does not meet API specifications.');
|
||||
});
|
||||
|
||||
test('fail reason spam_risk_too_many_posts maps to RateLimit category', function () {
|
||||
$exception = TikTokPublishException::fromFailReason('spam_risk_too_many_posts');
|
||||
|
||||
expect($exception)
|
||||
->toBeInstanceOf(TikTokPublishException::class)
|
||||
->and($exception->category)->toBe(ErrorCategory::RateLimit)
|
||||
->and($exception->userMessage)->toBe('Daily posting limit reached. Try again tomorrow.')
|
||||
->and($exception->platformErrorCode)->toBe('spam_risk_too_many_posts');
|
||||
});
|
||||
|
||||
test('fail reason video_pull_failed maps to ServerError category', function () {
|
||||
$exception = TikTokPublishException::fromFailReason('video_pull_failed');
|
||||
|
||||
expect($exception->category)->toBe(ErrorCategory::ServerError)
|
||||
->and($exception->userMessage)->toBe('Failed to download video from URL.');
|
||||
});
|
||||
|
||||
test('fail reason file_format_check_failed maps to MediaFormat category', function () {
|
||||
$exception = TikTokPublishException::fromFailReason('file_format_check_failed');
|
||||
|
||||
expect($exception->category)->toBe(ErrorCategory::MediaFormat)
|
||||
->and($exception->userMessage)->toBe('Unsupported media format.');
|
||||
});
|
||||
|
||||
test('unknown error code falls through with Unknown category', function () {
|
||||
$response = Http::response([
|
||||
'error' => [
|
||||
'code' => 'some_unknown_error',
|
||||
'message' => 'Something went wrong.',
|
||||
'log_id' => 'ghi789',
|
||||
],
|
||||
], 400);
|
||||
|
||||
$fakeResponse = Http::fake(['*' => $response])->post('https://open.tiktokapis.com/test');
|
||||
|
||||
$exception = TikTokPublishException::fromApiResponse($fakeResponse);
|
||||
|
||||
expect($exception->category)->toBe(ErrorCategory::Unknown)
|
||||
->and($exception->userMessage)->toBe('Something went wrong.');
|
||||
});
|
||||
|
||||
test('fromFailReason passes raw response through', function () {
|
||||
$rawResponse = '{"status":"failed","fail_reason":"video_pull_failed"}';
|
||||
|
||||
$exception = TikTokPublishException::fromFailReason('video_pull_failed', $rawResponse);
|
||||
|
||||
expect($exception->rawResponse)->toBe($rawResponse);
|
||||
});
|
||||
|
||||
test('platform returns tiktok', function () {
|
||||
$exception = TikTokPublishException::fromFailReason('internal');
|
||||
|
||||
expect($exception->platform())->toBe('tiktok');
|
||||
});
|
||||
119
tests/Unit/Exceptions/Social/YouTubePublishExceptionTest.php
Normal file
119
tests/Unit/Exceptions/Social/YouTubePublishExceptionTest.php
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Exceptions\Social\ErrorCategory;
|
||||
use App\Exceptions\Social\YouTubePublishException;
|
||||
use App\Exceptions\TokenExpiredException;
|
||||
use Google\Service\Exception;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
// fromGoogleException tests
|
||||
|
||||
test('invalidTitle reason maps to ContentPolicy category', function () {
|
||||
$e = new Exception('message', 400, null, [['reason' => 'invalidTitle', 'message' => 'title invalid']]);
|
||||
|
||||
$exception = YouTubePublishException::fromGoogleException($e);
|
||||
|
||||
expect($exception)
|
||||
->toBeInstanceOf(YouTubePublishException::class)
|
||||
->and($exception->userMessage)->toBe('Video title is invalid or empty.')
|
||||
->and($exception->category)->toBe(ErrorCategory::ContentPolicy)
|
||||
->and($exception->platformErrorCode)->toBe('invalidTitle');
|
||||
});
|
||||
|
||||
test('uploadLimitExceeded reason maps to RateLimit category', function () {
|
||||
$e = new Exception('message', 403, null, [['reason' => 'uploadLimitExceeded', 'message' => 'limit exceeded']]);
|
||||
|
||||
$exception = YouTubePublishException::fromGoogleException($e);
|
||||
|
||||
expect($exception->userMessage)->toBe('Daily upload limit reached. Try again tomorrow.')
|
||||
->and($exception->category)->toBe(ErrorCategory::RateLimit);
|
||||
});
|
||||
|
||||
test('forbidden reason maps to Permission category', function () {
|
||||
$e = new Exception('message', 403, null, [['reason' => 'forbidden', 'message' => 'no permission']]);
|
||||
|
||||
$exception = YouTubePublishException::fromGoogleException($e);
|
||||
|
||||
expect($exception->userMessage)->toBe("You don't have permission to upload to this channel.")
|
||||
->and($exception->category)->toBe(ErrorCategory::Permission);
|
||||
});
|
||||
|
||||
test('HTTP 401 throws TokenExpiredException', function () {
|
||||
$e = new Exception('Unauthorized', 401, null, [['reason' => 'someReason', 'message' => 'token expired']]);
|
||||
|
||||
YouTubePublishException::fromGoogleException($e);
|
||||
})->throws(TokenExpiredException::class);
|
||||
|
||||
test('authError reason throws TokenExpiredException', function () {
|
||||
$e = new Exception('Auth error', 403, null, [['reason' => 'authError', 'message' => 'auth failed']]);
|
||||
|
||||
YouTubePublishException::fromGoogleException($e);
|
||||
})->throws(TokenExpiredException::class);
|
||||
|
||||
test('unauthorized reason throws TokenExpiredException', function () {
|
||||
$e = new Exception('Unauthorized', 403, null, [['reason' => 'unauthorized', 'message' => 'not authorized']]);
|
||||
|
||||
YouTubePublishException::fromGoogleException($e);
|
||||
})->throws(TokenExpiredException::class);
|
||||
|
||||
test('unknown reason falls through to Unknown category with original message', function () {
|
||||
$e = new Exception('original error message', 400, null, [['reason' => 'someUnknownReason', 'message' => 'original error message']]);
|
||||
|
||||
$exception = YouTubePublishException::fromGoogleException($e);
|
||||
|
||||
expect($exception->category)->toBe(ErrorCategory::Unknown)
|
||||
->and($exception->userMessage)->toBe('original error message');
|
||||
});
|
||||
|
||||
test('platform returns youtube', function () {
|
||||
$e = new Exception('message', 400, null, [['reason' => 'invalidTitle', 'message' => 'title invalid']]);
|
||||
|
||||
$exception = YouTubePublishException::fromGoogleException($e);
|
||||
|
||||
expect($exception->platform())->toBe('youtube');
|
||||
});
|
||||
|
||||
// fromApiResponse tests
|
||||
|
||||
test('fromApiResponse with invalidTitle reason maps to ContentPolicy', function () {
|
||||
$response = Http::response([
|
||||
'error' => [
|
||||
'code' => 400,
|
||||
'message' => 'Invalid video title',
|
||||
'errors' => [
|
||||
['reason' => 'invalidTitle', 'message' => 'title invalid'],
|
||||
],
|
||||
],
|
||||
], 400);
|
||||
|
||||
$fakeResponse = Http::fake(['*' => $response])->post('https://www.googleapis.com/youtube/v3/videos');
|
||||
|
||||
$exception = YouTubePublishException::fromApiResponse($fakeResponse);
|
||||
|
||||
expect($exception)
|
||||
->toBeInstanceOf(YouTubePublishException::class)
|
||||
->and($exception->userMessage)->toBe('Video title is invalid or empty.')
|
||||
->and($exception->category)->toBe(ErrorCategory::ContentPolicy)
|
||||
->and($exception->platformErrorCode)->toBe('invalidTitle');
|
||||
});
|
||||
|
||||
test('fromApiResponse with unknown reason uses fallback message and Unknown category', function () {
|
||||
$response = Http::response([
|
||||
'error' => [
|
||||
'code' => 400,
|
||||
'message' => 'Something went wrong on YouTube.',
|
||||
'errors' => [
|
||||
['reason' => 'weirdUnknownReason', 'message' => 'Something went wrong on YouTube.'],
|
||||
],
|
||||
],
|
||||
], 400);
|
||||
|
||||
$fakeResponse = Http::fake(['*' => $response])->post('https://www.googleapis.com/youtube/v3/videos');
|
||||
|
||||
$exception = YouTubePublishException::fromApiResponse($fakeResponse);
|
||||
|
||||
expect($exception->category)->toBe(ErrorCategory::Unknown)
|
||||
->and($exception->userMessage)->toBe('Something went wrong on YouTube.');
|
||||
});
|
||||
139
tests/Unit/Services/Media/MediaOptimizerTest.php
Normal file
139
tests/Unit/Services/Media/MediaOptimizerTest.php
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Services\Media\MediaOptimizer;
|
||||
use Intervention\Image\Drivers\Gd\Driver;
|
||||
use Intervention\Image\ImageManager;
|
||||
|
||||
function createTestImage(int $width, int $height, string $format = 'image/jpeg'): string
|
||||
{
|
||||
$manager = new ImageManager(Driver::class);
|
||||
$image = $manager->createImage($width, $height)->fill('cccccc');
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'test_img_');
|
||||
$encoded = $image->encodeUsingMediaType($format);
|
||||
file_put_contents($tempFile, (string) $encoded);
|
||||
|
||||
return $tempFile;
|
||||
}
|
||||
|
||||
$tempFiles = [];
|
||||
|
||||
afterEach(function () use (&$tempFiles) {
|
||||
foreach ($tempFiles as $file) {
|
||||
@unlink($file);
|
||||
}
|
||||
$tempFiles = [];
|
||||
});
|
||||
|
||||
it('resizes wide image for instagram', function () use (&$tempFiles) {
|
||||
$source = createTestImage(3000, 2000);
|
||||
$tempFiles[] = $source;
|
||||
|
||||
$optimizer = new MediaOptimizer;
|
||||
$result = $optimizer->optimizeImage($source, Platform::Instagram);
|
||||
$tempFiles[] = $result;
|
||||
|
||||
$manager = new ImageManager(Driver::class);
|
||||
$optimized = $manager->decodePath($result);
|
||||
|
||||
expect($optimized->width())->toBeLessThanOrEqual(1440);
|
||||
|
||||
$bytes = file_get_contents($result);
|
||||
expect(ord($bytes[0]))->toBe(0xFF)
|
||||
->and(ord($bytes[1]))->toBe(0xD8);
|
||||
});
|
||||
|
||||
it('resizes for bluesky under 1mb', function () use (&$tempFiles) {
|
||||
$source = createTestImage(2000, 2000);
|
||||
$tempFiles[] = $source;
|
||||
|
||||
$optimizer = new MediaOptimizer;
|
||||
$result = $optimizer->optimizeImage($source, Platform::Bluesky);
|
||||
$tempFiles[] = $result;
|
||||
|
||||
expect(filesize($result))->toBeLessThan(976 * 1024);
|
||||
});
|
||||
|
||||
it('does not upscale small images', function () use (&$tempFiles) {
|
||||
$source = createTestImage(500, 500);
|
||||
$tempFiles[] = $source;
|
||||
|
||||
$optimizer = new MediaOptimizer;
|
||||
$result = $optimizer->optimizeImage($source, Platform::Instagram);
|
||||
$tempFiles[] = $result;
|
||||
|
||||
$manager = new ImageManager(Driver::class);
|
||||
$optimized = $manager->decodePath($result);
|
||||
|
||||
expect($optimized->width())->toBe(500);
|
||||
});
|
||||
|
||||
it('converts png to jpeg for instagram', function () use (&$tempFiles) {
|
||||
$source = createTestImage(800, 600, 'image/png');
|
||||
$tempFiles[] = $source;
|
||||
|
||||
$optimizer = new MediaOptimizer;
|
||||
$result = $optimizer->optimizeImage($source, Platform::Instagram);
|
||||
$tempFiles[] = $result;
|
||||
|
||||
$bytes = file_get_contents($result);
|
||||
expect(ord($bytes[0]))->toBe(0xFF)
|
||||
->and(ord($bytes[1]))->toBe(0xD8);
|
||||
});
|
||||
|
||||
it('resizes for tiktok max 1080', function () use (&$tempFiles) {
|
||||
$source = createTestImage(2000, 2000);
|
||||
$tempFiles[] = $source;
|
||||
|
||||
$optimizer = new MediaOptimizer;
|
||||
$result = $optimizer->optimizeImage($source, Platform::TikTok);
|
||||
$tempFiles[] = $result;
|
||||
|
||||
$manager = new ImageManager(Driver::class);
|
||||
$optimized = $manager->decodePath($result);
|
||||
|
||||
expect($optimized->width())->toBeLessThanOrEqual(1080);
|
||||
});
|
||||
|
||||
it('resizes for pinterest max 1000', function () use (&$tempFiles) {
|
||||
$source = createTestImage(2000, 3000);
|
||||
$tempFiles[] = $source;
|
||||
|
||||
$optimizer = new MediaOptimizer;
|
||||
$result = $optimizer->optimizeImage($source, Platform::Pinterest);
|
||||
$tempFiles[] = $result;
|
||||
|
||||
$manager = new ImageManager(Driver::class);
|
||||
$optimized = $manager->decodePath($result);
|
||||
|
||||
expect($optimized->width())->toBeLessThanOrEqual(1000);
|
||||
});
|
||||
|
||||
it('handles all platforms without error', function () use (&$tempFiles) {
|
||||
$source = createTestImage(1000, 800);
|
||||
$tempFiles[] = $source;
|
||||
|
||||
$optimizer = new MediaOptimizer;
|
||||
|
||||
foreach (Platform::cases() as $platform) {
|
||||
$result = $optimizer->optimizeImage($source, $platform);
|
||||
$tempFiles[] = $result;
|
||||
|
||||
expect(file_exists($result))->toBeTrue()
|
||||
->and(filesize($result))->toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns valid temp file path', function () use (&$tempFiles) {
|
||||
$source = createTestImage(800, 600);
|
||||
$tempFiles[] = $source;
|
||||
|
||||
$optimizer = new MediaOptimizer;
|
||||
$result = $optimizer->optimizeImage($source, Platform::Facebook);
|
||||
$tempFiles[] = $result;
|
||||
|
||||
expect(file_exists($result))->toBeTrue()
|
||||
->and(filesize($result))->toBeGreaterThan(0);
|
||||
});
|
||||
Loading…
Reference in a new issue