fix: keep post drafts unscheduled by default (#209)

* fix: keep post drafts unscheduled by default

* Align schedule validation and keep drafts unscheduled.

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.

* 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.

* 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.

* 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.

* 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.

* Remove normalizeStatus helper.

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 <cursoragent@cursor.com>

* Drop is_string status guards from schedule validation.

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 <cursoragent@cursor.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Paulo Castellano <paulo@castellanos.llc>
This commit is contained in:
Matteo Martini 2026-08-01 22:39:18 +02:00 committed by GitHub
parent 53a5a8bf22
commit a33ff5d00f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 537 additions and 60 deletions

View file

@ -96,13 +96,17 @@ public static function execute(Workspace $workspace, User $user, array $data): P
/**
* @param array<string, mixed> $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();
}

View file

@ -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');
}

View file

@ -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;
}

View file

@ -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.'),

View file

@ -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.'),

View file

@ -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<mixed>
*/
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'],
),
];
}
}

View file

@ -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 () {

View file

@ -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

View file

@ -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,

View file

@ -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([

View file

@ -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 () {

View file

@ -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();
});