From 27a96aa3bc81ddccae543903b8fbc9e6243f96ea Mon Sep 17 00:00:00 2001 From: "Dr. Belt" Date: Sun, 26 Jul 2026 18:45:09 +0200 Subject: [PATCH 1/8] fix: keep post drafts unscheduled by default --- app/Actions/Post/CreatePost.php | 8 ++- .../Requests/Api/Post/UpdatePostRequest.php | 1 + app/Mcp/Tools/Post/CreatePostTool.php | 2 +- app/Mcp/Tools/Post/UpdatePostTool.php | 7 +- tests/Feature/Api/PostApiTest.php | 6 ++ tests/Feature/Mcp/PostToolTest.php | 68 ++++++++++++++++++- tests/Feature/PostControllerTest.php | 4 +- tests/Feature/PostTemplateControllerTest.php | 4 +- tests/Unit/Actions/Post/CreatePostTest.php | 38 +++++++++++ 9 files changed, 127 insertions(+), 11 deletions(-) create mode 100644 tests/Unit/Actions/Post/CreatePostTest.php diff --git a/app/Actions/Post/CreatePost.php b/app/Actions/Post/CreatePost.php index 26535cecf..ff374f88c 100644 --- a/app/Actions/Post/CreatePost.php +++ b/app/Actions/Post/CreatePost.php @@ -96,14 +96,16 @@ public static function execute(Workspace $workspace, User $user, array $data): P /** * @param array $data */ - private static function resolveScheduledAt(array $data): Carbon + private static function resolveScheduledAt(array $data): ?Carbon { if ($scheduledAt = data_get($data, 'scheduled_at')) { return Carbon::parse($scheduledAt)->utc(); } - $date = data_get($data, 'date') ?: Carbon::now('UTC')->format('Y-m-d'); + if (! array_key_exists('date', $data) || blank(data_get($data, 'date'))) { + return null; + } - return Carbon::parse($date, 'UTC')->setTime(9, 0)->utc(); + return Carbon::parse(data_get($data, 'date'), 'UTC')->setTime(9, 0)->utc(); } } diff --git a/app/Http/Requests/Api/Post/UpdatePostRequest.php b/app/Http/Requests/Api/Post/UpdatePostRequest.php index ccd89e563..6c165bc90 100644 --- a/app/Http/Requests/Api/Post/UpdatePostRequest.php +++ b/app/Http/Requests/Api/Post/UpdatePostRequest.php @@ -56,6 +56,7 @@ public function rules(): array ], ...PostPlatformMetaRules::rules(), 'scheduled_at' => [ + Rule::requiredIf($this->input('status') === Status::Scheduled->value), 'nullable', 'date', Rule::when( diff --git a/app/Mcp/Tools/Post/CreatePostTool.php b/app/Mcp/Tools/Post/CreatePostTool.php index 73730b92a..95258218c 100644 --- a/app/Mcp/Tools/Post/CreatePostTool.php +++ b/app/Mcp/Tools/Post/CreatePostTool.php @@ -55,7 +55,7 @@ public function schema(JsonSchema $schema): array { return [ 'content' => $schema->string()->description('The post caption/text body. Optional — can be edited later.'), - 'scheduled_at' => $schema->string()->description('ISO 8601 datetime in the future (e.g. 2026-05-10T15:30:00Z). Defaults to today at 09:00 UTC.'), + 'scheduled_at' => $schema->string()->description('Optional ISO 8601 datetime in the future (e.g. 2026-05-10T15:30:00Z). Omit it or pass null to create an unscheduled draft.'), 'label_ids' => $schema->array() ->items($schema->string()) ->description('Workspace label IDs to attach to the post.'), diff --git a/app/Mcp/Tools/Post/UpdatePostTool.php b/app/Mcp/Tools/Post/UpdatePostTool.php index 46e643b64..70fc9ed28 100644 --- a/app/Mcp/Tools/Post/UpdatePostTool.php +++ b/app/Mcp/Tools/Post/UpdatePostTool.php @@ -40,7 +40,12 @@ public function handle(Request $request): Response|ResponseFactory $validated = $request->validate([ 'post_id' => ['required', 'uuid'], 'content' => ['nullable', 'string', 'max:10000'], - 'scheduled_at' => ['nullable', 'date', 'after:now'], + 'scheduled_at' => [ + Rule::requiredIf(data_get($request->all(), 'status') === Status::Scheduled->value), + 'nullable', + 'date', + 'after:now', + ], 'status' => ['sometimes', 'string', Rule::in([Status::Draft->value, Status::Scheduled->value])], 'label_ids' => ['sometimes', 'array'], 'label_ids.*' => ['uuid', Rule::exists('workspace_labels', 'id')->where('workspace_id', $workspace->id)], diff --git a/tests/Feature/Api/PostApiTest.php b/tests/Feature/Api/PostApiTest.php index 567607a60..99da82822 100644 --- a/tests/Feature/Api/PostApiTest.php +++ b/tests/Feature/Api/PostApiTest.php @@ -537,6 +537,12 @@ 'scheduled_at' => now()->subDay(), ]); + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->putJson(route('api.posts.update', $post), [ + 'status' => 'scheduled', + ]) + ->assertJsonValidationErrors(['scheduled_at']); + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) ->putJson(route('api.posts.update', $post), [ 'status' => 'scheduled', diff --git a/tests/Feature/Mcp/PostToolTest.php b/tests/Feature/Mcp/PostToolTest.php index ba60f0101..39b9936d1 100644 --- a/tests/Feature/Mcp/PostToolTest.php +++ b/tests/Feature/Mcp/PostToolTest.php @@ -115,6 +115,50 @@ expect($post->created_via)->toBe(CreatedVia::Mcp); }); +test('create post without scheduled_at creates unscheduled draft', function () { + $response = TryPostServer::actingAs($this->user) + ->tool(CreatePostTool::class, [ + 'content' => 'Draft without schedule', + 'platforms' => [ + ['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'], + ], + ]); + + $response->assertOk() + ->assertStructuredContent(function (AssertableJson $json) { + $json->where('content', 'Draft without schedule') + ->where('status', 'draft') + ->where('scheduled_at', null) + ->etc(); + }); + + $post = Post::where('workspace_id', $this->workspace->id) + ->where('content', 'Draft without schedule') + ->firstOrFail(); + + expect($post->status->value)->toBe('draft') + ->and($post->scheduled_at)->toBeNull(); +}); + +test('create post with explicit null scheduled_at creates unscheduled draft', function () { + $response = TryPostServer::actingAs($this->user) + ->tool(CreatePostTool::class, [ + 'content' => 'Draft with null schedule', + 'scheduled_at' => null, + ]); + + $response->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json + ->where('status', 'draft') + ->where('scheduled_at', null) + ->etc()); + + expect(Post::where('workspace_id', $this->workspace->id) + ->where('content', 'Draft with null schedule') + ->firstOrFail() + ->scheduled_at)->toBeNull(); +}); + test('create post with platforms enables only those', function () { $response = TryPostServer::actingAs($this->user) ->tool(CreatePostTool::class, [ @@ -133,7 +177,7 @@ expect($enabled->first()->content_type->value)->toBe('linkedin_post'); }); -test('create post without args creates empty draft for today', function () { +test('create post without args creates unscheduled empty draft', function () { $response = TryPostServer::actingAs($this->user) ->tool(CreatePostTool::class, []); @@ -141,10 +185,12 @@ ->assertStructuredContent(function (AssertableJson $json) { $json->where('content', '') ->where('status', 'draft') + ->where('scheduled_at', null) ->etc(); }); - expect(Post::where('workspace_id', $this->workspace->id)->count())->toBe(1); + $post = Post::where('workspace_id', $this->workspace->id)->firstOrFail(); + expect($post->scheduled_at)->toBeNull(); }); test('create post rejects scheduled_at in the past', function () { @@ -338,6 +384,24 @@ ->assertStructuredContent(fn (AssertableJson $json) => $json->where('platforms.0.meta.aspect_ratio', '4:5')->etc()); }); +test('update post rejects scheduled status without scheduled_at', function () { + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(UpdatePostTool::class, [ + 'post_id' => $post->id, + 'status' => 'scheduled', + ]); + + $response->assertHasErrors(); + + expect($post->fresh()->status->value)->toBe('draft') + ->and($post->fresh()->scheduled_at)->toBeNull(); +}); + test('update post accepts a valid aspect_ratio and persists it', function () { $post = Post::factory()->create([ 'workspace_id' => $this->workspace->id, diff --git a/tests/Feature/PostControllerTest.php b/tests/Feature/PostControllerTest.php index 2ea4c55b5..bc8fd0c42 100644 --- a/tests/Feature/PostControllerTest.php +++ b/tests/Feature/PostControllerTest.php @@ -262,11 +262,11 @@ expect($post->postPlatforms)->toHaveCount(1); }); -test('store post defaults scheduled_at to today when no date is provided', function () { +test('store post leaves scheduled_at null 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')); + expect($post->scheduled_at)->toBeNull(); }); test('store post schedules draft on the date param when provided', function () { diff --git a/tests/Feature/PostTemplateControllerTest.php b/tests/Feature/PostTemplateControllerTest.php index 7e20d520c..db0a60dc5 100644 --- a/tests/Feature/PostTemplateControllerTest.php +++ b/tests/Feature/PostTemplateControllerTest.php @@ -127,7 +127,7 @@ ->assertNotFound(); }); -test('apply defaults scheduled_at to today when no date is provided', function () { +test('apply leaves scheduled_at null when no date is provided', function () { Http::fake(['api.unsplash.com/*' => Http::response(['results' => []])]); $this->actingAs($this->user) @@ -135,7 +135,7 @@ ->assertOk(); $post = $this->workspace->posts()->latest()->first(); - expect($post->scheduled_at->format('Y-m-d'))->toBe(now('UTC')->format('Y-m-d')); + expect($post->scheduled_at)->toBeNull(); }); test('apply schedules the post on the date param when provided', function () { diff --git a/tests/Unit/Actions/Post/CreatePostTest.php b/tests/Unit/Actions/Post/CreatePostTest.php new file mode 100644 index 000000000..c9c46cd2f --- /dev/null +++ b/tests/Unit/Actions/Post/CreatePostTest.php @@ -0,0 +1,38 @@ +invoke(null, $data); + + return $value ? CarbonImmutable::instance($value) : null; +} + +test('create post resolver does not default missing scheduled_at to today at 09 utc', function () { + CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-07-26 15:02:37', 'UTC')); + + expect(resolvedCreatePostScheduledAt([]))->toBeNull(); + + CarbonImmutable::setTestNow(); +}); + +test('create post resolver treats explicit null scheduled_at as unscheduled', function () { + expect(resolvedCreatePostScheduledAt(['scheduled_at' => null]))->toBeNull(); +}); + +test('create post resolver preserves explicit future scheduled_at in utc', function () { + $scheduledAt = resolvedCreatePostScheduledAt(['scheduled_at' => '2099-12-31T15:30:00+02:00']); + + expect($scheduledAt?->format('Y-m-d H:i:s'))->toBe('2099-12-31 13:30:00'); +}); + +test('create post resolver keeps explicit legacy date fallback at 09 utc', function () { + $scheduledAt = resolvedCreatePostScheduledAt(['date' => '2099-12-31']); + + expect($scheduledAt?->format('Y-m-d H:i:s'))->toBe('2099-12-31 09:00:00'); +}); From 38b094578ce120a8cf5b81cda2380234f55f7033 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 18:16:27 +0000 Subject: [PATCH 2/8] Align schedule validation and keep drafts unscheduled. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Require scheduled_at only when status is scheduled and the post has no usable future schedule. Share that rule across web, API, and MCP, keep create without a date as null, and preserve the legacy date → 09:00 UTC fallback. --- app/Actions/Post/CreatePost.php | 2 +- .../Requests/Api/Post/UpdatePostRequest.php | 20 +++--- .../Requests/App/Post/UpdatePostRequest.php | 6 +- app/Mcp/Tools/Post/UpdatePostTool.php | 12 +++- app/Support/PostStatusRules.php | 14 +++++ tests/Feature/Api/PostApiTest.php | 22 ++++++- tests/Feature/Mcp/PostToolTest.php | 63 ++++++++++--------- tests/Feature/PostControllerTest.php | 57 +++++++++++++++++ tests/Unit/Actions/Post/CreatePostTest.php | 38 ----------- tests/Unit/Support/PostStatusRulesTest.php | 19 ++++++ 10 files changed, 168 insertions(+), 85 deletions(-) delete mode 100644 tests/Unit/Actions/Post/CreatePostTest.php diff --git a/app/Actions/Post/CreatePost.php b/app/Actions/Post/CreatePost.php index ff374f88c..be8074e53 100644 --- a/app/Actions/Post/CreatePost.php +++ b/app/Actions/Post/CreatePost.php @@ -102,7 +102,7 @@ private static function resolveScheduledAt(array $data): ?Carbon return Carbon::parse($scheduledAt)->utc(); } - if (! array_key_exists('date', $data) || blank(data_get($data, 'date'))) { + if (blank(data_get($data, 'date'))) { return null; } diff --git a/app/Http/Requests/Api/Post/UpdatePostRequest.php b/app/Http/Requests/Api/Post/UpdatePostRequest.php index 6c165bc90..d9f649b9d 100644 --- a/app/Http/Requests/Api/Post/UpdatePostRequest.php +++ b/app/Http/Requests/Api/Post/UpdatePostRequest.php @@ -14,6 +14,7 @@ use App\Rules\ContentTypeMatchesPostPlatform; use App\Support\PostMediaRules; use App\Support\PostPlatformMetaRules; +use App\Support\PostStatusRules; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Collection; use Illuminate\Validation\Rule; @@ -47,7 +48,7 @@ public function rules(): array ], ...PostMediaRules::rules(hosted: false), 'platforms' => ['sometimes', 'array'], - 'platforms.*.id' => ['required', 'uuid', Rule::exists('post_platforms', 'id')->where('post_id', $this->route('post') instanceof Post ? $this->route('post')->id : $this->route('post'))], + 'platforms.*.id' => ['required', 'uuid', Rule::exists('post_platforms', 'id')->where('post_id', $this->route('post')->id)], 'platforms.*.content_type' => [ 'sometimes', 'string', @@ -56,7 +57,10 @@ public function rules(): array ], ...PostPlatformMetaRules::rules(), 'scheduled_at' => [ - Rule::requiredIf($this->input('status') === Status::Scheduled->value), + Rule::requiredIf(fn (): bool => PostStatusRules::requiresExplicitSchedule( + $this->route('post'), + $this->input('status'), + )), 'nullable', 'date', Rule::when( @@ -98,12 +102,8 @@ public function withValidator(Validator $validator): void */ private function addMediaCompatibilityErrors(Validator $validator): void { - $routePost = $this->route('post'); - $post = $routePost instanceof Post ? $routePost : Post::find($routePost); - - if (! $post) { - return; - } + /** @var Post $post */ + $post = $this->route('post'); $media = $this->has('media') ? (array) $this->input('media', []) : (array) ($post->media ?? []); @@ -127,11 +127,11 @@ private function resolveSelectedPlatforms(): Collection return collect(); } + /** @var Post $post */ $post = $this->route('post'); - $postId = $post instanceof Post ? $post->id : $post; return PostPlatform::query() - ->where('post_id', $postId) + ->where('post_id', $post->id) ->whereIn('id', $ids) ->pluck('platform', 'id'); } diff --git a/app/Http/Requests/App/Post/UpdatePostRequest.php b/app/Http/Requests/App/Post/UpdatePostRequest.php index 18d7f539d..2b8643742 100644 --- a/app/Http/Requests/App/Post/UpdatePostRequest.php +++ b/app/Http/Requests/App/Post/UpdatePostRequest.php @@ -11,6 +11,7 @@ use App\Rules\ContentTypeCompatibleWithMedia; use App\Support\PostMediaRules; use App\Support\PostPlatformMetaRules; +use App\Support\PostStatusRules; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Collection; use Illuminate\Validation\Rule; @@ -44,7 +45,10 @@ public function rules(): array ], ...PostMediaRules::rules(hosted: true), 'scheduled_at' => [ - 'sometimes', + Rule::requiredIf(fn (): bool => PostStatusRules::requiresExplicitSchedule( + $this->route('post'), + $this->input('status'), + )), 'nullable', 'date', Rule::when( diff --git a/app/Mcp/Tools/Post/UpdatePostTool.php b/app/Mcp/Tools/Post/UpdatePostTool.php index 70fc9ed28..34b305665 100644 --- a/app/Mcp/Tools/Post/UpdatePostTool.php +++ b/app/Mcp/Tools/Post/UpdatePostTool.php @@ -41,10 +41,16 @@ public function handle(Request $request): Response|ResponseFactory 'post_id' => ['required', 'uuid'], 'content' => ['nullable', 'string', 'max:10000'], 'scheduled_at' => [ - Rule::requiredIf(data_get($request->all(), 'status') === Status::Scheduled->value), + Rule::requiredIf(fn (): bool => PostStatusRules::requiresExplicitSchedule( + $post, + data_get($request->all(), 'status'), + )), 'nullable', 'date', - 'after:now', + Rule::when( + data_get($request->all(), 'status') === Status::Scheduled->value, + ['after:now'], + ), ], 'status' => ['sometimes', 'string', Rule::in([Status::Draft->value, Status::Scheduled->value])], 'label_ids' => ['sometimes', 'array'], @@ -99,7 +105,7 @@ public function schema(JsonSchema $schema): array return [ 'post_id' => $schema->string()->required()->description('UUID of the post to update.'), 'content' => $schema->string()->description('New caption/text body.'), - 'scheduled_at' => $schema->string()->description('ISO 8601 datetime in the future. Required if status is "scheduled".'), + 'scheduled_at' => $schema->string()->description('Future ISO 8601 datetime. Required for status "scheduled" unless the post already has a future schedule.'), 'status' => $schema->string() ->enum([Status::Draft->value, Status::Scheduled->value]) ->description('Post status. Use "draft" to keep editing, "scheduled" to schedule the post. Use publish-post-tool for immediate publish.'), diff --git a/app/Support/PostStatusRules.php b/app/Support/PostStatusRules.php index dff97d97f..b67321116 100644 --- a/app/Support/PostStatusRules.php +++ b/app/Support/PostStatusRules.php @@ -48,4 +48,18 @@ public static function editBlockedMessage(): string { return __(self::EDIT_BLOCKED_MESSAGE_KEY); } + + /** + * True when status is scheduled and the post has no future schedule to reuse. + */ + public static function requiresExplicitSchedule(?Post $post, mixed $status): bool + { + if ($status !== PostStatus::Scheduled->value) { + return false; + } + + $existing = $post?->scheduled_at; + + return $existing === null || $existing->isPast(); + } } diff --git a/tests/Feature/Api/PostApiTest.php b/tests/Feature/Api/PostApiTest.php index 99da82822..98fe1b67b 100644 --- a/tests/Feature/Api/PostApiTest.php +++ b/tests/Feature/Api/PostApiTest.php @@ -71,7 +71,8 @@ ], ]) ->assertCreated() - ->assertJsonPath('status', PostStatus::Draft->value); + ->assertJsonPath('status', PostStatus::Draft->value) + ->assertJsonPath('scheduled_at', null); $post = Post::where('workspace_id', $this->workspace->id)->first(); expect($post)->not->toBeNull(); @@ -551,6 +552,25 @@ ->assertJsonValidationErrors(['scheduled_at']); }); +it('accepts scheduled status reusing an existing future scheduled_at', function () { + $scheduledAt = now()->addDay()->startOfSecond(); + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Draft, + 'scheduled_at' => $scheduledAt, + ]); + + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->putJson(route('api.posts.update', $post), [ + 'status' => 'scheduled', + ]) + ->assertOk() + ->assertJsonPath('status', PostStatus::Scheduled->value); + + expect($post->fresh()->scheduled_at->toDateTimeString())->toBe($scheduledAt->toDateTimeString()); +}); + it('accepts draft status with no scheduled_at', function () { $post = Post::factory()->create([ 'workspace_id' => $this->workspace->id, diff --git a/tests/Feature/Mcp/PostToolTest.php b/tests/Feature/Mcp/PostToolTest.php index 39b9936d1..2f2816b7d 100644 --- a/tests/Feature/Mcp/PostToolTest.php +++ b/tests/Feature/Mcp/PostToolTest.php @@ -115,36 +115,14 @@ expect($post->created_via)->toBe(CreatedVia::Mcp); }); -test('create post without scheduled_at creates unscheduled draft', function () { +test('create post without scheduled_at creates unscheduled draft', function (array $extra) { $response = TryPostServer::actingAs($this->user) ->tool(CreatePostTool::class, [ 'content' => 'Draft without schedule', 'platforms' => [ ['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'], ], - ]); - - $response->assertOk() - ->assertStructuredContent(function (AssertableJson $json) { - $json->where('content', 'Draft without schedule') - ->where('status', 'draft') - ->where('scheduled_at', null) - ->etc(); - }); - - $post = Post::where('workspace_id', $this->workspace->id) - ->where('content', 'Draft without schedule') - ->firstOrFail(); - - expect($post->status->value)->toBe('draft') - ->and($post->scheduled_at)->toBeNull(); -}); - -test('create post with explicit null scheduled_at creates unscheduled draft', function () { - $response = TryPostServer::actingAs($this->user) - ->tool(CreatePostTool::class, [ - 'content' => 'Draft with null schedule', - 'scheduled_at' => null, + ...$extra, ]); $response->assertOk() @@ -154,10 +132,14 @@ ->etc()); expect(Post::where('workspace_id', $this->workspace->id) - ->where('content', 'Draft with null schedule') + ->where('content', 'Draft without schedule') + ->latest('created_at') ->firstOrFail() ->scheduled_at)->toBeNull(); -}); +})->with([ + 'omitted' => [[]], + 'explicit null' => [['scheduled_at' => null]], +]); test('create post with platforms enables only those', function () { $response = TryPostServer::actingAs($this->user) @@ -390,16 +372,35 @@ 'user_id' => $this->user->id, ]); - $response = TryPostServer::actingAs($this->user) + TryPostServer::actingAs($this->user) ->tool(UpdatePostTool::class, [ 'post_id' => $post->id, 'status' => 'scheduled', - ]); + ]) + ->assertHasErrors(); - $response->assertHasErrors(); + expect($post->fresh()->status->value)->toBe('draft'); +}); + +test('update post accepts scheduled status reusing an existing future scheduled_at', function () { + $scheduledAt = now()->addDay()->startOfSecond(); + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'scheduled_at' => $scheduledAt, + ]); + + TryPostServer::actingAs($this->user) + ->tool(UpdatePostTool::class, [ + 'post_id' => $post->id, + 'status' => 'scheduled', + ]) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json + ->where('status', 'scheduled') + ->etc()); - expect($post->fresh()->status->value)->toBe('draft') - ->and($post->fresh()->scheduled_at)->toBeNull(); + expect($post->fresh()->scheduled_at->toDateTimeString())->toBe($scheduledAt->toDateTimeString()); }); test('update post accepts a valid aspect_ratio and persists it', function () { diff --git a/tests/Feature/PostControllerTest.php b/tests/Feature/PostControllerTest.php index bc8fd0c42..66933d8ec 100644 --- a/tests/Feature/PostControllerTest.php +++ b/tests/Feature/PostControllerTest.php @@ -606,6 +606,63 @@ expect($post->scheduled_at->toDateTimeString())->toBe(now()->toDateTimeString()); }); +test('update rejects scheduled status without scheduled_at when post has none', function () { + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Draft, + 'scheduled_at' => null, + 'content' => 'Test content', + ]); + + $postPlatform = PostPlatform::factory()->create([ + 'post_id' => $post->id, + 'social_account_id' => $this->socialAccount->id, + ]); + + $this->actingAs($this->user)->put(route('app.posts.update', $post), [ + 'status' => 'scheduled', + 'content' => 'Test content', + 'platforms' => [ + [ + 'id' => $postPlatform->id, + 'content_type' => ContentType::LinkedInPost->value, + ], + ], + ])->assertSessionHasErrors('scheduled_at'); +}); + +test('update accepts scheduled status reusing an existing future scheduled_at', function () { + $scheduledAt = now()->addDay()->startOfSecond(); + + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Draft, + 'scheduled_at' => $scheduledAt, + 'content' => 'Test content', + ]); + + $postPlatform = PostPlatform::factory()->create([ + 'post_id' => $post->id, + 'social_account_id' => $this->socialAccount->id, + ]); + + $this->actingAs($this->user)->put(route('app.posts.update', $post), [ + 'status' => 'scheduled', + 'content' => 'Test content', + 'platforms' => [ + [ + 'id' => $postPlatform->id, + 'content_type' => ContentType::LinkedInPost->value, + ], + ], + ])->assertRedirect(); + + expect($post->fresh()->status)->toBe(PostStatus::Scheduled) + ->and($post->fresh()->scheduled_at->toDateTimeString())->toBe($scheduledAt->toDateTimeString()); +}); + // Destroy tests test('destroy post requires authentication', function () { $post = Post::factory()->create([ diff --git a/tests/Unit/Actions/Post/CreatePostTest.php b/tests/Unit/Actions/Post/CreatePostTest.php deleted file mode 100644 index c9c46cd2f..000000000 --- a/tests/Unit/Actions/Post/CreatePostTest.php +++ /dev/null @@ -1,38 +0,0 @@ -invoke(null, $data); - - return $value ? CarbonImmutable::instance($value) : null; -} - -test('create post resolver does not default missing scheduled_at to today at 09 utc', function () { - CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-07-26 15:02:37', 'UTC')); - - expect(resolvedCreatePostScheduledAt([]))->toBeNull(); - - CarbonImmutable::setTestNow(); -}); - -test('create post resolver treats explicit null scheduled_at as unscheduled', function () { - expect(resolvedCreatePostScheduledAt(['scheduled_at' => null]))->toBeNull(); -}); - -test('create post resolver preserves explicit future scheduled_at in utc', function () { - $scheduledAt = resolvedCreatePostScheduledAt(['scheduled_at' => '2099-12-31T15:30:00+02:00']); - - expect($scheduledAt?->format('Y-m-d H:i:s'))->toBe('2099-12-31 13:30:00'); -}); - -test('create post resolver keeps explicit legacy date fallback at 09 utc', function () { - $scheduledAt = resolvedCreatePostScheduledAt(['date' => '2099-12-31']); - - expect($scheduledAt?->format('Y-m-d H:i:s'))->toBe('2099-12-31 09:00:00'); -}); diff --git a/tests/Unit/Support/PostStatusRulesTest.php b/tests/Unit/Support/PostStatusRulesTest.php index e0d78ecc9..1532d9a19 100644 --- a/tests/Unit/Support/PostStatusRulesTest.php +++ b/tests/Unit/Support/PostStatusRulesTest.php @@ -45,3 +45,22 @@ PostStatus::Scheduled, PostStatus::Failed, ]); + +test('requires explicit schedule when status is scheduled and post has no usable schedule', function (?string $scheduledAt, bool $expected) { + $post = Post::factory()->make([ + 'scheduled_at' => $scheduledAt, + ]); + + expect(PostStatusRules::requiresExplicitSchedule($post, PostStatus::Scheduled->value))->toBe($expected); +})->with([ + 'missing schedule' => [null, true], + 'past schedule' => [now()->subHour()->toDateTimeString(), true], + 'future schedule' => [now()->addDay()->toDateTimeString(), false], +]); + +test('does not require explicit schedule for non scheduled statuses', function () { + $post = Post::factory()->make(['scheduled_at' => null]); + + expect(PostStatusRules::requiresExplicitSchedule($post, PostStatus::Draft->value))->toBeFalse() + ->and(PostStatusRules::requiresExplicitSchedule(null, PostStatus::Scheduled->value))->toBeTrue(); +}); From 3c8e9f97400b95e4beeb6f401e947704d34375e2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 18:24:06 +0000 Subject: [PATCH 3/8] Polish schedule validation typing and tests. Type requiresExplicitSchedule status as ?string, reuse a local status variable in request/tool validation, tighten the web reject assertion, and collapse overlapping MCP unscheduled-create cases. --- .../Requests/Api/Post/UpdatePostRequest.php | 9 ++-- .../Requests/App/Post/UpdatePostRequest.php | 9 ++-- app/Mcp/Tools/Post/UpdatePostTool.php | 10 ++--- app/Support/PostStatusRules.php | 2 +- tests/Feature/Mcp/PostToolTest.php | 44 ++++++++----------- tests/Feature/PostControllerTest.php | 7 ++- 6 files changed, 42 insertions(+), 39 deletions(-) diff --git a/app/Http/Requests/Api/Post/UpdatePostRequest.php b/app/Http/Requests/Api/Post/UpdatePostRequest.php index d9f649b9d..a09a9a18e 100644 --- a/app/Http/Requests/Api/Post/UpdatePostRequest.php +++ b/app/Http/Requests/Api/Post/UpdatePostRequest.php @@ -29,8 +29,11 @@ public function authorize(): bool public function rules(): array { + $status = $this->input('status'); + $status = is_string($status) ? $status : null; + $enforcesPlatformLimits = in_array( - $this->input('status'), + $status, [Status::Scheduled->value, Status::Publishing->value], true, ); @@ -59,12 +62,12 @@ public function rules(): array 'scheduled_at' => [ Rule::requiredIf(fn (): bool => PostStatusRules::requiresExplicitSchedule( $this->route('post'), - $this->input('status'), + $status, )), 'nullable', 'date', Rule::when( - $this->input('status') === Status::Scheduled->value, + $status === Status::Scheduled->value, ['after:now'] ), ], diff --git a/app/Http/Requests/App/Post/UpdatePostRequest.php b/app/Http/Requests/App/Post/UpdatePostRequest.php index 2b8643742..c8bd14b83 100644 --- a/app/Http/Requests/App/Post/UpdatePostRequest.php +++ b/app/Http/Requests/App/Post/UpdatePostRequest.php @@ -26,8 +26,11 @@ public function authorize(): bool public function rules(): array { + $status = $this->input('status'); + $status = is_string($status) ? $status : null; + $enforcesMediaCompatibility = in_array( - $this->input('status'), + $status, [Status::Scheduled->value, Status::Publishing->value], true, ); @@ -47,12 +50,12 @@ public function rules(): array 'scheduled_at' => [ Rule::requiredIf(fn (): bool => PostStatusRules::requiresExplicitSchedule( $this->route('post'), - $this->input('status'), + $status, )), 'nullable', 'date', Rule::when( - $this->input('status') === Status::Scheduled->value, + $status === Status::Scheduled->value, ['after:now'] ), ], diff --git a/app/Mcp/Tools/Post/UpdatePostTool.php b/app/Mcp/Tools/Post/UpdatePostTool.php index 34b305665..320c2add5 100644 --- a/app/Mcp/Tools/Post/UpdatePostTool.php +++ b/app/Mcp/Tools/Post/UpdatePostTool.php @@ -37,18 +37,18 @@ public function handle(Request $request): Response|ResponseFactory return Response::error('Post not found.'); } + $status = data_get($request->all(), 'status'); + $status = is_string($status) ? $status : null; + $validated = $request->validate([ 'post_id' => ['required', 'uuid'], 'content' => ['nullable', 'string', 'max:10000'], 'scheduled_at' => [ - Rule::requiredIf(fn (): bool => PostStatusRules::requiresExplicitSchedule( - $post, - data_get($request->all(), 'status'), - )), + Rule::requiredIf(fn (): bool => PostStatusRules::requiresExplicitSchedule($post, $status)), 'nullable', 'date', Rule::when( - data_get($request->all(), 'status') === Status::Scheduled->value, + $status === Status::Scheduled->value, ['after:now'], ), ], diff --git a/app/Support/PostStatusRules.php b/app/Support/PostStatusRules.php index b67321116..47478309d 100644 --- a/app/Support/PostStatusRules.php +++ b/app/Support/PostStatusRules.php @@ -52,7 +52,7 @@ public static function editBlockedMessage(): string /** * True when status is scheduled and the post has no future schedule to reuse. */ - public static function requiresExplicitSchedule(?Post $post, mixed $status): bool + public static function requiresExplicitSchedule(?Post $post, ?string $status): bool { if ($status !== PostStatus::Scheduled->value) { return false; diff --git a/tests/Feature/Mcp/PostToolTest.php b/tests/Feature/Mcp/PostToolTest.php index 2f2816b7d..49cbbf6fc 100644 --- a/tests/Feature/Mcp/PostToolTest.php +++ b/tests/Feature/Mcp/PostToolTest.php @@ -115,30 +115,40 @@ expect($post->created_via)->toBe(CreatedVia::Mcp); }); -test('create post without scheduled_at creates unscheduled draft', function (array $extra) { - $response = TryPostServer::actingAs($this->user) - ->tool(CreatePostTool::class, [ +test('create post creates unscheduled draft without a schedule', function (string $case) { + $payload = match ($case) { + 'empty' => [], + 'omitted' => [ 'content' => 'Draft without schedule', 'platforms' => [ ['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'], ], - ...$extra, - ]); + ], + 'null' => [ + 'content' => 'Draft without schedule', + 'platforms' => [ + ['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'], + ], + 'scheduled_at' => null, + ], + }; - $response->assertOk() + TryPostServer::actingAs($this->user) + ->tool(CreatePostTool::class, $payload) + ->assertOk() ->assertStructuredContent(fn (AssertableJson $json) => $json ->where('status', 'draft') ->where('scheduled_at', null) ->etc()); expect(Post::where('workspace_id', $this->workspace->id) - ->where('content', 'Draft without schedule') ->latest('created_at') ->firstOrFail() ->scheduled_at)->toBeNull(); })->with([ - 'omitted' => [[]], - 'explicit null' => [['scheduled_at' => null]], + 'empty args' => ['empty'], + 'omitted schedule' => ['omitted'], + 'explicit null schedule' => ['null'], ]); test('create post with platforms enables only those', function () { @@ -159,22 +169,6 @@ expect($enabled->first()->content_type->value)->toBe('linkedin_post'); }); -test('create post without args creates unscheduled empty draft', function () { - $response = TryPostServer::actingAs($this->user) - ->tool(CreatePostTool::class, []); - - $response->assertOk() - ->assertStructuredContent(function (AssertableJson $json) { - $json->where('content', '') - ->where('status', 'draft') - ->where('scheduled_at', null) - ->etc(); - }); - - $post = Post::where('workspace_id', $this->workspace->id)->firstOrFail(); - expect($post->scheduled_at)->toBeNull(); -}); - test('create post rejects scheduled_at in the past', function () { $response = TryPostServer::actingAs($this->user) ->tool(CreatePostTool::class, ['scheduled_at' => '2020-01-01T00:00:00Z']); diff --git a/tests/Feature/PostControllerTest.php b/tests/Feature/PostControllerTest.php index 66933d8ec..c7088419c 100644 --- a/tests/Feature/PostControllerTest.php +++ b/tests/Feature/PostControllerTest.php @@ -630,6 +630,8 @@ ], ], ])->assertSessionHasErrors('scheduled_at'); + + expect($post->fresh()->status)->toBe(PostStatus::Draft); }); test('update accepts scheduled status reusing an existing future scheduled_at', function () { @@ -659,8 +661,9 @@ ], ])->assertRedirect(); - expect($post->fresh()->status)->toBe(PostStatus::Scheduled) - ->and($post->fresh()->scheduled_at->toDateTimeString())->toBe($scheduledAt->toDateTimeString()); + $post->refresh(); + expect($post->status)->toBe(PostStatus::Scheduled) + ->and($post->scheduled_at->toDateTimeString())->toBe($scheduledAt->toDateTimeString()); }); // Destroy tests From 4049b189bbd83f1c8f59452563d430267ea24067 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 18:26:06 +0000 Subject: [PATCH 4/8] Centralize status helper in post update validation. Reuse the typed status() helper across FormRequests and the already-parsed $status in UpdatePostTool so schedule checks stay consistent and less noisy. --- app/Http/Requests/Api/Post/UpdatePostRequest.php | 12 +++++++++--- app/Http/Requests/App/Post/UpdatePostRequest.php | 12 +++++++++--- app/Mcp/Tools/Post/UpdatePostTool.php | 2 +- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/app/Http/Requests/Api/Post/UpdatePostRequest.php b/app/Http/Requests/Api/Post/UpdatePostRequest.php index a09a9a18e..5c4f9de54 100644 --- a/app/Http/Requests/Api/Post/UpdatePostRequest.php +++ b/app/Http/Requests/Api/Post/UpdatePostRequest.php @@ -29,8 +29,7 @@ public function authorize(): bool public function rules(): array { - $status = $this->input('status'); - $status = is_string($status) ? $status : null; + $status = $this->status(); $enforcesPlatformLimits = in_array( $status, @@ -79,7 +78,7 @@ public function rules(): array public function withValidator(Validator $validator): void { $validator->after(function (Validator $validator): void { - if (! in_array($this->input('status'), [Status::Scheduled->value, Status::Publishing->value], true)) { + if (! in_array($this->status(), [Status::Scheduled->value, Status::Publishing->value], true)) { return; } @@ -120,6 +119,13 @@ private function addMediaCompatibilityErrors(Validator $validator): void } } + private function status(): ?string + { + $status = $this->input('status'); + + return is_string($status) ? $status : null; + } + /** * @return Collection */ diff --git a/app/Http/Requests/App/Post/UpdatePostRequest.php b/app/Http/Requests/App/Post/UpdatePostRequest.php index c8bd14b83..b08ac1c03 100644 --- a/app/Http/Requests/App/Post/UpdatePostRequest.php +++ b/app/Http/Requests/App/Post/UpdatePostRequest.php @@ -26,8 +26,7 @@ public function authorize(): bool public function rules(): array { - $status = $this->input('status'); - $status = is_string($status) ? $status : null; + $status = $this->status(); $enforcesMediaCompatibility = in_array( $status, @@ -99,12 +98,19 @@ public function withValidator(Validator $validator): void private function isPublishingOrScheduling(): bool { return in_array( - $this->input('status'), + $this->status(), [Status::Scheduled->value, Status::Publishing->value], true, ); } + private function status(): ?string + { + $status = $this->input('status'); + + return is_string($status) ? $status : null; + } + /** * @return Collection */ diff --git a/app/Mcp/Tools/Post/UpdatePostTool.php b/app/Mcp/Tools/Post/UpdatePostTool.php index 320c2add5..4fa5453a1 100644 --- a/app/Mcp/Tools/Post/UpdatePostTool.php +++ b/app/Mcp/Tools/Post/UpdatePostTool.php @@ -74,7 +74,7 @@ public function handle(Request $request): Response|ResponseFactory // here, or stored) against the post's stored media — the tool can't change // media, so a misconfigured post can't be scheduled even without resubmitting // content_type. Mirrors the public API's withValidator check. - if (data_get($validated, 'status') === Status::Scheduled->value) { + if ($status === Status::Scheduled->value) { $errors = ContentTypeCompatibleWithMedia::errorsFor( ContentTypeCompatibleWithMedia::entriesForUpdate($post, data_get($validated, 'platforms')), (array) ($post->media ?? []), From 1d47756b58d56b35a8d0233fa83c5d0b1d4610ce Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 18:30:47 +0000 Subject: [PATCH 5/8] Share scheduled_at update rules across web, API, and MCP. Centralize schedule validation in PostStatusRules, normalize status parsing in one place, and align past-schedule coverage across entry points. --- app/Actions/Post/CreatePost.php | 6 ++-- .../Requests/Api/Post/UpdatePostRequest.php | 28 +++++-------------- .../Requests/App/Post/UpdatePostRequest.php | 26 +++-------------- app/Mcp/Tools/Post/UpdatePostTool.php | 13 ++------- app/Support/PostStatusRules.php | 28 +++++++++++++++++++ tests/Feature/Mcp/PostToolTest.php | 11 +++++++- tests/Feature/PostControllerTest.php | 19 ++++++++++--- tests/Unit/Support/PostStatusRulesTest.php | 6 ++++ 8 files changed, 76 insertions(+), 61 deletions(-) diff --git a/app/Actions/Post/CreatePost.php b/app/Actions/Post/CreatePost.php index be8074e53..2cda0693c 100644 --- a/app/Actions/Post/CreatePost.php +++ b/app/Actions/Post/CreatePost.php @@ -102,10 +102,12 @@ private static function resolveScheduledAt(array $data): ?Carbon return Carbon::parse($scheduledAt)->utc(); } - if (blank(data_get($data, 'date'))) { + $date = data_get($data, 'date'); + + if (blank($date)) { return null; } - return Carbon::parse(data_get($data, 'date'), 'UTC')->setTime(9, 0)->utc(); + return Carbon::parse($date, 'UTC')->setTime(9, 0)->utc(); } } diff --git a/app/Http/Requests/Api/Post/UpdatePostRequest.php b/app/Http/Requests/Api/Post/UpdatePostRequest.php index 5c4f9de54..6c41333ce 100644 --- a/app/Http/Requests/Api/Post/UpdatePostRequest.php +++ b/app/Http/Requests/Api/Post/UpdatePostRequest.php @@ -29,7 +29,7 @@ public function authorize(): bool public function rules(): array { - $status = $this->status(); + $status = PostStatusRules::normalizeStatus($this->input('status')); $enforcesPlatformLimits = in_array( $status, @@ -58,18 +58,7 @@ public function rules(): array new ContentTypeMatchesPostPlatform, ], ...PostPlatformMetaRules::rules(), - 'scheduled_at' => [ - Rule::requiredIf(fn (): bool => PostStatusRules::requiresExplicitSchedule( - $this->route('post'), - $status, - )), - 'nullable', - 'date', - Rule::when( - $status === Status::Scheduled->value, - ['after:now'] - ), - ], + 'scheduled_at' => PostStatusRules::scheduledAtRules($this->route('post'), $status), 'label_ids' => ['sometimes', 'array'], 'label_ids.*' => ['uuid', Rule::exists('workspace_labels', 'id')->where('workspace_id', $this->user()->currentWorkspace->id)], ]; @@ -78,7 +67,11 @@ public function rules(): array public function withValidator(Validator $validator): void { $validator->after(function (Validator $validator): void { - if (! in_array($this->status(), [Status::Scheduled->value, Status::Publishing->value], true)) { + if (! in_array( + PostStatusRules::normalizeStatus($this->input('status')), + [Status::Scheduled->value, Status::Publishing->value], + true, + )) { return; } @@ -119,13 +112,6 @@ private function addMediaCompatibilityErrors(Validator $validator): void } } - private function status(): ?string - { - $status = $this->input('status'); - - return is_string($status) ? $status : null; - } - /** * @return Collection */ diff --git a/app/Http/Requests/App/Post/UpdatePostRequest.php b/app/Http/Requests/App/Post/UpdatePostRequest.php index b08ac1c03..edc4cf07d 100644 --- a/app/Http/Requests/App/Post/UpdatePostRequest.php +++ b/app/Http/Requests/App/Post/UpdatePostRequest.php @@ -26,7 +26,7 @@ public function authorize(): bool public function rules(): array { - $status = $this->status(); + $status = PostStatusRules::normalizeStatus($this->input('status')); $enforcesMediaCompatibility = in_array( $status, @@ -46,18 +46,7 @@ public function rules(): array ), ], ...PostMediaRules::rules(hosted: true), - 'scheduled_at' => [ - Rule::requiredIf(fn (): bool => PostStatusRules::requiresExplicitSchedule( - $this->route('post'), - $status, - )), - 'nullable', - 'date', - Rule::when( - $status === Status::Scheduled->value, - ['after:now'] - ), - ], + 'scheduled_at' => PostStatusRules::scheduledAtRules($this->route('post'), $status), 'platforms' => ['sometimes', 'array'], 'platforms.*.id' => ['required', 'uuid', Rule::exists('post_platforms', 'id')->where('post_id', $this->route('post')->id)], 'platforms.*.content_type' => [ @@ -74,7 +63,7 @@ public function rules(): array public function withValidator(Validator $validator): void { - $validator->after(function ($validator): void { + $validator->after(function (Validator $validator): void { if (! $this->isPublishingOrScheduling()) { return; } @@ -98,19 +87,12 @@ public function withValidator(Validator $validator): void private function isPublishingOrScheduling(): bool { return in_array( - $this->status(), + PostStatusRules::normalizeStatus($this->input('status')), [Status::Scheduled->value, Status::Publishing->value], true, ); } - private function status(): ?string - { - $status = $this->input('status'); - - return is_string($status) ? $status : null; - } - /** * @return Collection */ diff --git a/app/Mcp/Tools/Post/UpdatePostTool.php b/app/Mcp/Tools/Post/UpdatePostTool.php index 4fa5453a1..266093787 100644 --- a/app/Mcp/Tools/Post/UpdatePostTool.php +++ b/app/Mcp/Tools/Post/UpdatePostTool.php @@ -37,21 +37,12 @@ public function handle(Request $request): Response|ResponseFactory return Response::error('Post not found.'); } - $status = data_get($request->all(), 'status'); - $status = is_string($status) ? $status : null; + $status = PostStatusRules::normalizeStatus(data_get($request->all(), 'status')); $validated = $request->validate([ 'post_id' => ['required', 'uuid'], 'content' => ['nullable', 'string', 'max:10000'], - 'scheduled_at' => [ - Rule::requiredIf(fn (): bool => PostStatusRules::requiresExplicitSchedule($post, $status)), - 'nullable', - 'date', - Rule::when( - $status === Status::Scheduled->value, - ['after:now'], - ), - ], + 'scheduled_at' => PostStatusRules::scheduledAtRules($post, $status), 'status' => ['sometimes', 'string', Rule::in([Status::Draft->value, Status::Scheduled->value])], 'label_ids' => ['sometimes', 'array'], 'label_ids.*' => ['uuid', Rule::exists('workspace_labels', 'id')->where('workspace_id', $workspace->id)], diff --git a/app/Support/PostStatusRules.php b/app/Support/PostStatusRules.php index 47478309d..28d164e8a 100644 --- a/app/Support/PostStatusRules.php +++ b/app/Support/PostStatusRules.php @@ -6,7 +6,12 @@ use App\Enums\Post\Status as PostStatus; use App\Models\Post; +use Illuminate\Validation\Rule; +/** + * Shared post status helpers — edit/delete gates plus the `scheduled_at` + * update contract used by web, API, and MCP so those entry points cannot drift. + */ class PostStatusRules { private const EDIT_BLOCKED_MESSAGE_KEY = 'posts.cannot_edit_finalized'; @@ -49,6 +54,11 @@ public static function editBlockedMessage(): string return __(self::EDIT_BLOCKED_MESSAGE_KEY); } + public static function normalizeStatus(mixed $status): ?string + { + return is_string($status) ? $status : null; + } + /** * True when status is scheduled and the post has no future schedule to reuse. */ @@ -62,4 +72,22 @@ public static function requiresExplicitSchedule(?Post $post, ?string $status): b return $existing === null || $existing->isPast(); } + + /** + * Validation rules for `scheduled_at` on post update (web, API, MCP). + * + * @return list + */ + public static function scheduledAtRules(?Post $post, ?string $status): array + { + return [ + Rule::requiredIf(fn (): bool => self::requiresExplicitSchedule($post, $status)), + 'nullable', + 'date', + Rule::when( + $status === PostStatus::Scheduled->value, + ['after:now'], + ), + ]; + } } diff --git a/tests/Feature/Mcp/PostToolTest.php b/tests/Feature/Mcp/PostToolTest.php index 49cbbf6fc..6524584cf 100644 --- a/tests/Feature/Mcp/PostToolTest.php +++ b/tests/Feature/Mcp/PostToolTest.php @@ -360,10 +360,11 @@ ->assertStructuredContent(fn (AssertableJson $json) => $json->where('platforms.0.meta.aspect_ratio', '4:5')->etc()); }); -test('update post rejects scheduled status without scheduled_at', function () { +test('update post rejects scheduled status without a future scheduled_at', function () { $post = Post::factory()->create([ 'workspace_id' => $this->workspace->id, 'user_id' => $this->user->id, + 'scheduled_at' => now()->subDay(), ]); TryPostServer::actingAs($this->user) @@ -373,6 +374,14 @@ ]) ->assertHasErrors(); + TryPostServer::actingAs($this->user) + ->tool(UpdatePostTool::class, [ + 'post_id' => $post->id, + 'status' => 'scheduled', + 'scheduled_at' => now()->subHour()->toIso8601String(), + ]) + ->assertHasErrors(); + expect($post->fresh()->status->value)->toBe('draft'); }); diff --git a/tests/Feature/PostControllerTest.php b/tests/Feature/PostControllerTest.php index c7088419c..1eaf7cc81 100644 --- a/tests/Feature/PostControllerTest.php +++ b/tests/Feature/PostControllerTest.php @@ -606,12 +606,12 @@ expect($post->scheduled_at->toDateTimeString())->toBe(now()->toDateTimeString()); }); -test('update rejects scheduled status without scheduled_at when post has none', function () { +test('update rejects scheduled status without a future scheduled_at', function () { $post = Post::factory()->create([ 'workspace_id' => $this->workspace->id, 'user_id' => $this->user->id, 'status' => PostStatus::Draft, - 'scheduled_at' => null, + 'scheduled_at' => now()->subDay(), 'content' => 'Test content', ]); @@ -620,7 +620,7 @@ 'social_account_id' => $this->socialAccount->id, ]); - $this->actingAs($this->user)->put(route('app.posts.update', $post), [ + $payload = [ 'status' => 'scheduled', 'content' => 'Test content', 'platforms' => [ @@ -629,7 +629,18 @@ 'content_type' => ContentType::LinkedInPost->value, ], ], - ])->assertSessionHasErrors('scheduled_at'); + ]; + + $this->actingAs($this->user) + ->put(route('app.posts.update', $post), $payload) + ->assertSessionHasErrors('scheduled_at'); + + $this->actingAs($this->user) + ->put(route('app.posts.update', $post), [ + ...$payload, + 'scheduled_at' => now()->subHour()->toIso8601String(), + ]) + ->assertSessionHasErrors('scheduled_at'); expect($post->fresh()->status)->toBe(PostStatus::Draft); }); diff --git a/tests/Unit/Support/PostStatusRulesTest.php b/tests/Unit/Support/PostStatusRulesTest.php index 1532d9a19..f98697960 100644 --- a/tests/Unit/Support/PostStatusRulesTest.php +++ b/tests/Unit/Support/PostStatusRulesTest.php @@ -64,3 +64,9 @@ expect(PostStatusRules::requiresExplicitSchedule($post, PostStatus::Draft->value))->toBeFalse() ->and(PostStatusRules::requiresExplicitSchedule(null, PostStatus::Scheduled->value))->toBeTrue(); }); + +test('normalizeStatus keeps strings and rejects non strings', function () { + expect(PostStatusRules::normalizeStatus(PostStatus::Scheduled->value))->toBe(PostStatus::Scheduled->value) + ->and(PostStatusRules::normalizeStatus(null))->toBeNull() + ->and(PostStatusRules::normalizeStatus(['scheduled']))->toBeNull(); +}); From 4153ef141e37923a4722d7ae4e6e5dec934de930 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 1 Aug 2026 18:41:20 +0000 Subject: [PATCH 6/8] Cover the full unscheduled-draft checklist in Pest. Add feature coverage for null/past schedule rejection, explicit scheduling, draft saves, publish-now without a schedule, calendar exclusion, and 09:00 UTC date defaults across web, API, and MCP. --- tests/Feature/Api/PostApiTest.php | 66 +++++++- tests/Feature/Mcp/PostPublishToolTest.php | 5 +- tests/Feature/Mcp/PostToolTest.php | 55 ++++++- tests/Feature/PostControllerTest.php | 161 ++++++++++++++++++- tests/Feature/PostTemplateControllerTest.php | 2 +- 5 files changed, 276 insertions(+), 13 deletions(-) diff --git a/tests/Feature/Api/PostApiTest.php b/tests/Feature/Api/PostApiTest.php index 98fe1b67b..a52ee1069 100644 --- a/tests/Feature/Api/PostApiTest.php +++ b/tests/Feature/Api/PostApiTest.php @@ -6,11 +6,13 @@ use App\Enums\Post\Status as PostStatus; use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; +use App\Jobs\PublishPost; use App\Models\Post; use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Models\Workspace; use App\Models\WorkspaceLabel; +use Illuminate\Support\Facades\Bus; beforeEach(function () { $result = createApiTestToken(); @@ -77,6 +79,7 @@ $post = Post::where('workspace_id', $this->workspace->id)->first(); expect($post)->not->toBeNull(); expect($post->created_via)->toBe(CreatedVia::Api); + expect($post->scheduled_at)->toBeNull(); }); it('ignores a client-supplied created_via and always records api', function () { @@ -530,12 +533,12 @@ ->assertJsonValidationErrors(['platforms.0.content_type']); }); -it('rejects scheduled status without a future scheduled_at', function () { +it('rejects scheduled status without a future scheduled_at', function (?string $existingScheduledAt) { $post = Post::factory()->create([ 'workspace_id' => $this->workspace->id, 'user_id' => $this->user->id, 'status' => PostStatus::Draft, - 'scheduled_at' => now()->subDay(), + 'scheduled_at' => $existingScheduledAt, ]); $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) @@ -550,7 +553,10 @@ 'scheduled_at' => now()->subHour()->toIso8601String(), ]) ->assertJsonValidationErrors(['scheduled_at']); -}); +})->with([ + 'missing schedule' => [null], + 'past schedule' => [now()->subDay()->toDateTimeString()], +]); it('accepts scheduled status reusing an existing future scheduled_at', function () { $scheduledAt = now()->addDay()->startOfSecond(); @@ -571,18 +577,70 @@ expect($post->fresh()->scheduled_at->toDateTimeString())->toBe($scheduledAt->toDateTimeString()); }); +it('schedules an unscheduled draft with an explicit future scheduled_at', function () { + $scheduledAt = now()->addDay()->startOfSecond(); + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Draft, + 'scheduled_at' => null, + ]); + + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->putJson(route('api.posts.update', $post), [ + 'status' => 'scheduled', + 'scheduled_at' => $scheduledAt->toIso8601String(), + ]) + ->assertOk() + ->assertJsonPath('status', PostStatus::Scheduled->value); + + expect($post->fresh()->scheduled_at->toDateTimeString())->toBe($scheduledAt->toDateTimeString()); +}); + it('accepts draft status with no scheduled_at', function () { $post = Post::factory()->create([ 'workspace_id' => $this->workspace->id, 'user_id' => $this->user->id, 'status' => PostStatus::Draft, + 'scheduled_at' => null, ]); $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) ->putJson(route('api.posts.update', $post), [ 'status' => 'draft', ]) - ->assertOk(); + ->assertOk() + ->assertJsonPath('scheduled_at', null); + + expect($post->fresh()->scheduled_at)->toBeNull(); +}); + +it('publishes an unscheduled draft without requiring scheduled_at', function () { + Bus::fake(); + $this->freezeTime(); + + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Draft, + 'scheduled_at' => null, + ]); + + PostPlatform::factory()->create([ + 'post_id' => $post->id, + 'social_account_id' => $this->socialAccount->id, + 'enabled' => true, + ]); + + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->putJson(route('api.posts.update', $post), [ + 'status' => 'publishing', + ]) + ->assertOk() + ->assertJsonPath('status', PostStatus::Publishing->value); + + expect($post->fresh()->scheduled_at->toDateTimeString())->toBe(now()->toDateTimeString()); + Bus::assertDispatched(PublishPost::class); }); it('rejects creating a post with a past scheduled_at', function () { diff --git a/tests/Feature/Mcp/PostPublishToolTest.php b/tests/Feature/Mcp/PostPublishToolTest.php index 0c22c1d16..583f44545 100644 --- a/tests/Feature/Mcp/PostPublishToolTest.php +++ b/tests/Feature/Mcp/PostPublishToolTest.php @@ -179,11 +179,13 @@ test('publish post immediate dispatches PublishPost job', function () { Queue::fake(); + $this->freezeTime(); $post = Post::factory()->create([ 'workspace_id' => $this->workspace->id, 'user_id' => $this->user->id, 'status' => PostStatus::Draft, + 'scheduled_at' => null, ]); PostPlatform::factory()->linkedin()->create([ @@ -198,7 +200,8 @@ $response->assertOk(); Queue::assertPushed(PublishPost::class); - expect($post->fresh()->status)->toBe(PostStatus::Publishing); + expect($post->fresh()->status)->toBe(PostStatus::Publishing) + ->and($post->fresh()->scheduled_at->toDateTimeString())->toBe(now()->toDateTimeString()); // Regression: previously UpdatePost::execute disabled every platform when // called without a `platforms` key, leaving the publish job with nothing diff --git a/tests/Feature/Mcp/PostToolTest.php b/tests/Feature/Mcp/PostToolTest.php index 6524584cf..663e2824d 100644 --- a/tests/Feature/Mcp/PostToolTest.php +++ b/tests/Feature/Mcp/PostToolTest.php @@ -360,11 +360,11 @@ ->assertStructuredContent(fn (AssertableJson $json) => $json->where('platforms.0.meta.aspect_ratio', '4:5')->etc()); }); -test('update post rejects scheduled status without a future scheduled_at', function () { +test('update post rejects scheduled status without a future scheduled_at', function (?string $existingScheduledAt) { $post = Post::factory()->create([ 'workspace_id' => $this->workspace->id, 'user_id' => $this->user->id, - 'scheduled_at' => now()->subDay(), + 'scheduled_at' => $existingScheduledAt, ]); TryPostServer::actingAs($this->user) @@ -383,7 +383,10 @@ ->assertHasErrors(); expect($post->fresh()->status->value)->toBe('draft'); -}); +})->with([ + 'missing schedule' => [null], + 'past schedule' => [now()->subDay()->toDateTimeString()], +]); test('update post accepts scheduled status reusing an existing future scheduled_at', function () { $scheduledAt = now()->addDay()->startOfSecond(); @@ -406,6 +409,52 @@ expect($post->fresh()->scheduled_at->toDateTimeString())->toBe($scheduledAt->toDateTimeString()); }); +test('update post schedules an unscheduled draft with an explicit future scheduled_at', function () { + $scheduledAt = now()->addDay()->startOfSecond(); + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'scheduled_at' => null, + ]); + + TryPostServer::actingAs($this->user) + ->tool(UpdatePostTool::class, [ + 'post_id' => $post->id, + 'status' => 'scheduled', + 'scheduled_at' => $scheduledAt->toIso8601String(), + ]) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json + ->where('status', 'scheduled') + ->etc()); + + expect($post->fresh()->scheduled_at->toDateTimeString())->toBe($scheduledAt->toDateTimeString()); +}); + +test('update post keeps an unscheduled draft when saving as draft without scheduled_at', function () { + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'scheduled_at' => null, + 'content' => 'Original', + ]); + + TryPostServer::actingAs($this->user) + ->tool(UpdatePostTool::class, [ + 'post_id' => $post->id, + 'status' => 'draft', + 'content' => 'Still a draft', + ]) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json + ->where('status', 'draft') + ->where('scheduled_at', null) + ->etc()); + + expect($post->fresh()->scheduled_at)->toBeNull() + ->and($post->fresh()->content)->toBe('Still a draft'); +}); + test('update post accepts a valid aspect_ratio and persists it', function () { $post = Post::factory()->create([ 'workspace_id' => $this->workspace->id, diff --git a/tests/Feature/PostControllerTest.php b/tests/Feature/PostControllerTest.php index 1eaf7cc81..71551498f 100644 --- a/tests/Feature/PostControllerTest.php +++ b/tests/Feature/PostControllerTest.php @@ -198,6 +198,36 @@ ); }); +test('calendar does not include unscheduled drafts', function () { + $scheduledAt = now('UTC')->startOfWeek()->addDays(2)->setTime(12, 0); + + Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'content' => 'Unscheduled draft stays off the calendar', + 'status' => PostStatus::Draft, + 'scheduled_at' => null, + ]); + + Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'content' => 'Scheduled post appears on the calendar', + 'status' => PostStatus::Scheduled, + 'scheduled_at' => $scheduledAt, + ]); + + $dateKey = $scheduledAt->format('Y-m-d'); + + $this->actingAs($this->user) + ->get(route('app.calendar')) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->has("posts.{$dateKey}", 1) + ->where("posts.{$dateKey}.0.content", 'Scheduled post appears on the calendar') + ); +}); + // Create tests test('create requires authentication', function () { $response = $this->get(route('app.posts.create')); @@ -275,7 +305,7 @@ ])->assertRedirect(); $post = Post::where('workspace_id', $this->workspace->id)->first(); - expect($post->scheduled_at->format('Y-m-d'))->toBe('2026-06-15'); + expect($post->scheduled_at->utc()->format('Y-m-d H:i:s'))->toBe('2026-06-15 09:00:00'); }); test('store post rejects invalid date format', function () { @@ -320,6 +350,28 @@ ); }); +test('edit exposes null scheduled_at for an unscheduled draft', function () { + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Draft, + 'scheduled_at' => null, + ]); + + PostPlatform::factory()->create([ + 'post_id' => $post->id, + 'social_account_id' => $this->socialAccount->id, + ]); + + $this->actingAs($this->user) + ->get(route('app.posts.edit', $post)) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('posts/Edit') + ->where('post.scheduled_at', null) + ); +}); + test('edit post returns 404 for post from different workspace', function () { $otherWorkspace = Workspace::factory()->create(); $post = Post::factory()->create([ @@ -606,12 +658,46 @@ expect($post->scheduled_at->toDateTimeString())->toBe(now()->toDateTimeString()); }); -test('update rejects scheduled status without a future scheduled_at', function () { +test('publish now is allowed when the draft has no scheduled_at', function () { + Bus::fake(); + $this->freezeTime(); + + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Draft, + 'content' => 'Test content', + 'scheduled_at' => null, + ]); + + $postPlatform = PostPlatform::factory()->create([ + 'post_id' => $post->id, + 'social_account_id' => $this->socialAccount->id, + ]); + + $this->actingAs($this->user)->put(route('app.posts.update', $post), [ + 'status' => 'publishing', + 'content' => 'Test content', + 'platforms' => [ + [ + 'id' => $postPlatform->id, + 'content_type' => ContentType::LinkedInPost->value, + ], + ], + ])->assertRedirect(); + + $post->refresh(); + expect($post->status)->toBe(PostStatus::Publishing) + ->and($post->scheduled_at->toDateTimeString())->toBe(now()->toDateTimeString()); + Bus::assertDispatched(PublishPost::class); +}); + +test('update rejects scheduled status without a future scheduled_at', function (?string $existingScheduledAt) { $post = Post::factory()->create([ 'workspace_id' => $this->workspace->id, 'user_id' => $this->user->id, 'status' => PostStatus::Draft, - 'scheduled_at' => now()->subDay(), + 'scheduled_at' => $existingScheduledAt, 'content' => 'Test content', ]); @@ -643,7 +729,10 @@ ->assertSessionHasErrors('scheduled_at'); expect($post->fresh()->status)->toBe(PostStatus::Draft); -}); +})->with([ + 'missing schedule' => [null], + 'past schedule' => [now()->subDay()->toDateTimeString()], +]); test('update accepts scheduled status reusing an existing future scheduled_at', function () { $scheduledAt = now()->addDay()->startOfSecond(); @@ -677,6 +766,70 @@ ->and($post->scheduled_at->toDateTimeString())->toBe($scheduledAt->toDateTimeString()); }); +test('update schedules an unscheduled draft with an explicit future scheduled_at', function () { + $scheduledAt = now()->addDay()->startOfSecond(); + + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Draft, + 'scheduled_at' => null, + 'content' => 'Test content', + ]); + + $postPlatform = PostPlatform::factory()->create([ + 'post_id' => $post->id, + 'social_account_id' => $this->socialAccount->id, + ]); + + $this->actingAs($this->user)->put(route('app.posts.update', $post), [ + 'status' => 'scheduled', + 'scheduled_at' => $scheduledAt->toIso8601String(), + 'content' => 'Test content', + 'platforms' => [ + [ + 'id' => $postPlatform->id, + 'content_type' => ContentType::LinkedInPost->value, + ], + ], + ])->assertRedirect(); + + $post->refresh(); + expect($post->status)->toBe(PostStatus::Scheduled) + ->and($post->scheduled_at->toDateTimeString())->toBe($scheduledAt->toDateTimeString()); +}); + +test('update keeps an unscheduled draft when saving as draft without scheduled_at', function () { + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Draft, + 'scheduled_at' => null, + 'content' => 'Original', + ]); + + $postPlatform = PostPlatform::factory()->create([ + 'post_id' => $post->id, + 'social_account_id' => $this->socialAccount->id, + ]); + + $this->actingAs($this->user)->put(route('app.posts.update', $post), [ + 'status' => 'draft', + 'content' => 'Still a draft', + 'platforms' => [ + [ + 'id' => $postPlatform->id, + 'content_type' => ContentType::LinkedInPost->value, + ], + ], + ])->assertRedirect(); + + $post->refresh(); + expect($post->status)->toBe(PostStatus::Draft) + ->and($post->scheduled_at)->toBeNull() + ->and($post->content)->toBe('Still a draft'); +}); + // Destroy tests test('destroy post requires authentication', function () { $post = Post::factory()->create([ diff --git a/tests/Feature/PostTemplateControllerTest.php b/tests/Feature/PostTemplateControllerTest.php index db0a60dc5..6390adb52 100644 --- a/tests/Feature/PostTemplateControllerTest.php +++ b/tests/Feature/PostTemplateControllerTest.php @@ -148,7 +148,7 @@ ->assertOk(); $post = $this->workspace->posts()->latest()->first(); - expect($post->scheduled_at->format('Y-m-d'))->toBe('2026-06-15'); + expect($post->scheduled_at->utc()->format('Y-m-d H:i:s'))->toBe('2026-06-15 09:00:00'); }); test('apply rejects invalid date format', function () { From 346e26f7dbf2c68d078f897ef6a86eca334b3c10 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 1 Aug 2026 15:58:16 -0400 Subject: [PATCH 7/8] Remove normalizeStatus helper. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep the inline is_string check at the few call sites that read raw request status before validation — no shared wrapper needed. Co-authored-by: Cursor --- app/Http/Requests/Api/Post/UpdatePostRequest.php | 9 +++------ app/Http/Requests/App/Post/UpdatePostRequest.php | 5 +++-- app/Mcp/Tools/Post/UpdatePostTool.php | 3 ++- app/Support/PostStatusRules.php | 5 ----- tests/Unit/Support/PostStatusRulesTest.php | 6 ------ 5 files changed, 8 insertions(+), 20 deletions(-) diff --git a/app/Http/Requests/Api/Post/UpdatePostRequest.php b/app/Http/Requests/Api/Post/UpdatePostRequest.php index 6c41333ce..f56bea78f 100644 --- a/app/Http/Requests/Api/Post/UpdatePostRequest.php +++ b/app/Http/Requests/Api/Post/UpdatePostRequest.php @@ -29,7 +29,8 @@ public function authorize(): bool public function rules(): array { - $status = PostStatusRules::normalizeStatus($this->input('status')); + $status = $this->input('status'); + $status = is_string($status) ? $status : null; $enforcesPlatformLimits = in_array( $status, @@ -67,11 +68,7 @@ public function rules(): array public function withValidator(Validator $validator): void { $validator->after(function (Validator $validator): void { - if (! in_array( - PostStatusRules::normalizeStatus($this->input('status')), - [Status::Scheduled->value, Status::Publishing->value], - true, - )) { + if (! in_array($this->input('status'), [Status::Scheduled->value, Status::Publishing->value], true)) { return; } diff --git a/app/Http/Requests/App/Post/UpdatePostRequest.php b/app/Http/Requests/App/Post/UpdatePostRequest.php index edc4cf07d..e080a493b 100644 --- a/app/Http/Requests/App/Post/UpdatePostRequest.php +++ b/app/Http/Requests/App/Post/UpdatePostRequest.php @@ -26,7 +26,8 @@ public function authorize(): bool public function rules(): array { - $status = PostStatusRules::normalizeStatus($this->input('status')); + $status = $this->input('status'); + $status = is_string($status) ? $status : null; $enforcesMediaCompatibility = in_array( $status, @@ -87,7 +88,7 @@ public function withValidator(Validator $validator): void private function isPublishingOrScheduling(): bool { return in_array( - PostStatusRules::normalizeStatus($this->input('status')), + $this->input('status'), [Status::Scheduled->value, Status::Publishing->value], true, ); diff --git a/app/Mcp/Tools/Post/UpdatePostTool.php b/app/Mcp/Tools/Post/UpdatePostTool.php index 266093787..08cf20c65 100644 --- a/app/Mcp/Tools/Post/UpdatePostTool.php +++ b/app/Mcp/Tools/Post/UpdatePostTool.php @@ -37,7 +37,8 @@ public function handle(Request $request): Response|ResponseFactory return Response::error('Post not found.'); } - $status = PostStatusRules::normalizeStatus(data_get($request->all(), 'status')); + $status = data_get($request->all(), 'status'); + $status = is_string($status) ? $status : null; $validated = $request->validate([ 'post_id' => ['required', 'uuid'], diff --git a/app/Support/PostStatusRules.php b/app/Support/PostStatusRules.php index 28d164e8a..8e1d4974b 100644 --- a/app/Support/PostStatusRules.php +++ b/app/Support/PostStatusRules.php @@ -54,11 +54,6 @@ public static function editBlockedMessage(): string return __(self::EDIT_BLOCKED_MESSAGE_KEY); } - public static function normalizeStatus(mixed $status): ?string - { - return is_string($status) ? $status : null; - } - /** * True when status is scheduled and the post has no future schedule to reuse. */ diff --git a/tests/Unit/Support/PostStatusRulesTest.php b/tests/Unit/Support/PostStatusRulesTest.php index f98697960..1532d9a19 100644 --- a/tests/Unit/Support/PostStatusRulesTest.php +++ b/tests/Unit/Support/PostStatusRulesTest.php @@ -64,9 +64,3 @@ expect(PostStatusRules::requiresExplicitSchedule($post, PostStatus::Draft->value))->toBeFalse() ->and(PostStatusRules::requiresExplicitSchedule(null, PostStatus::Scheduled->value))->toBeTrue(); }); - -test('normalizeStatus keeps strings and rejects non strings', function () { - expect(PostStatusRules::normalizeStatus(PostStatus::Scheduled->value))->toBe(PostStatus::Scheduled->value) - ->and(PostStatusRules::normalizeStatus(null))->toBeNull() - ->and(PostStatusRules::normalizeStatus(['scheduled']))->toBeNull(); -}); From 29e43ce66c9855f0723bb5c9ff49692fabcf522e Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 1 Aug 2026 16:21:51 -0400 Subject: [PATCH 8/8] Drop is_string status guards from schedule validation. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accept mixed status in PostStatusRules and rely on strict comparisons with Rule::requiredIf / Rule::when — malformed input simply does not match. Co-authored-by: Cursor --- app/Http/Requests/Api/Post/UpdatePostRequest.php | 1 - app/Http/Requests/App/Post/UpdatePostRequest.php | 1 - app/Mcp/Tools/Post/UpdatePostTool.php | 1 - app/Support/PostStatusRules.php | 4 ++-- 4 files changed, 2 insertions(+), 5 deletions(-) diff --git a/app/Http/Requests/Api/Post/UpdatePostRequest.php b/app/Http/Requests/Api/Post/UpdatePostRequest.php index f56bea78f..79bfa5f5f 100644 --- a/app/Http/Requests/Api/Post/UpdatePostRequest.php +++ b/app/Http/Requests/Api/Post/UpdatePostRequest.php @@ -30,7 +30,6 @@ public function authorize(): bool public function rules(): array { $status = $this->input('status'); - $status = is_string($status) ? $status : null; $enforcesPlatformLimits = in_array( $status, diff --git a/app/Http/Requests/App/Post/UpdatePostRequest.php b/app/Http/Requests/App/Post/UpdatePostRequest.php index e080a493b..6aed0fab8 100644 --- a/app/Http/Requests/App/Post/UpdatePostRequest.php +++ b/app/Http/Requests/App/Post/UpdatePostRequest.php @@ -27,7 +27,6 @@ public function authorize(): bool public function rules(): array { $status = $this->input('status'); - $status = is_string($status) ? $status : null; $enforcesMediaCompatibility = in_array( $status, diff --git a/app/Mcp/Tools/Post/UpdatePostTool.php b/app/Mcp/Tools/Post/UpdatePostTool.php index 08cf20c65..9e1d5d436 100644 --- a/app/Mcp/Tools/Post/UpdatePostTool.php +++ b/app/Mcp/Tools/Post/UpdatePostTool.php @@ -38,7 +38,6 @@ public function handle(Request $request): Response|ResponseFactory } $status = data_get($request->all(), 'status'); - $status = is_string($status) ? $status : null; $validated = $request->validate([ 'post_id' => ['required', 'uuid'], diff --git a/app/Support/PostStatusRules.php b/app/Support/PostStatusRules.php index 8e1d4974b..9e16ca818 100644 --- a/app/Support/PostStatusRules.php +++ b/app/Support/PostStatusRules.php @@ -57,7 +57,7 @@ public static function editBlockedMessage(): string /** * True when status is scheduled and the post has no future schedule to reuse. */ - public static function requiresExplicitSchedule(?Post $post, ?string $status): bool + public static function requiresExplicitSchedule(?Post $post, mixed $status): bool { if ($status !== PostStatus::Scheduled->value) { return false; @@ -73,7 +73,7 @@ public static function requiresExplicitSchedule(?Post $post, ?string $status): b * * @return list */ - public static function scheduledAtRules(?Post $post, ?string $status): array + public static function scheduledAtRules(?Post $post, mixed $status): array { return [ Rule::requiredIf(fn (): bool => self::requiresExplicitSchedule($post, $status)),