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:
parent
53a5a8bf22
commit
a33ff5d00f
12 changed files with 537 additions and 60 deletions
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.'),
|
||||
|
|
|
|||
|
|
@ -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.'),
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue