Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions app/Actions/Post/CreatePost.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,16 @@ 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');
if (! array_key_exists('date', $data) || blank(data_get($data, 'date'))) {
return null;
}

return Carbon::parse($date, 'UTC')->setTime(9, 0)->utc();
return Carbon::parse(data_get($data, 'date'), 'UTC')->setTime(9, 0)->utc();
}
}
1 change: 1 addition & 0 deletions app/Http/Requests/Api/Post/UpdatePostRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ public function rules(): array
],
...PostPlatformMetaRules::rules(),
'scheduled_at' => [
Rule::requiredIf($this->input('status') === Status::Scheduled->value),
'nullable',
'date',
Rule::when(
Expand Down
2 changes: 1 addition & 1 deletion app/Mcp/Tools/Post/CreatePostTool.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.'),
Expand Down
7 changes: 6 additions & 1 deletion app/Mcp/Tools/Post/UpdatePostTool.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,12 @@ public function handle(Request $request): Response|ResponseFactory
$validated = $request->validate([
'post_id' => ['required', 'uuid'],
'content' => ['nullable', 'string', 'max:10000'],
'scheduled_at' => ['nullable', 'date', 'after:now'],
'scheduled_at' => [
Rule::requiredIf(data_get($request->all(), 'status') === Status::Scheduled->value),
'nullable',
'date',
'after:now',
],
'status' => ['sometimes', 'string', Rule::in([Status::Draft->value, Status::Scheduled->value])],
'label_ids' => ['sometimes', 'array'],
'label_ids.*' => ['uuid', Rule::exists('workspace_labels', 'id')->where('workspace_id', $workspace->id)],
Expand Down
6 changes: 6 additions & 0 deletions tests/Feature/Api/PostApiTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,12 @@
'scheduled_at' => now()->subDay(),
]);

$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->putJson(route('api.posts.update', $post), [
'status' => 'scheduled',
])
->assertJsonValidationErrors(['scheduled_at']);

$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->putJson(route('api.posts.update', $post), [
'status' => 'scheduled',
Expand Down
68 changes: 66 additions & 2 deletions tests/Feature/Mcp/PostToolTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,50 @@
expect($post->created_via)->toBe(CreatedVia::Mcp);
});

test('create post without scheduled_at creates unscheduled draft', function () {
$response = TryPostServer::actingAs($this->user)
->tool(CreatePostTool::class, [
'content' => 'Draft without schedule',
'platforms' => [
['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'],
],
]);

$response->assertOk()
->assertStructuredContent(function (AssertableJson $json) {
$json->where('content', 'Draft without schedule')
->where('status', 'draft')
->where('scheduled_at', null)
->etc();
});

$post = Post::where('workspace_id', $this->workspace->id)
->where('content', 'Draft without schedule')
->firstOrFail();

expect($post->status->value)->toBe('draft')
->and($post->scheduled_at)->toBeNull();
});

test('create post with explicit null scheduled_at creates unscheduled draft', function () {
$response = TryPostServer::actingAs($this->user)
->tool(CreatePostTool::class, [
'content' => 'Draft with null schedule',
'scheduled_at' => null,
]);

$response->assertOk()
->assertStructuredContent(fn (AssertableJson $json) => $json
->where('status', 'draft')
->where('scheduled_at', null)
->etc());

expect(Post::where('workspace_id', $this->workspace->id)
->where('content', 'Draft with null schedule')
->firstOrFail()
->scheduled_at)->toBeNull();
});

test('create post with platforms enables only those', function () {
$response = TryPostServer::actingAs($this->user)
->tool(CreatePostTool::class, [
Expand All @@ -133,18 +177,20 @@
expect($enabled->first()->content_type->value)->toBe('linkedin_post');
});

test('create post without args creates empty draft for today', function () {
test('create post without args creates unscheduled empty draft', function () {
$response = TryPostServer::actingAs($this->user)
->tool(CreatePostTool::class, []);

$response->assertOk()
->assertStructuredContent(function (AssertableJson $json) {
$json->where('content', '')
->where('status', 'draft')
->where('scheduled_at', null)
->etc();
});

expect(Post::where('workspace_id', $this->workspace->id)->count())->toBe(1);
$post = Post::where('workspace_id', $this->workspace->id)->firstOrFail();
expect($post->scheduled_at)->toBeNull();
});

test('create post rejects scheduled_at in the past', function () {
Expand Down Expand Up @@ -338,6 +384,24 @@
->assertStructuredContent(fn (AssertableJson $json) => $json->where('platforms.0.meta.aspect_ratio', '4:5')->etc());
});

test('update post rejects scheduled status without scheduled_at', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);

$response = TryPostServer::actingAs($this->user)
->tool(UpdatePostTool::class, [
'post_id' => $post->id,
'status' => 'scheduled',
]);

$response->assertHasErrors();

expect($post->fresh()->status->value)->toBe('draft')
->and($post->fresh()->scheduled_at)->toBeNull();
});

test('update post accepts a valid aspect_ratio and persists it', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
Expand Down
4 changes: 2 additions & 2 deletions tests/Feature/PostControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -262,11 +262,11 @@
expect($post->postPlatforms)->toHaveCount(1);
});

test('store post defaults scheduled_at to today when no date is provided', function () {
test('store post leaves scheduled_at null when no date is provided', function () {
$this->actingAs($this->user)->post(route('app.posts.store'))->assertRedirect();

$post = Post::where('workspace_id', $this->workspace->id)->first();
expect($post->scheduled_at->format('Y-m-d'))->toBe(now('UTC')->format('Y-m-d'));
expect($post->scheduled_at)->toBeNull();
});

test('store post schedules draft on the date param when provided', function () {
Expand Down
4 changes: 2 additions & 2 deletions tests/Feature/PostTemplateControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -127,15 +127,15 @@
->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)
->postJson(route('app.post-templates.apply', 'success_story'))
->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 () {
Expand Down
38 changes: 38 additions & 0 deletions tests/Unit/Actions/Post/CreatePostTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

use App\Actions\Post\CreatePost;
use Carbon\CarbonImmutable;

function resolvedCreatePostScheduledAt(array $data): ?CarbonImmutable
{
$method = new ReflectionMethod(CreatePost::class, 'resolveScheduledAt');
$value = $method->invoke(null, $data);

return $value ? CarbonImmutable::instance($value) : null;
}

test('create post resolver does not default missing scheduled_at to today at 09 utc', function () {
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-07-26 15:02:37', 'UTC'));

expect(resolvedCreatePostScheduledAt([]))->toBeNull();

CarbonImmutable::setTestNow();
});

test('create post resolver treats explicit null scheduled_at as unscheduled', function () {
expect(resolvedCreatePostScheduledAt(['scheduled_at' => null]))->toBeNull();
});

test('create post resolver preserves explicit future scheduled_at in utc', function () {
$scheduledAt = resolvedCreatePostScheduledAt(['scheduled_at' => '2099-12-31T15:30:00+02:00']);

expect($scheduledAt?->format('Y-m-d H:i:s'))->toBe('2099-12-31 13:30:00');
});

test('create post resolver keeps explicit legacy date fallback at 09 utc', function () {
$scheduledAt = resolvedCreatePostScheduledAt(['date' => '2099-12-31']);

expect($scheduledAt?->format('Y-m-d H:i:s'))->toBe('2099-12-31 09:00:00');
});