feat: adding tests..

This commit is contained in:
Paulo Castellano 2026-01-18 22:01:55 -03:00
parent d39de0752c
commit 100e52be54
5 changed files with 685 additions and 11 deletions

76
.env.ci
View file

@ -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}"

View file

@ -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

View file

@ -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');
});

View file

@ -0,0 +1,502 @@
<?php
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\TokenExpiredException;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Social\InstagramPublisher;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->socialAccount = SocialAccount::factory()->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);
});

View file

@ -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);
});