From 02425038aa6adaafd069e4e4f96c486870210dbf Mon Sep 17 00:00:00 2001 From: Jamie Ontiveros <54843+jonto@users.noreply.github.com> Date: Sat, 29 Aug 2026 10:05:33 -0400 Subject: [PATCH] Make the test suite pass on MySQL (#307) * 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) * 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) * 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) * 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) * 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) * 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. * 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. * 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. * 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. * 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. * 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. * 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. --------- Co-authored-by: Paulo Castellano --- .env.ci | 2 +- .github/actions/setup-laravel/action.yml | 4 +- .github/workflows/tests.yml | 52 ++- AGENTS.md | 14 + CLAUDE.md | 14 + ...entity_unique_to_social_accounts_table.php | 12 + docker/Dockerfile | 2 + tests/Feature/Api/PostMediaApiTest.php | 4 +- tests/Feature/Automation/DetailTabsTest.php | 2 +- .../VerifyUpcomingPostConnectionsTest.php | 21 +- tests/Feature/Mcp/AssetToolTest.php | 4 +- tests/Feature/Mcp/PostPublishToolTest.php | 2 +- tests/Feature/Mcp/PostToolTest.php | 4 +- tests/Feature/McpSettingsControllerTest.php | 2 +- .../Social/InstagramPublisherTest.php | 4 +- .../DuplicateIdentityMigrationTest.php | 410 ------------------ .../DuplicateIdentityRehearsalTest.php | 6 +- .../Feature/WorkspaceInviteControllerTest.php | 2 +- tests/Unit/RevokeAccessTokensTest.php | 2 +- 19 files changed, 117 insertions(+), 446 deletions(-) delete mode 100644 tests/Feature/SocialAccount/DuplicateIdentityMigrationTest.php diff --git a/.env.ci b/.env.ci index d7ce1a71..72d5ced8 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 fb27885c..9360d5c7 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 ab2e1962..38792ff1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -17,23 +17,47 @@ env: SESSION_DRIVER: array jobs: - backend: + tests: + name: Tests (${{ 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" - --health-interval=10s + ${{ matrix.health }} + --health-interval=5s --health-timeout=5s - --health-retries=3 + --health-retries=20 redis: image: redis:7 @@ -52,15 +76,25 @@ 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 + 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 diff --git a/AGENTS.md b/AGENTS.md index 9b1dbd0b..91027dd8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -232,6 +232,20 @@ ## Multiple social accounts per network 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. + +- **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.** 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. + - **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 3ec2deb8..06924478 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -296,6 +296,20 @@ ## Backend Validation - 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. + +- **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.** 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. + - **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 2eb9b1c9..9617dd41 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/docker/Dockerfile b/docker/Dockerfile index 3844c9d7..66e9d2a5 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 \ diff --git a/tests/Feature/Api/PostMediaApiTest.php b/tests/Feature/Api/PostMediaApiTest.php index 32901a01..ee6d69c6 100644 --- a/tests/Feature/Api/PostMediaApiTest.php +++ b/tests/Feature/Api/PostMediaApiTest.php @@ -662,7 +662,7 @@ 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([ + ->and(data_get($this->post->fresh()->media, '0.meta'))->toEqual([ 'width' => 1920, 'height' => 1080, 'duration' => 12.5, @@ -688,7 +688,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 acd05bdb..e33e0a9f 100644 --- a/tests/Feature/Automation/DetailTabsTest.php +++ b/tests/Feature/Automation/DetailTabsTest.php @@ -61,7 +61,7 @@ $automation->refresh(); expect($automation->name)->toBe('Renamed flow'); - expect($automation->nodes)->toBe($originalNodes); + expect($automation->nodes)->toEqual($originalNodes); }); it('renders the invocations tab with a scroll-paginated list', function () { diff --git a/tests/Feature/Jobs/VerifyUpcomingPostConnectionsTest.php b/tests/Feature/Jobs/VerifyUpcomingPostConnectionsTest.php index 3a468654..7b9e60cc 100644 --- a/tests/Feature/Jobs/VerifyUpcomingPostConnectionsTest.php +++ b/tests/Feature/Jobs/VerifyUpcomingPostConnectionsTest.php @@ -22,6 +22,15 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Mail; +/** + * 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($sql, 'select * from '.DB::getQueryGrammar()->wrapTable($table)); +} + test('marks the account expired and queues a notification when verify throws TokenExpiredException', function () { Mail::fake(); @@ -943,10 +952,10 @@ // 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 (str_starts_with($query->sql, 'select * from "post_platforms"')) { + if (verifyUpcomingSelectsFrom($query->sql, 'post_platforms')) { Post::where('id', $doomedPost->id)->delete(); } }; @@ -1090,7 +1099,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 +1141,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 +1188,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(); } }; diff --git a/tests/Feature/Mcp/AssetToolTest.php b/tests/Feature/Mcp/AssetToolTest.php index 02da5600..465dfe98 100644 --- a/tests/Feature/Mcp/AssetToolTest.php +++ b/tests/Feature/Mcp/AssetToolTest.php @@ -198,7 +198,7 @@ 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([ + ->and(data_get($this->post->fresh()->media, '0.meta'))->toEqual([ 'width' => 1920, 'height' => 1080, 'duration' => 12.5, @@ -225,7 +225,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/Mcp/PostPublishToolTest.php b/tests/Feature/Mcp/PostPublishToolTest.php index 583f4454..20baa88e 100644 --- a/tests/Feature/Mcp/PostPublishToolTest.php +++ b/tests/Feature/Mcp/PostPublishToolTest.php @@ -227,7 +227,7 @@ $response = TryPostServer::actingAs($this->user) ->tool(PublishPostTool::class, [ 'post_id' => $post->id, - 'scheduled_at' => '2099-12-31T15:30:00Z', + 'scheduled_at' => '2037-12-31T15:30:00Z', ]); $response->assertOk(); diff --git a/tests/Feature/Mcp/PostToolTest.php b/tests/Feature/Mcp/PostToolTest.php index bcdd2404..a4d5f5d6 100644 --- a/tests/Feature/Mcp/PostToolTest.php +++ b/tests/Feature/Mcp/PostToolTest.php @@ -143,14 +143,14 @@ $response = TryPostServer::actingAs($this->user) ->tool(CreatePostTool::class, [ 'content' => 'My new post', - 'scheduled_at' => '2099-12-31T15:30:00Z', + '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(); }); diff --git a/tests/Feature/McpSettingsControllerTest.php b/tests/Feature/McpSettingsControllerTest.php index ca300d35..28102f0f 100644 --- a/tests/Feature/McpSettingsControllerTest.php +++ b/tests/Feature/McpSettingsControllerTest.php @@ -129,7 +129,7 @@ ->assertSessionHas('flash.success'); expect($token->fresh()->revoked)->toBeTrue() - ->and(DB::table('oauth_refresh_tokens')->where('id', $refreshTokenId)->value('revoked'))->toBeTrue(); + ->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/Services/Social/InstagramPublisherTest.php b/tests/Feature/Services/Social/InstagramPublisherTest.php index 552f47a8..19501531 100644 --- a/tests/Feature/Services/Social/InstagramPublisherTest.php +++ b/tests/Feature/Services/Social/InstagramPublisherTest.php @@ -922,7 +922,7 @@ function fakeJpegBytes(int $width = 1200, int $height = 800): string 'url' => null, ]); - expect($this->postPlatform->fresh()->error_context['instagram_workflow'] ?? null)->toBe([ + expect($this->postPlatform->fresh()->error_context['instagram_workflow'] ?? null)->toEqual([ 'stage' => 'final_container', 'container_id' => 'container-123', 'media_id' => 'media-123456789', @@ -1272,7 +1272,7 @@ function fakeJpegBytes(int $width = 1200, int $height = 800): string 'url' => null, ]); - expect($this->postPlatform->fresh()->error_context['instagram_workflow'] ?? null)->toBe([ + expect($this->postPlatform->fresh()->error_context['instagram_workflow'] ?? null)->toEqual([ 'stage' => 'final_container', 'container_id' => 'container-123', 'media_id' => 'media-123456789', diff --git a/tests/Feature/SocialAccount/DuplicateIdentityMigrationTest.php b/tests/Feature/SocialAccount/DuplicateIdentityMigrationTest.php deleted file mode 100644 index c2f0606d..00000000 --- a/tests/Feature/SocialAccount/DuplicateIdentityMigrationTest.php +++ /dev/null @@ -1,410 +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', - ); - - 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(); - - expect($automation->fresh()->nodes)->toBe($nodes); -}); diff --git a/tests/Feature/SocialAccount/DuplicateIdentityRehearsalTest.php b/tests/Feature/SocialAccount/DuplicateIdentityRehearsalTest.php index fc7c1b90..0303d074 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,9 +24,7 @@ 'migrations/2026_08_21_130941_add_workspace_platform_identity_unique_to_social_accounts_table.php', ); - 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 72c3c271..c3d43b07 100644 --- a/tests/Feature/WorkspaceInviteControllerTest.php +++ b/tests/Feature/WorkspaceInviteControllerTest.php @@ -341,7 +341,7 @@ $response->assertRedirect(); expect($oauth->fresh()->revoked)->toBeFalse() - ->and(DB::table('oauth_refresh_tokens')->where('id', $refreshTokenId)->value('revoked'))->toBeFalse() + ->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 d876377d..d96d4a7d 100644 --- a/tests/Unit/RevokeAccessTokensTest.php +++ b/tests/Unit/RevokeAccessTokensTest.php @@ -31,7 +31,7 @@ RevokeAccessTokens::execute($token); expect(AccessToken::query()->find($token->id)->revoked)->toBeTrue(); - expect(DB::table('oauth_refresh_tokens')->where('id', $refreshId)->value('revoked'))->toBeTrue(); + expect((bool) DB::table('oauth_refresh_tokens')->where('id', $refreshId)->value('revoked'))->toBeTrue(); }); test('ignores already revoked tokens without error', function () {