test: add coverage for validation rules across REST + MCP + custom rules

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.
This commit is contained in:
Paulo Castellano 2026-05-04 13:31:44 -03:00
parent 3b96a9ebdb
commit a2f98d551c
6 changed files with 422 additions and 0 deletions

View file

@ -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,

View file

@ -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();
});

View file

@ -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 () {

View file

@ -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,

View file

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Models\SocialAccount;
use App\Models\Workspace;
use App\Rules\ContentTypeMatchesPlatform;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function runMatchesPlatformRule(string $contentType, ?string $accountId, array $extraData = []): array
{
$errors = [];
$rule = (new ContentTypeMatchesPlatform)->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([]);
});

View file

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\Workspace;
use App\Rules\ContentTypeMatchesPostPlatform;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function runMatchesPostPlatformRule(string $contentType, ?string $postPlatformId): array
{
$errors = [];
$rule = (new ContentTypeMatchesPostPlatform)->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([]);
});