diff --git a/tests/Feature/Api/PostApiTest.php b/tests/Feature/Api/PostApiTest.php index 832ac9e1..27897358 100644 --- a/tests/Feature/Api/PostApiTest.php +++ b/tests/Feature/Api/PostApiTest.php @@ -297,6 +297,131 @@ ->assertJsonValidationErrors(['label_ids.0']); }); +it('rejects creating a post with content_type not in the enum', function () { + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->postJson(route('api.posts.store'), [ + 'platforms' => [ + ['social_account_id' => $this->socialAccount->id, 'content_type' => 'made_up_type'], + ], + ]) + ->assertJsonValidationErrors(['platforms.0.content_type']); +}); + +it('rejects creating a post when content_type does not match the social account platform', function () { + // x_post on a LinkedIn account — ContentTypeMatchesPlatform should reject. + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->postJson(route('api.posts.store'), [ + 'platforms' => [ + ['social_account_id' => $this->socialAccount->id, 'content_type' => 'x_post'], + ], + ]) + ->assertJsonValidationErrors(['platforms.0.content_type']); +}); + +it('rejects creating a post with a label from another workspace', function () { + $otherWorkspace = Workspace::factory()->create(); + $foreignLabel = WorkspaceLabel::factory()->create(['workspace_id' => $otherWorkspace->id]); + + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->postJson(route('api.posts.store'), [ + 'platforms' => [ + ['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'], + ], + 'label_ids' => [$foreignLabel->id], + ]) + ->assertJsonValidationErrors(['label_ids.0']); +}); + +it('rejects updating a post with a platforms[].id that belongs to another post', function () { + $myPost = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Draft, + ]); + + $otherPost = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Draft, + ]); + $foreignPlatform = PostPlatform::factory()->linkedin()->create([ + 'post_id' => $otherPost->id, + 'social_account_id' => $this->socialAccount->id, + ]); + + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->putJson(route('api.posts.update', $myPost), [ + 'status' => 'draft', + 'platforms' => [ + ['id' => $foreignPlatform->id, 'content_type' => ContentType::LinkedInPost->value], + ], + ]) + ->assertJsonValidationErrors(['platforms.0.id']); +}); + +it('rejects updating a post when content_type does not match the post_platform', 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' => 'x_post'], + ], + ]) + ->assertJsonValidationErrors(['platforms.0.content_type']); +}); + +it('rejects scheduled status without a future scheduled_at', function () { + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Draft, + 'scheduled_at' => now()->subDay(), + ]); + + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->putJson(route('api.posts.update', $post), [ + 'status' => 'scheduled', + 'scheduled_at' => now()->subHour()->toIso8601String(), + ]) + ->assertJsonValidationErrors(['scheduled_at']); +}); + +it('accepts draft status with no scheduled_at', function () { + $post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Draft, + ]); + + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->putJson(route('api.posts.update', $post), [ + 'status' => 'draft', + ]) + ->assertOk(); +}); + +it('rejects creating a post with a past scheduled_at', function () { + $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken]) + ->postJson(route('api.posts.store'), [ + 'platforms' => [ + ['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'], + ], + 'scheduled_at' => now()->subDay()->toIso8601String(), + ]) + ->assertJsonValidationErrors(['scheduled_at']); +}); + it('list posts returns correct structure', function () { Post::factory()->create([ 'workspace_id' => $this->workspace->id, diff --git a/tests/Feature/Mcp/AttachMediaFromUrlToolTest.php b/tests/Feature/Mcp/AttachMediaFromUrlToolTest.php index 76192ae1..ddfe710e 100644 --- a/tests/Feature/Mcp/AttachMediaFromUrlToolTest.php +++ b/tests/Feature/Mcp/AttachMediaFromUrlToolTest.php @@ -102,3 +102,37 @@ $response->assertHasErrors(['Post not found.']); }); + +test('rejects urls with non-http(s) schemes', function () { + $response = TryPostServer::actingAs($this->user) + ->tool(AttachMediaFromUrlTool::class, [ + 'post_id' => $this->post->id, + 'urls' => ['ftp://cdn.example.com/photo.jpg'], + ]); + + $response->assertHasErrors(); + + expect($this->post->fresh()->media)->toBeEmpty(); +}); + +test('rejects malformed url strings', function () { + $response = TryPostServer::actingAs($this->user) + ->tool(AttachMediaFromUrlTool::class, [ + 'post_id' => $this->post->id, + 'urls' => ['not-a-url-at-all'], + ]); + + $response->assertHasErrors(); +}); + +test('rejects more than 10 urls per call', function () { + $urls = collect(range(1, 11))->map(fn ($i) => "https://cdn.example.com/photo-{$i}.jpg")->all(); + + $response = TryPostServer::actingAs($this->user) + ->tool(AttachMediaFromUrlTool::class, [ + 'post_id' => $this->post->id, + 'urls' => $urls, + ]); + + $response->assertHasErrors(); +}); diff --git a/tests/Feature/Mcp/PostPublishToolTest.php b/tests/Feature/Mcp/PostPublishToolTest.php index a75fafb2..c3b29b90 100644 --- a/tests/Feature/Mcp/PostPublishToolTest.php +++ b/tests/Feature/Mcp/PostPublishToolTest.php @@ -119,6 +119,57 @@ $response->assertHasErrors(['Cannot edit a published post.']); }); +test('update post rejects a platforms[].id that belongs to another post', function () { + $myPost = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Draft, + ]); + + $otherPost = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'status' => PostStatus::Draft, + ]); + $foreignPlatform = PostPlatform::factory()->linkedin()->create([ + 'post_id' => $otherPost->id, + 'social_account_id' => $this->socialAccount->id, + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(UpdatePostTool::class, [ + 'post_id' => $myPost->id, + 'platforms' => [ + ['id' => $foreignPlatform->id, 'content_type' => ContentType::LinkedInPost->value], + ], + ]); + + $response->assertHasErrors(); +}); + +test('update post rejects a content_type that does not match the post_platform', 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, + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(UpdatePostTool::class, [ + 'post_id' => $post->id, + 'platforms' => [ + ['id' => $postPlatform->id, 'content_type' => 'x_post'], + ], + ]); + + $response->assertHasErrors(); +}); + // PublishPostTool test('publish post immediate dispatches PublishPost job', function () { diff --git a/tests/Feature/Mcp/PostToolTest.php b/tests/Feature/Mcp/PostToolTest.php index df7977d0..07bdb5f0 100644 --- a/tests/Feature/Mcp/PostToolTest.php +++ b/tests/Feature/Mcp/PostToolTest.php @@ -13,6 +13,7 @@ use App\Models\SocialAccount; use App\Models\User; use App\Models\Workspace; +use App\Models\WorkspaceLabel; use Illuminate\Testing\Fluent\AssertableJson; beforeEach(function () { @@ -148,6 +149,61 @@ $response->assertHasErrors(); }); +test('create post rejects an inactive social account', function () { + $inactive = SocialAccount::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'platform' => Platform::LinkedIn, + 'is_active' => false, + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(CreatePostTool::class, [ + 'platforms' => [ + ['social_account_id' => $inactive->id, 'content_type' => 'linkedin_post'], + ], + ]); + + $response->assertHasErrors(); +}); + +test('create post rejects a content_type not in the enum', function () { + $response = TryPostServer::actingAs($this->user) + ->tool(CreatePostTool::class, [ + 'platforms' => [ + ['social_account_id' => $this->socialAccount->id, 'content_type' => 'made_up_type'], + ], + ]); + + $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) + ->tool(CreatePostTool::class, [ + 'platforms' => [ + ['social_account_id' => $this->socialAccount->id, 'content_type' => 'x_post'], + ], + ]); + + $response->assertHasErrors(); +}); + +test('create post rejects a label_id from another workspace', function () { + $otherWorkspace = Workspace::factory()->create(); + $foreignLabel = WorkspaceLabel::factory()->create(['workspace_id' => $otherWorkspace->id]); + + $response = TryPostServer::actingAs($this->user) + ->tool(CreatePostTool::class, [ + 'platforms' => [ + ['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'], + ], + 'label_ids' => [$foreignLabel->id], + ]); + + $response->assertHasErrors(); +}); + test('delete post removes from db', function () { $post = Post::factory()->create([ 'workspace_id' => $this->workspace->id, diff --git a/tests/Unit/Rules/ContentTypeMatchesPlatformTest.php b/tests/Unit/Rules/ContentTypeMatchesPlatformTest.php new file mode 100644 index 00000000..8a0fce74 --- /dev/null +++ b/tests/Unit/Rules/ContentTypeMatchesPlatformTest.php @@ -0,0 +1,81 @@ +setData(array_merge([ + 'platforms' => [ + ['social_account_id' => $accountId, 'content_type' => $contentType], + ], + ], $extraData)); + + $rule->validate('platforms.0.content_type', $contentType, function (string $message) use (&$errors): void { + $errors[] = $message; + }); + + return $errors; +} + +test('passes when content_type matches the social account platform', function () { + $workspace = Workspace::factory()->create(); + $linkedin = SocialAccount::factory()->create([ + 'workspace_id' => $workspace->id, + 'platform' => Platform::LinkedIn, + ]); + + expect(runMatchesPlatformRule(ContentType::LinkedInPost->value, $linkedin->id))->toBe([]); + expect(runMatchesPlatformRule(ContentType::LinkedInCarousel->value, $linkedin->id))->toBe([]); +}); + +test('fails when content_type belongs to a different platform', function () { + $workspace = Workspace::factory()->create(); + $linkedin = SocialAccount::factory()->create([ + 'workspace_id' => $workspace->id, + 'platform' => Platform::LinkedIn, + ]); + + $errors = runMatchesPlatformRule(ContentType::XPost->value, $linkedin->id); + + expect($errors)->toHaveCount(1); + expect($errors[0])->toContain('not compatible'); +}); + +test('passes when an instagram content_type is paired with an instagram-facebook account', function () { + $workspace = Workspace::factory()->create(); + $igFacebook = SocialAccount::factory()->create([ + 'workspace_id' => $workspace->id, + 'platform' => Platform::InstagramFacebook, + ]); + + // instagram_feed lists Instagram as its primary platform but is also + // compatible with InstagramFacebook accounts via compatiblePlatforms(). + expect(runMatchesPlatformRule(ContentType::InstagramFeed->value, $igFacebook->id))->toBe([]); + expect(runMatchesPlatformRule(ContentType::InstagramReel->value, $igFacebook->id))->toBe([]); +}); + +test('skips validation when social_account_id is missing', function () { + expect(runMatchesPlatformRule(ContentType::XPost->value, null))->toBe([]); +}); + +test('skips validation when content_type is not a known enum value', function () { + $workspace = Workspace::factory()->create(); + $linkedin = SocialAccount::factory()->create([ + 'workspace_id' => $workspace->id, + 'platform' => Platform::LinkedIn, + ]); + + // Unknown content_types are caught by Rule::in elsewhere; this rule + // intentionally no-ops so it doesn't double-report. + expect(runMatchesPlatformRule('completely_made_up', $linkedin->id))->toBe([]); +}); diff --git a/tests/Unit/Rules/ContentTypeMatchesPostPlatformTest.php b/tests/Unit/Rules/ContentTypeMatchesPostPlatformTest.php new file mode 100644 index 00000000..cb4f22db --- /dev/null +++ b/tests/Unit/Rules/ContentTypeMatchesPostPlatformTest.php @@ -0,0 +1,75 @@ +setData([ + 'platforms' => [ + ['id' => $postPlatformId, 'content_type' => $contentType], + ], + ]); + + $rule->validate('platforms.0.content_type', $contentType, function (string $message) use (&$errors): void { + $errors[] = $message; + }); + + return $errors; +} + +test('passes when content_type matches the post_platform social account', function () { + $workspace = Workspace::factory()->create(); + $linkedin = SocialAccount::factory()->create([ + 'workspace_id' => $workspace->id, + 'platform' => Platform::LinkedIn, + ]); + $post = Post::factory()->create(['workspace_id' => $workspace->id]); + $postPlatform = PostPlatform::factory()->linkedin()->create([ + 'post_id' => $post->id, + 'social_account_id' => $linkedin->id, + ]); + + expect(runMatchesPostPlatformRule(ContentType::LinkedInPost->value, $postPlatform->id))->toBe([]); + expect(runMatchesPostPlatformRule(ContentType::LinkedInCarousel->value, $postPlatform->id))->toBe([]); +}); + +test('fails when content_type belongs to a different platform than the post_platform', function () { + $workspace = Workspace::factory()->create(); + $linkedin = SocialAccount::factory()->create([ + 'workspace_id' => $workspace->id, + 'platform' => Platform::LinkedIn, + ]); + $post = Post::factory()->create(['workspace_id' => $workspace->id]); + $postPlatform = PostPlatform::factory()->linkedin()->create([ + 'post_id' => $post->id, + 'social_account_id' => $linkedin->id, + ]); + + $errors = runMatchesPostPlatformRule(ContentType::XPost->value, $postPlatform->id); + + expect($errors)->toHaveCount(1); + expect($errors[0])->toContain('not compatible'); +}); + +test('skips validation when platform id is missing', function () { + expect(runMatchesPostPlatformRule(ContentType::XPost->value, null))->toBe([]); +}); + +test('skips validation when post_platform does not exist', function () { + // Valid-format UUID that does not exist in the database. The rule + // intentionally no-ops so the missing-resource error is reported by + // the surrounding Rule::exists check rather than this rule. + expect(runMatchesPostPlatformRule(ContentType::XPost->value, '00000000-0000-0000-0000-000000000000'))->toBe([]); +});