diff --git a/.env.ci b/.env.ci index d040cedf..b72a8b5a 100644 --- a/.env.ci +++ b/.env.ci @@ -1,2 +1,78 @@ +APP_NAME="TryPost" APP_ENV=testing APP_KEY=base64:Zfo+2dkSaHvem5LCiZS/baYHv2Pv1vrSc0F2NaF29Ec= +APP_DEBUG=true +APP_URL=http://localhost + +SELF_HOSTED=true + +APP_LOCALE=en +APP_FALLBACK_LOCALE=en +APP_FAKER_LOCALE=en_US + +APP_MAINTENANCE_DRIVER=file + +BCRYPT_ROUNDS=4 + +LOG_CHANNEL=stack +LOG_STACK=single +LOG_DEPRECATIONS_CHANNEL=null +LOG_LEVEL=debug + +# Database (PostgreSQL for CI) +DB_CONNECTION=pgsql +DB_HOST=127.0.0.1 +DB_PORT=5432 +DB_DATABASE=trypost_test +DB_USERNAME=postgres +DB_PASSWORD=password + +# Session +SESSION_DRIVER=array +SESSION_LIFETIME=1440 +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=null + +# Broadcasting, Queue, Cache +BROADCAST_CONNECTION=null +QUEUE_CONNECTION=sync +CACHE_STORE=array + +# File Storage +FILESYSTEM_DISK=local + +# Redis +REDIS_HOST=127.0.0.1 +REDIS_PASSWORD=null +REDIS_PORT=6379 + +# Mail +MAIL_MAILER=array +MAIL_HOST=127.0.0.1 +MAIL_PORT=2525 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_ENCRYPTION=null +MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_NAME="${APP_NAME}" + +# Reverb (WebSockets) - disabled for testing +REVERB_APP_ID=1001 +REVERB_APP_KEY=test-reverb-key +REVERB_APP_SECRET=test-reverb-secret +REVERB_HOST="localhost" +REVERB_PORT=8080 +REVERB_SCHEME=http + +# Disable optional services +PULSE_ENABLED=false +TELESCOPE_ENABLED=false +NIGHTWATCH_ENABLED=false + +# Vite +VITE_APP_NAME="${APP_NAME}" +VITE_REVERB_APP_KEY="${REVERB_APP_KEY}" +VITE_REVERB_HOST="${REVERB_HOST}" +VITE_REVERB_PORT="${REVERB_PORT}" +VITE_REVERB_SCHEME="${REVERB_SCHEME}" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c9c423c4..8c3178fb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -69,17 +69,7 @@ jobs: run: npm run build - name: Prepare environment - run: | - cp .env.example .env - php artisan key:generate - - - name: Configure test environment - run: | - sed -i 's/DB_DATABASE=trypost/DB_DATABASE=trypost_test/' .env - sed -i 's/DB_PASSWORD=$/DB_PASSWORD=password/' .env - sed -i 's/CACHE_STORE=redis/CACHE_STORE=array/' .env - sed -i 's/QUEUE_CONNECTION=redis/QUEUE_CONNECTION=sync/' .env - sed -i 's/SESSION_DRIVER=database/SESSION_DRIVER=array/' .env + run: cp .env.ci .env - name: Run migrations run: php artisan migrate --force diff --git a/tests/Unit/Services/LinkedInPublisherTest.php b/tests/Unit/Services/LinkedInPublisherTest.php index 7aae2542..349a161a 100644 --- a/tests/Unit/Services/LinkedInPublisherTest.php +++ b/tests/Unit/Services/LinkedInPublisherTest.php @@ -9,6 +9,7 @@ use App\Models\Workspace; use App\Services\Social\LinkedInPublisher; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Storage; beforeEach(function () { $this->workspace = Workspace::factory()->create(); @@ -161,3 +162,58 @@ expect(fn () => $publisher->publish($this->postPlatform)) ->toThrow(TokenExpiredException::class); }); + +test('linkedin publisher handles token refresh failure', function () { + $this->socialAccount->update([ + 'token_expires_at' => now()->subHour(), + 'refresh_token' => 'refresh-token-123', + ]); + + Http::fake([ + 'https://www.linkedin.com/oauth/v2/accessToken' => Http::response([ + 'code' => 'INVALID_REFRESH_TOKEN', + 'message' => 'Invalid refresh token', + ], 400), + ]); + + $publisher = new LinkedInPublisher; + + expect(fn () => $publisher->publish($this->postPlatform)) + ->toThrow(\Exception::class); +}); + +test('linkedin publisher refreshes token when expiring soon', function () { + $this->socialAccount->update([ + 'token_expires_at' => now()->addMinutes(5), + 'refresh_token' => 'refresh-token-123', + ]); + + Http::fake([ + 'https://www.linkedin.com/oauth/v2/accessToken' => Http::response([ + 'access_token' => 'new-access-token', + 'refresh_token' => 'new-refresh-token', + 'expires_in' => 3600, + ], 200), + '*/rest/posts' => Http::response(null, 201, ['x-restli-id' => 'urn:li:share:123456']), + ]); + + $publisher = new LinkedInPublisher; + $result = $publisher->publish($this->postPlatform); + + expect($result['id'])->toBe('urn:li:share:123456'); + $this->socialAccount->refresh(); + expect($this->socialAccount->access_token)->toBe('new-access-token'); +}); + +test('linkedin publisher handles empty post id header', function () { + Http::fake([ + '*/rest/posts' => Http::response(null, 201), + ]); + + $publisher = new LinkedInPublisher; + $result = $publisher->publish($this->postPlatform); + + // When header is empty, id will be empty string or 'unknown' + expect($result)->toHaveKey('id'); + expect($result)->toHaveKey('url'); +}); diff --git a/tests/Unit/Services/Social/InstagramPublisherTest.php b/tests/Unit/Services/Social/InstagramPublisherTest.php new file mode 100644 index 00000000..7e4bbc83 --- /dev/null +++ b/tests/Unit/Services/Social/InstagramPublisherTest.php @@ -0,0 +1,502 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + + $this->socialAccount = SocialAccount::factory()->instagram()->create([ + 'workspace_id' => $this->workspace->id, + 'platform_user_id' => 'ig_123456789', + 'username' => 'testuser', + 'token_expires_at' => now()->addDays(60), + ]); + + $this->post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + ]); + + $this->postPlatform = PostPlatform::factory()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => $this->socialAccount->id, + 'platform' => Platform::Instagram, + 'content_type' => ContentType::InstagramFeed, + 'content' => 'Hello from Instagram!', + ]); + + $this->publisher = new InstagramPublisher; +}); + +test('instagram publisher throws exception without media', function () { + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(\Exception::class, 'Instagram requires at least one image or video'); +}); + +test('instagram publisher can publish single image', function () { + $this->postPlatform->media()->create([ + 'collection' => 'default', + 'type' => 'image', + 'path' => 'media/2026-01/test-image.jpg', + 'original_filename' => 'test.jpg', + 'mime_type' => 'image/jpeg', + 'size' => 12345, + 'order' => 0, + 'meta' => ['width' => 1920, 'height' => 1080], + ]); + + Http::fake([ + 'https://graph.instagram.com/v24.0/ig_123456789/media' => Http::response([ + 'id' => 'container-123', + ], 200), + 'https://graph.instagram.com/v24.0/container-123*' => Http::response([ + 'status_code' => 'FINISHED', + ], 200), + 'https://graph.instagram.com/v24.0/ig_123456789/media_publish' => Http::response([ + 'id' => 'media-123456789', + ], 200), + 'https://graph.instagram.com/v24.0/media-123456789*' => Http::response([ + 'permalink' => 'https://www.instagram.com/p/ABC123/', + ], 200), + ]); + + $result = $this->publisher->publish($this->postPlatform); + + expect($result)->toHaveKey('id'); + expect($result)->toHaveKey('url'); + expect($result['id'])->toBe('media-123456789'); + expect($result['url'])->toBe('https://www.instagram.com/p/ABC123/'); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/ig_123456789/media'); + }); +}); + +test('instagram publisher can publish reel', function () { + $this->postPlatform->update(['content_type' => ContentType::InstagramReel]); + + $this->postPlatform->media()->create([ + 'collection' => 'default', + 'type' => 'video', + 'path' => 'media/2026-01/test-video.mp4', + 'original_filename' => 'test.mp4', + 'mime_type' => 'video/mp4', + 'size' => 1234567, + 'order' => 0, + 'meta' => ['width' => 1080, 'height' => 1920, 'duration' => 30], + ]); + + Http::fake([ + 'https://graph.instagram.com/v24.0/ig_123456789/media' => Http::response([ + 'id' => 'container-123', + ], 200), + 'https://graph.instagram.com/v24.0/container-123*' => Http::response([ + 'status_code' => 'FINISHED', + ], 200), + 'https://graph.instagram.com/v24.0/ig_123456789/media_publish' => Http::response([ + 'id' => 'reel-123456789', + ], 200), + 'https://graph.instagram.com/v24.0/reel-123456789*' => Http::response([ + 'permalink' => 'https://www.instagram.com/reel/ABC123/', + ], 200), + ]); + + $result = $this->publisher->publish($this->postPlatform); + + expect($result['id'])->toBe('reel-123456789'); + expect($result['url'])->toBe('https://www.instagram.com/reel/ABC123/'); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/ig_123456789/media') + && (str_contains($request->body(), 'REELS') || str_contains($request->url(), 'media_publish')); + }); +}); + +test('instagram publisher can publish image story', function () { + $this->postPlatform->update(['content_type' => ContentType::InstagramStory]); + + $this->postPlatform->media()->create([ + 'collection' => 'default', + 'type' => 'image', + 'path' => 'media/2026-01/story.jpg', + 'original_filename' => 'story.jpg', + 'mime_type' => 'image/jpeg', + 'size' => 512000, + 'order' => 0, + ]); + + Http::fake([ + 'https://graph.instagram.com/v24.0/ig_123456789/media' => Http::response([ + 'id' => 'story-container-123', + ], 200), + 'https://graph.instagram.com/v24.0/story-container-123*' => Http::response([ + 'status_code' => 'FINISHED', + ], 200), + 'https://graph.instagram.com/v24.0/ig_123456789/media_publish' => Http::response([ + 'id' => 'story-123456789', + ], 200), + 'https://graph.instagram.com/v24.0/story-123456789*' => Http::response([ + 'permalink' => 'https://www.instagram.com/stories/testuser/123/', + ], 200), + ]); + + $result = $this->publisher->publish($this->postPlatform); + + expect($result['id'])->toBe('story-123456789'); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/ig_123456789/media'); + }); +}); + +test('instagram publisher can publish video story', function () { + $this->postPlatform->update(['content_type' => ContentType::InstagramStory]); + + $this->postPlatform->media()->create([ + 'collection' => 'default', + 'type' => 'video', + 'path' => 'media/2026-01/story.mp4', + 'original_filename' => 'story.mp4', + 'mime_type' => 'video/mp4', + 'size' => 5120000, + 'order' => 0, + ]); + + Http::fake([ + 'https://graph.instagram.com/v24.0/ig_123456789/media' => Http::response([ + 'id' => 'story-container-123', + ], 200), + 'https://graph.instagram.com/v24.0/story-container-123*' => Http::response([ + 'status_code' => 'FINISHED', + ], 200), + 'https://graph.instagram.com/v24.0/ig_123456789/media_publish' => Http::response([ + 'id' => 'story-video-123456789', + ], 200), + 'https://graph.instagram.com/v24.0/story-video-123456789*' => Http::response([ + 'permalink' => 'https://www.instagram.com/stories/testuser/456/', + ], 200), + ]); + + $result = $this->publisher->publish($this->postPlatform); + + expect($result['id'])->toBe('story-video-123456789'); +}); + +test('instagram publisher can publish carousel', function () { + // Create multiple media items + for ($i = 0; $i < 3; $i++) { + $this->postPlatform->media()->create([ + 'collection' => 'default', + 'type' => 'image', + 'path' => "media/2026-01/test-image-{$i}.jpg", + 'original_filename' => "test-{$i}.jpg", + 'mime_type' => 'image/jpeg', + 'size' => 12345, + 'order' => $i, + 'meta' => ['width' => 1920, 'height' => 1080], + ]); + } + + Http::fake([ + 'https://graph.instagram.com/v24.0/ig_123456789/media' => Http::sequence() + ->push(['id' => 'child-1'], 200) + ->push(['id' => 'child-2'], 200) + ->push(['id' => 'child-3'], 200) + ->push(['id' => 'carousel-container-123'], 200), + 'https://graph.instagram.com/v24.0/carousel-container-123*' => Http::response([ + 'status_code' => 'FINISHED', + ], 200), + 'https://graph.instagram.com/v24.0/ig_123456789/media_publish' => Http::response([ + 'id' => 'carousel-123456789', + ], 200), + 'https://graph.instagram.com/v24.0/carousel-123456789*' => Http::response([ + 'permalink' => 'https://www.instagram.com/p/CAROUSEL123/', + ], 200), + ]); + + $result = $this->publisher->publish($this->postPlatform); + + expect($result['id'])->toBe('carousel-123456789'); + expect($result['url'])->toBe('https://www.instagram.com/p/CAROUSEL123/'); +}); + +test('instagram publisher can publish carousel with videos', function () { + // Create image and video mix + $this->postPlatform->media()->create([ + 'collection' => 'default', + 'type' => 'image', + 'path' => 'media/2026-01/test-image.jpg', + 'original_filename' => 'test.jpg', + 'mime_type' => 'image/jpeg', + 'size' => 12345, + 'order' => 0, + ]); + $this->postPlatform->media()->create([ + 'collection' => 'default', + 'type' => 'video', + 'path' => 'media/2026-01/test-video.mp4', + 'original_filename' => 'test.mp4', + 'mime_type' => 'video/mp4', + 'size' => 1234567, + 'order' => 1, + ]); + + Http::fake([ + 'https://graph.instagram.com/v24.0/ig_123456789/media' => Http::sequence() + ->push(['id' => 'child-1'], 200) + ->push(['id' => 'child-2'], 200) + ->push(['id' => 'carousel-container-123'], 200), + 'https://graph.instagram.com/v24.0/child-2*' => Http::response([ + 'status_code' => 'FINISHED', + ], 200), + 'https://graph.instagram.com/v24.0/carousel-container-123*' => Http::response([ + 'status_code' => 'FINISHED', + ], 200), + 'https://graph.instagram.com/v24.0/ig_123456789/media_publish' => Http::response([ + 'id' => 'carousel-mix-123456789', + ], 200), + 'https://graph.instagram.com/v24.0/carousel-mix-123456789*' => Http::response([ + 'permalink' => 'https://www.instagram.com/p/CAROUSELMIX/', + ], 200), + ]); + + $result = $this->publisher->publish($this->postPlatform); + + expect($result['id'])->toBe('carousel-mix-123456789'); +}); + +test('instagram publisher throws exception on api error', function () { + $this->postPlatform->media()->create([ + 'collection' => 'default', + 'type' => 'image', + 'path' => 'media/2026-01/test-image.jpg', + 'original_filename' => 'test.jpg', + 'mime_type' => 'image/jpeg', + 'size' => 12345, + 'order' => 0, + ]); + + Http::fake([ + 'https://graph.instagram.com/v24.0/ig_123456789/media' => Http::response([ + 'error' => [ + 'message' => 'Invalid parameter', + 'type' => 'GraphMethodException', + 'code' => 100, + ], + ], 400), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(Exception::class); +}); + +test('instagram publisher throws token expired exception on oauth error', function () { + $this->postPlatform->media()->create([ + 'collection' => 'default', + 'type' => 'image', + 'path' => 'media/2026-01/test-image.jpg', + 'original_filename' => 'test.jpg', + 'mime_type' => 'image/jpeg', + 'size' => 12345, + 'order' => 0, + ]); + + Http::fake([ + 'https://graph.instagram.com/v24.0/ig_123456789/media' => Http::response([ + 'error' => [ + 'message' => 'Error validating access token', + 'type' => 'OAuthException', + 'code' => 190, + ], + ], 400), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(TokenExpiredException::class); +}); + +test('instagram publisher throws token expired exception on session expired subcode', function () { + $this->postPlatform->media()->create([ + 'collection' => 'default', + 'type' => 'image', + 'path' => 'media/2026-01/test-image.jpg', + 'original_filename' => 'test.jpg', + 'mime_type' => 'image/jpeg', + 'size' => 12345, + 'order' => 0, + ]); + + Http::fake([ + 'https://graph.instagram.com/v24.0/ig_123456789/media' => Http::response([ + 'error' => [ + 'message' => 'Session has expired', + 'type' => 'OAuthException', + 'code' => 190, + 'error_subcode' => 463, + ], + ], 400), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(TokenExpiredException::class); +}); + +test('instagram publisher throws exception for unsupported content type', function () { + $this->postPlatform->update(['content_type' => ContentType::LinkedInPost]); + + $this->postPlatform->media()->create([ + 'collection' => 'default', + 'type' => 'image', + 'path' => 'media/2026-01/test-image.jpg', + 'original_filename' => 'test.jpg', + 'mime_type' => 'image/jpeg', + 'size' => 12345, + 'order' => 0, + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(Exception::class, 'Unsupported Instagram content type'); +}); + +test('instagram publisher throws exception when no container id returned', function () { + $this->postPlatform->media()->create([ + 'collection' => 'default', + 'type' => 'image', + 'path' => 'media/2026-01/test-image.jpg', + 'original_filename' => 'test.jpg', + 'mime_type' => 'image/jpeg', + 'size' => 12345, + 'order' => 0, + ]); + + Http::fake([ + 'https://graph.instagram.com/v24.0/ig_123456789/media' => Http::response([ + 'success' => true, + // No id returned + ], 200), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(Exception::class, 'No container ID returned'); +}); + +test('instagram publisher handles media processing error', function () { + $this->postPlatform->media()->create([ + 'collection' => 'default', + 'type' => 'image', + 'path' => 'media/2026-01/test-image.jpg', + 'original_filename' => 'test.jpg', + 'mime_type' => 'image/jpeg', + 'size' => 12345, + 'order' => 0, + ]); + + Http::fake([ + 'https://graph.instagram.com/v24.0/ig_123456789/media' => Http::response([ + 'id' => 'container-123', + ], 200), + 'https://graph.instagram.com/v24.0/container-123*' => Http::response([ + 'status_code' => 'ERROR', + ], 200), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(Exception::class, 'Instagram media processing failed'); +}); + +test('instagram publisher waits for media processing', function () { + $this->postPlatform->media()->create([ + 'collection' => 'default', + 'type' => 'image', + 'path' => 'media/2026-01/test-image.jpg', + 'original_filename' => 'test.jpg', + 'mime_type' => 'image/jpeg', + 'size' => 12345, + 'order' => 0, + ]); + + Http::fake([ + 'https://graph.instagram.com/v24.0/ig_123456789/media' => Http::response([ + 'id' => 'container-123', + ], 200), + 'https://graph.instagram.com/v24.0/container-123*' => Http::sequence() + ->push(['status_code' => 'IN_PROGRESS'], 200) + ->push(['status_code' => 'IN_PROGRESS'], 200) + ->push(['status_code' => 'FINISHED'], 200), + 'https://graph.instagram.com/v24.0/ig_123456789/media_publish' => Http::response([ + 'id' => 'media-123456789', + ], 200), + 'https://graph.instagram.com/v24.0/media-123456789*' => Http::response([ + 'permalink' => 'https://www.instagram.com/p/ABC123/', + ], 200), + ]); + + $result = $this->publisher->publish($this->postPlatform); + + expect($result['id'])->toBe('media-123456789'); +}); + +test('instagram publisher throws exception when all carousel items fail', function () { + // Create multiple media items + for ($i = 0; $i < 3; $i++) { + $this->postPlatform->media()->create([ + 'collection' => 'default', + 'type' => 'image', + 'path' => "media/2026-01/test-image-{$i}.jpg", + 'original_filename' => "test-{$i}.jpg", + 'mime_type' => 'image/jpeg', + 'size' => 12345, + 'order' => $i, + ]); + } + + Http::fake([ + 'https://graph.instagram.com/v24.0/ig_123456789/media' => Http::response([ + 'error' => ['message' => 'Upload failed'], + ], 400), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(Exception::class, 'Failed to create any carousel items'); +}); + +test('instagram publisher handles publish failure', function () { + $this->postPlatform->media()->create([ + 'collection' => 'default', + 'type' => 'image', + 'path' => 'media/2026-01/test-image.jpg', + 'original_filename' => 'test.jpg', + 'mime_type' => 'image/jpeg', + 'size' => 12345, + 'order' => 0, + ]); + + Http::fake([ + 'https://graph.instagram.com/v24.0/ig_123456789/media' => Http::response([ + 'id' => 'container-123', + ], 200), + 'https://graph.instagram.com/v24.0/container-123*' => Http::response([ + 'status_code' => 'FINISHED', + ], 200), + 'https://graph.instagram.com/v24.0/ig_123456789/media_publish' => Http::response([ + 'error' => [ + 'message' => 'Publish failed', + 'type' => 'GraphMethodException', + 'code' => 100, + ], + ], 400), + ]); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(Exception::class); +}); diff --git a/tests/Unit/Services/XPublisherTest.php b/tests/Unit/Services/XPublisherTest.php index 3d2ad07b..dea9c474 100644 --- a/tests/Unit/Services/XPublisherTest.php +++ b/tests/Unit/Services/XPublisherTest.php @@ -140,3 +140,53 @@ expect(fn () => $publisher->publish($this->postPlatform)) ->toThrow(TokenExpiredException::class); }); + +test('x publisher refreshes token when expiring soon', function () { + $this->socialAccount->update([ + 'token_expires_at' => now()->addMinutes(5), + 'refresh_token' => 'refresh-token-123', + ]); + + Http::fake([ + '*/2/oauth2/token' => Http::response([ + 'access_token' => 'new-access-token', + 'refresh_token' => 'new-refresh-token', + 'expires_in' => 7200, + ], 200), + '*/2/tweets' => Http::response([ + 'data' => ['id' => 'tweet-123'], + ], 201), + ]); + + $publisher = new XPublisher; + $result = $publisher->publish($this->postPlatform); + + expect($result['id'])->toBe('tweet-123'); + $this->socialAccount->refresh(); + expect($this->socialAccount->access_token)->toBe('new-access-token'); +}); + +test('x publisher handles 403 error as generic error', function () { + Http::fake([ + '*' => Http::response([ + 'title' => 'Forbidden', + 'detail' => 'You do not have access', + ], 403), + ]); + + $publisher = new XPublisher; + + expect(fn () => $publisher->publish($this->postPlatform)) + ->toThrow(\Exception::class, 'X API error'); +}); + +test('x publisher handles empty error response', function () { + Http::fake([ + '*' => Http::response(null, 500), + ]); + + $publisher = new XPublisher; + + expect(fn () => $publisher->publish($this->postPlatform)) + ->toThrow(\Exception::class); +});