trypost/tests/Feature/Api/PostApiTest.php

771 lines
28 KiB
PHP
Raw Normal View History

<?php
declare(strict_types=1);
use App\Enums\Post\CreatedVia;
use App\Enums\Post\Status as PostStatus;
use App\Enums\PostPlatform\ContentType;
use App\Enums\SocialAccount\Platform;
fix: keep post drafts unscheduled by default (#209) * fix: keep post drafts unscheduled by default * Align schedule validation and keep drafts unscheduled. Require scheduled_at only when status is scheduled and the post has no usable future schedule. Share that rule across web, API, and MCP, keep create without a date as null, and preserve the legacy date → 09:00 UTC fallback. * Polish schedule validation typing and tests. Type requiresExplicitSchedule status as ?string, reuse a local status variable in request/tool validation, tighten the web reject assertion, and collapse overlapping MCP unscheduled-create cases. * Centralize status helper in post update validation. Reuse the typed status() helper across FormRequests and the already-parsed $status in UpdatePostTool so schedule checks stay consistent and less noisy. * Share scheduled_at update rules across web, API, and MCP. Centralize schedule validation in PostStatusRules, normalize status parsing in one place, and align past-schedule coverage across entry points. * Cover the full unscheduled-draft checklist in Pest. Add feature coverage for null/past schedule rejection, explicit scheduling, draft saves, publish-now without a schedule, calendar exclusion, and 09:00 UTC date defaults across web, API, and MCP. * Remove normalizeStatus helper. Keep the inline is_string check at the few call sites that read raw request status before validation — no shared wrapper needed. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop is_string status guards from schedule validation. Accept mixed status in PostStatusRules and rely on strict comparisons with Rule::requiredIf / Rule::when — malformed input simply does not match. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Paulo Castellano <paulo@castellanos.llc>
2026-08-01 20:39:18 +00:00
use App\Jobs\PublishPost;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\Workspace;
fix: address PR review findings — publish, REST store, SSRF, race Code-review surfaced two correctness bugs and a security gap that needed to land before merging. - UpdatePost::execute disabled every platform when called without a `platforms` key. PublishPostTool relied on that path, so every publish-via-MCP queued a job whose handler then found nothing enabled to publish to. Wrap the platform toggle in `Arr::has($data, 'platforms')` (matches the existing label_ids guard a few lines up). Add a regression assertion to `PostPublishToolTest::publish post immediate dispatches PublishPost job` that the previously-enabled platform stays enabled. - StorePostRequest declared rules for only `platforms`, `scheduled_at`, and `status`. `validated()` then stripped `content`, `media`, and `label_ids`, so REST `POST /api/posts` silently created empty drafts. Added rules for content / media / label_ids (with workspace-scoped `Rule::exists` for labels) and dropped the unused `status` field — REST callers transition state via `PUT /posts/{id}`. Removed the dead `platforms.*.content` rule. Added a feature test that asserts content + media + labels roundtrip on create, plus a regression that an `is_active=false` social_account is rejected at validation. - CreatePost::execute now syncs label_ids itself so REST and MCP share the behavior. Removed the duplicate sync from CreatePostTool. - MCP UpdatePostTool didn't scope `platforms.*.id` to the post being updated, drifting from the REST UpdatePostRequest which adds `Rule::exists('post_platforms','id')->where('post_id', ...)`. Now it loads the post first (failing fast with `Post not found.` if the workspace check rejects), then uses the same Rule::exists. - MediaAttacher fetched any URL the caller passed, including loopback / link-local / private targets — classic SSRF pivot. Now `isPublicHttpUrl` rejects non-http(s) schemes, restricted IP ranges, and DNS hostnames whose A/AAAA records resolve into those ranges (covers DNS rebinding). Bypassed under `app()->runningUnitTests()` so `Http::fake()` keeps working. Streaming the response body lets us abort early once we exceed MAX_BYTES instead of buffering the full payload first; redirects are disabled so a 200→302 trick can't bypass the host check. - The `media[]` JSON column had a lost-update race in `attachFromUrls`: read `$post->media`, mutate in PHP, write back. Two concurrent calls clobbered each other. Now wrapped in a transaction with `lockForUpdate()`. - ESLint: `resources/js/actions/**` and `resources/js/routes/**` are auto-generated by Wayfinder on every build. Their import order matches PHP scan order, not alphabetical, so import/order fought eslint-fix forever. Added them to ignores.
2026-05-04 15:16:39 +00:00
use App\Models\WorkspaceLabel;
fix: keep post drafts unscheduled by default (#209) * fix: keep post drafts unscheduled by default * Align schedule validation and keep drafts unscheduled. Require scheduled_at only when status is scheduled and the post has no usable future schedule. Share that rule across web, API, and MCP, keep create without a date as null, and preserve the legacy date → 09:00 UTC fallback. * Polish schedule validation typing and tests. Type requiresExplicitSchedule status as ?string, reuse a local status variable in request/tool validation, tighten the web reject assertion, and collapse overlapping MCP unscheduled-create cases. * Centralize status helper in post update validation. Reuse the typed status() helper across FormRequests and the already-parsed $status in UpdatePostTool so schedule checks stay consistent and less noisy. * Share scheduled_at update rules across web, API, and MCP. Centralize schedule validation in PostStatusRules, normalize status parsing in one place, and align past-schedule coverage across entry points. * Cover the full unscheduled-draft checklist in Pest. Add feature coverage for null/past schedule rejection, explicit scheduling, draft saves, publish-now without a schedule, calendar exclusion, and 09:00 UTC date defaults across web, API, and MCP. * Remove normalizeStatus helper. Keep the inline is_string check at the few call sites that read raw request status before validation — no shared wrapper needed. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop is_string status guards from schedule validation. Accept mixed status in PostStatusRules and rely on strict comparisons with Rule::requiredIf / Rule::when — malformed input simply does not match. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Paulo Castellano <paulo@castellanos.llc>
2026-08-01 20:39:18 +00:00
use Illuminate\Support\Facades\Bus;
beforeEach(function () {
$result = createApiTestToken();
$this->user = $result['user'];
$this->workspace = $result['workspace'];
$this->plainToken = $result['plain_token'];
$this->socialAccount = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::LinkedIn,
]);
});
it('lists posts', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->getJson(route('api.posts.index'))
->assertOk()
->assertJsonCount(1, 'data');
});
it('shows a post', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->getJson(route('api.posts.show', $post))
->assertOk()
->assertJsonPath('id', $post->id);
});
it('cannot show post from another workspace', function () {
$otherWorkspace = Workspace::factory()->create();
$post = Post::factory()->create([
'workspace_id' => $otherWorkspace->id,
'user_id' => $this->user->id,
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->getJson(route('api.posts.show', $post))
->assertNotFound();
});
it('creates a post', function () {
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.store'), [
'platforms' => [
[
'social_account_id' => $this->socialAccount->id,
'content_type' => 'linkedin_post',
],
],
])
->assertCreated()
fix: keep post drafts unscheduled by default (#209) * fix: keep post drafts unscheduled by default * Align schedule validation and keep drafts unscheduled. Require scheduled_at only when status is scheduled and the post has no usable future schedule. Share that rule across web, API, and MCP, keep create without a date as null, and preserve the legacy date → 09:00 UTC fallback. * Polish schedule validation typing and tests. Type requiresExplicitSchedule status as ?string, reuse a local status variable in request/tool validation, tighten the web reject assertion, and collapse overlapping MCP unscheduled-create cases. * Centralize status helper in post update validation. Reuse the typed status() helper across FormRequests and the already-parsed $status in UpdatePostTool so schedule checks stay consistent and less noisy. * Share scheduled_at update rules across web, API, and MCP. Centralize schedule validation in PostStatusRules, normalize status parsing in one place, and align past-schedule coverage across entry points. * Cover the full unscheduled-draft checklist in Pest. Add feature coverage for null/past schedule rejection, explicit scheduling, draft saves, publish-now without a schedule, calendar exclusion, and 09:00 UTC date defaults across web, API, and MCP. * Remove normalizeStatus helper. Keep the inline is_string check at the few call sites that read raw request status before validation — no shared wrapper needed. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop is_string status guards from schedule validation. Accept mixed status in PostStatusRules and rely on strict comparisons with Rule::requiredIf / Rule::when — malformed input simply does not match. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Paulo Castellano <paulo@castellanos.llc>
2026-08-01 20:39:18 +00:00
->assertJsonPath('status', PostStatus::Draft->value)
->assertJsonPath('scheduled_at', null);
$post = Post::where('workspace_id', $this->workspace->id)->first();
expect($post)->not->toBeNull();
expect($post->created_via)->toBe(CreatedVia::Api);
fix: keep post drafts unscheduled by default (#209) * fix: keep post drafts unscheduled by default * Align schedule validation and keep drafts unscheduled. Require scheduled_at only when status is scheduled and the post has no usable future schedule. Share that rule across web, API, and MCP, keep create without a date as null, and preserve the legacy date → 09:00 UTC fallback. * Polish schedule validation typing and tests. Type requiresExplicitSchedule status as ?string, reuse a local status variable in request/tool validation, tighten the web reject assertion, and collapse overlapping MCP unscheduled-create cases. * Centralize status helper in post update validation. Reuse the typed status() helper across FormRequests and the already-parsed $status in UpdatePostTool so schedule checks stay consistent and less noisy. * Share scheduled_at update rules across web, API, and MCP. Centralize schedule validation in PostStatusRules, normalize status parsing in one place, and align past-schedule coverage across entry points. * Cover the full unscheduled-draft checklist in Pest. Add feature coverage for null/past schedule rejection, explicit scheduling, draft saves, publish-now without a schedule, calendar exclusion, and 09:00 UTC date defaults across web, API, and MCP. * Remove normalizeStatus helper. Keep the inline is_string check at the few call sites that read raw request status before validation — no shared wrapper needed. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop is_string status guards from schedule validation. Accept mixed status in PostStatusRules and rely on strict comparisons with Rule::requiredIf / Rule::when — malformed input simply does not match. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Paulo Castellano <paulo@castellanos.llc>
2026-08-01 20:39:18 +00:00
expect($post->scheduled_at)->toBeNull();
});
it('ignores a client-supplied created_via and always records api', function () {
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.store'), [
'created_via' => 'web',
'platforms' => [
[
'social_account_id' => $this->socialAccount->id,
'content_type' => 'linkedin_post',
],
],
])
->assertCreated();
$post = Post::where('workspace_id', $this->workspace->id)->first();
expect($post->created_via)->toBe(CreatedVia::Api);
});
fix: address PR review findings — publish, REST store, SSRF, race Code-review surfaced two correctness bugs and a security gap that needed to land before merging. - UpdatePost::execute disabled every platform when called without a `platforms` key. PublishPostTool relied on that path, so every publish-via-MCP queued a job whose handler then found nothing enabled to publish to. Wrap the platform toggle in `Arr::has($data, 'platforms')` (matches the existing label_ids guard a few lines up). Add a regression assertion to `PostPublishToolTest::publish post immediate dispatches PublishPost job` that the previously-enabled platform stays enabled. - StorePostRequest declared rules for only `platforms`, `scheduled_at`, and `status`. `validated()` then stripped `content`, `media`, and `label_ids`, so REST `POST /api/posts` silently created empty drafts. Added rules for content / media / label_ids (with workspace-scoped `Rule::exists` for labels) and dropped the unused `status` field — REST callers transition state via `PUT /posts/{id}`. Removed the dead `platforms.*.content` rule. Added a feature test that asserts content + media + labels roundtrip on create, plus a regression that an `is_active=false` social_account is rejected at validation. - CreatePost::execute now syncs label_ids itself so REST and MCP share the behavior. Removed the duplicate sync from CreatePostTool. - MCP UpdatePostTool didn't scope `platforms.*.id` to the post being updated, drifting from the REST UpdatePostRequest which adds `Rule::exists('post_platforms','id')->where('post_id', ...)`. Now it loads the post first (failing fast with `Post not found.` if the workspace check rejects), then uses the same Rule::exists. - MediaAttacher fetched any URL the caller passed, including loopback / link-local / private targets — classic SSRF pivot. Now `isPublicHttpUrl` rejects non-http(s) schemes, restricted IP ranges, and DNS hostnames whose A/AAAA records resolve into those ranges (covers DNS rebinding). Bypassed under `app()->runningUnitTests()` so `Http::fake()` keeps working. Streaming the response body lets us abort early once we exceed MAX_BYTES instead of buffering the full payload first; redirects are disabled so a 200→302 trick can't bypass the host check. - The `media[]` JSON column had a lost-update race in `attachFromUrls`: read `$post->media`, mutate in PHP, write back. Two concurrent calls clobbered each other. Now wrapped in a transaction with `lockForUpdate()`. - ESLint: `resources/js/actions/**` and `resources/js/routes/**` are auto-generated by Wayfinder on every build. Their import order matches PHP scan order, not alphabetical, so import/order fought eslint-fix forever. Added them to ignores.
2026-05-04 15:16:39 +00:00
it('creates a post with content, media, and labels', function () {
$label = WorkspaceLabel::factory()->create(['workspace_id' => $this->workspace->id]);
$payload = [
'content' => 'Hello from the API',
'media' => [['id' => 'media-1', 'path' => 'media/foo.jpg', 'url' => 'https://example.com/foo.jpg', 'type' => 'image']],
'platforms' => [
['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post'],
],
'label_ids' => [$label->id],
];
$response = $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.store'), $payload)
->assertCreated();
$post = Post::where('workspace_id', $this->workspace->id)->first();
expect($post->content)->toBe('Hello from the API');
expect($post->media)->toHaveCount(1);
expect($post->labels()->pluck('workspace_labels.id')->all())->toContain($label->id);
$response->assertJsonPath('content', 'Hello from the API');
});
it('rejects creating a post with an inactive social account', function () {
Allow multiple social accounts per network via env (#286) * feat: expose self-hosted mode to the accounts UI SocialAccountObserver already bypasses the one-account-per-network guard when trypost.self_hosted is true, but the frontend had no way to know that and always collapsed a network to a single card once any account existed - so self-hosted deployments could not surface a second LinkedIn (or Instagram) connection even though the backend would allow creating it. * feat: allow connecting multiple accounts per network when self-hosted NetworkConnectGrid always collapsed a network (LinkedIn profile/page, Instagram standalone/Facebook) to a single card once any account existed, with no way to trigger another OAuth flow - even though SocialAccountObserver already allows unlimited accounts per network in self-hosted mode. A self-hoster connecting their personal LinkedIn profile had no path back to the connect flow to also add a company page (or a second company page/showcase page). Render one card per connected account instead of collapsing to the first, and keep a standing "Connect another" card available for a network's existing connections when self-hosted. Hosted mode is unchanged: still one card per network, matching the backend's still-enforced one-account-per-network limit there. * test: cover the selfHosted prop on accounts and onboarding pages Backend behavior for connecting a second identity per network in self-hosted mode was already covered (LinkedInControllerTest, NetworkUniquenessTest) - these just confirm the new prop the frontend now depends on is actually present and reflects config correctly. * style: apply prettier formatting Pre-existing drift in this file unrelated to the selfHosted change. * refactor: read selfHosted from shared Inertia props The flag is already shared by HandleInertiaRequests, so the accounts and onboarding controllers do not need to pass it again. Co-authored-by: Cursor <cursoragent@cursor.com> * feat: gate multiple social accounts with a dedicated env Cloud cannot flip SELF_HOSTED, so one-per-network is now ALLOW_MULTIPLE_SOCIAL_ACCOUNTS (default false). Co-authored-by: Cursor <cursoragent@cursor.com> * fix: tighten multiple-account gates after review Keep every connected identity visible, share occupiesNetwork, and return network_taken instead of a generic connect error. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: bind reconnect to the card and unique social identity Reconnect now updates the selected account, and a unique index plus connectIdentity keep the same platform identity from being inserted twice. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: build the OAuth URL before opening the popup Keep the popup opener URL-only so reconnect query params are assembled at the call site. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: drop dead social-account guards and slim the connect grid Skip migration cleanup that production never needs, trust the platform enum in the observer, and move card theming out of the grid. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: scope social reconnect to the current network Drop dead instanceof/isset guards and filter reconnect targets in the query so a stale session cannot update another network's card. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim connectable-identity filtering Index OAuth identities by id so reconnect and occupancy use only/except instead of hand-rolled filters. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: slim social identity persist helpers Drop the unused occupiesNetwork exception and persist reconnects with update() instead of fill/save/fresh. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: keep reconnect updates on the original social card Co-authored-by: Cursor <cursoragent@cursor.com> * test: run the suite with multiple social accounts enabled phpunit.xml forced ALLOW_MULTIPLE_SOCIAL_ACCOUNTS=false, overriding the true value in .env.ci. That broke eight tests across Automation, MCP, PostApi, RefreshExpiringTokens and VerifyUpcomingConnections which only needed two accounts of one network as a fixture, not as a rule under test. Match .env.ci instead. Every test that exercises the one-per-network rule already sets the config itself; the accounts index test was the only one leaning on the implicit default, so it now pins it. * fix: align the multi-account fallback with the self-hosted default allow_multiple_social_accounts fell back to env('SELF_HOSTED', false) while self_hosted itself defaults to env('SELF_HOSTED', true). A self-hosted install that never wrote SELF_HOSTED to its .env resolved to false and silently lost multiple accounts per network on upgrade, which is the opposite of what the documented fallback promises. * fix: collapse duplicate identities before adding the unique index Installs predating the index can hold the same identity twice: the network guard was bypassed for multi-account installs and Pinterest always created a fresh row. Creating the index on that data aborts migrate mid-deploy. Keep the newest row per identity and move its post_platforms over before dropping the duplicates - the FK is nullOnDelete, so deleting outright would orphan drafts and scheduled posts. * fix: refuse a reconnect that authorized a different identity connectIdentity overwrote platform_user_id with whatever the provider returned, so reconnecting a card while signed into another account repointed the row - and every draft and scheduled post bound to it - at a stranger. LinkedIn guarded this at the controller and Facebook via its filtered page list; nothing covered X, TikTok, Threads, Discord, Bluesky, Mastodon, Pinterest, Instagram or Telegram. Enforce the identity match at the single choke point every connect flow goes through. Every call site already maps NetworkAlreadyConnectedException to network_taken, so the refusal surfaces without new plumbing. Also restore the null-platform guard in the observer: occupiesNetwork type-hints a non-nullable Platform, so a row without one died with a TypeError instead of the database's NOT NULL error. * fix: filter connectable identities on every picker step YouTubeController::select re-fetched the channels and matched the posted id straight off the raw list, unlike callback and selectChannel. With a live youtube_oauth session it let a POST name any channel the Google account owns and bind it to the reconnect target. It also read the reconnect from the session while the connect below it read youtube_oauth.reconnect_id, so the two could disagree - pass the resolved account through instead. filterConnectableIdentities also short-circuited in multi-account mode, and the unique index is scoped to platform rather than network. That let one Instagram account connect twice, once directly and once via Facebook, publishing every Instagram post to it twice. The existing except() already spans networkPlatformValues(), so dropping the short-circuit closes it. * refactor: type the connect cards and drop the dead accounts grid The cards computed inferred account as a required ConnectedAccount and then pushed undefined onto it (TS2345). CI only runs eslint so it stayed green, but vue-tsc and editors flag it. SocialAccountsGrid is referenced nowhere; its reconnect button was updated in this branch without passing the card id, which would have been a bug had anything rendered it. * test: keep the suite on the cloud one-account-per-network default CI runs the Cloud build, so the suite baseline should be the Cloud default rather than the self-hosted one. Put phpunit.xml and .env.ci back to false and make the eight tests that merely need two accounts of one network as a fixture opt in for themselves. This also un-deads the config()->set(true) calls the branch had already added to AuthenticationTest, SyncAccountUsageTest, HasUsageTraitTest and SocialAccountObserverTest, which the forced true had turned into no-ops. * fix: connect standalone instagram instead of reopening the picker The picker emits an already-resolved connect method, but this branch rewired @select from openOAuthPopup to startConnect. startConnect sends a bare 'instagram' straight back into its own picker branch, so choosing "Instagram" closed the dialog and immediately reopened it - the OAuth window never opened and the standalone flow was unreachable. Only the via-Facebook button still worked. Split the URL-opening tail out of startConnect and let the dialog call that directly. * fix: reject a telegram reconnect before burning the connect code The nonce was consumed before connectIdentity ran, so posting /connect in the wrong chat spent the one-off code and forced the user to generate a new one. Check the identity first and report wrong_chat instead of network_taken, which told them to disconnect an account when the real fix was posting in the channel they were reconnecting. * fix: leave one target per post when merging duplicate accounts post_platforms has no unique on (post_id, social_account_id), so a post holding a row per duplicate account ended up with two enabled rows aimed at the surviving account and would publish to it twice. Keep one row per post, preferring a published one so history survives. * refactor: collapse the repeated connect-flow boilerplate Four shapes were copy-pasted across the connect controllers: - the session + permission guard opening 16 actions, now connectWorkspace() throwing a ConnectPopupException that renders the popup itself - the reconnected/connected ternary in 13 places, now connectedCallback() - the "nothing left to connect" branch in 4 places, now noConnectableIdentities() - validatedReconnectId() re-querying what reconnectAccount() already does Facebook, Instagram-via-Facebook and YouTube also re-queried the reconnect account three or four times per callback; it is resolved once and passed down. The three GET pickers skipped the manageAccounts check their POST siblings had, and pick it up from the shared guard. Drops the color key from connectableOptions and the matching frontend field - nothing read it. Platform::color() stays; the disconnection emails use it. * fix: keep an expired connect popup out of the error log ConnectPopupException escapes to the framework handler so it can render itself, which also meant report() ran first: every session_expired and workspace_not_found popup filed an ERROR and a Nightwatch issue for what used to be a silent return. A stale popup is a normal outcome, so it now implements ShouldntReport. The Mastodon and Threads guards also cleared their provider session after connectWorkspace(), so a workspace that vanished mid-flow left the client secret and the OAuth state behind. Clear first, then resolve. clearMastodonSession() no longer touches social_connect_workspace - whatever closes the popup already does. * fix: stop telling users to disconnect an account that is not the problem Two flows reused popup_callback.network_taken - "This workspace already has an account for this network. Disconnect it first." - for situations where that is neither true nor actionable: - reconnecting a card while signed into a different account on the provider, now wrong_account - an empty picker in multi-account mode, where every page or channel on that login is simply already connected, now all_connected NetworkAlreadyConnectedException carries the message key so the catch sites stay one line. handleCallback() also drops its $platform argument; it read $this->platform for the reconnect lookup and the identity filter either way, so a caller passing a different platform would have scoped the lookup to the wrong network. * refactor: filter linkedin identities with the shared helper The picker hand-rolled its own reconnect narrowing because the profile and the pages arrive in two different shapes. Flatten them into one pool of LinkedIn identities, run the shared filter, and split them again for the view - the same path Facebook, YouTube and Instagram already take. Side effect worth having: the picker previously only narrowed on a reconnect, so it would offer an identity that is already connected and only fail once the user picked it. It now hides taken identities up front and says so when nothing is left. * fix: keep the linkedin picker's own empty state Routing the picker through the shared filter made every empty pool look like "nothing left to take", including the pool LinkedIn never filled. A self-hoster running pages-only who administers no page was told the network was already connected, or that every account on the login was taken - both false - and the picker's own "you are not an admin of any LinkedIn page" state became unreachable. Only treat it as taken when filtering is what emptied it. Splitting the pool back also compared the person id loosely on one side and strictly on the other; one predicate now drives both. Threads had two forget() calls for a key the top of the action already clears, and YouTube's picker resolved the reconnect account twice on the failure path. * fix: keep the enabled row when collapsing duplicate post targets SyncPostPlatforms seeds a disabled post_platforms row for every account in the workspace, so the usual duplicate is one row the user actually checked next to one they never saw - both pending, both created in the same second. Ordering only by published-then-newest made that a coin flip, and PublishPost iterates enabled() only, so half the time a scheduled post would silently stop reaching that account and take its caption and per-platform meta with it. This runs once against production data and the dropped row is gone, so enabled now beats disabled. Also: the empty-pool exit from the LinkedIn picker was the only one leaving linkedin_pending - and its tokens - in the session. The rationale comments move to the docblocks they belong in, and usePage() comes out of the cards computed. * fix: stop the migration destroying publish history and automations Two ways the one-shot merge lost data that cannot be rebuilt: Surplus published post_platforms rows were deleted. Two duplicate accounts really could each have published, and each row carries the platform_post_id for a live post on the network - dropping one leaves that post unmanageable and invisible to metrics. The docblock claimed published beat everything; now the code does, and only unpublished repeats collapse. Automation nodes persist social_account_id inside a JSON column with no foreign key, so deleting the loser left RunGenerateNode skipping that target, or generating nothing at all when it was the node's only account. The ids are rewritten - current and legacy shapes both - and entries the merge just turned into duplicates are collapsed. Ordering is now total (null created_at sorts oldest on every engine, then id) so a rehearsal on a replica keeps the same rows as the real run. The LinkedIn picker also passes onboardingProgress inline: it clears linkedin_pending on the empty path, and a deferred reload would re-GET the route and swap the empty state for a session-expired popup. * fix: make the identity merge auditable and stop a second delivery Self-hosted installs run this unattended and it cannot be undone, so each collapsed group now logs the workspace, the identity, which row was kept, which were dropped, and how many post_platforms and automations it touched. down() says plainly that it drops the index only. Two narrower fixes: A post holding a published row plus an enabled unpublished row for the same account kept both, and PostPlatform::scopeEnabled() filters on `enabled` alone with no status check - so a republish would deliver the same content to that identity twice. Once a published row exists, every unpublished repeat goes. The automation dedupe ran on every automation in the workspace, not just the ones the merge rewrote. A node legitimately holding two entries for one account under different content types would be collapsed to whichever came first in the array. It now runs only where an id was actually substituted. * test: rehearse the identity merge against a messy database Every test on this migration so far covered a case someone thought to write, which is why three separate review rounds each found a defect the earlier ones missed. This builds a deliberately messy database instead - three workspaces, four networks, one to three copies of each identity, posts mixing published, pending and failed rows across the duplicates with enabled flags varying, and automations referencing them in both the current and legacy JSON shapes - then runs the real migration and asserts what must be true afterwards rather than what happens to a particular fixture. Invariants: no duplicate identity survives, no published row is ever destroyed, no post ends up enabled twice against one account, nothing in post_platforms or automations points at a deleted account, and the newest row of each identity is the one kept. The generator is seeded, so a failure reproduces, and it asserts its own output is adversarial - roughly nine duplicate groups and fourteen published rows - so it cannot quietly degrade into passing on an empty problem. Verified by mutation: dropping the automation repoint, the published guard, or the repeated-target collapse each fails exactly the invariant that covers it. * fix: stop the youtube picker refetching itself into a cleared session HandleInertiaRequests defers onboardingProgress for anyone mid-onboarding - exactly the people connecting their first accounts - so Inertia re-GETs the picker route right after it mounts. For Facebook and Instagram that re-entry is harmless and deliberately left deferred, but YouTube calls the Google API again, and fetchChannels() turns any failure into an empty list that clears the connect session and swaps the mounted picker for an error the user cannot retry from. Same guard the LinkedIn picker already got. LinkedIn also answered a reconnect that authorized a different identity with "Page not found", including in the person branch where no page is involved. Every other platform says wrong_account, which this PR added. * refactor: drop the unreachable youtube channel picker Google's own delegation screen already lists every channel on the account and makes the user pick one before it issues the token, so channels?mine=true always answers with that single channel and count($channels) === 1 always won. The picker behind it was never reached - its Vue page was deleted back in 7c00c338 (January) and nothing broke, which is the clearest evidence it was dead. Removes selectChannel(), select(), both routes, the youtube_oauth session payload and the tests that drove them. If Google ever does return more than one, the callback connects the first and logs a warning rather than routing to a screen that no longer exists. * fix: serialize connects so two popups cannot seat one network twice The observer's occupiesNetwork() is a check-then-insert with nothing holding the gap, and the new unique index covers the identity, not the network. Two tabs finishing OAuth at the same moment for *different* identities on one network both passed the exists() check and both inserted, leaving a Cloud workspace with the two accounts the rule exists to prevent. The same-identity race was already safe - the unique violation is caught and re-queried. A database constraint cannot hold this: allow_multiple_social_accounts is a runtime flag, so the rule is on for Cloud and off for self-hosted, and an index cannot read config. Lock per workspace and network instead, the way markAsDisconnected() and ConnectionVerifier already do. This covers connectIdentity(), which every OAuth flow and the Telegram action go through. A direct create() still answers to the observer alone, and a self-hosted install running file cache across several nodes locks per node. * fix: handle a busy connect lock on the telegram path Every other caller funnels LockTimeoutException into its generic \Exception catch and closes the popup with error_connecting. Telegram has no such catch, so the new lock could 500 the webhook - and because the nonce is spent before connectIdentity runs, Telegram's retry of the same update short-circuits on the consumed code and returns without dispatching anything. The dialog would spin forever on a code that can no longer be used. Also restores coverage the picker removal dropped: the deleted select tests were the only ones driving a multi-channel response, so nothing exercised the reconnect narrowing to its own card, or multi-account mode skipping an already-connected channel. Both are back against the callback, and removing the narrowing in filterConnectableIdentities fails them. * fix: stop the instagram login seating an account already held via facebook filterConnectableIdentities() drops every identity already connected on the network, which is what keeps one Instagram account from being seated twice under its two platforms. Every flow that persists an identity ran it except the direct Instagram Login callback, so the guard only held in one direction: InstagramFacebookController refused an account already connected as `instagram`, but the reverse was allowed through. With multiple accounts per network enabled the observer's network check is bypassed and the unique index does not span platforms, so authorizing the same account through the direct flow created a second row. Both then seed a post_platform row and the post goes out twice to one account. * fix: name the real reason when a linkedin profile reconnect switches member Reconnecting a card narrows the authorized identities to that card's own, so authorizing a different LinkedIn login empties the pool. selectIdentity() reported that as "Page not found." for every card, including personal profiles where no page was ever involved. A profile reconnect has no page to be missing: an empty pool there can only mean this login is a different member. Say so with the wrong_account wording select() already uses for the same condition. Page reconnects keep page_not_found, where the organization really can be absent from the login. * fix: surface the busy telegram connect instead of a generic failure The connect lock timing out dispatches its own 'busy' reason so the dialog can tell the user to retry, but the dialog only mapped network_taken and wrong_chat and fell back to error_generic for everything else. The reason reached the browser and died there, leaving "Could not start the connection" for a case that just needs another moment. * test: cover reconnect on every flow that gained it rememberConnectSession() gave Instagram, TikTok, Threads, Mastodon and Bluesky a reconnect path they did not have before — TikTok had been actively clearing social_reconnect_id on connect — and none of them had a test for it. Facebook, LinkedIn, YouTube, X, Discord, Pinterest and Telegram already did. Each now covers both halves: authorizing the same identity refreshes the existing card and reports it as a reconnect, and authorizing a different one is refused with wrong_account instead of quietly seating a stranger on the card and every post scheduled against it. * fix: repair what a reconnect leaves behind when it cannot proceed cleanly Two things connectIdentity got wrong once the reconnect path existed. A reconnect through the other variant of a network moves the card to the new platform — same identity, different API flavor. Post targets carry their own platform snapshot, and that snapshot picks the publisher, the queue and the scopes checked before publishing. Left behind, it failed every pending post on a permission the account no longer needs: an Instagram card moved to the Facebook variant still demanded instagram_business_content_publish and stopped with "Missing permissions". Pending targets now follow the card and reset a content type the new platform cannot publish; published targets keep theirs, since they record what really went out under a platform_post_id from that API. The network lock timing out also arrived as a raw LockTimeoutException, which every OAuth callback filed through its generic catch: an error log and "Error connecting account" for the exact race the lock exists to absorb. It now carries a busy messageKey through the branch each flow already handles, the same way the Telegram path already reported it. * refactor: resolve the linkedin reconnect card once per select select() already looked the card up before deciding whether the chosen identity matches it, then connectPerson() and connectOrganization() looked it up again on their own — two identical queries per submit, and two places that could disagree about what is being reconnected. The caller passes what it already holds. * test: render the grid's multi-account branch phpunit.xml forces ALLOW_MULTIPLE_SOCIAL_ACCOUNTS false and no browser test overrode it, so the card the flag exists to add never rendered anywhere. The pair pins both sides: a taken network offers no second card when multiples are off, and offers one when they are on. * test: pin why the linkedin select guards exist connectIdentity() already refuses a mismatched reconnect and answers with the same wrong_account message, so every existing test passes with the two guards in select() deleted — which is exactly how they would get deleted. What they actually buy is skipping the avatar download that building the connect payload runs first. Both now assert the fetch never happens, so the guards fail loudly instead of looking redundant. * fix: carry retrying targets through a variant move, atomically Two holes in the move added a commit ago. It only carried pending targets, but a retrying one is not finished either — the publish job reschedules itself and reads the snapshot fresh on the next attempt, so leaving it behind meant it retried against the old variant until it exhausted its budget on a permission the account no longer needs. Failed and published targets stay put; a publishing one has a job mid-flight already working from the snapshot it read. The card and its targets also moved in three separate statements, so a crash between them left exactly the split this was meant to close. They share a transaction now. * chore: drop the dusk selectors nothing reads Laravel Dusk is not installed — no laravel/dusk requirement, no DuskTestCase, no browse(). Browser tests run on pest-plugin-browser driving Playwright, and its @selector resolves to data-testid. The 45 dusk attributes left across 18 components selected nothing. CLAUDE.md was the reason they kept coming back: it told every agent to add them. Its browser-testing section now describes the setup that exists — data-testid targeting, the wait helper these tests need because assertions do not auto-wait on SPA paint, and why BrowserTestCase keeps Vite real. Verified before removing: every @selector used in tests/Browser resolves to a data-testid, seven of them through bound :data-testid, so none depended on a dusk attribute. * chore: drop the last one-account-per-network helper hasConnectedPlatform() has no callers left anywhere — app, tests, views or routes. It sat directly above getSocialAccount(), which this branch already removed, and is the same leftover from when a workspace could hold one account per platform. --------- Co-authored-by: Paulo Castellano <paulo@castellanos.llc> Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 10:28:14 +00:00
config()->set('trypost.allow_multiple_social_accounts', true);
fix: address PR review findings — publish, REST store, SSRF, race Code-review surfaced two correctness bugs and a security gap that needed to land before merging. - UpdatePost::execute disabled every platform when called without a `platforms` key. PublishPostTool relied on that path, so every publish-via-MCP queued a job whose handler then found nothing enabled to publish to. Wrap the platform toggle in `Arr::has($data, 'platforms')` (matches the existing label_ids guard a few lines up). Add a regression assertion to `PostPublishToolTest::publish post immediate dispatches PublishPost job` that the previously-enabled platform stays enabled. - StorePostRequest declared rules for only `platforms`, `scheduled_at`, and `status`. `validated()` then stripped `content`, `media`, and `label_ids`, so REST `POST /api/posts` silently created empty drafts. Added rules for content / media / label_ids (with workspace-scoped `Rule::exists` for labels) and dropped the unused `status` field — REST callers transition state via `PUT /posts/{id}`. Removed the dead `platforms.*.content` rule. Added a feature test that asserts content + media + labels roundtrip on create, plus a regression that an `is_active=false` social_account is rejected at validation. - CreatePost::execute now syncs label_ids itself so REST and MCP share the behavior. Removed the duplicate sync from CreatePostTool. - MCP UpdatePostTool didn't scope `platforms.*.id` to the post being updated, drifting from the REST UpdatePostRequest which adds `Rule::exists('post_platforms','id')->where('post_id', ...)`. Now it loads the post first (failing fast with `Post not found.` if the workspace check rejects), then uses the same Rule::exists. - MediaAttacher fetched any URL the caller passed, including loopback / link-local / private targets — classic SSRF pivot. Now `isPublicHttpUrl` rejects non-http(s) schemes, restricted IP ranges, and DNS hostnames whose A/AAAA records resolve into those ranges (covers DNS rebinding). Bypassed under `app()->runningUnitTests()` so `Http::fake()` keeps working. Streaming the response body lets us abort early once we exceed MAX_BYTES instead of buffering the full payload first; redirects are disabled so a 200→302 trick can't bypass the host check. - The `media[]` JSON column had a lost-update race in `attachFromUrls`: read `$post->media`, mutate in PHP, write back. Two concurrent calls clobbered each other. Now wrapped in a transaction with `lockForUpdate()`. - ESLint: `resources/js/actions/**` and `resources/js/routes/**` are auto-generated by Wayfinder on every build. Their import order matches PHP scan order, not alphabetical, so import/order fought eslint-fix forever. Added them to ignores.
2026-05-04 15:16:39 +00:00
$inactive = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::LinkedIn,
'is_active' => false,
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.store'), [
'platforms' => [
['social_account_id' => $inactive->id, 'content_type' => 'linkedin_post'],
],
])
->assertJsonValidationErrors(['platforms.0.social_account_id']);
});
it('deletes a post', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->deleteJson(route('api.posts.destroy', $post))
->assertNoContent();
expect(Post::find($post->id))->toBeNull();
});
it('cannot delete post from another workspace', function () {
$otherWorkspace = Workspace::factory()->create();
$post = Post::factory()->create([
'workspace_id' => $otherWorkspace->id,
'user_id' => $this->user->id,
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->deleteJson(route('api.posts.destroy', $post))
->assertNotFound();
});
it('updates a post', 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' => ContentType::LinkedInPost->value,
],
],
])
->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([
'workspace_id' => $otherWorkspace->id,
'platform' => Platform::LinkedIn,
]);
$post = Post::factory()->create([
'workspace_id' => $otherWorkspace->id,
'user_id' => $this->user->id,
]);
$postPlatform = PostPlatform::factory()->linkedin()->create([
'post_id' => $post->id,
'social_account_id' => $otherSocialAccount->id,
'enabled' => true,
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->putJson(route('api.posts.update', $post), [
'status' => 'draft',
'platforms' => [
[
'id' => $postPlatform->id,
'content_type' => ContentType::LinkedInPost->value,
],
],
])
->assertNotFound();
});
it('cannot update post in any terminal state', function (PostStatus $status) {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => $status,
]);
$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' => ContentType::LinkedInPost->value,
],
],
])
->assertUnprocessable();
})->with([
PostStatus::Published,
PostStatus::PartiallyPublished,
PostStatus::Failed,
PostStatus::Publishing,
]);
it('validates post update fields', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->putJson(route('api.posts.update', $post), [
'platforms' => [
['content' => 'missing id'],
],
])
->assertUnprocessable();
});
it('validates post creation requires platforms', function () {
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.store'), [])
->assertUnprocessable()
->assertJsonValidationErrors(['platforms']);
});
it('validates post creation platform fields', function () {
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.store'), [
'platforms' => [
['content' => 'missing social_account_id and content_type'],
],
])
->assertUnprocessable()
->assertJsonValidationErrors(['platforms.0.social_account_id', 'platforms.0.content_type']);
});
it('validates post update invalid status', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->putJson(route('api.posts.update', $post), [
'status' => 'invalid_status',
])
->assertUnprocessable()
->assertJsonValidationErrors(['status']);
});
it('validates post update scheduled_at must be date', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->putJson(route('api.posts.update', $post), [
'scheduled_at' => 'not-a-date',
])
->assertUnprocessable()
->assertJsonValidationErrors(['scheduled_at']);
});
it('validates post update label_ids must be uuids', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->putJson(route('api.posts.update', $post), [
'label_ids' => ['not-a-uuid'],
])
->assertUnprocessable()
->assertJsonValidationErrors(['label_ids.0']);
});
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.
2026-05-04 16:31:44 +00:00
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 scheduling an over-limit threads post via the api store', function () {
$threadsAccount = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::Threads,
]);
$response = $this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.store'), [
'content' => str_repeat('a', 537),
'scheduled_at' => now()->addDay()->toIso8601String(),
'platforms' => [
['social_account_id' => $threadsAccount->id, 'content_type' => ContentType::ThreadsPost->value],
],
]);
$response->assertUnprocessable()->assertJsonValidationErrors(['content']);
expect($response->json('errors.content.0'))
->toContain('Threads')
->toContain('500')
->toContain('37');
});
it('accepts creating an over-limit draft (no scheduled_at) via the api store', function () {
$threadsAccount = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::Threads,
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.store'), [
'content' => str_repeat('a', 1000),
'platforms' => [
['social_account_id' => $threadsAccount->id, 'content_type' => ContentType::ThreadsPost->value],
],
])
->assertCreated();
});
it('rejects scheduling an over-limit threads post via the api update', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
$threadsAccount = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::Threads,
]);
$threadsPlatform = PostPlatform::factory()->threads()->create([
'post_id' => $post->id,
'social_account_id' => $threadsAccount->id,
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->putJson(route('api.posts.update', $post), [
'status' => PostStatus::Scheduled->value,
'content' => str_repeat('a', 600),
'scheduled_at' => now()->addDay()->toIso8601String(),
'platforms' => [
['id' => $threadsPlatform->id, 'content_type' => ContentType::ThreadsPost->value],
],
])
->assertUnprocessable()
->assertJsonValidationErrors(['content']);
});
it('saving an over-limit threads post as draft via api skips the content-length check', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
$threadsAccount = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::Threads,
]);
$threadsPlatform = PostPlatform::factory()->threads()->create([
'post_id' => $post->id,
'social_account_id' => $threadsAccount->id,
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->putJson(route('api.posts.update', $post), [
'status' => PostStatus::Draft->value,
'content' => str_repeat('a', 1000),
'platforms' => [
['id' => $threadsPlatform->id, 'content_type' => ContentType::ThreadsPost->value],
],
])
->assertSuccessful();
});
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.
2026-05-04 16:31:44 +00:00
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']);
});
fix: keep post drafts unscheduled by default (#209) * fix: keep post drafts unscheduled by default * Align schedule validation and keep drafts unscheduled. Require scheduled_at only when status is scheduled and the post has no usable future schedule. Share that rule across web, API, and MCP, keep create without a date as null, and preserve the legacy date → 09:00 UTC fallback. * Polish schedule validation typing and tests. Type requiresExplicitSchedule status as ?string, reuse a local status variable in request/tool validation, tighten the web reject assertion, and collapse overlapping MCP unscheduled-create cases. * Centralize status helper in post update validation. Reuse the typed status() helper across FormRequests and the already-parsed $status in UpdatePostTool so schedule checks stay consistent and less noisy. * Share scheduled_at update rules across web, API, and MCP. Centralize schedule validation in PostStatusRules, normalize status parsing in one place, and align past-schedule coverage across entry points. * Cover the full unscheduled-draft checklist in Pest. Add feature coverage for null/past schedule rejection, explicit scheduling, draft saves, publish-now without a schedule, calendar exclusion, and 09:00 UTC date defaults across web, API, and MCP. * Remove normalizeStatus helper. Keep the inline is_string check at the few call sites that read raw request status before validation — no shared wrapper needed. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop is_string status guards from schedule validation. Accept mixed status in PostStatusRules and rely on strict comparisons with Rule::requiredIf / Rule::when — malformed input simply does not match. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Paulo Castellano <paulo@castellanos.llc>
2026-08-01 20:39:18 +00:00
it('rejects scheduled status without a future scheduled_at', function (?string $existingScheduledAt) {
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.
2026-05-04 16:31:44 +00:00
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Draft,
fix: keep post drafts unscheduled by default (#209) * fix: keep post drafts unscheduled by default * Align schedule validation and keep drafts unscheduled. Require scheduled_at only when status is scheduled and the post has no usable future schedule. Share that rule across web, API, and MCP, keep create without a date as null, and preserve the legacy date → 09:00 UTC fallback. * Polish schedule validation typing and tests. Type requiresExplicitSchedule status as ?string, reuse a local status variable in request/tool validation, tighten the web reject assertion, and collapse overlapping MCP unscheduled-create cases. * Centralize status helper in post update validation. Reuse the typed status() helper across FormRequests and the already-parsed $status in UpdatePostTool so schedule checks stay consistent and less noisy. * Share scheduled_at update rules across web, API, and MCP. Centralize schedule validation in PostStatusRules, normalize status parsing in one place, and align past-schedule coverage across entry points. * Cover the full unscheduled-draft checklist in Pest. Add feature coverage for null/past schedule rejection, explicit scheduling, draft saves, publish-now without a schedule, calendar exclusion, and 09:00 UTC date defaults across web, API, and MCP. * Remove normalizeStatus helper. Keep the inline is_string check at the few call sites that read raw request status before validation — no shared wrapper needed. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop is_string status guards from schedule validation. Accept mixed status in PostStatusRules and rely on strict comparisons with Rule::requiredIf / Rule::when — malformed input simply does not match. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Paulo Castellano <paulo@castellanos.llc>
2026-08-01 20:39:18 +00:00
'scheduled_at' => $existingScheduledAt,
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.
2026-05-04 16:31:44 +00:00
]);
fix: keep post drafts unscheduled by default (#209) * fix: keep post drafts unscheduled by default * Align schedule validation and keep drafts unscheduled. Require scheduled_at only when status is scheduled and the post has no usable future schedule. Share that rule across web, API, and MCP, keep create without a date as null, and preserve the legacy date → 09:00 UTC fallback. * Polish schedule validation typing and tests. Type requiresExplicitSchedule status as ?string, reuse a local status variable in request/tool validation, tighten the web reject assertion, and collapse overlapping MCP unscheduled-create cases. * Centralize status helper in post update validation. Reuse the typed status() helper across FormRequests and the already-parsed $status in UpdatePostTool so schedule checks stay consistent and less noisy. * Share scheduled_at update rules across web, API, and MCP. Centralize schedule validation in PostStatusRules, normalize status parsing in one place, and align past-schedule coverage across entry points. * Cover the full unscheduled-draft checklist in Pest. Add feature coverage for null/past schedule rejection, explicit scheduling, draft saves, publish-now without a schedule, calendar exclusion, and 09:00 UTC date defaults across web, API, and MCP. * Remove normalizeStatus helper. Keep the inline is_string check at the few call sites that read raw request status before validation — no shared wrapper needed. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop is_string status guards from schedule validation. Accept mixed status in PostStatusRules and rely on strict comparisons with Rule::requiredIf / Rule::when — malformed input simply does not match. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Paulo Castellano <paulo@castellanos.llc>
2026-08-01 20:39:18 +00:00
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->putJson(route('api.posts.update', $post), [
'status' => 'scheduled',
])
->assertJsonValidationErrors(['scheduled_at']);
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.
2026-05-04 16:31:44 +00:00
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->putJson(route('api.posts.update', $post), [
'status' => 'scheduled',
'scheduled_at' => now()->subHour()->toIso8601String(),
])
->assertJsonValidationErrors(['scheduled_at']);
fix: keep post drafts unscheduled by default (#209) * fix: keep post drafts unscheduled by default * Align schedule validation and keep drafts unscheduled. Require scheduled_at only when status is scheduled and the post has no usable future schedule. Share that rule across web, API, and MCP, keep create without a date as null, and preserve the legacy date → 09:00 UTC fallback. * Polish schedule validation typing and tests. Type requiresExplicitSchedule status as ?string, reuse a local status variable in request/tool validation, tighten the web reject assertion, and collapse overlapping MCP unscheduled-create cases. * Centralize status helper in post update validation. Reuse the typed status() helper across FormRequests and the already-parsed $status in UpdatePostTool so schedule checks stay consistent and less noisy. * Share scheduled_at update rules across web, API, and MCP. Centralize schedule validation in PostStatusRules, normalize status parsing in one place, and align past-schedule coverage across entry points. * Cover the full unscheduled-draft checklist in Pest. Add feature coverage for null/past schedule rejection, explicit scheduling, draft saves, publish-now without a schedule, calendar exclusion, and 09:00 UTC date defaults across web, API, and MCP. * Remove normalizeStatus helper. Keep the inline is_string check at the few call sites that read raw request status before validation — no shared wrapper needed. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop is_string status guards from schedule validation. Accept mixed status in PostStatusRules and rely on strict comparisons with Rule::requiredIf / Rule::when — malformed input simply does not match. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Paulo Castellano <paulo@castellanos.llc>
2026-08-01 20:39:18 +00:00
})->with([
'missing schedule' => [null],
'past schedule' => [now()->subDay()->toDateTimeString()],
]);
it('accepts scheduled status reusing an existing future scheduled_at', function () {
$scheduledAt = now()->addDay()->startOfSecond();
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Draft,
'scheduled_at' => $scheduledAt,
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->putJson(route('api.posts.update', $post), [
'status' => 'scheduled',
])
->assertOk()
->assertJsonPath('status', PostStatus::Scheduled->value);
expect($post->fresh()->scheduled_at->toDateTimeString())->toBe($scheduledAt->toDateTimeString());
});
it('schedules an unscheduled draft with an explicit future scheduled_at', function () {
$scheduledAt = now()->addDay()->startOfSecond();
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Draft,
'scheduled_at' => null,
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->putJson(route('api.posts.update', $post), [
'status' => 'scheduled',
'scheduled_at' => $scheduledAt->toIso8601String(),
])
->assertOk()
->assertJsonPath('status', PostStatus::Scheduled->value);
expect($post->fresh()->scheduled_at->toDateTimeString())->toBe($scheduledAt->toDateTimeString());
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.
2026-05-04 16:31:44 +00:00
});
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,
fix: keep post drafts unscheduled by default (#209) * fix: keep post drafts unscheduled by default * Align schedule validation and keep drafts unscheduled. Require scheduled_at only when status is scheduled and the post has no usable future schedule. Share that rule across web, API, and MCP, keep create without a date as null, and preserve the legacy date → 09:00 UTC fallback. * Polish schedule validation typing and tests. Type requiresExplicitSchedule status as ?string, reuse a local status variable in request/tool validation, tighten the web reject assertion, and collapse overlapping MCP unscheduled-create cases. * Centralize status helper in post update validation. Reuse the typed status() helper across FormRequests and the already-parsed $status in UpdatePostTool so schedule checks stay consistent and less noisy. * Share scheduled_at update rules across web, API, and MCP. Centralize schedule validation in PostStatusRules, normalize status parsing in one place, and align past-schedule coverage across entry points. * Cover the full unscheduled-draft checklist in Pest. Add feature coverage for null/past schedule rejection, explicit scheduling, draft saves, publish-now without a schedule, calendar exclusion, and 09:00 UTC date defaults across web, API, and MCP. * Remove normalizeStatus helper. Keep the inline is_string check at the few call sites that read raw request status before validation — no shared wrapper needed. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop is_string status guards from schedule validation. Accept mixed status in PostStatusRules and rely on strict comparisons with Rule::requiredIf / Rule::when — malformed input simply does not match. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Paulo Castellano <paulo@castellanos.llc>
2026-08-01 20:39:18 +00:00
'scheduled_at' => null,
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.
2026-05-04 16:31:44 +00:00
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->putJson(route('api.posts.update', $post), [
'status' => 'draft',
])
fix: keep post drafts unscheduled by default (#209) * fix: keep post drafts unscheduled by default * Align schedule validation and keep drafts unscheduled. Require scheduled_at only when status is scheduled and the post has no usable future schedule. Share that rule across web, API, and MCP, keep create without a date as null, and preserve the legacy date → 09:00 UTC fallback. * Polish schedule validation typing and tests. Type requiresExplicitSchedule status as ?string, reuse a local status variable in request/tool validation, tighten the web reject assertion, and collapse overlapping MCP unscheduled-create cases. * Centralize status helper in post update validation. Reuse the typed status() helper across FormRequests and the already-parsed $status in UpdatePostTool so schedule checks stay consistent and less noisy. * Share scheduled_at update rules across web, API, and MCP. Centralize schedule validation in PostStatusRules, normalize status parsing in one place, and align past-schedule coverage across entry points. * Cover the full unscheduled-draft checklist in Pest. Add feature coverage for null/past schedule rejection, explicit scheduling, draft saves, publish-now without a schedule, calendar exclusion, and 09:00 UTC date defaults across web, API, and MCP. * Remove normalizeStatus helper. Keep the inline is_string check at the few call sites that read raw request status before validation — no shared wrapper needed. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop is_string status guards from schedule validation. Accept mixed status in PostStatusRules and rely on strict comparisons with Rule::requiredIf / Rule::when — malformed input simply does not match. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Paulo Castellano <paulo@castellanos.llc>
2026-08-01 20:39:18 +00:00
->assertOk()
->assertJsonPath('scheduled_at', null);
expect($post->fresh()->scheduled_at)->toBeNull();
});
it('publishes an unscheduled draft without requiring scheduled_at', function () {
Bus::fake();
$this->freezeTime();
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'status' => PostStatus::Draft,
'scheduled_at' => null,
]);
PostPlatform::factory()->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' => 'publishing',
])
->assertOk()
->assertJsonPath('status', PostStatus::Publishing->value);
expect($post->fresh()->scheduled_at->toDateTimeString())->toBe(now()->toDateTimeString());
Bus::assertDispatched(PublishPost::class);
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.
2026-05-04 16:31:44 +00:00
});
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,
'user_id' => $this->user->id,
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->getJson(route('api.posts.index'))
->assertOk()
->assertJsonStructure([
'data' => [
'*' => ['id', 'status', 'scheduled_at', 'published_at', 'created_at', 'updated_at'],
],
]);
});
it('show post returns correct structure', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->getJson(route('api.posts.show', $post))
->assertOk()
->assertJsonStructure(['id', 'status', 'scheduled_at', 'published_at']);
});
it('creates a post with platform meta (aspect_ratio) and returns it', function () {
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.store'), [
'platforms' => [
['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post', 'meta' => ['aspect_ratio' => '4:5']],
],
])
->assertCreated()
->assertJsonPath('platforms.0.meta.aspect_ratio', '4:5');
$platform = Post::where('workspace_id', $this->workspace->id)->first()
->postPlatforms()->where('social_account_id', $this->socialAccount->id)->first();
expect($platform->meta['aspect_ratio'])->toBe('4:5');
});
it('rejects creating a post with an invalid aspect_ratio', function () {
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.store'), [
'platforms' => [
['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post', 'meta' => ['aspect_ratio' => '3:2']],
],
])
->assertJsonValidationErrors(['platforms.0.meta.aspect_ratio']);
});
it('rejects updating a post with an invalid aspect_ratio', 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' => 'linkedin_post', 'meta' => ['aspect_ratio' => '3:2']],
],
])
->assertJsonValidationErrors(['platforms.0.meta.aspect_ratio']);
});
it('accepts a valid aspect_ratio on update and persists it', 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' => 'linkedin_post', 'meta' => ['aspect_ratio' => '16:9']],
],
])
->assertOk()
->assertJsonPath('platforms.0.meta.aspect_ratio', '16:9');
expect($postPlatform->fresh()->meta['aspect_ratio'])->toBe('16:9');
});
it('accepts the original aspect_ratio (no crop) on create', function () {
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.store'), [
'platforms' => [
['social_account_id' => $this->socialAccount->id, 'content_type' => 'linkedin_post', 'meta' => ['aspect_ratio' => 'original']],
],
])
->assertCreated();
$platform = Post::where('workspace_id', $this->workspace->id)->first()
->postPlatforms()->where('social_account_id', $this->socialAccount->id)->first();
expect($platform->meta['aspect_ratio'])->toBe('original');
});