From 9eb8dd60a9735d4ce7853c4a0c1744c8af785917 Mon Sep 17 00:00:00 2001 From: Jamie Ontiveros <54843+jonto@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:06:22 -0400 Subject: [PATCH 01/12] Give the foreign key a backing index before dropping the unique social_accounts.workspace_id carries a foreign key, and the composite unique index is the only one covering it, as its leftmost prefix. MySQL refuses to drop the sole index backing a foreign key (SQLSTATE[HY000] 1553), so both rehearsal suites failed in beforeEach and never ran a single assertion on MySQL. Add a plain index on workspace_id first; PostgreSQL has no such requirement and simply carries it. This unmasks one assertion underneath that had never executed: the automation graph comparison at DuplicateIdentityMigrationTest.php:419 depended on JSON object key order, which MySQL normalises on storage. (cherry picked from commit 98a494bd2205e873321a18232f63b358ae259fdf) --- .../SocialAccount/DuplicateIdentityMigrationTest.php | 12 +++++++++++- .../SocialAccount/DuplicateIdentityRehearsalTest.php | 9 +++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/tests/Feature/SocialAccount/DuplicateIdentityMigrationTest.php b/tests/Feature/SocialAccount/DuplicateIdentityMigrationTest.php index c2f0606da..7e6a46d9d 100644 --- a/tests/Feature/SocialAccount/DuplicateIdentityMigrationTest.php +++ b/tests/Feature/SocialAccount/DuplicateIdentityMigrationTest.php @@ -24,6 +24,15 @@ 'migrations/2026_08_21_130941_add_workspace_platform_identity_unique_to_social_accounts_table.php', ); + // social_accounts.workspace_id has a foreign key, and the composite unique + // index is the only one covering it (as its leftmost prefix). MySQL refuses + // to drop the sole index backing a foreign key — SQLSTATE[HY000] 1553 — so + // give the constraint another index to rest on first. PostgreSQL has no such + // requirement and simply carries the extra index. + Schema::table('social_accounts', function (Blueprint $table) { + $table->index('workspace_id', 'social_accounts_workspace_id_fk_backing'); + }); + Schema::table('social_accounts', function (Blueprint $table) { $table->dropUnique('social_accounts_workspace_platform_identity_unique'); }); @@ -406,5 +415,6 @@ $this->migration->up(); - expect($automation->fresh()->nodes)->toBe($nodes); + // toEqual, not toBe: MySQL normalises JSON object key order on storage. + expect($automation->fresh()->nodes)->toEqual($nodes); }); diff --git a/tests/Feature/SocialAccount/DuplicateIdentityRehearsalTest.php b/tests/Feature/SocialAccount/DuplicateIdentityRehearsalTest.php index fc7c1b903..0031eb6a3 100644 --- a/tests/Feature/SocialAccount/DuplicateIdentityRehearsalTest.php +++ b/tests/Feature/SocialAccount/DuplicateIdentityRehearsalTest.php @@ -26,6 +26,15 @@ 'migrations/2026_08_21_130941_add_workspace_platform_identity_unique_to_social_accounts_table.php', ); + // social_accounts.workspace_id has a foreign key, and the composite unique + // index is the only one covering it (as its leftmost prefix). MySQL refuses + // to drop the sole index backing a foreign key — SQLSTATE[HY000] 1553 — so + // give the constraint another index to rest on first. PostgreSQL has no such + // requirement and simply carries the extra index. + Schema::table('social_accounts', function (Blueprint $table) { + $table->index('workspace_id', 'social_accounts_workspace_id_fk_backing'); + }); + Schema::table('social_accounts', function (Blueprint $table) { $table->dropUnique('social_accounts_workspace_platform_identity_unique'); }); From 37d14b1062632db263164b595ac84c07a67ec491 Mon Sep 17 00:00:00 2001 From: Jamie Ontiveros <54843+jonto@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:48:39 -0400 Subject: [PATCH 02/12] Compare JSON payloads without depending on key order MySQL normalises JSON object keys (length, then lexicographic) on storage, so an identity comparison against a literal asserts how the driver chose to lay the object out rather than what it contains. PostgreSQL preserves insertion order, which is why these passed there. toEqual compares associative arrays recursively without regard to key order. Applied to every assertion in this class, including the few that pass today only because their keys already happen to match MySQL's ordering. (cherry picked from commit 3124023c548d6c2b8b52126afc6fc5f38d461ea6) --- tests/Feature/Api/PostMediaApiTest.php | 5 +++-- tests/Feature/Automation/DetailTabsTest.php | 4 +++- tests/Feature/Mcp/AssetToolTest.php | 5 +++-- tests/Feature/Services/Social/InstagramPublisherTest.php | 8 ++++++-- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/tests/Feature/Api/PostMediaApiTest.php b/tests/Feature/Api/PostMediaApiTest.php index 32901a01f..c6fc2f7cc 100644 --- a/tests/Feature/Api/PostMediaApiTest.php +++ b/tests/Feature/Api/PostMediaApiTest.php @@ -662,7 +662,8 @@ expect($this->post->fresh()->media)->toHaveCount(1) ->and(data_get($this->post->fresh()->media, '0.id'))->toBe($asset->id) ->and(data_get($this->post->fresh()->media, '0.size'))->toBe(12345) - ->and(data_get($this->post->fresh()->media, '0.meta'))->toBe([ + // toEqual, not toBe: MySQL normalises JSON object key order. + ->and(data_get($this->post->fresh()->media, '0.meta'))->toEqual([ 'width' => 1920, 'height' => 1080, 'duration' => 12.5, @@ -688,7 +689,7 @@ ]) ->assertOk(); - expect(data_get($this->post->fresh()->media, '0.meta'))->toBe([ + expect(data_get($this->post->fresh()->media, '0.meta'))->toEqual([ 'width' => 800, 'height' => 600, 'alt_text' => 'From library', diff --git a/tests/Feature/Automation/DetailTabsTest.php b/tests/Feature/Automation/DetailTabsTest.php index acd05bdb9..fcb8da7d2 100644 --- a/tests/Feature/Automation/DetailTabsTest.php +++ b/tests/Feature/Automation/DetailTabsTest.php @@ -61,7 +61,9 @@ $automation->refresh(); expect($automation->name)->toBe('Renamed flow'); - expect($automation->nodes)->toBe($originalNodes); + // toEqual, not toBe: MySQL normalises JSON object key order, so the graph + // round-trips with the same content in a different key order. + expect($automation->nodes)->toEqual($originalNodes); }); it('renders the invocations tab with a scroll-paginated list', function () { diff --git a/tests/Feature/Mcp/AssetToolTest.php b/tests/Feature/Mcp/AssetToolTest.php index 02da5600b..178a56aba 100644 --- a/tests/Feature/Mcp/AssetToolTest.php +++ b/tests/Feature/Mcp/AssetToolTest.php @@ -198,7 +198,8 @@ expect($this->post->fresh()->media)->toHaveCount(1) ->and(data_get($this->post->fresh()->media, '0.size'))->toBe(12345) - ->and(data_get($this->post->fresh()->media, '0.meta'))->toBe([ + // toEqual, not toBe: MySQL normalises JSON object key order. + ->and(data_get($this->post->fresh()->media, '0.meta'))->toEqual([ 'width' => 1920, 'height' => 1080, 'duration' => 12.5, @@ -225,7 +226,7 @@ ]) ->assertOk(); - expect(data_get($this->post->fresh()->media, '0.meta'))->toBe([ + expect(data_get($this->post->fresh()->media, '0.meta'))->toEqual([ 'width' => 800, 'height' => 600, 'alt_text' => 'From library', diff --git a/tests/Feature/Services/Social/InstagramPublisherTest.php b/tests/Feature/Services/Social/InstagramPublisherTest.php index 552f47a81..72d06ba3b 100644 --- a/tests/Feature/Services/Social/InstagramPublisherTest.php +++ b/tests/Feature/Services/Social/InstagramPublisherTest.php @@ -922,7 +922,9 @@ function fakeJpegBytes(int $width = 1200, int $height = 800): string 'url' => null, ]); - expect($this->postPlatform->fresh()->error_context['instagram_workflow'] ?? null)->toBe([ + // toEqual, not toBe: MySQL normalises JSON object key order, so an identity + // comparison would depend on how the driver chose to store the object. + expect($this->postPlatform->fresh()->error_context['instagram_workflow'] ?? null)->toEqual([ 'stage' => 'final_container', 'container_id' => 'container-123', 'media_id' => 'media-123456789', @@ -1272,7 +1274,9 @@ function fakeJpegBytes(int $width = 1200, int $height = 800): string 'url' => null, ]); - expect($this->postPlatform->fresh()->error_context['instagram_workflow'] ?? null)->toBe([ + // toEqual, not toBe: MySQL normalises JSON object key order, so an identity + // comparison would depend on how the driver chose to store the object. + expect($this->postPlatform->fresh()->error_context['instagram_workflow'] ?? null)->toEqual([ 'stage' => 'final_container', 'container_id' => 'container-123', 'media_id' => 'media-123456789', From bbfefde008b5d619aa0c2126302bf766e74ef24f Mon Sep 17 00:00:00 2001 From: Jamie Ontiveros <54843+jonto@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:45:25 -0400 Subject: [PATCH 03/12] Match logged SQL without depending on identifier quoting Four DB::listen predicates matched 'select * from "post_platforms"'. PostgreSQL quotes identifiers with double quotes and MySQL with backticks, so on MySQL the predicates never matched, the simulated mid-run pause never fired, and the race these tests exist to cover went unexercised while the tests still reported failures elsewhere. Compare against the unquoted form via a small helper. (cherry picked from commit 67a81df5de155e80227df748b34cd8b3cfd744f9) --- .../VerifyUpcomingPostConnectionsTest.php | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/tests/Feature/Jobs/VerifyUpcomingPostConnectionsTest.php b/tests/Feature/Jobs/VerifyUpcomingPostConnectionsTest.php index 3a4686543..9e9ad7c8e 100644 --- a/tests/Feature/Jobs/VerifyUpcomingPostConnectionsTest.php +++ b/tests/Feature/Jobs/VerifyUpcomingPostConnectionsTest.php @@ -22,6 +22,17 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Mail; +/** + * Whether a logged query is a plain select against $table, regardless of how the + * driver quotes identifiers — PostgreSQL emits "post_platforms", MySQL emits + * `post_platforms`. Matching the quoted form directly makes these listeners + * silently never fire on MySQL, so the race they simulate goes untested. + */ +function verifyUpcomingSelectsFrom(string $sql, string $table): bool +{ + return str_starts_with(str_replace(['"', '`'], '', $sql), "select * from {$table}"); +} + test('marks the account expired and queues a notification when verify throws TokenExpiredException', function () { Mail::fake(); @@ -946,7 +957,7 @@ // \"post_platforms\" where ...)", which contains but doesn't start with // this prefix, so those never trip the listener. $listener = function ($query) use ($doomedPost) { - if (str_starts_with($query->sql, 'select * from "post_platforms"')) { + if (verifyUpcomingSelectsFrom($query->sql, 'post_platforms')) { Post::where('id', $doomedPost->id)->delete(); } }; @@ -1090,7 +1101,7 @@ // but before the per-account loop reaches it, reproducing the race the // fresh() re-check at the top of each account's iteration exists to close. $listener = function ($query) use ($account) { - if (str_starts_with($query->sql, 'select * from "post_platforms"')) { + if (verifyUpcomingSelectsFrom($query->sql, 'post_platforms')) { $account->update(['is_active' => false]); } }; @@ -1132,7 +1143,7 @@ // reaching it shouldn't get warned about a connection its owner // deliberately paused, even though it's already broken. $listener = function ($query) use ($account) { - if (str_starts_with($query->sql, 'select * from "post_platforms"')) { + if (verifyUpcomingSelectsFrom($query->sql, 'post_platforms')) { $account->update(['is_active' => false]); } }; @@ -1179,7 +1190,7 @@ // needs the account to still resolve as non-null going into the loop, // then disappear before the guard's own re-fetch runs. $listener = function ($query) use ($account) { - if (str_starts_with($query->sql, 'select * from "social_accounts"')) { + if (verifyUpcomingSelectsFrom($query->sql, 'social_accounts')) { $account->delete(); } }; From 1af9579e74d7e0718cc333adb38c4095883a8b27 Mon Sep 17 00:00:00 2001 From: Jamie Ontiveros <54843+jonto@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:44:39 -0400 Subject: [PATCH 04/12] Cast raw boolean reads in tests so they pass on MySQL Three assertions read oauth_refresh_tokens.revoked through the query builder rather than Eloquent, so no cast applies and the driver's native representation leaks into the test: a real boolean on PostgreSQL, 1 on MySQL. Cast explicitly at the call site. (cherry picked from commit 2911c5c48cf65d24a34a41e667335c40005839a7) --- tests/Feature/McpSettingsControllerTest.php | 3 ++- tests/Feature/WorkspaceInviteControllerTest.php | 3 ++- tests/Unit/RevokeAccessTokensTest.php | 4 +++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/Feature/McpSettingsControllerTest.php b/tests/Feature/McpSettingsControllerTest.php index ca300d353..ea749f703 100644 --- a/tests/Feature/McpSettingsControllerTest.php +++ b/tests/Feature/McpSettingsControllerTest.php @@ -129,7 +129,8 @@ ->assertSessionHas('flash.success'); expect($token->fresh()->revoked)->toBeTrue() - ->and(DB::table('oauth_refresh_tokens')->where('id', $refreshTokenId)->value('revoked'))->toBeTrue(); + // Raw query-builder read: cast explicitly, the driver decides the shape. + ->and((bool) DB::table('oauth_refresh_tokens')->where('id', $refreshTokenId)->value('revoked'))->toBeTrue(); }); it('lists a client when its access token expired but its refresh token is live', function (): void { diff --git a/tests/Feature/WorkspaceInviteControllerTest.php b/tests/Feature/WorkspaceInviteControllerTest.php index 72c3c271c..a37490ca9 100644 --- a/tests/Feature/WorkspaceInviteControllerTest.php +++ b/tests/Feature/WorkspaceInviteControllerTest.php @@ -341,7 +341,8 @@ $response->assertRedirect(); expect($oauth->fresh()->revoked)->toBeFalse() - ->and(DB::table('oauth_refresh_tokens')->where('id', $refreshTokenId)->value('revoked'))->toBeFalse() + // Raw query-builder read: cast explicitly, the driver decides the shape. + ->and((bool) DB::table('oauth_refresh_tokens')->where('id', $refreshTokenId)->value('revoked'))->toBeFalse() ->and($member->fresh()->can('createPost', $this->workspace))->toBeFalse() ->and($member->fresh()->can('view', $this->workspace))->toBeTrue(); }); diff --git a/tests/Unit/RevokeAccessTokensTest.php b/tests/Unit/RevokeAccessTokensTest.php index d876377dc..676e8651a 100644 --- a/tests/Unit/RevokeAccessTokensTest.php +++ b/tests/Unit/RevokeAccessTokensTest.php @@ -31,7 +31,9 @@ RevokeAccessTokens::execute($token); expect(AccessToken::query()->find($token->id)->revoked)->toBeTrue(); - expect(DB::table('oauth_refresh_tokens')->where('id', $refreshId)->value('revoked'))->toBeTrue(); + // Raw query-builder read: no Eloquent cast applies, so the driver's native + // boolean representation leaks through (bool on Postgres, 1 on MySQL). + expect((bool) DB::table('oauth_refresh_tokens')->where('id', $refreshId)->value('revoked'))->toBeTrue(); }); test('ignores already revoked tokens without error', function () { From 110bf0048fc1caf7556726084e1e82657bb9955a Mon Sep 17 00:00:00 2001 From: Jamie Ontiveros <54843+jonto@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:46:12 -0400 Subject: [PATCH 05/12] Use a scheduling date inside MySQL's TIMESTAMP range MySQL TIMESTAMP columns end at 2038-01-19, so the 2099 sentinel these tests used is rejected outright with SQLSTATE[22007]. 2037-12-31 still reads as a far-future schedule and works on both engines. (cherry picked from commit bde33eb239cdbd3a5567d4c21e1d85302913cdd7) --- tests/Feature/Mcp/PostPublishToolTest.php | 3 ++- tests/Feature/Mcp/PostToolTest.php | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/Feature/Mcp/PostPublishToolTest.php b/tests/Feature/Mcp/PostPublishToolTest.php index 583f44545..1a659d2fd 100644 --- a/tests/Feature/Mcp/PostPublishToolTest.php +++ b/tests/Feature/Mcp/PostPublishToolTest.php @@ -227,7 +227,8 @@ $response = TryPostServer::actingAs($this->user) ->tool(PublishPostTool::class, [ 'post_id' => $post->id, - 'scheduled_at' => '2099-12-31T15:30:00Z', + // Inside MySQL's TIMESTAMP range, which ends 2038-01-19. + 'scheduled_at' => '2037-12-31T15:30:00Z', ]); $response->assertOk(); diff --git a/tests/Feature/Mcp/PostToolTest.php b/tests/Feature/Mcp/PostToolTest.php index bcdd24042..1d8ec0223 100644 --- a/tests/Feature/Mcp/PostToolTest.php +++ b/tests/Feature/Mcp/PostToolTest.php @@ -143,14 +143,16 @@ $response = TryPostServer::actingAs($this->user) ->tool(CreatePostTool::class, [ 'content' => 'My new post', - 'scheduled_at' => '2099-12-31T15:30:00Z', + // Inside MySQL's TIMESTAMP range, which ends 2038-01-19; still far + // enough out to read as "far future" for scheduling assertions. + 'scheduled_at' => '2037-12-31T15:30:00Z', ]); $response->assertOk() ->assertStructuredContent(function (AssertableJson $json) { $json->where('content', 'My new post') ->where('status', 'draft') - ->where('scheduled_at', '2099-12-31 15:30:00') + ->where('scheduled_at', '2037-12-31 15:30:00') ->etc(); }); From ef798455ea0a774683d9ea5d3ce7038aa1b017c7 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 29 Aug 2026 09:44:14 -0300 Subject: [PATCH 06/12] Remove the duplicate-identity migration scenario test The suite rebuilt a pre-migration schema by dropping the unique index in beforeEach and re-running the migration by hand, exercising a database state the application never runs in. --- .../DuplicateIdentityMigrationTest.php | 420 ------------------ 1 file changed, 420 deletions(-) delete mode 100644 tests/Feature/SocialAccount/DuplicateIdentityMigrationTest.php diff --git a/tests/Feature/SocialAccount/DuplicateIdentityMigrationTest.php b/tests/Feature/SocialAccount/DuplicateIdentityMigrationTest.php deleted file mode 100644 index 7e6a46d9d..000000000 --- a/tests/Feature/SocialAccount/DuplicateIdentityMigrationTest.php +++ /dev/null @@ -1,420 +0,0 @@ -set('trypost.allow_multiple_social_accounts', true); - - $this->migration = require database_path( - 'migrations/2026_08_21_130941_add_workspace_platform_identity_unique_to_social_accounts_table.php', - ); - - // social_accounts.workspace_id has a foreign key, and the composite unique - // index is the only one covering it (as its leftmost prefix). MySQL refuses - // to drop the sole index backing a foreign key — SQLSTATE[HY000] 1553 — so - // give the constraint another index to rest on first. PostgreSQL has no such - // requirement and simply carries the extra index. - Schema::table('social_accounts', function (Blueprint $table) { - $table->index('workspace_id', 'social_accounts_workspace_id_fk_backing'); - }); - - Schema::table('social_accounts', function (Blueprint $table) { - $table->dropUnique('social_accounts_workspace_platform_identity_unique'); - }); - - $this->workspace = Workspace::factory()->create(); -}); - -test('it collapses duplicate identities and keeps the newest row', function () { - $older = SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::Pinterest, - 'platform_user_id' => 'pin-1', - 'username' => 'older', - 'created_at' => now()->subDay(), - ]); - - $newer = SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::Pinterest, - 'platform_user_id' => 'pin-1', - 'username' => 'newer', - 'created_at' => now(), - ]); - - $this->migration->up(); - - expect(SocialAccount::whereKey($newer->id)->exists())->toBeTrue() - ->and(SocialAccount::whereKey($older->id)->exists())->toBeFalse() - ->and($this->workspace->socialAccounts()->count())->toBe(1); -}); - -test('it moves posts from the dropped duplicate onto the surviving account', function () { - $older = SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::Pinterest, - 'platform_user_id' => 'pin-1', - 'created_at' => now()->subDay(), - ]); - - $newer = SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::Pinterest, - 'platform_user_id' => 'pin-1', - 'created_at' => now(), - ]); - - $post = Post::factory()->create(['workspace_id' => $this->workspace->id]); - - $platform = PostPlatform::factory()->create([ - 'post_id' => $post->id, - 'social_account_id' => $older->id, - 'platform' => Platform::Pinterest, - ]); - - $this->migration->up(); - - expect($platform->fresh()->social_account_id)->toBe($newer->id); -}); - -test('it leaves a post with a single target when both duplicates were selected', function () { - $older = SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::Pinterest, - 'platform_user_id' => 'pin-1', - 'created_at' => now()->subDay(), - ]); - - $newer = SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::Pinterest, - 'platform_user_id' => 'pin-1', - 'created_at' => now(), - ]); - - $post = Post::factory()->create(['workspace_id' => $this->workspace->id]); - - foreach ([$older, $newer] as $account) { - PostPlatform::factory()->create([ - 'post_id' => $post->id, - 'social_account_id' => $account->id, - 'platform' => Platform::Pinterest, - ]); - } - - $this->migration->up(); - - expect(PostPlatform::where('post_id', $post->id)->count())->toBe(1) - ->and(PostPlatform::where('post_id', $post->id)->first()->social_account_id)->toBe($newer->id); -}); - -test('it never deletes a published row when collapsing repeated post targets', function () { - $older = SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::Pinterest, - 'platform_user_id' => 'pin-1', - 'created_at' => now()->subDay(), - ]); - - $newer = SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::Pinterest, - 'platform_user_id' => 'pin-1', - 'created_at' => now(), - ]); - - $post = Post::factory()->create(['workspace_id' => $this->workspace->id]); - - // Both duplicates were enabled, so the post really did go out twice and - // each row holds the platform_post_id for a live post on the network. - $rows = collect([$older, $newer])->map(fn (SocialAccount $account) => PostPlatform::factory()->create([ - 'post_id' => $post->id, - 'social_account_id' => $account->id, - 'platform' => Platform::Pinterest, - 'status' => PostPlatformStatus::Published, - ])); - - $this->migration->up(); - - expect(PostPlatform::where('post_id', $post->id)->pluck('id')->sort()->values()->all()) - ->toBe($rows->pluck('id')->sort()->values()->all()); -}); - -test('it keeps the enabled row when collapsing repeated post targets', function () { - $older = SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::Pinterest, - 'platform_user_id' => 'pin-1', - 'created_at' => now()->subDay(), - ]); - - $newer = SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::Pinterest, - 'platform_user_id' => 'pin-1', - 'created_at' => now(), - ]); - - $post = Post::factory()->create(['workspace_id' => $this->workspace->id]); - - // SyncPostPlatforms seeds a disabled row for every account, so the enabled - // one is not necessarily the newest. - $enabled = PostPlatform::factory()->create([ - 'post_id' => $post->id, - 'social_account_id' => $older->id, - 'platform' => Platform::Pinterest, - 'status' => PostPlatformStatus::Pending, - 'enabled' => true, - ]); - - PostPlatform::factory()->create([ - 'post_id' => $post->id, - 'social_account_id' => $newer->id, - 'platform' => Platform::Pinterest, - 'status' => PostPlatformStatus::Pending, - 'enabled' => false, - ]); - - $this->migration->up(); - - expect(PostPlatform::where('post_id', $post->id)->pluck('id')->all())->toBe([$enabled->id]); -}); - -test('it leaves distinct identities untouched', function () { - $first = SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::Pinterest, - 'platform_user_id' => 'pin-1', - ]); - - $second = SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::Pinterest, - 'platform_user_id' => 'pin-2', - ]); - - $this->migration->up(); - - expect(SocialAccount::whereKey($first->id)->exists())->toBeTrue() - ->and(SocialAccount::whereKey($second->id)->exists())->toBeTrue(); -}); - -test('it restores the unique index so duplicates cannot come back', function () { - SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::Pinterest, - 'platform_user_id' => 'pin-1', - 'created_at' => now()->subDay(), - ]); - - SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::Pinterest, - 'platform_user_id' => 'pin-1', - 'created_at' => now(), - ]); - - $this->migration->up(); - - expect(fn () => SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::Pinterest, - 'platform_user_id' => 'pin-1', - ]))->toThrow(UniqueConstraintViolationException::class); -}); - -test('it repoints automation nodes at the surviving account', function () { - $older = SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::Pinterest, - 'platform_user_id' => 'pin-1', - 'created_at' => now()->subDay(), - ]); - - $newer = SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::Pinterest, - 'platform_user_id' => 'pin-1', - 'created_at' => now(), - ]); - - $automation = Automation::factory()->for($this->workspace)->create([ - 'nodes' => [ - [ - 'id' => 'node-1', - 'type' => 'generate', - 'config' => [ - 'accounts' => [ - ['social_account_id' => $older->id, 'content_type' => 'pinterest_pin'], - ], - ], - ], - ], - ]); - - $this->migration->up(); - - expect(data_get($automation->fresh()->nodes, '0.config.accounts.0.social_account_id'))->toBe($newer->id); -}); - -test('it collapses automation targets that the merge turned into duplicates', function () { - $older = SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::Pinterest, - 'platform_user_id' => 'pin-1', - 'created_at' => now()->subDay(), - ]); - - $newer = SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::Pinterest, - 'platform_user_id' => 'pin-1', - 'created_at' => now(), - ]); - - $automation = Automation::factory()->for($this->workspace)->create([ - 'nodes' => [ - [ - 'id' => 'node-1', - 'type' => 'generate', - 'config' => [ - 'accounts' => [ - ['social_account_id' => $older->id, 'content_type' => 'pinterest_pin'], - ['social_account_id' => $newer->id, 'content_type' => 'pinterest_pin'], - ], - ], - ], - ], - ]); - - $this->migration->up(); - - expect(data_get($automation->fresh()->nodes, '0.config.accounts'))->toHaveCount(1) - ->and(data_get($automation->fresh()->nodes, '0.config.accounts.0.social_account_id'))->toBe($newer->id); -}); - -test('it repoints the legacy social_account_ids shape too', function () { - $older = SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::Pinterest, - 'platform_user_id' => 'pin-1', - 'created_at' => now()->subDay(), - ]); - - $newer = SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::Pinterest, - 'platform_user_id' => 'pin-1', - 'created_at' => now(), - ]); - - $automation = Automation::factory()->for($this->workspace)->create([ - 'nodes' => [ - [ - 'id' => 'node-1', - 'type' => 'generate', - 'config' => ['social_account_ids' => [$older->id, $newer->id]], - ], - ], - ]); - - $this->migration->up(); - - expect(data_get($automation->fresh()->nodes, '0.config.social_account_ids'))->toBe([$newer->id]); -}); - -test('it drops every unpublished repeat once the post already published there', function () { - $older = SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::Pinterest, - 'platform_user_id' => 'pin-1', - 'created_at' => now()->subDay(), - ]); - - $newer = SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::Pinterest, - 'platform_user_id' => 'pin-1', - 'created_at' => now(), - ]); - - $post = Post::factory()->create(['workspace_id' => $this->workspace->id]); - - $published = PostPlatform::factory()->create([ - 'post_id' => $post->id, - 'social_account_id' => $older->id, - 'platform' => Platform::Pinterest, - 'status' => PostPlatformStatus::Published, - ]); - - // Enabled and pending against the duplicate: a republish would deliver the - // same content to the same identity a second time. - PostPlatform::factory()->create([ - 'post_id' => $post->id, - 'social_account_id' => $newer->id, - 'platform' => Platform::Pinterest, - 'status' => PostPlatformStatus::Pending, - 'enabled' => true, - ]); - - $this->migration->up(); - - expect(PostPlatform::where('post_id', $post->id)->pluck('id')->all())->toBe([$published->id]); -}); - -test('it leaves automations the merge never touched alone', function () { - SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::Pinterest, - 'platform_user_id' => 'pin-1', - 'created_at' => now()->subDay(), - ]); - - SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::Pinterest, - 'platform_user_id' => 'pin-1', - 'created_at' => now(), - ]); - - $untouched = SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::X, - 'platform_user_id' => 'x-1', - ]); - - $nodes = [ - [ - 'id' => 'node-1', - 'type' => 'generate', - 'config' => [ - 'accounts' => [ - ['social_account_id' => $untouched->id, 'content_type' => 'x_post'], - ['social_account_id' => $untouched->id, 'content_type' => 'x_thread'], - ], - ], - ], - ]; - - $automation = Automation::factory()->for($this->workspace)->create(['nodes' => $nodes]); - - $this->migration->up(); - - // toEqual, not toBe: MySQL normalises JSON object key order on storage. - expect($automation->fresh()->nodes)->toEqual($nodes); -}); From f49526a6ae2dcacc488f072cd44e2edccc12b27c Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 29 Aug 2026 10:02:41 -0300 Subject: [PATCH 07/12] Fix the MySQL rollback path and run CI on both engines The migration's down() dropped a unique whose leftmost prefix is an FK column, which MySQL refuses when nothing else backs the constraint (SQLSTATE 1553). It now creates a standalone index first, so migrate:rollback works on MySQL and stays a no-op change for PostgreSQL. up() is untouched: every database already migrated keeps its schema. The rehearsal test calls that down() instead of hand-rolling the drop, so it exercises the real rollback rather than an imitation of it. Matches logged SQL through the connection's query grammar rather than stripping quote characters, and adds a MySQL leg to the backend CI job. --- .env.ci | 2 +- .github/actions/setup-laravel/action.yml | 4 +- .github/workflows/tests.yml | 38 +++++++++++++++---- AGENTS.md | 13 +++++++ CLAUDE.md | 13 +++++++ ...entity_unique_to_social_accounts_table.php | 12 ++++++ tests/Feature/Api/PostMediaApiTest.php | 1 - tests/Feature/Automation/DetailTabsTest.php | 2 - .../VerifyUpcomingPostConnectionsTest.php | 12 +++--- tests/Feature/Mcp/AssetToolTest.php | 1 - tests/Feature/Mcp/PostPublishToolTest.php | 1 - tests/Feature/Mcp/PostToolTest.php | 2 - tests/Feature/McpSettingsControllerTest.php | 1 - .../Social/InstagramPublisherTest.php | 4 -- .../DuplicateIdentityRehearsalTest.php | 15 +------- .../Feature/WorkspaceInviteControllerTest.php | 1 - tests/Unit/RevokeAccessTokensTest.php | 2 - 17 files changed, 78 insertions(+), 46 deletions(-) diff --git a/.env.ci b/.env.ci index d7ce1a713..72d5ced86 100644 --- a/.env.ci +++ b/.env.ci @@ -20,7 +20,7 @@ LOG_STACK=single LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug -# Database (PostgreSQL for CI) +# Database (the workflow overrides DB_CONNECTION/DB_USERNAME/DB_PORT per engine) DB_CONNECTION=pgsql DB_HOST=127.0.0.1 DB_PORT=5432 diff --git a/.github/actions/setup-laravel/action.yml b/.github/actions/setup-laravel/action.yml index fb27885c9..9360d5c76 100644 --- a/.github/actions/setup-laravel/action.yml +++ b/.github/actions/setup-laravel/action.yml @@ -6,7 +6,7 @@ inputs: description: 'Also set up Node.js and install npm dependencies.' default: 'false' db-port: - description: 'Host port mapped to the Postgres service.' + description: 'Host port mapped to the database service.' required: true redis-port: description: 'Host port mapped to the Redis service.' @@ -19,7 +19,7 @@ runs: uses: shivammathur/setup-php@v2 with: php-version: '8.4' - extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, gd, redis + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, pdo_mysql, bcmath, intl, gd, redis coverage: none - name: Setup Node.js diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ab2e19625..90e44929f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -18,22 +18,46 @@ env: jobs: backend: + name: Backend (${{ matrix.name }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - name: PostgreSQL + connection: pgsql + image: postgres:16 + port: '5432' + username: postgres + health: '--health-cmd="pg_isready"' + - name: MySQL + connection: mysql + image: mysql:8.4 + port: '3306' + username: root + health: '--health-cmd="mysqladmin ping -h 127.0.0.1 -u root -ppassword"' + + env: + DB_CONNECTION: ${{ matrix.connection }} + DB_USERNAME: ${{ matrix.username }} + services: - postgres: - image: postgres:16 + database: + image: ${{ matrix.image }} env: POSTGRES_USER: postgres POSTGRES_PASSWORD: password POSTGRES_DB: trypost_test + MYSQL_ROOT_PASSWORD: password + MYSQL_DATABASE: trypost_test ports: - - 5432/tcp + - ${{ matrix.port }}/tcp options: >- - --health-cmd="pg_isready" + ${{ matrix.health }} --health-interval=10s --health-timeout=5s - --health-retries=3 + --health-retries=10 redis: image: redis:7 @@ -52,12 +76,12 @@ jobs: - name: Setup test environment uses: ./.github/actions/setup-laravel with: - db-port: ${{ job.services.postgres.ports['5432'] }} + db-port: ${{ job.services.database.ports[matrix.port] }} redis-port: ${{ job.services.redis.ports['6379'] }} - name: Run backend tests env: - DB_PORT: ${{ job.services.postgres.ports['5432'] }} + DB_PORT: ${{ job.services.database.ports[matrix.port] }} REDIS_PORT: ${{ job.services.redis.ports['6379'] }} run: php artisan test --compact --parallel diff --git a/AGENTS.md b/AGENTS.md index 9b1dbd0b8..2ccfaa21a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -232,6 +232,19 @@ One connected identity per social network per workspace is the Cloud default. Th Self-hosted compose / `.env.example` set this `true`. When the env is unset, the config falls back to `SELF_HOSTED` so existing self-hosted installs keep multiple accounts. Do **not** use `selfHosted` for the occupancy check (observer, Telegram connect, `NetworkConnectGrid`). +## Database engines (PostgreSQL + MySQL) + +TryPost runs on **both PostgreSQL and MySQL**. Cloud runs PostgreSQL; a self-hosted install may pick either. Every query, migration, and test must work on both — the suite is expected to be green on each. + +- Never use an engine-specific operator or function. Search uses `whereLike()` (Laravel handles the case-insensitive form per driver), never `ilike` or a raw `LOWER(...)` comparison. +- Traps that only surface on MySQL: + - **JSON object key order is not preserved.** MySQL reorders object keys on storage (by length, then lexicographically); PostgreSQL keeps insertion order. Assert JSON read back from the database with `toEqual` (recursive, order-independent), never `toBe`/`assertSame`. Array *element* order is preserved on both. + - **`$table->timestamp()` tops out at 2038-01-19.** Never pick a far-future sentinel date beyond that — `2037-12-31` reads as "far future" and works everywhere. + - **Raw query-builder reads carry no Eloquent cast**, so the driver's native shape leaks through: `DB::table(...)->value('some_bool')` is `true` on PostgreSQL and `1` on MySQL. Read through the model, or use `assertDatabaseHas`. + - **Identifier quoting differs** — PostgreSQL emits `"post_platforms"`, MySQL emits backticks. Never match logged SQL (`DB::listen`) against a quoted identifier. + - **MySQL refuses to drop the only index backing a foreign key** (SQLSTATE `1553`). A migration `down()` that drops a unique whose leftmost prefix is an FK column must create a standalone index for that column first. + - **DDL implicitly commits**, which defeats `RefreshDatabase`'s rollback: schema changes made inside a test leak into the tests that follow. Keep them idempotent. + ## Social Platform API Documentation (official sources) **Always consult the official docs below before implementing or changing OAuth, publishing, deletion, rate-limit, or any other platform-specific behavior — never guess endpoints, scopes, rate limits, or capabilities from memory.** APIs shift over time; a behavior confirmed in a past session may no longer hold. One entry per social network we integrate with: diff --git a/CLAUDE.md b/CLAUDE.md index 3ec2deb86..f2f5b91cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -296,6 +296,19 @@ Self-hosted compose / `.env.example` set this `true`. When the env is unset, the - Validation rules always live in a dedicated `Illuminate\Foundation\Http\FormRequest` subclass under `app/Http/Requests/App//`. Controller actions must type-hint the FormRequest as the parameter — NEVER call `$request->validate([...])` inline in the controller. - Naming: `Request.php` (e.g. `StorePostRequest`, `UpdatePostRequest`, `LinkPreviewRequest`). +## Database engines (PostgreSQL + MySQL) + +TryPost runs on **both PostgreSQL and MySQL**. Cloud runs PostgreSQL; a self-hosted install may pick either. Every query, migration, and test must work on both — the suite is expected to be green on each. + +- Never use an engine-specific operator or function. Search uses `whereLike()` (Laravel handles the case-insensitive form per driver), never `ilike` or a raw `LOWER(...)` comparison. +- Traps that only surface on MySQL: + - **JSON object key order is not preserved.** MySQL reorders object keys on storage (by length, then lexicographically); PostgreSQL keeps insertion order. Assert JSON read back from the database with `toEqual` (recursive, order-independent), never `toBe`/`assertSame`. Array *element* order is preserved on both. + - **`$table->timestamp()` tops out at 2038-01-19.** Never pick a far-future sentinel date beyond that — `2037-12-31` reads as "far future" and works everywhere. + - **Raw query-builder reads carry no Eloquent cast**, so the driver's native shape leaks through: `DB::table(...)->value('some_bool')` is `true` on PostgreSQL and `1` on MySQL. Read through the model, or use `assertDatabaseHas`. + - **Identifier quoting differs** — PostgreSQL emits `"post_platforms"`, MySQL emits backticks. Never match logged SQL (`DB::listen`) against a quoted identifier. + - **MySQL refuses to drop the only index backing a foreign key** (SQLSTATE `1553`). A migration `down()` that drops a unique whose leftmost prefix is an FK column must create a standalone index for that column first. + - **DDL implicitly commits**, which defeats `RefreshDatabase`'s rollback: schema changes made inside a test leak into the tests that follow. Keep them idempotent. + ## Per-Platform Post Meta (`PostPlatform.meta`) - All `platforms.*.meta` validation (the parent array rule AND every per-platform sub-key: `aspect_ratio`, TikTok `privacy_level`/flags, Pinterest `board_id`, Discord `channel_id`/`mentions`/`embeds`, etc.) lives in ONE place: `App\Support\PostPlatformMetaRules`. diff --git a/database/migrations/2026_08_21_130941_add_workspace_platform_identity_unique_to_social_accounts_table.php b/database/migrations/2026_08_21_130941_add_workspace_platform_identity_unique_to_social_accounts_table.php index 2eb9b1c90..9617dd411 100644 --- a/database/migrations/2026_08_21_130941_add_workspace_platform_identity_unique_to_social_accounts_table.php +++ b/database/migrations/2026_08_21_130941_add_workspace_platform_identity_unique_to_social_accounts_table.php @@ -29,9 +29,21 @@ public function up(): void * Drops the index only. The data merge in `up()` is one-way: the losing * rows are gone, so rolling back leaves the collapsed identities collapsed. * Every merge is logged at warning level so it can be reconstructed. + * + * `social_accounts.workspace_id` carries a foreign key, and the composite + * unique is the only index covering it - as its leftmost prefix. MySQL + * refuses to drop the sole index backing a foreign key, so the constraint + * needs another one to rest on first. PostgreSQL has no such requirement + * and simply carries the extra index. */ public function down(): void { + if (! Schema::hasIndex('social_accounts', 'social_accounts_workspace_id_index')) { + Schema::table('social_accounts', function (Blueprint $table) { + $table->index('workspace_id'); + }); + } + Schema::table('social_accounts', function (Blueprint $table) { $table->dropUnique('social_accounts_workspace_platform_identity_unique'); }); diff --git a/tests/Feature/Api/PostMediaApiTest.php b/tests/Feature/Api/PostMediaApiTest.php index c6fc2f7cc..ee6d69c60 100644 --- a/tests/Feature/Api/PostMediaApiTest.php +++ b/tests/Feature/Api/PostMediaApiTest.php @@ -662,7 +662,6 @@ expect($this->post->fresh()->media)->toHaveCount(1) ->and(data_get($this->post->fresh()->media, '0.id'))->toBe($asset->id) ->and(data_get($this->post->fresh()->media, '0.size'))->toBe(12345) - // toEqual, not toBe: MySQL normalises JSON object key order. ->and(data_get($this->post->fresh()->media, '0.meta'))->toEqual([ 'width' => 1920, 'height' => 1080, diff --git a/tests/Feature/Automation/DetailTabsTest.php b/tests/Feature/Automation/DetailTabsTest.php index fcb8da7d2..e33e0a9fd 100644 --- a/tests/Feature/Automation/DetailTabsTest.php +++ b/tests/Feature/Automation/DetailTabsTest.php @@ -61,8 +61,6 @@ $automation->refresh(); expect($automation->name)->toBe('Renamed flow'); - // toEqual, not toBe: MySQL normalises JSON object key order, so the graph - // round-trips with the same content in a different key order. expect($automation->nodes)->toEqual($originalNodes); }); diff --git a/tests/Feature/Jobs/VerifyUpcomingPostConnectionsTest.php b/tests/Feature/Jobs/VerifyUpcomingPostConnectionsTest.php index 9e9ad7c8e..7b9e60cc5 100644 --- a/tests/Feature/Jobs/VerifyUpcomingPostConnectionsTest.php +++ b/tests/Feature/Jobs/VerifyUpcomingPostConnectionsTest.php @@ -23,14 +23,12 @@ use Illuminate\Support\Facades\Mail; /** - * Whether a logged query is a plain select against $table, regardless of how the - * driver quotes identifiers — PostgreSQL emits "post_platforms", MySQL emits - * `post_platforms`. Matching the quoted form directly makes these listeners - * silently never fire on MySQL, so the race they simulate goes untested. + * Whether a logged query is a plain select against $table, asking the connection + * how it quotes identifiers rather than assuming a driver. */ function verifyUpcomingSelectsFrom(string $sql, string $table): bool { - return str_starts_with(str_replace(['"', '`'], '', $sql), "select * from {$table}"); + return str_starts_with($sql, 'select * from '.DB::getQueryGrammar()->wrapTable($table)); } test('marks the account expired and queues a notification when verify throws TokenExpiredException', function () { @@ -954,8 +952,8 @@ function verifyUpcomingSelectsFrom(string $sql, string $table): bool // str_starts_with (not str_contains) deliberately excludes the // recentlyWarnedAbout()/recentlyDisconnected() exists() subqueries — // Laravel compiles ->exists() as "select exists(select * from - // \"post_platforms\" where ...)", which contains but doesn't start with - // this prefix, so those never trip the listener. + // post_platforms where ...)", which contains but doesn't start with this + // prefix, so those never trip the listener. $listener = function ($query) use ($doomedPost) { if (verifyUpcomingSelectsFrom($query->sql, 'post_platforms')) { Post::where('id', $doomedPost->id)->delete(); diff --git a/tests/Feature/Mcp/AssetToolTest.php b/tests/Feature/Mcp/AssetToolTest.php index 178a56aba..465dfe981 100644 --- a/tests/Feature/Mcp/AssetToolTest.php +++ b/tests/Feature/Mcp/AssetToolTest.php @@ -198,7 +198,6 @@ expect($this->post->fresh()->media)->toHaveCount(1) ->and(data_get($this->post->fresh()->media, '0.size'))->toBe(12345) - // toEqual, not toBe: MySQL normalises JSON object key order. ->and(data_get($this->post->fresh()->media, '0.meta'))->toEqual([ 'width' => 1920, 'height' => 1080, diff --git a/tests/Feature/Mcp/PostPublishToolTest.php b/tests/Feature/Mcp/PostPublishToolTest.php index 1a659d2fd..20baa88e1 100644 --- a/tests/Feature/Mcp/PostPublishToolTest.php +++ b/tests/Feature/Mcp/PostPublishToolTest.php @@ -227,7 +227,6 @@ $response = TryPostServer::actingAs($this->user) ->tool(PublishPostTool::class, [ 'post_id' => $post->id, - // Inside MySQL's TIMESTAMP range, which ends 2038-01-19. 'scheduled_at' => '2037-12-31T15:30:00Z', ]); diff --git a/tests/Feature/Mcp/PostToolTest.php b/tests/Feature/Mcp/PostToolTest.php index 1d8ec0223..a4d5f5d61 100644 --- a/tests/Feature/Mcp/PostToolTest.php +++ b/tests/Feature/Mcp/PostToolTest.php @@ -143,8 +143,6 @@ $response = TryPostServer::actingAs($this->user) ->tool(CreatePostTool::class, [ 'content' => 'My new post', - // Inside MySQL's TIMESTAMP range, which ends 2038-01-19; still far - // enough out to read as "far future" for scheduling assertions. 'scheduled_at' => '2037-12-31T15:30:00Z', ]); diff --git a/tests/Feature/McpSettingsControllerTest.php b/tests/Feature/McpSettingsControllerTest.php index ea749f703..28102f0f1 100644 --- a/tests/Feature/McpSettingsControllerTest.php +++ b/tests/Feature/McpSettingsControllerTest.php @@ -129,7 +129,6 @@ ->assertSessionHas('flash.success'); expect($token->fresh()->revoked)->toBeTrue() - // Raw query-builder read: cast explicitly, the driver decides the shape. ->and((bool) DB::table('oauth_refresh_tokens')->where('id', $refreshTokenId)->value('revoked'))->toBeTrue(); }); diff --git a/tests/Feature/Services/Social/InstagramPublisherTest.php b/tests/Feature/Services/Social/InstagramPublisherTest.php index 72d06ba3b..19501531b 100644 --- a/tests/Feature/Services/Social/InstagramPublisherTest.php +++ b/tests/Feature/Services/Social/InstagramPublisherTest.php @@ -922,8 +922,6 @@ function fakeJpegBytes(int $width = 1200, int $height = 800): string 'url' => null, ]); - // toEqual, not toBe: MySQL normalises JSON object key order, so an identity - // comparison would depend on how the driver chose to store the object. expect($this->postPlatform->fresh()->error_context['instagram_workflow'] ?? null)->toEqual([ 'stage' => 'final_container', 'container_id' => 'container-123', @@ -1274,8 +1272,6 @@ function fakeJpegBytes(int $width = 1200, int $height = 800): string 'url' => null, ]); - // toEqual, not toBe: MySQL normalises JSON object key order, so an identity - // comparison would depend on how the driver chose to store the object. expect($this->postPlatform->fresh()->error_context['instagram_workflow'] ?? null)->toEqual([ 'stage' => 'final_container', 'container_id' => 'container-123', diff --git a/tests/Feature/SocialAccount/DuplicateIdentityRehearsalTest.php b/tests/Feature/SocialAccount/DuplicateIdentityRehearsalTest.php index 0031eb6a3..0303d0743 100644 --- a/tests/Feature/SocialAccount/DuplicateIdentityRehearsalTest.php +++ b/tests/Feature/SocialAccount/DuplicateIdentityRehearsalTest.php @@ -9,9 +9,7 @@ use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Models\Workspace; -use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Schema; /** * A rehearsal rather than a scenario test: build a deliberately messy database @@ -26,18 +24,7 @@ 'migrations/2026_08_21_130941_add_workspace_platform_identity_unique_to_social_accounts_table.php', ); - // social_accounts.workspace_id has a foreign key, and the composite unique - // index is the only one covering it (as its leftmost prefix). MySQL refuses - // to drop the sole index backing a foreign key — SQLSTATE[HY000] 1553 — so - // give the constraint another index to rest on first. PostgreSQL has no such - // requirement and simply carries the extra index. - Schema::table('social_accounts', function (Blueprint $table) { - $table->index('workspace_id', 'social_accounts_workspace_id_fk_backing'); - }); - - Schema::table('social_accounts', function (Blueprint $table) { - $table->dropUnique('social_accounts_workspace_platform_identity_unique'); - }); + $this->migration->down(); }); /** diff --git a/tests/Feature/WorkspaceInviteControllerTest.php b/tests/Feature/WorkspaceInviteControllerTest.php index a37490ca9..c3d43b071 100644 --- a/tests/Feature/WorkspaceInviteControllerTest.php +++ b/tests/Feature/WorkspaceInviteControllerTest.php @@ -341,7 +341,6 @@ $response->assertRedirect(); expect($oauth->fresh()->revoked)->toBeFalse() - // Raw query-builder read: cast explicitly, the driver decides the shape. ->and((bool) DB::table('oauth_refresh_tokens')->where('id', $refreshTokenId)->value('revoked'))->toBeFalse() ->and($member->fresh()->can('createPost', $this->workspace))->toBeFalse() ->and($member->fresh()->can('view', $this->workspace))->toBeTrue(); diff --git a/tests/Unit/RevokeAccessTokensTest.php b/tests/Unit/RevokeAccessTokensTest.php index 676e8651a..d96d4a7df 100644 --- a/tests/Unit/RevokeAccessTokensTest.php +++ b/tests/Unit/RevokeAccessTokensTest.php @@ -31,8 +31,6 @@ RevokeAccessTokens::execute($token); expect(AccessToken::query()->find($token->id)->revoked)->toBeTrue(); - // Raw query-builder read: no Eloquent cast applies, so the driver's native - // boolean representation leaks through (bool on Postgres, 1 on MySQL). expect((bool) DB::table('oauth_refresh_tokens')->where('id', $refreshId)->value('revoked'))->toBeTrue(); }); From e6aeee4230162dc34b869bee295d51ab1a190427 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 29 Aug 2026 10:07:34 -0300 Subject: [PATCH 08/12] Use a readiness check both database images can run mysql:8.4 installs mysql-community-server-minimal, which ships neither mysqladmin nor the mysql client, so a mysqladmin health command never succeeds and the service never reports healthy. Both images run their init phase without networking, so an open port is the point either engine starts accepting connections - one check covers both, and the per-engine matrix key goes away. --- .github/workflows/tests.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 90e44929f..77577990d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -30,13 +30,11 @@ jobs: image: postgres:16 port: '5432' username: postgres - health: '--health-cmd="pg_isready"' - name: MySQL connection: mysql image: mysql:8.4 port: '3306' username: root - health: '--health-cmd="mysqladmin ping -h 127.0.0.1 -u root -ppassword"' env: DB_CONNECTION: ${{ matrix.connection }} @@ -53,11 +51,13 @@ jobs: MYSQL_DATABASE: trypost_test ports: - ${{ matrix.port }}/tcp + # Both images run their init phase without networking, so an open port + # is the point either engine starts accepting connections. options: >- - ${{ matrix.health }} - --health-interval=10s + --health-cmd="bash -c 'echo > /dev/tcp/127.0.0.1/${{ matrix.port }}'" + --health-interval=5s --health-timeout=5s - --health-retries=10 + --health-retries=20 redis: image: redis:7 From e8bd5bf60cac104e3de596a39a0742f6ca0c3490 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 29 Aug 2026 10:09:52 -0300 Subject: [PATCH 09/12] Use each engine's own readiness tool pg_isready and mysqladmin ping are what the respective images ship for this, and the mysql image's entrypoint invokes mysqladmin itself, so it is present. Keeps 20 retries, which MySQL needs to finish initialising. --- .github/workflows/tests.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 77577990d..6f6c45c60 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -30,11 +30,13 @@ jobs: image: postgres:16 port: '5432' username: postgres + health: '--health-cmd="pg_isready"' - name: MySQL connection: mysql image: mysql:8.4 port: '3306' username: root + health: '--health-cmd="mysqladmin ping -h 127.0.0.1 -u root -ppassword"' env: DB_CONNECTION: ${{ matrix.connection }} @@ -51,10 +53,8 @@ jobs: MYSQL_DATABASE: trypost_test ports: - ${{ matrix.port }}/tcp - # Both images run their init phase without networking, so an open port - # is the point either engine starts accepting connections. options: >- - --health-cmd="bash -c 'echo > /dev/tcp/127.0.0.1/${{ matrix.port }}'" + ${{ matrix.health }} --health-interval=5s --health-timeout=5s --health-retries=20 From 362d52745f137f72de46566163366f05c8358e18 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 29 Aug 2026 10:11:45 -0300 Subject: [PATCH 10/12] State the two-engine ceiling as a rule, not a test detail The 2038 TIMESTAMP limit binds anything written to the column, not just the sentinel dates in fixtures, and the same reasoning generalises: what the app supports is the intersection of both engines. --- AGENTS.md | 3 ++- CLAUDE.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2ccfaa21a..91027dd8f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -236,10 +236,11 @@ Self-hosted compose / `.env.example` set this `true`. When the env is unset, the TryPost runs on **both PostgreSQL and MySQL**. Cloud runs PostgreSQL; a self-hosted install may pick either. Every query, migration, and test must work on both — the suite is expected to be green on each. +- **What the app supports is the intersection of the two engines, never the superset of one.** When they differ, take the narrower behaviour — a feature that only holds on PostgreSQL is a feature TryPost does not have. - Never use an engine-specific operator or function. Search uses `whereLike()` (Laravel handles the case-insensitive form per driver), never `ilike` or a raw `LOWER(...)` comparison. - Traps that only surface on MySQL: - **JSON object key order is not preserved.** MySQL reorders object keys on storage (by length, then lexicographically); PostgreSQL keeps insertion order. Assert JSON read back from the database with `toEqual` (recursive, order-independent), never `toBe`/`assertSame`. Array *element* order is preserved on both. - - **`$table->timestamp()` tops out at 2038-01-19.** Never pick a far-future sentinel date beyond that — `2037-12-31` reads as "far future" and works everywhere. + - **`$table->timestamp()` tops out at 2038-01-19.** PostgreSQL has no such limit, so 2038-01-19 is the app's ceiling: nothing written to a `timestamp()` column may go past it — scheduled posts, expiry sentinels and test fixtures alike. `2037-12-31` reads as "far future" and works on both. Do not widen a column to escape the limit without a deliberate decision; it changes what self-hosted MySQL installs can store. - **Raw query-builder reads carry no Eloquent cast**, so the driver's native shape leaks through: `DB::table(...)->value('some_bool')` is `true` on PostgreSQL and `1` on MySQL. Read through the model, or use `assertDatabaseHas`. - **Identifier quoting differs** — PostgreSQL emits `"post_platforms"`, MySQL emits backticks. Never match logged SQL (`DB::listen`) against a quoted identifier. - **MySQL refuses to drop the only index backing a foreign key** (SQLSTATE `1553`). A migration `down()` that drops a unique whose leftmost prefix is an FK column must create a standalone index for that column first. diff --git a/CLAUDE.md b/CLAUDE.md index f2f5b91cf..06924478f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -300,10 +300,11 @@ Self-hosted compose / `.env.example` set this `true`. When the env is unset, the TryPost runs on **both PostgreSQL and MySQL**. Cloud runs PostgreSQL; a self-hosted install may pick either. Every query, migration, and test must work on both — the suite is expected to be green on each. +- **What the app supports is the intersection of the two engines, never the superset of one.** When they differ, take the narrower behaviour — a feature that only holds on PostgreSQL is a feature TryPost does not have. - Never use an engine-specific operator or function. Search uses `whereLike()` (Laravel handles the case-insensitive form per driver), never `ilike` or a raw `LOWER(...)` comparison. - Traps that only surface on MySQL: - **JSON object key order is not preserved.** MySQL reorders object keys on storage (by length, then lexicographically); PostgreSQL keeps insertion order. Assert JSON read back from the database with `toEqual` (recursive, order-independent), never `toBe`/`assertSame`. Array *element* order is preserved on both. - - **`$table->timestamp()` tops out at 2038-01-19.** Never pick a far-future sentinel date beyond that — `2037-12-31` reads as "far future" and works everywhere. + - **`$table->timestamp()` tops out at 2038-01-19.** PostgreSQL has no such limit, so 2038-01-19 is the app's ceiling: nothing written to a `timestamp()` column may go past it — scheduled posts, expiry sentinels and test fixtures alike. `2037-12-31` reads as "far future" and works on both. Do not widen a column to escape the limit without a deliberate decision; it changes what self-hosted MySQL installs can store. - **Raw query-builder reads carry no Eloquent cast**, so the driver's native shape leaks through: `DB::table(...)->value('some_bool')` is `true` on PostgreSQL and `1` on MySQL. Read through the model, or use `assertDatabaseHas`. - **Identifier quoting differs** — PostgreSQL emits `"post_platforms"`, MySQL emits backticks. Never match logged SQL (`DB::listen`) against a quoted identifier. - **MySQL refuses to drop the only index backing a foreign key** (SQLSTATE `1553`). A migration `down()` that drops a unique whose leftmost prefix is an FK column must create a standalone index for that column first. From 0222c1595536257b0896d798c17c7ccbe3116014 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 29 Aug 2026 10:32:57 -0300 Subject: [PATCH 11/12] Let the release image connect to MySQL The published image installed only pdo_pgsql, so DB_CONNECTION=mysql failed with "could not find driver" before any query ran - the app supports MySQL but the image people actually deploy could not reach it. mysql-client mirrors the postgresql-client already present, for artisan db and dumps. --- docker/Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker/Dockerfile b/docker/Dockerfile index 3844c9d7c..66e9d2a56 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -26,6 +26,7 @@ RUN apk add --no-cache \ tzdata \ postgresql-client \ postgresql-dev \ + mysql-client \ libpng-dev \ libjpeg-turbo-dev \ freetype-dev \ @@ -40,6 +41,7 @@ RUN apk add --no-cache \ && docker-php-ext-install -j"$(nproc)" \ pdo_pgsql \ pgsql \ + pdo_mysql \ gd \ zip \ opcache \ From 7f74b8ee53133568b5dcb1a6d7dc90f429e29bed Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sat, 29 Aug 2026 10:48:01 -0300 Subject: [PATCH 12/12] Keep "backend" a single required status check Matrixing the job split its check in two, so the "backend" context the branch protection requires was never reported and every PR sat waiting on it. The matrix is now "tests" and a small "backend" job gates on it, which keeps the required check stable however many engines the matrix grows to - and leaves the open PRs mergeable without a rebase. --- .github/workflows/tests.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6f6c45c60..38792ff17 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -17,8 +17,8 @@ env: SESSION_DRIVER: array jobs: - backend: - name: Backend (${{ matrix.name }}) + tests: + name: Tests (${{ matrix.name }}) runs-on: ubuntu-latest strategy: @@ -85,6 +85,16 @@ jobs: REDIS_PORT: ${{ job.services.redis.ports['6379'] }} run: php artisan test --compact --parallel + backend: + name: backend + runs-on: ubuntu-latest + needs: [tests] + if: always() + + steps: + - name: Fail unless every engine passed + run: '[ "${{ needs.tests.result }}" = "success" ]' + e2e: runs-on: ubuntu-latest