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.
This commit is contained in:
Paulo Castellano 2026-03-30 16:11:38 -03:00
parent 06e01797d1
commit ceb7b92b74
22 changed files with 449 additions and 21 deletions

View file

@ -6,6 +6,7 @@
use App\Enums\Post\Status as PostStatus;
use App\Enums\PostPlatform\ContentType;
use App\Enums\PostPlatform\Status as PostPlatformStatus;
use App\Models\Post;
use App\Models\User;
use App\Models\Workspace;
@ -35,7 +36,7 @@ public static function execute(Workspace $workspace, User $user, array $data): P
'platform' => $account->platform->value,
'content' => '',
'content_type' => ContentType::defaultFor($account->platform),
'status' => 'pending',
'status' => PostPlatformStatus::Pending,
'enabled' => true,
]);
}

View file

@ -50,9 +50,9 @@ public static function execute(Workspace $workspace, Post $post, array $data): a
$updateData['content_type'] = data_get($platformData, 'content_type');
}
if (isset($platformData['meta'])) {
if (data_get($platformData, 'meta') !== null) {
$postPlatform = $post->postPlatforms()->where('id', data_get($platformData, 'id'))->first();
$updateData['meta'] = array_merge($postPlatform->meta ?? [], $platformData['meta']);
$updateData['meta'] = array_merge($postPlatform->meta ?? [], data_get($platformData, 'meta'));
}
$post->postPlatforms()

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Enums\PostPlatform;
enum Status: string
{
case Pending = 'pending';
case Publishing = 'publishing';
case Published = 'published';
case Failed = 'failed';
}

View file

@ -31,7 +31,7 @@ public function broadcastWith(): array
return [
'post_platform' => [
'id' => $this->postPlatform->id,
'status' => $this->postPlatform->status,
'status' => $this->postPlatform->status->value,
'platform_url' => $this->postPlatform->platform_url,
'error_message' => $this->postPlatform->error_message,
'published_at' => $this->postPlatform->published_at?->toISOString(),

View file

@ -92,8 +92,20 @@ public function reorder(Request $request): JsonResponse
'media.*.order' => 'required|integer|min:0',
]);
$workspace = $request->user()->currentWorkspace;
$mediaIds = collect($request->input('media'))->pluck('id');
// Verify all media belongs to the current workspace
$ownedCount = Media::whereIn('id', $mediaIds)
->whereHasMorph('mediable', [PostPlatform::class], fn ($query) => $query->whereHas('post', fn ($q) => $q->where('workspace_id', $workspace->id))
)->count();
if ($ownedCount !== $mediaIds->count()) {
abort(403);
}
foreach ($request->input('media') as $item) {
Media::where('id', $item['id'])->update(['order' => $item['order']]);
Media::where('id', data_get($item, 'id'))->update(['order' => data_get($item, 'order')]);
}
return response()->json(['success' => true]);

View file

@ -7,11 +7,16 @@
use App\Models\Post;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
class PublishPost implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public int $backoff = 30;
public function __construct(public Post $post) {}
public function handle(): void
@ -22,4 +27,12 @@ public function handle(): void
PublishToSocialPlatform::dispatch($postPlatform);
}
}
public function failed(\Throwable $exception): void
{
Log::error('PublishPost job failed', [
'post_id' => $this->post->id,
'error' => $exception->getMessage(),
]);
}
}

View file

@ -4,9 +4,12 @@
namespace App\Jobs;
use App\Enums\PostPlatform\Status as PostPlatformStatus;
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Events\PostPlatformStatusUpdated;
use App\Exceptions\TokenExpiredException;
use App\Mail\PostPublishFailed;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Services\Social\BlueskyPublisher;
use App\Services\Social\FacebookPublisher;
@ -22,6 +25,7 @@
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
class PublishToSocialPlatform implements ShouldQueue
{
@ -106,8 +110,8 @@ private function updatePostStatus(): void
$enabledPlatforms = $post->postPlatforms->where('enabled', true);
$total = $enabledPlatforms->count();
$publishedCount = $enabledPlatforms->where('status', 'published')->count();
$failedCount = $enabledPlatforms->where('status', 'failed')->count();
$publishedCount = $enabledPlatforms->where('status', PostPlatformStatus::Published)->count();
$failedCount = $enabledPlatforms->where('status', PostPlatformStatus::Failed)->count();
$finishedCount = $publishedCount + $failedCount;
// Only update post status when all platforms have finished
@ -119,8 +123,19 @@ private function updatePostStatus(): void
$post->markAsPublished();
} elseif ($publishedCount > 0) {
$post->markAsPartiallyPublished();
$this->notifyOwner($post);
} else {
$post->markAsFailed();
$this->notifyOwner($post);
}
}
private function notifyOwner(Post $post): void
{
$owner = $post->workspace->owner;
if ($owner) {
Mail::to($owner)->send(new PostPublishFailed($post));
}
}
}

View file

@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Enums\PostPlatform\Status;
use App\Models\Post;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class PostPublishFailed extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(
public Post $post
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: "Your post failed to publish in {$this->post->workspace->name}",
);
}
public function content(): Content
{
$failedPlatforms = $this->post->postPlatforms()
->with('socialAccount')
->where('enabled', true)
->get()
->filter(fn ($pp) => $pp->status === Status::Failed)
->map(fn ($pp) => [
'name' => $pp->platform->label().' (@'.data_get($pp, 'socialAccount.username', data_get($pp, 'socialAccount.display_name', '')).')',
'error' => $pp->error_message,
])
->values()
->all();
return new Content(
view: 'mail.post-publish-failed',
with: [
'title' => 'Your post failed to publish',
'previewText' => 'One or more platforms failed to publish your post.',
'body' => "Your scheduled post in the {$this->post->workspace->name} workspace failed to publish on one or more platforms.",
'failedPlatforms' => $failedPlatforms,
'url' => route('app.posts.edit', $this->post),
],
);
}
public function attachments(): array
{
return [];
}
}

View file

@ -5,6 +5,7 @@
namespace App\Models;
use App\Enums\PostPlatform\ContentType;
use App\Enums\PostPlatform\Status;
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Models\Traits\HasMedia;
use Database\Factories\PostPlatformFactory;
@ -39,6 +40,7 @@ protected function casts(): array
'enabled' => 'boolean',
'platform' => SocialPlatform::class,
'content_type' => ContentType::class,
'status' => Status::class,
'published_at' => 'datetime',
'meta' => 'array',
];
@ -56,13 +58,13 @@ public function socialAccount(): BelongsTo
public function markAsPublishing(): void
{
$this->update(['status' => 'publishing']);
$this->update(['status' => Status::Publishing]);
}
public function markAsPublished(string $platformPostId, ?string $platformUrl = null): void
{
$this->update([
'status' => 'published',
'status' => Status::Published,
'platform_post_id' => $platformPostId,
'platform_url' => $platformUrl,
'published_at' => now(),
@ -72,7 +74,7 @@ public function markAsPublished(string $platformPostId, ?string $platformUrl = n
public function markAsFailed(string $errorMessage): void
{
$this->update([
'status' => 'failed',
'status' => Status::Failed,
'error_message' => $errorMessage,
]);
}

View file

@ -5,6 +5,7 @@
namespace Database\Factories;
use App\Enums\PostPlatform\ContentType;
use App\Enums\PostPlatform\Status;
use App\Enums\SocialAccount\Platform;
use App\Models\Post;
use App\Models\PostPlatform;
@ -30,7 +31,7 @@ public function definition(): array
'platform' => Platform::LinkedIn,
'content' => $this->faker->paragraph(),
'content_type' => ContentType::LinkedInPost,
'status' => 'pending',
'status' => Status::Pending,
'meta' => [],
];
}
@ -45,7 +46,7 @@ public function disabled(): static
public function published(): static
{
return $this->state(fn (array $attributes) => [
'status' => 'published',
'status' => Status::Published,
'platform_post_id' => $this->faker->uuid(),
'platform_url' => $this->faker->url(),
'published_at' => now(),
@ -55,7 +56,7 @@ public function published(): static
public function failed(): static
{
return $this->state(fn (array $attributes) => [
'status' => 'failed',
'status' => Status::Failed,
'error_message' => 'Failed to publish',
]);
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('post_platforms', function (Blueprint $table) {
$table->index(['post_id', 'enabled']);
$table->index('social_account_id');
});
}
public function down(): void
{
Schema::table('post_platforms', function (Blueprint $table) {
$table->dropIndex(['post_id', 'enabled']);
$table->dropIndex(['social_account_id']);
});
}
};

1
lang/php_en.json Normal file

File diff suppressed because one or more lines are too long

1
lang/php_es.json Normal file

File diff suppressed because one or more lines are too long

1
lang/php_pt-BR.json Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,50 @@
<x-main>
<div class="bg-zinc-50 sm:px-4 font-sans">
<table align="center">
<tr>
<td class="w-[552px] max-w-full">
<x-header />
<table class="w-full">
<tr>
<td class="p-12 sm:px-6 text-base text-zinc-700 bg-white rounded shadow-sm">
<h1 class="m-0 mb-6 text-2xl sm:leading-8 text-black font-semibold">
@{{ $title }}
</h1>
<p class="m-0 leading-6">
@{{ $body }}
</p>
<div class="mt-4 p-4 bg-zinc-50 rounded">
<p class="m-0 text-sm font-semibold text-zinc-900">
Failed platforms:
</p>
<ul class="m-0 mt-2 pl-5 leading-6 text-sm">
@foreach($failedPlatforms as $platform)
<li>
<strong>@{{ $platform['name'] }}</strong>
@if($platform['error'])
— @{{ $platform['error'] }}
@endif
</li>
@endforeach
</ul>
</div>
<x-spacer height="24px" />
<div class="flex items-center justify-center">
<x-button href="@{{ $url }}">
View Post &rarr;
</x-button>
</div>
</td>
</tr>
</table>
<x-footer />
</td>
</tr>
</table>
</div>
</x-main>

View file

@ -4,7 +4,7 @@ import { IconCheck } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { storeStep2 } from '@/actions/App/Http/Controllers/App/OnboardingController';
import { storeConnect } from '@/actions/App/Http/Controllers/App/OnboardingController';
import { Button } from '@/components/ui/button';
import AuthLayout from '@/layouts/AuthLayout.vue';
@ -78,7 +78,7 @@ onUnmounted(() => window.removeEventListener('message', handleOAuthMessage));
const submit = () => {
isSubmitting.value = true;
router.post(storeStep2.url());
router.post(storeConnect.url());
};
</script>

View file

@ -0,0 +1,125 @@
<!DOCTYPE html>
<html lang="en" xmlns:v="urn:schemas-microsoft-com:vml">
<head>
<meta charset="utf-8">
<meta name="x-apple-disable-message-reformatting">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings xmlns:o="urn:schemas-microsoft-com:office:office">
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<style>
td,th,div,p,a,h1,h2,h3,h4,h5,h6 {font-family: "Segoe UI", sans-serif; mso-line-height-rule: exactly;}
</style>
<![endif]-->
@if(isset($title))
<title>{{ $title }}</title>
@endif
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet" media="screen">
<style>
.hover-i-text-decoration-underline:hover {
text-decoration: underline !important
}
@media (max-width: 600px) {
.sm-my-8 {
margin-top: 32px !important;
margin-bottom: 32px !important
}
.sm-px-4 {
padding-left: 16px !important;
padding-right: 16px !important
}
.sm-px-6 {
padding-left: 24px !important;
padding-right: 24px !important
}
.sm-leading-8 {
line-height: 32px !important
}
}
</style>
</head>
<body style="margin: 0; width: 100%; padding: 0; -webkit-font-smoothing: antialiased; word-break: break-word">
@if(isset($previewText))
<div style="display: none">
{{ $previewText }}
&#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847;
</div>
@endif
<div role="article" aria-roledescription="email" aria-label="{{ $title }}" lang="en">
<div class="sm-px-4" style="background-color: #fafafa; font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif">
<table align="center" cellpadding="0" cellspacing="0" role="none">
<tr>
<td style="width: 552px; max-width: 100%">
<div class="sm-my-8" style="margin-top: 48px; margin-bottom: 48px; text-align: center">
<a href="https://trypost.it" target="_blank">
<img src="{{ email_asset('/images/emails/logo-header.png') }}" width="160" alt="Trypost" style="max-width: 100%; vertical-align: middle">
</a>
</div>
<table style="width: 100%" cellpadding="0" cellspacing="0" role="none">
<tr>
<td class="sm-px-6" style="border-radius: 4px; background-color: #fffffe; padding: 48px; font-size: 16px; color: #3f3f46; box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05)">
<h1 class="sm-leading-8" style="margin: 0 0 24px; font-size: 24px; font-weight: 600; color: #000001">
{{ $title }}
</h1>
<p style="margin: 0; line-height: 24px">
{{ $body }}
</p>
<div style="margin-top: 16px; border-radius: 4px; background-color: #fafafa; padding: 16px">
<p style="margin: 0; font-size: 14px; font-weight: 600; color: #18181b">
Failed platforms:
</p>
<ul style="margin: 8px 0 0; padding-left: 20px; font-size: 14px; line-height: 24px">
@foreach($failedPlatforms as $platform)
<li>
<strong>{{ $platform['name'] }}</strong>
@if($platform['error'])
{{ $platform['error'] }}
@endif
</li>
@endforeach
</ul>
</div>
<div role="separator" style="line-height: 24px">&zwj;</div>
<div style="display: flex; align-items: center; justify-content: center">
<div>
<a href="{{ $url }}" style="display: inline-block; text-decoration: none; padding: 16px 24px; font-size: 16px; line-height: 1; border-radius: 8px; background-color: #262626; color: #ffffff">
<!--[if mso]><i style="mso-font-width: 150%; mso-text-raise: 31px" hidden>&emsp;</i><![endif]-->
<span style="mso-text-raise: 16px">View Post &rarr;</span>
<!--[if mso]><i hidden style="mso-font-width: 150%">&emsp;&#8203;</i><![endif]-->
</a>
</div>
</div>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="center" style="padding: 24px; text-align: center; font-size: 12px; color: #52525b">
<p style="margin: 0 0 8px">
Open-source social media scheduling tool
</p>
@if(isset($unsubscribe_url))
<p style="margin: 8px 0 0">
<a href="{{ unsubscribe_url }}" target="_blank" class="hover-i-text-decoration-underline" style="color: #52525b; text-decoration: none">
Unsubscribe
</a>
</p>
@endif
</td>
</tr>
</table>
</div>
</div>
</body>
</html>

View file

@ -56,7 +56,7 @@ function () {
});
// Social Connect routes
Route::middleware(['auth', 'verified'])->group(function () {
Route::middleware(['auth', 'verified', 'throttle:6,1'])->group(function () {
Route::get('connect/linkedin', [LinkedInController::class, 'connect'])->name('app.social.linkedin.connect');
Route::get('accounts/linkedin/callback', [LinkedInController::class, 'callback'])->name('app.social.linkedin.callback');

View file

@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Enums\Post\Status as PostStatus;
use App\Enums\PostPlatform\Status as PlatformStatus;
use App\Enums\SocialAccount\Status as AccountStatus;
use App\Events\PostPlatformStatusUpdated;
use App\Exceptions\TokenExpiredException;
@ -17,6 +18,7 @@
use Illuminate\Support\Facades\Mail;
beforeEach(function () {
Mail::fake();
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->socialAccount = SocialAccount::factory()->linkedin()->create([
@ -63,7 +65,7 @@
(new PublishToSocialPlatform($this->postPlatform))->handle();
$this->postPlatform->refresh();
expect($this->postPlatform->status)->toBe('published');
expect($this->postPlatform->status)->toBe(PlatformStatus::Published);
expect($this->postPlatform->platform_post_id)->toBe('post-123');
expect($this->postPlatform->platform_url)->toBe('https://linkedin.com/post/123');
});
@ -79,7 +81,7 @@
(new PublishToSocialPlatform($this->postPlatform))->handle();
$this->postPlatform->refresh();
expect($this->postPlatform->status)->toBe('failed');
expect($this->postPlatform->status)->toBe(PlatformStatus::Failed);
expect($this->postPlatform->error_message)->toBe('API Error');
});
@ -97,7 +99,7 @@
$this->postPlatform->refresh();
$this->socialAccount->refresh();
expect($this->postPlatform->status)->toBe('failed');
expect($this->postPlatform->status)->toBe(PlatformStatus::Failed);
expect($this->socialAccount->status)->toBe(AccountStatus::Disconnected);
});
@ -175,7 +177,7 @@
(new PublishToSocialPlatform($this->postPlatform))->handle();
$this->postPlatform->refresh();
expect($this->postPlatform->status)->toBe('failed');
expect($this->postPlatform->status)->toBe(PlatformStatus::Failed);
expect($this->postPlatform->error_message)->toBe(__('posts.errors.account_disconnected'));
});
@ -194,6 +196,6 @@
(new PublishToSocialPlatform($this->postPlatform))->handle();
$this->postPlatform->refresh();
expect($this->postPlatform->status)->toBe('failed');
expect($this->postPlatform->status)->toBe(PlatformStatus::Failed);
expect($this->postPlatform->error_message)->toBe(__('posts.errors.account_disconnected'));
});

View file

@ -140,3 +140,51 @@
expect(Media::where('mediable_id', $otherPostPlatform->id)->count())->toBe(1);
});
// Reorder tests
test('reorder media updates order', function () {
$media1 = $this->postPlatform->addMedia(UploadedFile::fake()->image('img1.jpg'), 'media');
$media2 = $this->postPlatform->addMedia(UploadedFile::fake()->image('img2.jpg'), 'media');
$response = $this->actingAs($this->user)->postJson(route('app.medias.reorder'), [
'media' => [
['id' => $media1->id, 'order' => 1],
['id' => $media2->id, 'order' => 0],
],
]);
$response->assertOk();
expect($media1->refresh()->order)->toBe(1);
expect($media2->refresh()->order)->toBe(0);
});
test('reorder media rejects media from other workspace', function () {
$otherUser = User::factory()->create(['setup' => Setup::Completed]);
$otherWorkspace = Workspace::factory()->create(['user_id' => $otherUser->id]);
$otherUser->update(['current_workspace_id' => $otherWorkspace->id]);
$otherPost = Post::factory()->create([
'workspace_id' => $otherWorkspace->id,
'user_id' => $otherUser->id,
]);
$otherAccount = SocialAccount::factory()->create([
'workspace_id' => $otherWorkspace->id,
]);
$otherPlatform = PostPlatform::factory()->create([
'post_id' => $otherPost->id,
'social_account_id' => $otherAccount->id,
]);
$otherMedia = $otherPlatform->addMedia(UploadedFile::fake()->image('img.jpg'), 'media');
$response = $this->actingAs($this->user)->postJson(route('app.medias.reorder'), [
'media' => [
['id' => $otherMedia->id, 'order' => 0],
],
]);
$response->assertForbidden();
});

View file

@ -12,6 +12,7 @@
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceLabel;
use Illuminate\Support\Facades\Mail;
beforeEach(function () {
$this->user = User::factory()->create(['setup' => Setup::Completed]);
@ -233,6 +234,7 @@
});
test('publish now updates scheduled_at to current time', function () {
Mail::fake();
$this->freezeTime();
$post = Post::factory()->create([

View file

@ -5,6 +5,8 @@
use App\Enums\UserWorkspace\Role;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
test('profile page is displayed', function () {
$user = User::factory()->create();
@ -176,3 +178,55 @@
// Verify member's current_workspace_id is updated to the other workspace
expect($member->fresh()->current_workspace_id)->toBe($otherWorkspace->id);
});
test('user can upload profile photo', function () {
Storage::fake();
$user = User::factory()->create();
$response = $this
->actingAs($user)
->post(route('app.profile.upload-photo'), [
'photo' => UploadedFile::fake()->image('avatar.jpg', 200, 200),
]);
$response->assertRedirect();
$user->refresh();
expect($user->has_photo)->toBeTrue();
expect($user->photo_url)->not->toBeNull();
});
test('user cannot upload non-image file as photo', function () {
$user = User::factory()->create();
$response = $this
->actingAs($user)
->post(route('app.profile.upload-photo'), [
'photo' => UploadedFile::fake()->create('document.pdf', 100),
]);
$response->assertSessionHasErrors('photo');
});
test('user can delete profile photo', function () {
Storage::fake();
$user = User::factory()->create();
// Upload first
$this->actingAs($user)->post(route('app.profile.upload-photo'), [
'photo' => UploadedFile::fake()->image('avatar.jpg', 200, 200),
]);
$user->refresh();
expect($user->has_photo)->toBeTrue();
// Delete
$response = $this->actingAs($user)->delete(route('app.profile.delete-photo'));
$response->assertRedirect();
$user->refresh();
expect($user->has_photo)->toBeFalse();
});