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 <paulo@castellanos.llc>
This commit is contained in:
Jamie Ontiveros 2026-08-29 10:05:33 -04:00 committed by GitHub
parent c66c68b115
commit 02425038aa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 117 additions and 446 deletions

View file

@ -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

View file

@ -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

View file

@ -17,23 +17,47 @@ env:
SESSION_DRIVER: array
jobs:
backend:
tests:
name: Tests (${{ matrix.name }})
runs-on: ubuntu-latest
services:
postgres:
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:
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

View file

@ -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:

View file

@ -296,6 +296,20 @@ ## Backend Validation
- Validation rules always live in a dedicated `Illuminate\Foundation\Http\FormRequest` subclass under `app/Http/Requests/App/<Group>/`. Controller actions must type-hint the FormRequest as the parameter — NEVER call `$request->validate([...])` inline in the controller.
- Naming: `<Verb><Resource>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`.

View file

@ -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');
});

View file

@ -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 \

View file

@ -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',

View file

@ -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 () {

View file

@ -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();
}
};

View file

@ -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',

View file

@ -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();

View file

@ -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();
});

View file

@ -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 {

View file

@ -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',

View file

@ -1,410 +0,0 @@
<?php
declare(strict_types=1);
use App\Enums\PostPlatform\Status as PostPlatformStatus;
use App\Enums\SocialAccount\Platform;
use App\Models\Automation;
use App\Models\Post;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Models\Workspace;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\Schema;
/**
* Installs that predate the unique index could hold the same identity twice, so
* the migration has to collapse them before the index can be created.
*/
beforeEach(function () {
config()->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);
});

View file

@ -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();
});
/**

View file

@ -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();
});

View file

@ -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 () {