fix(facebook): empty-message rejection + state consistency + no re-publish on terminal

Production incident: a customer's Facebook Page post failed with 'The post
is empty. Please enter a message to share.' (error code 197) and ended up
with a contradictory DB state (status=published + error_message=set).

Three independent bugs were uncovered:

A. FacebookPublisher sends 'message'/'description' as null when the user
   posts media without text. Graph API requires the key be omitted, not
   null. Fixed in publishSingleImagePost, publishMultiImagePost,
   publishVideoPost, publishReel.

B. markAsPublished/markAsFailed leak stale fields across transitions
   (a published row could retain error_message from a prior failure,
   vice-versa). Both transitions now explicitly clear the opposite
   side's fields.

C. status='failed' was editable in the UI and the backend, so users
   were re-clicking Publish, generating duplicate failure emails and
   the contradictory state from bug B. The frontend isReadOnly check
   and the UpdatePost backend guard now treat Published/PartiallyPublished/
   Failed/Publishing as terminal. To retry, the user duplicates the post.

11 new tests guarantee these can't regress silently: FB payload shape
per content type, PostPlatform field-clearing on transitions, and the
terminal-status block at the controller level.
This commit is contained in:
Paulo Castellano 2026-05-15 13:01:49 -03:00
parent 19f3b02941
commit 3f6032c152
17 changed files with 377 additions and 23 deletions

View file

@ -20,8 +20,10 @@ class UpdatePost
*/
public static function execute(Workspace $workspace, Post $post, array $data): array
{
if ($post->status === PostStatus::Published) {
return ['post' => $post, 'action' => PostAction::AlreadyPublished];
$terminalStatuses = [PostStatus::Published, PostStatus::PartiallyPublished, PostStatus::Failed, PostStatus::Publishing];
if (in_array($post->status, $terminalStatuses, true)) {
return ['post' => $post, 'action' => PostAction::Finalized];
}
$scheduledAt = $post->scheduled_at;

View file

@ -7,6 +7,7 @@
enum Action: string
{
case AlreadyPublished = 'already_published';
case Finalized = 'finalized';
case Publishing = 'publishing';
case Scheduled = 'scheduled';
}

View file

@ -67,7 +67,7 @@ public function update(UpdatePostRequest $request, Post $post): PostResource|Jso
$result = UpdatePost::execute($request->user()->currentWorkspace, $post, $request->validated());
if (data_get($result, 'action') === PostAction::AlreadyPublished) {
if (in_array(data_get($result, 'action'), [PostAction::AlreadyPublished, PostAction::Finalized], true)) {
return response()->json(
['message' => 'Cannot edit a published post.'],
Response::HTTP_UNPROCESSABLE_ENTITY

View file

@ -219,7 +219,7 @@ public function edit(Request $request, Post $post): Response|RedirectResponse
$this->authorize('update', $post);
if (in_array($post->status, [PostStatus::Publishing, PostStatus::Published, PostStatus::PartiallyPublished], true)) {
if (in_array($post->status, [PostStatus::Publishing, PostStatus::Published, PostStatus::PartiallyPublished, PostStatus::Failed], true)) {
return redirect()->route('app.posts.show', $post);
}
@ -289,6 +289,13 @@ public function update(UpdatePostRequest $request, Post $post): RedirectResponse
return back();
}
if ($action === PostAction::Finalized) {
session()->flash('flash.banner', __('posts.flash.cannot_edit_finalized'));
session()->flash('flash.bannerStyle', 'danger');
return back();
}
if ($action === PostAction::Publishing) {
return redirect()->route('app.posts.show', $post);
}

View file

@ -54,7 +54,7 @@ public function handle(Request $request): Response|ResponseFactory
$result = UpdatePost::execute($workspace, $post, $payload);
if (data_get($result, 'action') === PostAction::AlreadyPublished) {
if (in_array(data_get($result, 'action'), [PostAction::AlreadyPublished, PostAction::Finalized], true)) {
return Response::error('Cannot edit a published post.');
}

View file

@ -102,6 +102,8 @@ public function markAsPublished(string $platformPostId, ?string $platformUrl = n
'platform_post_id' => $platformPostId,
'platform_url' => $platformUrl,
'published_at' => $now,
'error_message' => null,
'error_context' => null,
]);
$this->socialAccount?->update(['last_used_at' => $now]);
@ -113,6 +115,8 @@ public function markAsFailed(string $errorMessage, ?array $errorContext = null):
'status' => Status::Failed,
'error_message' => $errorMessage,
'error_context' => $errorContext,
'platform_post_id' => null,
'platform_url' => null,
]);
}
}

View file

@ -102,11 +102,16 @@ private function publishTextPost(string $pageId, string $accessToken, string $co
private function publishSingleImagePost(string $pageId, string $accessToken, ?string $content, $media): array
{
$response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/photos", [
'message' => $content,
$payload = [
'url' => $media->url,
'access_token' => $accessToken,
]);
];
if (! empty($content)) {
$payload['message'] = $content;
}
$response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/photos", $payload);
if ($response->failed()) {
Log::error('Facebook single image post failed', [
@ -159,10 +164,13 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str
// Create the post with attached media
$postData = [
'message' => $content,
'access_token' => $accessToken,
];
if (! empty($content)) {
$postData['message'] = $content;
}
foreach ($attachedMedia as $index => $media) {
$postData["attached_media[{$index}]"] = json_encode($media);
}
@ -188,12 +196,16 @@ private function publishMultiImagePost(string $pageId, string $accessToken, ?str
private function publishVideoPost(string $pageId, string $accessToken, ?string $content, $media): array
{
// Use resumable upload for videos
$response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/videos", [
'description' => $content,
$payload = [
'file_url' => $media->url,
'access_token' => $accessToken,
]);
];
if (! empty($content)) {
$payload['description'] = $content;
}
$response = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/videos", $payload);
if ($response->failed()) {
Log::error('Facebook video post failed', [
@ -287,13 +299,18 @@ private function publishReel(string $pageId, string $accessToken, ?string $conte
}
// Phase 3 (finish) — publish the reel.
$finishResponse = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/video_reels", [
$finishPayload = [
'upload_phase' => 'finish',
'video_id' => $videoId,
'video_state' => 'PUBLISHED',
'description' => $content,
'access_token' => $accessToken,
]);
];
if (! empty($content)) {
$finishPayload['description'] = $content;
}
$finishResponse = $this->socialHttp()->post("{$this->baseUrl}/{$pageId}/video_reels", $finishPayload);
if ($finishResponse->failed()) {
$this->handleApiError($finishResponse);

View file

@ -476,6 +476,7 @@
'deleted' => 'Post deleted successfully!',
'duplicated' => 'Post duplicated as a draft.',
'cannot_edit_published' => 'Published posts cannot be edited.',
'cannot_edit_finalized' => 'This post has already been processed and cannot be re-published. Duplicate it to try again.',
'cannot_delete_published' => 'Published posts cannot be deleted.',
'connect_first' => 'Connect at least one social network before creating a post.',
],

View file

@ -476,6 +476,7 @@
'deleted' => '¡Post eliminado correctamente!',
'duplicated' => 'Post duplicado como borrador.',
'cannot_edit_published' => 'Los posts publicados no se pueden editar.',
'cannot_edit_finalized' => 'Este post ya fue procesado y no puede republicarse. Duplícalo para intentar de nuevo.',
'cannot_delete_published' => 'Los posts publicados no se pueden eliminar.',
'connect_first' => 'Conecta al menos una red social antes de crear un post.',
],

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -476,6 +476,7 @@
'deleted' => 'Post excluído com sucesso!',
'duplicated' => 'Post duplicado como rascunho.',
'cannot_edit_published' => 'Posts publicados não podem ser editados.',
'cannot_edit_finalized' => 'Este post já foi processado e não pode ser republicado. Duplique-o para tentar de novo.',
'cannot_delete_published' => 'Posts publicados não podem ser excluídos.',
'connect_first' => 'Conecte pelo menos uma rede social antes de criar um post.',
],

View file

@ -98,7 +98,7 @@ const props = defineProps<{
const post = computed(() => props.post);
// Terminal states the user cannot recover from delete, navigate, but never edit.
const isReadOnly = computed(() => ['publishing', 'published', 'partially_published'].includes(post.value.status));
const isReadOnly = computed(() => ['publishing', 'published', 'partially_published', 'failed'].includes(post.value.status));
const isPublishing = computed(() => post.value.status === 'publishing');
const isScheduled = computed(() => post.value.status === 'scheduled');
// Locked states terminal + scheduled. Field edits and auto-save suppressed;

View file

@ -7,12 +7,14 @@
use App\Enums\PostPlatform\Status;
use App\Enums\SocialAccount\Platform;
use App\Enums\UserWorkspace\Role;
use App\Jobs\PublishPost;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceLabel;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
@ -308,7 +310,7 @@
});
test('edit redirects to show for non-editable statuses', function () {
foreach ([PostStatus::Published, PostStatus::PartiallyPublished, PostStatus::Publishing] as $status) {
foreach ([PostStatus::Published, PostStatus::PartiallyPublished, PostStatus::Publishing, PostStatus::Failed] as $status) {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
@ -326,8 +328,8 @@
}
});
test('edit allows draft, scheduled, and failed posts', function () {
foreach ([PostStatus::Draft, PostStatus::Scheduled, PostStatus::Failed] as $status) {
test('edit allows draft and scheduled posts', function () {
foreach ([PostStatus::Draft, PostStatus::Scheduled] as $status) {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
@ -415,6 +417,138 @@
$response->assertRedirect();
});
test('cannot re-publish a failed post', function () {
Bus::fake();
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Failed,
]);
$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 content',
'platforms' => [
[
'id' => $postPlatform->id,
'content_type' => ContentType::LinkedInPost->value,
],
],
]);
$response->assertRedirect();
$response->assertSessionHas('flash.bannerStyle', 'danger');
$post->refresh();
expect($post->status)->toBe(PostStatus::Failed);
Bus::assertNotDispatched(PublishPost::class);
});
test('cannot update a post in publishing state', function () {
Bus::fake();
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Publishing,
]);
$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' => 'draft',
'content' => 'Test content',
'platforms' => [
[
'id' => $postPlatform->id,
'content_type' => ContentType::LinkedInPost->value,
],
],
]);
$response->assertRedirect();
$response->assertSessionHas('flash.bannerStyle', 'danger');
$post->refresh();
expect($post->status)->toBe(PostStatus::Publishing);
Bus::assertNotDispatched(PublishPost::class);
});
test('cannot update a partially published post', function () {
Bus::fake();
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::PartiallyPublished,
]);
$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 content',
'platforms' => [
[
'id' => $postPlatform->id,
'content_type' => ContentType::LinkedInPost->value,
],
],
]);
$response->assertRedirect();
$response->assertSessionHas('flash.bannerStyle', 'danger');
$post->refresh();
expect($post->status)->toBe(PostStatus::PartiallyPublished);
Bus::assertNotDispatched(PublishPost::class);
});
test('cannot update a published post', function () {
Bus::fake();
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Published,
]);
$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 content',
'platforms' => [
[
'id' => $postPlatform->id,
'content_type' => ContentType::LinkedInPost->value,
],
],
]);
$response->assertRedirect();
$response->assertSessionHas('flash.bannerStyle', 'danger');
$post->refresh();
expect($post->status)->toBe(PostStatus::Published);
Bus::assertNotDispatched(PublishPost::class);
});
test('publish now updates scheduled_at to current time', function () {
Mail::fake();
$this->freezeTime();

View file

@ -499,3 +499,125 @@
expect($result['id'])->toBe('post-123');
});
test('single image post without caption omits message from payload', function () {
$this->post->update([
'content' => null,
'media' => [
[
'id' => 'test-media-id',
'path' => 'media/2026-01/image.jpg',
'url' => 'https://example.com/media/2026-01/image.jpg',
'mime_type' => 'image/jpeg',
'original_filename' => 'image.jpg',
],
],
]);
Http::fake([
'*/page_123/photos' => Http::response(['id' => 'photo-123', 'post_id' => 'post-123'], 200),
]);
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
return str_contains($request->url(), '/page_123/photos')
&& ! array_key_exists('message', $request->data());
});
});
test('multi-image post without caption omits message from payload', function () {
$mediaItems = [];
for ($i = 1; $i <= 2; $i++) {
$mediaItems[] = [
'id' => "test-media-{$i}",
'path' => "media/2026-01/image{$i}.jpg",
'url' => "https://example.com/media/2026-01/image{$i}.jpg",
'mime_type' => 'image/jpeg',
'original_filename' => "image{$i}.jpg",
];
}
$this->post->update(['content' => null, 'media' => $mediaItems]);
Http::fake([
'*/page_123/photos' => Http::sequence()
->push(['id' => 'photo_1'], 200)
->push(['id' => 'photo_2'], 200),
'*/page_123/feed' => Http::response(['id' => 'multi_post_789'], 200),
]);
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
return str_contains($request->url(), '/page_123/feed')
&& ! array_key_exists('message', $request->data());
});
});
test('video post without description omits description from payload', function () {
$this->post->update([
'content' => null,
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/video.mp4',
'url' => 'https://example.com/media/2026-01/video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'video.mp4',
],
],
]);
Http::fake([
'*/page_123/videos' => Http::response(['id' => 'video_123'], 200),
]);
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
return str_contains($request->url(), '/page_123/videos')
&& ! array_key_exists('description', $request->data());
});
});
test('reel post without description omits description from payload (finish phase)', function () {
$this->postPlatform->update(['content_type' => ContentType::FacebookReel]);
$this->post->update([
'content' => null,
'media' => [
[
'id' => 'test-media-reel',
'path' => 'media/2026-01/reel.mp4',
'url' => 'https://example.com/media/2026-01/reel.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'reel.mp4',
],
],
]);
Http::fake([
'*/page_123/video_reels' => Http::sequence()
->push([
'video_id' => 'reel_video_123',
'upload_url' => 'https://rupload.facebook.com/video-upload/v25.0/reel_video_123',
], 200)
->push(['id' => 'reel_123', 'success' => true], 200),
'*example.com/media/*' => Http::response('fake-video-binary-content', 200),
'*rupload.facebook.com/*' => Http::response(['success' => true], 200),
]);
$this->publisher->publish($this->postPlatform);
Http::assertSent(function ($request) {
if (! str_contains($request->url(), '/page_123/video_reels')) {
return false;
}
$data = $request->data();
return data_get($data, 'upload_phase') === 'finish'
&& ! array_key_exists('description', $data);
});
});

View file

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
use App\Enums\PostPlatform\Status;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
beforeEach(function () {
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
$this->socialAccount = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
]);
$this->postPlatform = PostPlatform::factory()->create([
'post_id' => $this->post->id,
'social_account_id' => $this->socialAccount->id,
]);
});
test('markAsPublished clears stale error_message and error_context', function () {
$this->postPlatform->update([
'status' => Status::Failed,
'error_message' => 'The post is empty. Please enter a message to share.',
'error_context' => ['platform_error_code' => 197, 'content_length' => 0],
]);
$this->postPlatform->markAsPublished('platform_post_abc', 'https://www.facebook.com/platform_post_abc');
$this->postPlatform->refresh();
expect($this->postPlatform->status)->toBe(Status::Published)
->and($this->postPlatform->platform_post_id)->toBe('platform_post_abc')
->and($this->postPlatform->error_message)->toBeNull()
->and($this->postPlatform->error_context)->toBeNull();
});
test('markAsFailed clears stale platform_post_id and platform_url', function () {
$this->postPlatform->update([
'status' => Status::Published,
'platform_post_id' => 'old_post_id',
'platform_url' => 'https://www.facebook.com/old_post_id',
'published_at' => now(),
]);
$this->postPlatform->markAsFailed('Something went wrong.', ['platform_error_code' => 500]);
$this->postPlatform->refresh();
expect($this->postPlatform->status)->toBe(Status::Failed)
->and($this->postPlatform->error_message)->toBe('Something went wrong.')
->and($this->postPlatform->platform_post_id)->toBeNull()
->and($this->postPlatform->platform_url)->toBeNull();
});