diff --git a/app/Actions/Post/CreatePost.php b/app/Actions/Post/CreatePost.php index 26535cecf..2cda0693c 100644 --- a/app/Actions/Post/CreatePost.php +++ b/app/Actions/Post/CreatePost.php @@ -96,13 +96,17 @@ 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'); + $date = data_get($data, 'date'); + + if (blank($date)) { + return null; + } 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 ccd89e563..79bfa5f5f 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; @@ -28,8 +29,10 @@ public function authorize(): bool public function rules(): array { + $status = $this->input('status'); + $enforcesPlatformLimits = in_array( - $this->input('status'), + $status, [Status::Scheduled->value, Status::Publishing->value], true, ); @@ -47,7 +50,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', @@ -55,14 +58,7 @@ public function rules(): array new ContentTypeMatchesPostPlatform, ], ...PostPlatformMetaRules::rules(), - 'scheduled_at' => [ - 'nullable', - 'date', - Rule::when( - $this->input('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)], ]; @@ -97,12 +93,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 ?? []); @@ -126,11 +118,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..6aed0fab8 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; @@ -25,8 +26,10 @@ public function authorize(): bool public function rules(): array { + $status = $this->input('status'); + $enforcesMediaCompatibility = in_array( - $this->input('status'), + $status, [Status::Scheduled->value, Status::Publishing->value], true, ); @@ -43,15 +46,7 @@ public function rules(): array ), ], ...PostMediaRules::rules(hosted: true), - 'scheduled_at' => [ - 'sometimes', - 'nullable', - 'date', - Rule::when( - $this->input('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' => [ @@ -68,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; } 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..9e1d5d436 100644 --- a/app/Mcp/Tools/Post/UpdatePostTool.php +++ b/app/Mcp/Tools/Post/UpdatePostTool.php @@ -37,10 +37,12 @@ public function handle(Request $request): Response|ResponseFactory return Response::error('Post not found.'); } + $status = data_get($request->all(), 'status'); + $validated = $request->validate([ 'post_id' => ['required', 'uuid'], 'content' => ['nullable', 'string', 'max:10000'], - 'scheduled_at' => ['nullable', 'date', '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)], @@ -63,7 +65,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 ?? []), @@ -94,7 +96,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..9e16ca818 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'; @@ -48,4 +53,36 @@ 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(); + } + + /** + * Validation rules for `scheduled_at` on post update (web, API, MCP). + * + * @return list + */ + public static function scheduledAtRules(?Post $post, mixed $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/Api/PostApiTest.php b/tests/Feature/Api/PostApiTest.php index 567607a60..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(); @@ -71,11 +73,13 @@ ], ]) ->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(); expect($post->created_via)->toBe(CreatedVia::Api); + expect($post->scheduled_at)->toBeNull(); }); it('ignores a client-supplied created_via and always records api', function () { @@ -529,20 +533,68 @@ ->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]) + ->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', '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(); + $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('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 () { @@ -550,13 +602,45 @@ '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 ba60f0101..663e2824d 100644 --- a/tests/Feature/Mcp/PostToolTest.php +++ b/tests/Feature/Mcp/PostToolTest.php @@ -115,6 +115,42 @@ expect($post->created_via)->toBe(CreatedVia::Mcp); }); +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'], + ], + ], + 'null' => [ + 'content' => 'Draft without schedule', + 'platforms' => [ + ['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'], + ], + 'scheduled_at' => null, + ], + }; + + 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) + ->latest('created_at') + ->firstOrFail() + ->scheduled_at)->toBeNull(); +})->with([ + 'empty args' => ['empty'], + 'omitted schedule' => ['omitted'], + 'explicit null schedule' => ['null'], +]); + test('create post with platforms enables only those', function () { $response = TryPostServer::actingAs($this->user) ->tool(CreatePostTool::class, [ @@ -133,20 +169,6 @@ expect($enabled->first()->content_type->value)->toBe('linkedin_post'); }); -test('create post without args creates empty draft for today', function () { - $response = TryPostServer::actingAs($this->user) - ->tool(CreatePostTool::class, []); - - $response->assertOk() - ->assertStructuredContent(function (AssertableJson $json) { - $json->where('content', '') - ->where('status', 'draft') - ->etc(); - }); - - expect(Post::where('workspace_id', $this->workspace->id)->count())->toBe(1); -}); - 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']); @@ -338,6 +360,101 @@ ->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 (?string $existingScheduledAt) { + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'scheduled_at' => $existingScheduledAt, + ]); + + TryPostServer::actingAs($this->user) + ->tool(UpdatePostTool::class, [ + 'post_id' => $post->id, + 'status' => 'scheduled', + ]) + ->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'); +})->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(); + $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()->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 2ea4c55b5..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')); @@ -262,11 +292,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 () { @@ -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,6 +658,178 @@ expect($post->scheduled_at->toDateTimeString())->toBe(now()->toDateTimeString()); }); +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' => $existingScheduledAt, + 'content' => 'Test content', + ]); + + $postPlatform = PostPlatform::factory()->create([ + 'post_id' => $post->id, + 'social_account_id' => $this->socialAccount->id, + ]); + + $payload = [ + 'status' => 'scheduled', + 'content' => 'Test content', + 'platforms' => [ + [ + 'id' => $postPlatform->id, + 'content_type' => ContentType::LinkedInPost->value, + ], + ], + ]; + + $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); +})->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(); + + $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(); + + $post->refresh(); + expect($post->status)->toBe(PostStatus::Scheduled) + ->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 7e20d520c..6390adb52 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 () { @@ -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 () { 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(); +});