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..38792ff17 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 9b1dbd0b8..91027dd8f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -232,6 +232,20 @@ 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. + +- **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 3ec2deb86..06924478f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -296,6 +296,20 @@ 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. + +- **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 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/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 \ diff --git a/tests/Feature/Api/PostMediaApiTest.php b/tests/Feature/Api/PostMediaApiTest.php index 32901a01f..ee6d69c60 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 acd05bdb9..e33e0a9fd 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 3a4686543..7b9e60cc5 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 02da5600b..465dfe981 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 583f44545..20baa88e1 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 bcdd24042..a4d5f5d61 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 ca300d353..28102f0f1 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 552f47a81..19501531b 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 c2f0606da..000000000 --- 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 fc7c1b903..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,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 72c3c271c..c3d43b071 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 d876377dc..d96d4a7df 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 () {