+
{{ formatTime(post.scheduled_at) }}
{
+{{ post.post_platforms.length - 3 }}
+
+
+ {{ post.content?.trim() || $t('calendar.no_content') }}
+
assertJsonValidationErrors(['format']);
});
+test('start accepts instagram_carousel as a generation format', function () {
+ Bus::fake();
+
+ $account = SocialAccount::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'platform' => Platform::Instagram,
+ ]);
+
+ $this->actingAs($this->user)
+ ->postJson(route('app.posts.ai.create'), [
+ 'prompt' => 'Five tips about productivity',
+ 'format' => 'instagram_carousel',
+ 'social_account_id' => $account->id,
+ 'image_count' => 5,
+ ])
+ ->assertStatus(Response::HTTP_ACCEPTED);
+
+ Bus::assertDispatched(StreamPostCreation::class, fn ($job) => $job->format === 'instagram_carousel');
+});
+
test('start rejects social_account_id from another workspace', function () {
Bus::fake();
diff --git a/tests/Feature/Ai/StreamPostCreationTest.php b/tests/Feature/Ai/StreamPostCreationTest.php
new file mode 100644
index 00000000..55963bd2
--- /dev/null
+++ b/tests/Feature/Ai/StreamPostCreationTest.php
@@ -0,0 +1,91 @@
+user = User::factory()->create();
+ $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
+ $this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
+ $this->user->update(['current_workspace_id' => $this->workspace->id]);
+
+ $this->account = SocialAccount::factory()->instagram()->create([
+ 'workspace_id' => $this->workspace->id,
+ ]);
+});
+
+function runStreamPostCreation(string $format, SocialAccount $account, int $imageCount): void
+{
+ (new StreamPostCreation(
+ userId: $account->workspace->user_id,
+ creationId: (string) Str::uuid(),
+ workspaceId: $account->workspace_id,
+ format: $format,
+ socialAccountId: $account->id,
+ imageCount: $imageCount,
+ prompt: 'Five tips about productivity',
+ ))->handle();
+}
+
+test('AI carousel generation stores the post as an instagram feed, never instagram_carousel', function () {
+ // Empty image_keywords make TemplateImageGenerator::render() return null, so
+ // the storage decision is exercised without touching the image pipeline.
+ PostContentGenerator::fake([[
+ 'caption' => 'Swipe to see the tips',
+ 'slides' => [
+ ['title' => 'Tip 1', 'body' => 'First tip', 'image_keywords' => []],
+ ['title' => 'Tip 2', 'body' => 'Second tip', 'image_keywords' => []],
+ ],
+ ]]);
+ PostContentHumanizer::fake([[
+ 'caption' => 'Swipe to see the tips',
+ 'slides' => [
+ ['title' => 'Tip 1', 'body' => 'First tip'],
+ ['title' => 'Tip 2', 'body' => 'Second tip'],
+ ],
+ ]]);
+
+ runStreamPostCreation('instagram_carousel', $this->account, 2);
+
+ $platform = PostPlatform::where('social_account_id', $this->account->id)->firstOrFail();
+
+ expect($platform->content_type)->toBe(ContentType::InstagramFeed);
+ expect($platform->meta['aspect_ratio'] ?? null)->toBe('4:5');
+});
+
+test('AI single feed generation stores the post as an instagram feed', function () {
+ PostContentGenerator::fake([[
+ 'content' => 'A single productivity tip',
+ 'image_title' => 'Tip',
+ 'image_body' => 'Do less',
+ 'image_keywords' => [],
+ ]]);
+ PostContentHumanizer::fake([[
+ 'content' => 'A single productivity tip',
+ 'image_title' => 'Tip',
+ 'image_body' => 'Do less',
+ ]]);
+
+ runStreamPostCreation('instagram_feed', $this->account, 0);
+
+ $platform = PostPlatform::where('social_account_id', $this->account->id)->firstOrFail();
+
+ expect($platform->content_type)->toBe(ContentType::InstagramFeed);
+});
diff --git a/tests/Feature/Api/PostApiTest.php b/tests/Feature/Api/PostApiTest.php
index 592ed299..5c6018a8 100644
--- a/tests/Feature/Api/PostApiTest.php
+++ b/tests/Feature/Api/PostApiTest.php
@@ -167,6 +167,39 @@
->assertOk();
});
+it('rejects creating a post with instagram_carousel — carousel is not a stored content_type', function () {
+ $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
+ ->postJson(route('api.posts.store'), [
+ 'platforms' => [
+ ['social_account_id' => $this->socialAccount->id, 'content_type' => 'instagram_carousel'],
+ ],
+ ])
+ ->assertJsonValidationErrors(['platforms.0.content_type']);
+});
+
+it('rejects updating a post with instagram_carousel — carousel is not a stored content_type', function () {
+ $post = Post::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'user_id' => $this->user->id,
+ 'status' => PostStatus::Draft,
+ ]);
+
+ $postPlatform = PostPlatform::factory()->linkedin()->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' => 'draft',
+ 'platforms' => [
+ ['id' => $postPlatform->id, 'content_type' => 'instagram_carousel'],
+ ],
+ ])
+ ->assertJsonValidationErrors(['platforms.0.content_type']);
+});
+
it('cannot update post from another workspace', function () {
$otherWorkspace = Workspace::factory()->create();
$otherSocialAccount = SocialAccount::factory()->create([
diff --git a/tests/Feature/Mcp/PostToolTest.php b/tests/Feature/Mcp/PostToolTest.php
index 07bdb5f0..f06b46d7 100644
--- a/tests/Feature/Mcp/PostToolTest.php
+++ b/tests/Feature/Mcp/PostToolTest.php
@@ -9,7 +9,9 @@
use App\Mcp\Tools\Post\DeletePostTool;
use App\Mcp\Tools\Post\GetPostTool;
use App\Mcp\Tools\Post\ListPostsTool;
+use App\Mcp\Tools\Post\UpdatePostTool;
use App\Models\Post;
+use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
@@ -177,6 +179,38 @@
$response->assertHasErrors();
});
+test('create post rejects instagram_carousel — carousel is not a stored content_type', function () {
+ $response = TryPostServer::actingAs($this->user)
+ ->tool(CreatePostTool::class, [
+ 'platforms' => [
+ ['social_account_id' => $this->socialAccount->id, 'content_type' => 'instagram_carousel'],
+ ],
+ ]);
+
+ $response->assertHasErrors();
+});
+
+test('update post rejects instagram_carousel — carousel is not a stored content_type', function () {
+ $post = Post::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'user_id' => $this->user->id,
+ ]);
+ $platform = PostPlatform::factory()->create([
+ 'post_id' => $post->id,
+ 'social_account_id' => $this->socialAccount->id,
+ ]);
+
+ $response = TryPostServer::actingAs($this->user)
+ ->tool(UpdatePostTool::class, [
+ 'post_id' => $post->id,
+ 'platforms' => [
+ ['id' => $platform->id, 'content_type' => 'instagram_carousel'],
+ ],
+ ]);
+
+ $response->assertHasErrors();
+});
+
test('create post rejects a content_type that does not match the social account platform', function () {
// x_post on a LinkedIn account — ContentTypeMatchesPlatform should reject.
$response = TryPostServer::actingAs($this->user)
diff --git a/tests/Feature/PostControllerTest.php b/tests/Feature/PostControllerTest.php
index 628b0f49..a7da0b14 100644
--- a/tests/Feature/PostControllerTest.php
+++ b/tests/Feature/PostControllerTest.php
@@ -176,6 +176,27 @@
);
});
+test('calendar payload exposes post content for rendering', function () {
+ $scheduledAt = now('UTC')->startOfWeek()->addDays(2)->setTime(12, 0);
+
+ Post::factory()->create([
+ 'workspace_id' => $this->workspace->id,
+ 'user_id' => $this->user->id,
+ 'content' => 'Caption visible in the calendar',
+ 'status' => PostStatus::Scheduled,
+ 'scheduled_at' => $scheduledAt,
+ ]);
+
+ $dateKey = $scheduledAt->format('Y-m-d');
+
+ $response = $this->actingAs($this->user)->get(route('app.calendar'));
+
+ $response->assertOk();
+ $response->assertInertia(fn ($page) => $page
+ ->where("posts.{$dateKey}.0.content", 'Caption visible in the calendar')
+ );
+});
+
// Create tests
test('create requires authentication', function () {
$response = $this->get(route('app.posts.create'));
diff --git a/tests/Feature/UpdatePostRequestTest.php b/tests/Feature/UpdatePostRequestTest.php
index 74b740e3..5bba5d0b 100644
--- a/tests/Feature/UpdatePostRequestTest.php
+++ b/tests/Feature/UpdatePostRequestTest.php
@@ -525,3 +525,19 @@
expect(data_get($this->post->media, '0.source'))->toBe('ai');
expect(data_get($this->post->media, '0.source_meta.title'))->toBe('Fix ECP typo');
});
+
+test('instagram_carousel is rejected as a content_type — carousel is a feed post with multiple images', function () {
+ $response = $this->actingAs($this->user)
+ ->put(route('app.posts.update', $this->post), [
+ 'status' => Status::Draft->value,
+ 'platforms' => [
+ [
+ 'id' => $this->postPlatform->id,
+ 'content_type' => 'instagram_carousel',
+ 'meta' => [],
+ ],
+ ],
+ ]);
+
+ $response->assertSessionHasErrors('platforms.0.content_type');
+});
diff --git a/tests/Feature/WorkspaceControllerTest.php b/tests/Feature/WorkspaceControllerTest.php
index 1922f0c5..aa0ed4f2 100644
--- a/tests/Feature/WorkspaceControllerTest.php
+++ b/tests/Feature/WorkspaceControllerTest.php
@@ -232,6 +232,27 @@
expect($this->workspace->refresh()->image_style->value)->toBe('minimalist');
});
+test('update workspace settings updates the name when only the name is submitted', function () {
+ $this->actingAs($this->user)
+ ->from(route('app.workspace.settings'))
+ ->put(route('app.workspace.settings.update'), [
+ 'name' => 'Name Only Update',
+ ])
+ ->assertRedirect(route('app.workspace.settings'))
+ ->assertSessionHasNoErrors();
+
+ expect($this->workspace->refresh()->name)->toBe('Name Only Update');
+});
+
+test('update workspace settings still requires brand_font and image_style when submitted empty', function () {
+ $this->actingAs($this->user)
+ ->put(route('app.workspace.settings.update'), [
+ 'name' => $this->workspace->name,
+ 'brand_font' => '',
+ 'image_style' => '',
+ ])->assertSessionHasErrors(['brand_font', 'image_style']);
+});
+
test('update workspace settings rejects unknown image_style values', function () {
$this->actingAs($this->user)
->put(route('app.workspace.settings.update'), [
diff --git a/tests/Unit/Enums/ContentTypeTest.php b/tests/Unit/Enums/ContentTypeTest.php
index b2a34d3c..7a771504 100644
--- a/tests/Unit/Enums/ContentTypeTest.php
+++ b/tests/Unit/Enums/ContentTypeTest.php
@@ -53,9 +53,12 @@
expect(ContentType::XPost->aspectRatio())->toBeNull();
});
+test('instagram_carousel is not a content type — it is an AI generation format only', function () {
+ expect(ContentType::tryFrom('instagram_carousel'))->toBeNull();
+});
+
test('content type has correct max media count', function () {
- expect(ContentType::InstagramFeed->maxMediaCount())->toBe(1);
- expect(ContentType::InstagramCarousel->maxMediaCount())->toBe(10);
+ expect(ContentType::InstagramFeed->maxMediaCount())->toBe(10);
expect(ContentType::InstagramReel->maxMediaCount())->toBe(1);
expect(ContentType::LinkedInCarousel->maxMediaCount())->toBe(20);
expect(ContentType::XPost->maxMediaCount())->toBe(4);