2026-01-18 23:33:45 +00:00
|
|
|
<?php
|
|
|
|
|
|
refactor: settings redesign, Spanish translations, language system, strict_types
Settings pages:
- Redesign layout to match Sendkit (max-w-4xl, space-y-12, Separator sections)
- Merge Members page into Workspace settings with Table, invite Dialog, ConfirmDeleteModal
- Add workspace logo upload/delete routes and controller methods
- Translate all hardcoded strings in Workspace.vue modals
Language system:
- Drop languages table, replace language_id FK with locale string column on users
- Create config/languages.php for available languages and default locale
- Add Spanish (es) translations (13 files)
- Simplify HandleInertiaRequests, ProfileController, RegisteredUserController
Code quality:
- Add declare(strict_types=1) to all PHP files
- Fix MastodonPublisher using wrong attribute (filename -> original_filename)
- Fix HasMediaTest for new has_photo/photo_url accessors
- Fix PublishToSocialPlatformTest type error revealed by strict_types
- Remove orphaned Language model from AppServiceProvider morph map
- Update User TypeScript interface (has_photo, photo_url, locale)
- Eager load media relation on workspaces to prevent N+1
- Add 8 new tests for workspace logo upload/delete
- Update workspace settings test to assert members/invitations props
All 710 tests passing.
2026-03-30 03:20:43 +00:00
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
2026-01-18 23:33:45 +00:00
|
|
|
use App\Enums\Post\Status as PostStatus;
|
|
|
|
|
use App\Enums\PostPlatform\ContentType;
|
2026-05-02 15:22:42 +00:00
|
|
|
use App\Enums\PostPlatform\Status;
|
2026-01-18 23:33:45 +00:00
|
|
|
use App\Enums\SocialAccount\Platform;
|
2026-03-31 03:40:18 +00:00
|
|
|
use App\Enums\UserWorkspace\Role;
|
2026-01-18 23:33:45 +00:00
|
|
|
use App\Models\Post;
|
|
|
|
|
use App\Models\PostPlatform;
|
|
|
|
|
use App\Models\SocialAccount;
|
|
|
|
|
use App\Models\User;
|
|
|
|
|
use App\Models\Workspace;
|
2026-01-26 18:18:35 +00:00
|
|
|
use App\Models\WorkspaceLabel;
|
2026-05-02 15:22:42 +00:00
|
|
|
use Illuminate\Support\Facades\Http;
|
feat: PostPlatform enum, failure email, DB indexes, rate limiting, tests
Publishing improvements:
- Create PostPlatformStatus enum (Pending, Publishing, Published, Failed)
- Update PostPlatform model, jobs, factories to use enum
- Add PostPublishFailed email notification when post fails to publish
- Maizzle template + blade for failure email with platform details
- PublishPost job: add $tries=3, $backoff=30, failed() method
- Fix broadcast event to serialize enum status value
Security:
- Add rate limiting (throttle:6,1) on social connect endpoints
- Fix MediaController::reorder IDOR vulnerability
- Fix Connect.vue broken import (storeStep2 -> storeConnect)
- Fix UpdatePost data_get() consistency
Database:
- Add composite index on post_platforms (post_id, enabled)
- Add index on post_platforms (social_account_id)
Tests:
- Add 3 tests for profile photo upload/delete
- Add 2 tests for media reorder (including IDOR check)
- Fix publish tests for PostPlatformStatus enum
- Add Mail::fake() to publish tests
Cleanup:
- Remove unused AppHeader.vue and AppHeaderLayout.vue
- Remove dead BillingController methods
All 733 tests passing.
2026-03-30 19:11:38 +00:00
|
|
|
use Illuminate\Support\Facades\Mail;
|
2026-01-18 23:33:45 +00:00
|
|
|
|
|
|
|
|
beforeEach(function () {
|
2026-04-17 02:05:51 +00:00
|
|
|
$this->user = User::factory()->create([]);
|
2026-01-18 23:33:45 +00:00
|
|
|
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
2026-04-15 01:22:04 +00:00
|
|
|
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
|
2026-01-18 23:33:45 +00:00
|
|
|
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
|
|
|
|
|
|
|
|
|
$this->socialAccount = SocialAccount::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'platform' => Platform::LinkedIn,
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Index tests
|
|
|
|
|
test('posts index requires authentication', function () {
|
2026-03-29 22:24:28 +00:00
|
|
|
$response = $this->get(route('app.posts.index'));
|
2026-01-18 23:33:45 +00:00
|
|
|
|
|
|
|
|
$response->assertRedirect(route('login'));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('posts index shows posts for current workspace', function () {
|
|
|
|
|
$post = Post::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
]);
|
|
|
|
|
|
2026-03-29 22:24:28 +00:00
|
|
|
$response = $this->actingAs($this->user)->get(route('app.posts.index'));
|
2026-01-18 23:33:45 +00:00
|
|
|
|
|
|
|
|
$response->assertOk();
|
|
|
|
|
$response->assertInertia(fn ($page) => $page
|
|
|
|
|
->component('posts/Index', false)
|
|
|
|
|
->has('posts.data', 1)
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('posts index redirects to create workspace if no workspace', function () {
|
|
|
|
|
$this->user->update(['current_workspace_id' => null]);
|
|
|
|
|
|
2026-03-29 22:24:28 +00:00
|
|
|
$response = $this->actingAs($this->user)->get(route('app.posts.index'));
|
2026-01-18 23:33:45 +00:00
|
|
|
|
2026-03-29 22:24:28 +00:00
|
|
|
$response->assertRedirect(route('app.workspaces.create'));
|
2026-01-18 23:33:45 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Calendar tests
|
|
|
|
|
test('calendar requires authentication', function () {
|
2026-03-29 22:24:28 +00:00
|
|
|
$response = $this->get(route('app.calendar'));
|
2026-01-18 23:33:45 +00:00
|
|
|
|
|
|
|
|
$response->assertRedirect(route('login'));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('calendar shows posts for current week', function () {
|
2026-03-29 22:24:28 +00:00
|
|
|
$response = $this->actingAs($this->user)->get(route('app.calendar'));
|
2026-01-18 23:33:45 +00:00
|
|
|
|
|
|
|
|
$response->assertOk();
|
|
|
|
|
$response->assertInertia(fn ($page) => $page
|
|
|
|
|
->component('posts/Calendar')
|
|
|
|
|
->has('workspace')
|
|
|
|
|
->has('posts')
|
|
|
|
|
->has('currentWeekStart')
|
|
|
|
|
->has('view')
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('calendar supports month view', function () {
|
2026-03-29 22:24:28 +00:00
|
|
|
$response = $this->actingAs($this->user)->get(route('app.calendar', ['view' => 'month']));
|
2026-01-18 23:33:45 +00:00
|
|
|
|
|
|
|
|
$response->assertOk();
|
|
|
|
|
$response->assertInertia(fn ($page) => $page
|
|
|
|
|
->where('view', 'month')
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-03 12:36:50 +00:00
|
|
|
// Create tests
|
|
|
|
|
test('create requires authentication', function () {
|
|
|
|
|
$response = $this->get(route('app.posts.create'));
|
|
|
|
|
|
|
|
|
|
$response->assertRedirect(route('login'));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('create renders the wizard page', function () {
|
|
|
|
|
$response = $this->actingAs($this->user)->get(route('app.posts.create'));
|
|
|
|
|
|
|
|
|
|
$response->assertOk();
|
|
|
|
|
$response->assertInertia(fn ($page) => $page
|
|
|
|
|
->component('posts/Create', false)
|
|
|
|
|
->where('date', null)
|
|
|
|
|
->has('socialAccounts', 1)
|
|
|
|
|
->where('socialAccounts.0.id', $this->socialAccount->id)
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('create forwards date query param to the page', function () {
|
|
|
|
|
$response = $this->actingAs($this->user)->get(route('app.posts.create', ['date' => '2026-06-01']));
|
|
|
|
|
|
|
|
|
|
$response->assertOk();
|
|
|
|
|
$response->assertInertia(fn ($page) => $page
|
|
|
|
|
->component('posts/Create', false)
|
|
|
|
|
->where('date', '2026-06-01')
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('create redirects to workspaces.create when user has no workspace', function () {
|
|
|
|
|
$newUser = User::factory()->create();
|
|
|
|
|
|
|
|
|
|
$response = $this->actingAs($newUser)->get(route('app.posts.create'));
|
|
|
|
|
|
|
|
|
|
$response->assertRedirect(route('app.workspaces.create'));
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-22 01:08:18 +00:00
|
|
|
// Store tests
|
|
|
|
|
test('store post requires authentication', function () {
|
2026-03-29 22:24:28 +00:00
|
|
|
$response = $this->post(route('app.posts.store'));
|
2026-01-18 23:33:45 +00:00
|
|
|
|
|
|
|
|
$response->assertRedirect(route('login'));
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-22 01:08:18 +00:00
|
|
|
test('store post redirects to accounts if no social accounts connected', function () {
|
2026-01-18 23:33:45 +00:00
|
|
|
$this->socialAccount->delete();
|
|
|
|
|
|
2026-03-29 22:24:28 +00:00
|
|
|
$response = $this->actingAs($this->user)->post(route('app.posts.store'));
|
2026-01-18 23:33:45 +00:00
|
|
|
|
2026-03-29 22:24:28 +00:00
|
|
|
$response->assertRedirect(route('app.accounts'));
|
2026-01-18 23:33:45 +00:00
|
|
|
});
|
|
|
|
|
|
2026-01-22 01:08:18 +00:00
|
|
|
test('store post creates draft and redirects to edit', function () {
|
2026-03-29 22:24:28 +00:00
|
|
|
$response = $this->actingAs($this->user)->post(route('app.posts.store'));
|
2026-01-18 23:33:45 +00:00
|
|
|
|
|
|
|
|
$response->assertRedirect();
|
|
|
|
|
|
|
|
|
|
$post = Post::where('workspace_id', $this->workspace->id)->first();
|
|
|
|
|
expect($post)->not->toBeNull();
|
|
|
|
|
expect($post->status)->toBe(PostStatus::Draft);
|
|
|
|
|
expect($post->postPlatforms)->toHaveCount(1);
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-06 20:21:30 +00:00
|
|
|
test('store post defaults scheduled_at to today when no date is provided', function () {
|
|
|
|
|
$this->actingAs($this->user)->post(route('app.posts.store'))->assertRedirect();
|
|
|
|
|
|
|
|
|
|
$post = Post::where('workspace_id', $this->workspace->id)->first();
|
|
|
|
|
expect($post->scheduled_at->format('Y-m-d'))->toBe(now('UTC')->format('Y-m-d'));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('store post schedules draft on the date param when provided', function () {
|
|
|
|
|
$this->actingAs($this->user)->post(route('app.posts.store'), [
|
|
|
|
|
'date' => '2026-06-15',
|
|
|
|
|
])->assertRedirect();
|
|
|
|
|
|
|
|
|
|
$post = Post::where('workspace_id', $this->workspace->id)->first();
|
|
|
|
|
expect($post->scheduled_at->format('Y-m-d'))->toBe('2026-06-15');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('store post rejects invalid date format', function () {
|
|
|
|
|
$this->actingAs($this->user)
|
|
|
|
|
->post(route('app.posts.store'), ['date' => 'not-a-date'])
|
|
|
|
|
->assertSessionHasErrors(['date']);
|
|
|
|
|
|
|
|
|
|
expect(Post::where('workspace_id', $this->workspace->id)->count())->toBe(0);
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-18 23:33:45 +00:00
|
|
|
// Edit tests
|
|
|
|
|
test('edit post requires authentication', function () {
|
|
|
|
|
$post = Post::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
]);
|
|
|
|
|
|
2026-03-29 22:24:28 +00:00
|
|
|
$response = $this->get(route('app.posts.edit', $post));
|
2026-01-18 23:33:45 +00:00
|
|
|
|
|
|
|
|
$response->assertRedirect(route('login'));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('edit post shows edit page', function () {
|
|
|
|
|
$post = Post::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
'status' => PostStatus::Draft,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
PostPlatform::factory()->create([
|
|
|
|
|
'post_id' => $post->id,
|
|
|
|
|
'social_account_id' => $this->socialAccount->id,
|
|
|
|
|
]);
|
|
|
|
|
|
2026-03-29 22:24:28 +00:00
|
|
|
$response = $this->actingAs($this->user)->get(route('app.posts.edit', $post));
|
2026-01-18 23:33:45 +00:00
|
|
|
|
|
|
|
|
$response->assertOk();
|
|
|
|
|
$response->assertInertia(fn ($page) => $page
|
|
|
|
|
->component('posts/Edit')
|
|
|
|
|
->has('post')
|
|
|
|
|
->has('socialAccounts')
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('edit post returns 404 for post from different workspace', function () {
|
|
|
|
|
$otherWorkspace = Workspace::factory()->create();
|
|
|
|
|
$post = Post::factory()->create([
|
|
|
|
|
'workspace_id' => $otherWorkspace->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
]);
|
|
|
|
|
|
2026-03-29 22:24:28 +00:00
|
|
|
$response = $this->actingAs($this->user)->get(route('app.posts.edit', $post));
|
2026-01-18 23:33:45 +00:00
|
|
|
|
|
|
|
|
$response->assertNotFound();
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-02 15:22:42 +00:00
|
|
|
test('edit redirects to show for non-editable statuses', function () {
|
|
|
|
|
foreach ([PostStatus::Published, PostStatus::PartiallyPublished, PostStatus::Publishing] as $status) {
|
|
|
|
|
$post = Post::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
'status' => $status,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
PostPlatform::factory()->create([
|
|
|
|
|
'post_id' => $post->id,
|
|
|
|
|
'social_account_id' => $this->socialAccount->id,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->actingAs($this->user)
|
|
|
|
|
->get(route('app.posts.edit', $post))
|
|
|
|
|
->assertRedirect(route('app.posts.show', $post));
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-01-18 23:33:45 +00:00
|
|
|
|
2026-05-02 15:22:42 +00:00
|
|
|
test('edit allows draft, scheduled, and failed posts', function () {
|
|
|
|
|
foreach ([PostStatus::Draft, PostStatus::Scheduled, PostStatus::Failed] as $status) {
|
|
|
|
|
$post = Post::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
'status' => $status,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
PostPlatform::factory()->create([
|
|
|
|
|
'post_id' => $post->id,
|
|
|
|
|
'social_account_id' => $this->socialAccount->id,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->actingAs($this->user)
|
|
|
|
|
->get(route('app.posts.edit', $post))
|
|
|
|
|
->assertOk();
|
|
|
|
|
}
|
2026-01-18 23:33:45 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Update tests
|
|
|
|
|
test('update post requires authentication', function () {
|
|
|
|
|
$post = Post::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
]);
|
|
|
|
|
|
2026-03-29 22:24:28 +00:00
|
|
|
$response = $this->put(route('app.posts.update', $post), []);
|
2026-01-18 23:33:45 +00:00
|
|
|
|
|
|
|
|
$response->assertRedirect(route('login'));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('update post saves changes', function () {
|
|
|
|
|
$post = Post::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
'status' => PostStatus::Draft,
|
2026-04-15 23:11:36 +00:00
|
|
|
'content' => 'Original content',
|
2026-01-18 23:33:45 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$postPlatform = PostPlatform::factory()->create([
|
|
|
|
|
'post_id' => $post->id,
|
|
|
|
|
'social_account_id' => $this->socialAccount->id,
|
|
|
|
|
]);
|
|
|
|
|
|
2026-03-29 22:24:28 +00:00
|
|
|
$response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [
|
2026-01-18 23:33:45 +00:00
|
|
|
'status' => 'draft',
|
2026-04-15 23:11:36 +00:00
|
|
|
'content' => 'Updated content',
|
2026-01-18 23:33:45 +00:00
|
|
|
'platforms' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => $postPlatform->id,
|
|
|
|
|
'content_type' => ContentType::LinkedInPost->value,
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response->assertRedirect();
|
|
|
|
|
|
2026-04-15 23:11:36 +00:00
|
|
|
$post->refresh();
|
|
|
|
|
expect($post->content)->toBe('Updated content');
|
2026-01-18 23:33:45 +00:00
|
|
|
$postPlatform->refresh();
|
fix: overhaul social publishing — validation, uploads, token refresh
- Fix UpdatePostRequest missing content_type, synced, meta fields
(content_type was silently dropped, causing Instagram Reels to post as Feed)
- Create API FormRequests (StorePostRequest, UpdatePostRequest) replacing inline validation
- Fix syntax errors in all publishers ($media->isVideo() missing variable)
- Fix Instagram Feed with single video calling publishSingleImage instead of publishReel
- Fix TikTok hardcoded SELF_ONLY privacy — now queries creator_info API
- Refactor YouTubePublisher to use google/apiclient SDK with chunked resumable upload
- Fix all publishers using file_get_contents for large videos (memory overflow)
— X, LinkedIn, LinkedInPage, Pinterest, Bluesky, Mastodon now use temp file + stream
- Fix Media::isVideo/isImage to use mime_type instead of extension
- Fix Threads not saving refresh_token (was null, now saves access_token)
- Add Instagram token refresh to publisher and ConnectionVerifier
- Fix PublishToSocialPlatform job: tries 3→1 (prevents duplicate uploads),
timeout 60→600s, added failed() method for cleanup
- Increase Horizon worker timeout 60→630s, Redis retry_after 90→660s
- Increase upload limit 500MB→1GB
- Add mastodon to getDefaultContentType in Edit.vue
2026-03-31 22:25:19 +00:00
|
|
|
expect($postPlatform->content_type)->toBe(ContentType::LinkedInPost);
|
2026-01-18 23:33:45 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('update post cannot update published posts', function () {
|
|
|
|
|
$post = Post::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
'status' => PostStatus::Published,
|
|
|
|
|
]);
|
|
|
|
|
|
fix: overhaul social publishing — validation, uploads, token refresh
- Fix UpdatePostRequest missing content_type, synced, meta fields
(content_type was silently dropped, causing Instagram Reels to post as Feed)
- Create API FormRequests (StorePostRequest, UpdatePostRequest) replacing inline validation
- Fix syntax errors in all publishers ($media->isVideo() missing variable)
- Fix Instagram Feed with single video calling publishSingleImage instead of publishReel
- Fix TikTok hardcoded SELF_ONLY privacy — now queries creator_info API
- Refactor YouTubePublisher to use google/apiclient SDK with chunked resumable upload
- Fix all publishers using file_get_contents for large videos (memory overflow)
— X, LinkedIn, LinkedInPage, Pinterest, Bluesky, Mastodon now use temp file + stream
- Fix Media::isVideo/isImage to use mime_type instead of extension
- Fix Threads not saving refresh_token (was null, now saves access_token)
- Add Instagram token refresh to publisher and ConnectionVerifier
- Fix PublishToSocialPlatform job: tries 3→1 (prevents duplicate uploads),
timeout 60→600s, added failed() method for cleanup
- Increase Horizon worker timeout 60→630s, Redis retry_after 90→660s
- Increase upload limit 500MB→1GB
- Add mastodon to getDefaultContentType in Edit.vue
2026-03-31 22:25:19 +00:00
|
|
|
$postPlatform = PostPlatform::factory()->create([
|
|
|
|
|
'post_id' => $post->id,
|
|
|
|
|
'social_account_id' => $this->socialAccount->id,
|
|
|
|
|
]);
|
|
|
|
|
|
2026-03-29 22:24:28 +00:00
|
|
|
$response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [
|
2026-01-18 23:33:45 +00:00
|
|
|
'status' => 'draft',
|
2026-04-15 23:11:36 +00:00
|
|
|
'content' => 'Test content',
|
fix: overhaul social publishing — validation, uploads, token refresh
- Fix UpdatePostRequest missing content_type, synced, meta fields
(content_type was silently dropped, causing Instagram Reels to post as Feed)
- Create API FormRequests (StorePostRequest, UpdatePostRequest) replacing inline validation
- Fix syntax errors in all publishers ($media->isVideo() missing variable)
- Fix Instagram Feed with single video calling publishSingleImage instead of publishReel
- Fix TikTok hardcoded SELF_ONLY privacy — now queries creator_info API
- Refactor YouTubePublisher to use google/apiclient SDK with chunked resumable upload
- Fix all publishers using file_get_contents for large videos (memory overflow)
— X, LinkedIn, LinkedInPage, Pinterest, Bluesky, Mastodon now use temp file + stream
- Fix Media::isVideo/isImage to use mime_type instead of extension
- Fix Threads not saving refresh_token (was null, now saves access_token)
- Add Instagram token refresh to publisher and ConnectionVerifier
- Fix PublishToSocialPlatform job: tries 3→1 (prevents duplicate uploads),
timeout 60→600s, added failed() method for cleanup
- Increase Horizon worker timeout 60→630s, Redis retry_after 90→660s
- Increase upload limit 500MB→1GB
- Add mastodon to getDefaultContentType in Edit.vue
2026-03-31 22:25:19 +00:00
|
|
|
'platforms' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => $postPlatform->id,
|
|
|
|
|
'content_type' => ContentType::LinkedInPost->value,
|
|
|
|
|
],
|
|
|
|
|
],
|
2026-01-18 23:33:45 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response->assertRedirect();
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-26 19:01:14 +00:00
|
|
|
test('publish now updates scheduled_at to current time', function () {
|
feat: PostPlatform enum, failure email, DB indexes, rate limiting, tests
Publishing improvements:
- Create PostPlatformStatus enum (Pending, Publishing, Published, Failed)
- Update PostPlatform model, jobs, factories to use enum
- Add PostPublishFailed email notification when post fails to publish
- Maizzle template + blade for failure email with platform details
- PublishPost job: add $tries=3, $backoff=30, failed() method
- Fix broadcast event to serialize enum status value
Security:
- Add rate limiting (throttle:6,1) on social connect endpoints
- Fix MediaController::reorder IDOR vulnerability
- Fix Connect.vue broken import (storeStep2 -> storeConnect)
- Fix UpdatePost data_get() consistency
Database:
- Add composite index on post_platforms (post_id, enabled)
- Add index on post_platforms (social_account_id)
Tests:
- Add 3 tests for profile photo upload/delete
- Add 2 tests for media reorder (including IDOR check)
- Fix publish tests for PostPlatformStatus enum
- Add Mail::fake() to publish tests
Cleanup:
- Remove unused AppHeader.vue and AppHeaderLayout.vue
- Remove dead BillingController methods
All 733 tests passing.
2026-03-30 19:11:38 +00:00
|
|
|
Mail::fake();
|
2026-01-26 19:01:14 +00:00
|
|
|
$this->freezeTime();
|
|
|
|
|
|
|
|
|
|
$post = Post::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
'status' => PostStatus::Draft,
|
2026-04-15 23:11:36 +00:00
|
|
|
'content' => 'Test content',
|
2026-01-26 19:01:14 +00:00
|
|
|
'scheduled_at' => now()->addDays(7),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$postPlatform = PostPlatform::factory()->create([
|
|
|
|
|
'post_id' => $post->id,
|
|
|
|
|
'social_account_id' => $this->socialAccount->id,
|
|
|
|
|
]);
|
|
|
|
|
|
2026-03-29 22:24:28 +00:00
|
|
|
$response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [
|
2026-01-26 19:01:14 +00:00
|
|
|
'status' => 'publishing',
|
2026-04-15 23:11:36 +00:00
|
|
|
'content' => 'Test content',
|
2026-01-26 19:01:14 +00:00
|
|
|
'platforms' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => $postPlatform->id,
|
|
|
|
|
'content_type' => ContentType::LinkedInPost->value,
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response->assertRedirect();
|
|
|
|
|
|
|
|
|
|
$post->refresh();
|
|
|
|
|
expect($post->scheduled_at->toDateTimeString())->toBe(now()->toDateTimeString());
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-18 23:33:45 +00:00
|
|
|
// Destroy tests
|
|
|
|
|
test('destroy post requires authentication', function () {
|
|
|
|
|
$post = Post::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
]);
|
|
|
|
|
|
2026-03-29 22:24:28 +00:00
|
|
|
$response = $this->delete(route('app.posts.destroy', $post));
|
2026-01-18 23:33:45 +00:00
|
|
|
|
|
|
|
|
$response->assertRedirect(route('login'));
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-01 18:04:13 +00:00
|
|
|
test('destroy post deletes the post and redirects to posts index', function () {
|
2026-01-18 23:33:45 +00:00
|
|
|
$post = Post::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
]);
|
|
|
|
|
|
2026-01-22 01:08:18 +00:00
|
|
|
$response = $this->actingAs($this->user)
|
2026-03-29 22:24:28 +00:00
|
|
|
->delete(route('app.posts.destroy', $post));
|
2026-01-18 23:33:45 +00:00
|
|
|
|
2026-04-01 18:04:13 +00:00
|
|
|
$response->assertRedirect(route('app.posts.index'));
|
2026-01-18 23:33:45 +00:00
|
|
|
expect(Post::find($post->id))->toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-01 18:04:13 +00:00
|
|
|
test('destroy post with redirect param redirects to calendar', function () {
|
2026-01-26 17:00:37 +00:00
|
|
|
$post = Post::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response = $this->actingAs($this->user)
|
2026-04-01 18:04:13 +00:00
|
|
|
->delete(route('app.posts.destroy', $post).'?redirect=app.calendar');
|
2026-01-26 17:00:37 +00:00
|
|
|
|
2026-04-01 18:04:13 +00:00
|
|
|
$response->assertRedirect(route('app.calendar'));
|
2026-01-26 17:00:37 +00:00
|
|
|
expect(Post::find($post->id))->toBeNull();
|
2026-04-01 18:04:13 +00:00
|
|
|
});
|
2026-01-26 17:00:37 +00:00
|
|
|
|
|
|
|
|
test('destroy post with redirect param redirects to specified route', function () {
|
|
|
|
|
$post = Post::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response = $this->actingAs($this->user)
|
2026-03-29 22:24:28 +00:00
|
|
|
->delete(route('app.posts.destroy', $post).'?redirect=app.posts.index');
|
2026-01-26 17:00:37 +00:00
|
|
|
|
2026-03-29 22:24:28 +00:00
|
|
|
$response->assertRedirect(route('app.posts.index'));
|
2026-01-26 17:00:37 +00:00
|
|
|
expect(Post::find($post->id))->toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-18 23:33:45 +00:00
|
|
|
test('destroy post returns 404 for post from different workspace', function () {
|
|
|
|
|
$otherWorkspace = Workspace::factory()->create();
|
|
|
|
|
$post = Post::factory()->create([
|
|
|
|
|
'workspace_id' => $otherWorkspace->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
]);
|
|
|
|
|
|
2026-03-29 22:24:28 +00:00
|
|
|
$response = $this->actingAs($this->user)->delete(route('app.posts.destroy', $post));
|
2026-01-18 23:33:45 +00:00
|
|
|
|
|
|
|
|
$response->assertNotFound();
|
|
|
|
|
});
|
2026-01-26 18:18:35 +00:00
|
|
|
|
|
|
|
|
// Label tests
|
|
|
|
|
test('edit post includes workspace labels', function () {
|
|
|
|
|
$post = Post::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
'status' => PostStatus::Draft,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
PostPlatform::factory()->create([
|
|
|
|
|
'post_id' => $post->id,
|
|
|
|
|
'social_account_id' => $this->socialAccount->id,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$label = WorkspaceLabel::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'name' => 'Marketing',
|
|
|
|
|
'color' => '#FF0000',
|
|
|
|
|
]);
|
|
|
|
|
|
2026-03-29 22:24:28 +00:00
|
|
|
$response = $this->actingAs($this->user)->get(route('app.posts.edit', $post));
|
2026-01-26 18:18:35 +00:00
|
|
|
|
|
|
|
|
$response->assertOk();
|
|
|
|
|
$response->assertInertia(fn ($page) => $page
|
|
|
|
|
->component('posts/Edit')
|
|
|
|
|
->has('labels', 1)
|
|
|
|
|
->where('labels.0.name', 'Marketing')
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('update post can attach labels', function () {
|
|
|
|
|
$post = Post::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
'status' => PostStatus::Draft,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$postPlatform = PostPlatform::factory()->create([
|
|
|
|
|
'post_id' => $post->id,
|
|
|
|
|
'social_account_id' => $this->socialAccount->id,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$label = WorkspaceLabel::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
]);
|
|
|
|
|
|
2026-03-29 22:24:28 +00:00
|
|
|
$response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [
|
2026-01-26 18:18:35 +00:00
|
|
|
'status' => 'draft',
|
|
|
|
|
'platforms' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => $postPlatform->id,
|
|
|
|
|
'content_type' => ContentType::LinkedInPost->value,
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
'label_ids' => [$label->id],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response->assertRedirect();
|
|
|
|
|
|
|
|
|
|
$post->refresh();
|
|
|
|
|
expect($post->labels)->toHaveCount(1);
|
|
|
|
|
expect($post->labels->first()->id)->toBe($label->id);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('update post can detach labels', function () {
|
|
|
|
|
$post = Post::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
'status' => PostStatus::Draft,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$postPlatform = PostPlatform::factory()->create([
|
|
|
|
|
'post_id' => $post->id,
|
|
|
|
|
'social_account_id' => $this->socialAccount->id,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$label = WorkspaceLabel::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$post->labels()->attach($label);
|
|
|
|
|
|
2026-03-29 22:24:28 +00:00
|
|
|
$response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [
|
2026-01-26 18:18:35 +00:00
|
|
|
'status' => 'draft',
|
|
|
|
|
'platforms' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => $postPlatform->id,
|
|
|
|
|
'content_type' => ContentType::LinkedInPost->value,
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
'label_ids' => [],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response->assertRedirect();
|
|
|
|
|
|
|
|
|
|
$post->refresh();
|
|
|
|
|
expect($post->labels)->toHaveCount(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('update post can sync multiple labels', function () {
|
|
|
|
|
$post = Post::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
'status' => PostStatus::Draft,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$postPlatform = PostPlatform::factory()->create([
|
|
|
|
|
'post_id' => $post->id,
|
|
|
|
|
'social_account_id' => $this->socialAccount->id,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$label1 = WorkspaceLabel::factory()->create(['workspace_id' => $this->workspace->id]);
|
|
|
|
|
$label2 = WorkspaceLabel::factory()->create(['workspace_id' => $this->workspace->id]);
|
|
|
|
|
$label3 = WorkspaceLabel::factory()->create(['workspace_id' => $this->workspace->id]);
|
|
|
|
|
|
|
|
|
|
// Attach initial label
|
|
|
|
|
$post->labels()->attach($label1);
|
|
|
|
|
|
|
|
|
|
// Update with different labels
|
2026-03-29 22:24:28 +00:00
|
|
|
$response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [
|
2026-01-26 18:18:35 +00:00
|
|
|
'status' => 'draft',
|
|
|
|
|
'platforms' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => $postPlatform->id,
|
|
|
|
|
'content_type' => ContentType::LinkedInPost->value,
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
'label_ids' => [$label2->id, $label3->id],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response->assertRedirect();
|
|
|
|
|
|
|
|
|
|
$post->refresh();
|
|
|
|
|
expect($post->labels)->toHaveCount(2);
|
|
|
|
|
expect($post->labels->pluck('id')->toArray())->toEqualCanonicalizing([$label2->id, $label3->id]);
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-02 15:22:42 +00:00
|
|
|
test('platform metrics returns unsupported when post not published', function () {
|
|
|
|
|
$post = Post::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
'status' => PostStatus::Draft,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$pp = PostPlatform::factory()->create([
|
|
|
|
|
'post_id' => $post->id,
|
|
|
|
|
'social_account_id' => $this->socialAccount->id,
|
|
|
|
|
'status' => Status::Pending,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response = $this->actingAs($this->user)
|
|
|
|
|
->getJson(route('app.posts.platforms.metrics', ['post' => $post->id, 'postPlatform' => $pp->id]));
|
|
|
|
|
|
|
|
|
|
$response->assertOk();
|
|
|
|
|
$response->assertJson(['unsupported' => true, 'reason' => 'not_published']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('platform metrics returns 404 for post in another workspace', function () {
|
|
|
|
|
$otherWorkspace = Workspace::factory()->create();
|
|
|
|
|
$otherPost = Post::factory()->create(['workspace_id' => $otherWorkspace->id]);
|
|
|
|
|
$pp = PostPlatform::factory()->create([
|
|
|
|
|
'post_id' => $otherPost->id,
|
|
|
|
|
'social_account_id' => $this->socialAccount->id,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->actingAs($this->user)
|
|
|
|
|
->getJson(route('app.posts.platforms.metrics', ['post' => $otherPost->id, 'postPlatform' => $pp->id]))
|
|
|
|
|
->assertNotFound();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('platform metrics returns 404 when post platform belongs to different post', function () {
|
|
|
|
|
$post = Post::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$otherPost = Post::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$pp = PostPlatform::factory()->create([
|
|
|
|
|
'post_id' => $otherPost->id,
|
|
|
|
|
'social_account_id' => $this->socialAccount->id,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->actingAs($this->user)
|
|
|
|
|
->getJson(route('app.posts.platforms.metrics', ['post' => $post->id, 'postPlatform' => $pp->id]))
|
|
|
|
|
->assertNotFound();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('platform metrics dispatches X analytics for X platform', function () {
|
|
|
|
|
$xAccount = SocialAccount::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'platform' => Platform::X,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$post = Post::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$pp = PostPlatform::factory()->create([
|
|
|
|
|
'post_id' => $post->id,
|
|
|
|
|
'social_account_id' => $xAccount->id,
|
|
|
|
|
'platform' => Platform::X,
|
|
|
|
|
'status' => Status::Published,
|
|
|
|
|
'platform_post_id' => '1234567890',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
Http::fake([
|
|
|
|
|
'https://api.x.com/2/tweets/1234567890*' => Http::response([
|
|
|
|
|
'data' => [
|
|
|
|
|
'public_metrics' => [
|
|
|
|
|
'impression_count' => 500,
|
|
|
|
|
'like_count' => 42,
|
|
|
|
|
'retweet_count' => 7,
|
|
|
|
|
'reply_count' => 3,
|
|
|
|
|
'quote_count' => 1,
|
|
|
|
|
'bookmark_count' => 4,
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
], 200),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response = $this->actingAs($this->user)
|
|
|
|
|
->getJson(route('app.posts.platforms.metrics', ['post' => $post->id, 'postPlatform' => $pp->id]));
|
|
|
|
|
|
|
|
|
|
$response->assertOk();
|
|
|
|
|
$response->assertJsonFragment(['label' => 'Impressions', 'value' => 500]);
|
|
|
|
|
$response->assertJsonFragment(['label' => 'Likes', 'value' => 42]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('show page renders for non-editable posts', function () {
|
|
|
|
|
$post = Post::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
'status' => PostStatus::Published,
|
|
|
|
|
'content' => 'Hello world',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
PostPlatform::factory()->create([
|
|
|
|
|
'post_id' => $post->id,
|
|
|
|
|
'social_account_id' => $this->socialAccount->id,
|
|
|
|
|
'enabled' => true,
|
|
|
|
|
'platform_url' => 'https://linkedin.com/posts/abc',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response = $this->actingAs($this->user)->get(route('app.posts.show', $post));
|
|
|
|
|
|
|
|
|
|
$response->assertOk();
|
|
|
|
|
$response->assertInertia(fn ($page) => $page
|
|
|
|
|
->component('posts/Show', false)
|
2026-05-04 01:11:58 +00:00
|
|
|
->has('post.platforms', 1)
|
2026-05-02 15:22:42 +00:00
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('show page redirects editable posts to edit', function () {
|
|
|
|
|
foreach ([PostStatus::Draft, PostStatus::Scheduled, PostStatus::Failed] as $status) {
|
|
|
|
|
$post = Post::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
'status' => $status,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->actingAs($this->user)
|
|
|
|
|
->get(route('app.posts.show', $post))
|
|
|
|
|
->assertRedirect(route('app.posts.edit', $post));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('destroy blocks published posts', function () {
|
|
|
|
|
foreach ([PostStatus::Publishing, PostStatus::Published, PostStatus::PartiallyPublished] as $status) {
|
|
|
|
|
$post = Post::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
'status' => $status,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->actingAs($this->user)
|
|
|
|
|
->delete(route('app.posts.destroy', $post))
|
|
|
|
|
->assertRedirect();
|
|
|
|
|
|
|
|
|
|
expect(Post::find($post->id))->not->toBeNull();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('show page returns 404 for post in another workspace', function () {
|
|
|
|
|
$otherWorkspace = Workspace::factory()->create();
|
|
|
|
|
$post = Post::factory()->create([
|
|
|
|
|
'workspace_id' => $otherWorkspace->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$this->actingAs($this->user)
|
|
|
|
|
->get(route('app.posts.show', $post))
|
|
|
|
|
->assertNotFound();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('update post redirects to show page after publishing', function () {
|
|
|
|
|
$post = Post::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
'status' => PostStatus::Draft,
|
|
|
|
|
'content' => 'Test',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$postPlatform = PostPlatform::factory()->create([
|
|
|
|
|
'post_id' => $post->id,
|
|
|
|
|
'social_account_id' => $this->socialAccount->id,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [
|
|
|
|
|
'status' => 'publishing',
|
|
|
|
|
'content' => 'Test',
|
|
|
|
|
'platforms' => [
|
|
|
|
|
['id' => $postPlatform->id, 'content_type' => ContentType::LinkedInPost->value],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response->assertRedirect(route('app.posts.show', $post));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('update post rejects scheduling youtube short with image', function () {
|
|
|
|
|
$youtubeAccount = SocialAccount::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'platform' => Platform::YouTube,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$post = Post::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
'status' => PostStatus::Draft,
|
|
|
|
|
'content' => 'Test',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$postPlatform = PostPlatform::factory()->create([
|
|
|
|
|
'post_id' => $post->id,
|
|
|
|
|
'social_account_id' => $youtubeAccount->id,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [
|
|
|
|
|
'status' => 'scheduled',
|
|
|
|
|
'scheduled_at' => now()->addDay()->toIso8601String(),
|
|
|
|
|
'media' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'media-1',
|
|
|
|
|
'path' => 'media/foo.jpg',
|
|
|
|
|
'url' => 'https://example.com/foo.jpg',
|
|
|
|
|
'type' => 'image',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
'platforms' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => $postPlatform->id,
|
|
|
|
|
'content_type' => ContentType::YouTubeShort->value,
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response->assertSessionHasErrors('platforms.0.content_type');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('update post rejects scheduling instagram reel with no media', function () {
|
|
|
|
|
$instagramAccount = SocialAccount::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'platform' => Platform::Instagram,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$post = Post::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
'status' => PostStatus::Draft,
|
|
|
|
|
'content' => 'Test',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$postPlatform = PostPlatform::factory()->create([
|
|
|
|
|
'post_id' => $post->id,
|
|
|
|
|
'social_account_id' => $instagramAccount->id,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [
|
|
|
|
|
'status' => 'scheduled',
|
|
|
|
|
'scheduled_at' => now()->addDay()->toIso8601String(),
|
|
|
|
|
'platforms' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => $postPlatform->id,
|
|
|
|
|
'content_type' => ContentType::InstagramReel->value,
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response->assertSessionHasErrors('platforms.0.content_type');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('update post rejects invalid instagram aspect_ratio meta', function () {
|
|
|
|
|
$instagramAccount = SocialAccount::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'platform' => Platform::Instagram,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$post = Post::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
'status' => PostStatus::Draft,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$postPlatform = PostPlatform::factory()->create([
|
|
|
|
|
'post_id' => $post->id,
|
|
|
|
|
'social_account_id' => $instagramAccount->id,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [
|
|
|
|
|
'status' => 'draft',
|
|
|
|
|
'platforms' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => $postPlatform->id,
|
|
|
|
|
'content_type' => ContentType::InstagramFeed->value,
|
|
|
|
|
'meta' => ['aspect_ratio' => '2:1'],
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response->assertSessionHasErrors('platforms.0.meta.aspect_ratio');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('update post accepts valid instagram aspect_ratio meta', function () {
|
|
|
|
|
$instagramAccount = SocialAccount::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'platform' => Platform::Instagram,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$post = Post::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
'status' => PostStatus::Draft,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$postPlatform = PostPlatform::factory()->create([
|
|
|
|
|
'post_id' => $post->id,
|
|
|
|
|
'social_account_id' => $instagramAccount->id,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [
|
|
|
|
|
'status' => 'draft',
|
|
|
|
|
'platforms' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => $postPlatform->id,
|
|
|
|
|
'content_type' => ContentType::InstagramFeed->value,
|
|
|
|
|
'meta' => ['aspect_ratio' => '4:5'],
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response->assertSessionDoesntHaveErrors('platforms.0.meta.aspect_ratio');
|
|
|
|
|
$postPlatform->refresh();
|
|
|
|
|
expect(data_get($postPlatform->meta, 'aspect_ratio'))->toBe('4:5');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('scheduling without content_type per platform fails', function () {
|
|
|
|
|
$youtubeAccount = SocialAccount::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'platform' => Platform::YouTube,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$post = Post::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
'status' => PostStatus::Draft,
|
|
|
|
|
'content' => 'Test',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$postPlatform = PostPlatform::factory()->create([
|
|
|
|
|
'post_id' => $post->id,
|
|
|
|
|
'social_account_id' => $youtubeAccount->id,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [
|
|
|
|
|
'status' => 'scheduled',
|
|
|
|
|
'scheduled_at' => now()->addDay()->toIso8601String(),
|
|
|
|
|
'platforms' => [
|
|
|
|
|
['id' => $postPlatform->id],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response->assertSessionHasErrors('platforms.0.content_type');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('draft post does not enforce media-vs-content-type compatibility', function () {
|
|
|
|
|
$youtubeAccount = SocialAccount::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'platform' => Platform::YouTube,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$post = Post::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
'status' => PostStatus::Draft,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$postPlatform = PostPlatform::factory()->create([
|
|
|
|
|
'post_id' => $post->id,
|
|
|
|
|
'social_account_id' => $youtubeAccount->id,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [
|
|
|
|
|
'status' => 'draft',
|
|
|
|
|
'media' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => 'media-1',
|
|
|
|
|
'path' => 'media/foo.jpg',
|
|
|
|
|
'url' => 'https://example.com/foo.jpg',
|
|
|
|
|
'type' => 'image',
|
|
|
|
|
'mime_type' => 'image/jpeg',
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
'platforms' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => $postPlatform->id,
|
|
|
|
|
'content_type' => ContentType::YouTubeShort->value,
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response->assertSessionDoesntHaveErrors('platforms.0.content_type');
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-26 18:18:35 +00:00
|
|
|
test('update post validates label_ids exist', function () {
|
|
|
|
|
$post = Post::factory()->create([
|
|
|
|
|
'workspace_id' => $this->workspace->id,
|
|
|
|
|
'user_id' => $this->user->id,
|
|
|
|
|
'status' => PostStatus::Draft,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$postPlatform = PostPlatform::factory()->create([
|
|
|
|
|
'post_id' => $post->id,
|
|
|
|
|
'social_account_id' => $this->socialAccount->id,
|
|
|
|
|
]);
|
|
|
|
|
|
2026-03-29 22:24:28 +00:00
|
|
|
$response = $this->actingAs($this->user)->put(route('app.posts.update', $post), [
|
2026-01-26 18:18:35 +00:00
|
|
|
'status' => 'draft',
|
|
|
|
|
'platforms' => [
|
|
|
|
|
[
|
|
|
|
|
'id' => $postPlatform->id,
|
|
|
|
|
'content_type' => ContentType::LinkedInPost->value,
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
'label_ids' => ['non-existent-uuid'],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$response->assertSessionHasErrors('label_ids.0');
|
|
|
|
|
});
|
2026-03-31 03:40:18 +00:00
|
|
|
|
|
|
|
|
// Member authorization tests
|
|
|
|
|
test('member can view posts index', function () {
|
2026-04-15 01:22:04 +00:00
|
|
|
$member = User::factory()->create([
|
|
|
|
|
'account_id' => $this->workspace->account_id,
|
|
|
|
|
]);
|
2026-03-31 03:40:18 +00:00
|
|
|
$this->workspace->members()->attach($member->id, ['role' => Role::Member->value]);
|
|
|
|
|
$member->update(['current_workspace_id' => $this->workspace->id]);
|
|
|
|
|
|
|
|
|
|
$response = $this->actingAs($member)->get(route('app.posts.index'));
|
|
|
|
|
|
|
|
|
|
$response->assertOk();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('member can create post', function () {
|
2026-04-15 01:22:04 +00:00
|
|
|
$member = User::factory()->create([
|
|
|
|
|
'account_id' => $this->workspace->account_id,
|
|
|
|
|
]);
|
2026-03-31 03:40:18 +00:00
|
|
|
$this->workspace->members()->attach($member->id, ['role' => Role::Member->value]);
|
|
|
|
|
$member->update(['current_workspace_id' => $this->workspace->id]);
|
|
|
|
|
|
|
|
|
|
$response = $this->actingAs($member)->post(route('app.posts.store'));
|
|
|
|
|
|
|
|
|
|
$response->assertRedirect();
|
|
|
|
|
});
|