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:
parent
3b96a9ebdb
commit
a2f98d551c
6 changed files with 422 additions and 0 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
81
tests/Unit/Rules/ContentTypeMatchesPlatformTest.php
Normal file
81
tests/Unit/Rules/ContentTypeMatchesPlatformTest.php
Normal 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([]);
|
||||
});
|
||||
75
tests/Unit/Rules/ContentTypeMatchesPostPlatformTest.php
Normal file
75
tests/Unit/Rules/ContentTypeMatchesPostPlatformTest.php
Normal 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([]);
|
||||
});
|
||||
Loading…
Reference in a new issue