From a2f98d551c261c54fa416efd6a06d60b08960b92 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 4 May 2026 13:31:44 -0300 Subject: [PATCH] test: add coverage for validation rules across REST + MCP + custom rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous suite asserted happy paths and a couple of basic field omissions but didn't probe the rules themselves. Adds 26 tests across 5 files: REST API (tests/Feature/Api/PostApiTest.php) — 9 new: - content_type not in the enum - content_type mismatched with the social account's platform - label_id from another workspace - platforms[].id from another post on update (cross-post leak) - content_type mismatched with the post_platform on update - status=scheduled requires future scheduled_at - status=draft works with no scheduled_at - past scheduled_at on store MCP create-post-tool (tests/Feature/Mcp/PostToolTest.php) — 5 new: - inactive social account - content_type not in the enum - content_type mismatched with the social account's platform - label_id from another workspace - already had: scheduled_at past MCP update-post-tool (tests/Feature/Mcp/PostPublishToolTest.php) — 2 new: - platforms[].id from another post (regression for the new Rule::exists scoping) - content_type mismatched with the post_platform MCP attach-media-from-url-tool (tests/Feature/Mcp/AttachMediaFromUrlToolTest.php) — 3 new: - non-http(s) scheme (ftp://...) - malformed url string - more than 10 URLs per call Custom rules unit tests — 2 new files: - ContentTypeMatchesPlatformTest covers happy path, cross-platform mismatch, the Instagram + InstagramFacebook compatibility bridge, and the no-op cases (missing account_id, unknown content_type — those are caught by Rule::in elsewhere). - ContentTypeMatchesPostPlatformTest covers the equivalent shape for the update flow that pivots through post_platform.id. --- tests/Feature/Api/PostApiTest.php | 125 ++++++++++++++++++ .../Mcp/AttachMediaFromUrlToolTest.php | 34 +++++ tests/Feature/Mcp/PostPublishToolTest.php | 51 +++++++ tests/Feature/Mcp/PostToolTest.php | 56 ++++++++ .../Rules/ContentTypeMatchesPlatformTest.php | 81 ++++++++++++ .../ContentTypeMatchesPostPlatformTest.php | 75 +++++++++++ 6 files changed, 422 insertions(+) create mode 100644 tests/Unit/Rules/ContentTypeMatchesPlatformTest.php create mode 100644 tests/Unit/Rules/ContentTypeMatchesPostPlatformTest.php 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([]); +});