Skip to content

Make the test suite pass on MySQL - #307

Merged
paulocastellano merged 13 commits into
trypostit:mainfrom
jonto:fix/mysql-test-suite
Aug 29, 2026
Merged

Make the test suite pass on MySQL#307
paulocastellano merged 13 commits into
trypostit:mainfrom
jonto:fix/mysql-test-suite

Conversation

@jonto

@jonto jonto commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #302, which made search work on MySQL but left the suite unable to prove it. This makes the test suite pass on MySQL, so the assertions added there become load-bearing rather than decorative.

32 failures → 0. No application changes — every edit is in tests/.

The five classes

Commits are ordered largest first and are independently reviewable.

18 — SQLSTATE[HY000] 1553, index backing a foreign key.
social_accounts.workspace_id has a foreign key, and the composite unique index is the only thing covering it, as its leftmost prefix. MySQL refuses to drop the sole index backing a foreign key, so both DuplicateIdentity*Test files failed in
beforeEach — meaning no assertion in either suite had ever executed on MySQL. Giving the constraint another index to rest on first fixes it; PostgreSQL has no such requirement and simply carries the extra index.

That also unmasked one assertion underneath which had never run: the automation graph comparison now at DuplicateIdentityMigrationTest.php:419 depended on JSON object key order. It is fixed in the same commit, which is why that commit shows 19 rather than 18.

5 — JSON object key order.
MySQL normalises JSON object keys on storage (length, then lexicographic); PostgreSQL preserves insertion order. toBe is assertSame, so it was asserting how the driver chose to lay the object out rather than what it contains. toEqual compares associative arrays recursively without regard to key order.

Applied to every assertion in this class, including two that pass today only because their keys already happen to match MySQL's ordering — one added key and they would have broken.

4 — identifier quoting in DB::listen predicates.
VerifyUpcomingPostConnectionsTest matched select * from "post_platforms". MySQL emits backticks, so those predicates never matched, the simulated mid-run pause never fired, and the race those tests exist to cover went unexercised. They now compare against the unquoted form.

3 — raw boolean reads.
Not a casting gap: AccessToken casts revoked correctly. These three assertions read oauth_refresh_tokens through the query builder, where no cast applies, so the driver's native representation leaks through — a real boolean on
PostgreSQL, 1 on MySQL. Cast explicitly at the call site.

2 — TIMESTAMP range.
A 2099-12-31 sentinel against a column that tops out at 2038-01-19. 2037-12-31 still reads as a far-future schedule and works on both engines.

Verification

Full suite on both engines, on this branch:

Engine Passed Failed
PostgreSQL 16 3938 0
MySQL 8.0.46 3938 0

Same test count and same assertion count on both, so this fixes MySQL without altering PostgreSQL behaviour anywhere.

On sequencing

I offered continue-on-error first and the fixes after, on the assumption they would take a while. They didn't, so this lands the fixes complete and a MySQL CI leg can follow as a small second PR that is green and required from day one —
no red job on main at any point, and no follow-up flip. Happy to switch back if you'd rather have the leg first.

One measurement for that decision: the MySQL suite takes roughly 600s against 280s for PostgreSQL. The 18 rehearsal tests account for about 365s of it, versus 4.5s on PostgreSQL — not caused by these fixes, that is simply what they cost once they actually run. Likely the DDL in beforeEach forcing implicit commits and defeating RefreshDatabase's rollback, though I haven't confirmed it and it felt out of scope here.

One note on #283

While checking for conflicts I noticed #283 adds a number of toBe([...]) assertions, seven of which read back through the database. I checked them against MySQL and they are fine — they assert on JSON arrays, and MySQL preserves array
element order; only object keys get reordered. No action needed, just flagging that I looked, since the two PRs touch the same file.

jonto and others added 12 commits August 27, 2026 17:12
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)
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)
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)
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)
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)
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.
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.
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.
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.
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.
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.
@paulocastellano

Copy link
Copy Markdown
Contributor

Thanks for this — really solid work, and the writeup made it easy to follow.

The part that mattered most was catching that both DuplicateIdentity* suites were failing in beforeEach, so not a single assertion in either file had ever executed on MySQL. That's the kind of thing that stays invisible until someone actually runs the suite on the other engine.

I pushed a few things on top. Summarising so nothing is a surprise:

The 1553 was not a test problem. The migration's own down() does the same dropUnique, so migrate:rollback fails on MySQL exactly the way your beforeEach did:

SQLSTATE[HY000]: General error: 1553 Cannot drop index
'social_accounts_workspace_platform_identity_unique': needed in a foreign key constraint

create_social_accounts_table never creates a standalone index for workspace_id, and MySQL discards the implicit FK index once the composite unique covers the column as its leftmost prefix — so the unique ends up as the constraint's only backing. Fixed in down() instead of in the fixture, guarded with Schema::hasIndex because MySQL commits DDL implicitly and the index survives between tests. up() is untouched, so every database already migrated keeps its schema.

DuplicateIdentityRehearsalTest now calls $this->migration->down() rather than hand-rolling the drop, which drops the workaround index and makes it exercise the real rollback instead of an imitation of it.

Identifier quoting. Went with asking the connection instead of stripping quote characters:

str_starts_with($sql, 'select * from '.DB::getQueryGrammar()->wrapTable($table))

Identical behaviour to the original on PostgreSQL, correct on any driver, and it keeps the closing quote as an anchor — without it the prefix would also match a table merely starting with that name.

Removed DuplicateIdentityMigrationTest. Not a reflection on your changes: rebuilding a pre-migration schema in beforeEach tests a database state the application never runs in. The rehearsal suite covers the migration's behaviour and keeps its value.

CI now runs both engines. The backend job is a matrix over PostgreSQL 16 and MySQL 8.4, fail-fast: false so a break in one doesn't hide the other, plus pdo_mysql in the setup action. You had offered the CI leg as a follow-up — since the fixes landed complete, it made sense to bring it in here so the assertions are protected from day one.

One thing your PR surfaced indirectly. The published Docker image installs only pdo_pgsql, so DB_CONNECTION=mysql died with could not find driver before any query ran — the app supported MySQL but the image people actually deploy could not reach it. Added pdo_mysql + mysql-client and verified end to end: the image migrates, seeds and rolls back against a real mysql:8.4 container.

Also documented the engine differences you found in CLAUDE.md / AGENTS.md, framed as a standing rule — what the app supports is the intersection of the two engines, never the superset of one. The 2038 TIMESTAMP ceiling is written up there as the app's real limit rather than a test detail, so your 2037-12-31 choice is now the documented convention.

Full suite is green on both, same test and assertion counts: 3927 passed, 14652 assertions.

On your sequencing question — landing the fixes complete was the right call, agreed. And thanks for checking #283 for conflicts while you were in there.

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.
@paulocastellano
paulocastellano merged commit 0242503 into trypostit:main Aug 29, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants