From 5d81824c300caef42617c7526eb852439c0bac8e Mon Sep 17 00:00:00 2001 From: "Dr. Belt" Date: Sun, 26 Jul 2026 18:45:09 +0200 Subject: [PATCH] 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'); +});