From 3e7c367182a74c860562153e0c4c2154473607af Mon Sep 17 00:00:00 2001 From: kj Date: Sat, 5 Sep 2026 16:47:39 -0300 Subject: [PATCH] test(integration): cover query failure state cleanup --- Makefile | 5 -- tests/Integration/ModelFailureTest.php | 100 +++++++++++++++++++++ tests/Integration/ModelTransactionTest.php | 12 +++ 3 files changed, 112 insertions(+), 5 deletions(-) create mode 100644 tests/Integration/ModelFailureTest.php diff --git a/Makefile b/Makefile index 8128dd6..d002265 100644 --- a/Makefile +++ b/Makefile @@ -25,11 +25,6 @@ # localhost because Database.php's DSN has no port field; stop any local # server on those ports first (db-up aborts naming the port). # -# Known red: ModelFilterTest::orderByRandReturnsEveryRow fails on the -# sqlite and pgsql legs BY DESIGN until the core RAND translation is fixed -# (finding F1 in openspec/changes/*/add-multi-engine-model-tests/findings.md); -# the mysql leg passes it and everything else. -# # Publishing ritual (human only — AI agents must never run it; it commits): # 1. make publish MSG="feat: ..." (MSG optional, defaults to "sync: ") # 2. review the new master commit (git log -1 refs/heads/master); if the diff --git a/tests/Integration/ModelFailureTest.php b/tests/Integration/ModelFailureTest.php new file mode 100644 index 0000000..33737ac --- /dev/null +++ b/tests/Integration/ModelFailureTest.php @@ -0,0 +1,100 @@ + */ + private static array $ids = []; + + private const BAD_SQL = 'SELECT * FROM table_that_does_not_exist'; + + public static function setUpBeforeClass(): void + { + self::createTable('users', [ + 'id' => 'pk', + 'username' => 'string', + 'email' => 'string', + 'bio' => 'text', + 'age' => 'number', + 'is_active' => 'bool', + 'last_login' => 'timestamp', + ]); + + foreach (['fer', 'fiona', 'frog'] as $name) { + $user = new User(); + $user->username = $name; + $user->email = $name . '@duckbrain.dev'; + $user->age = 33; + $user->save(); + self::$ids[] = (int) $user->id; + } + } + + private static function runQuery(string $sql, bool $resetQuery = true): void + { + (new ReflectionMethod(User::class, 'query'))->invokeArgs(null, [$sql, $resetQuery]); + } + + private static function currentSql(): string + { + return (new ReflectionMethod(User::class, 'buildQuery'))->invoke(null); + } + + #[Test] + public function failingQueryDoesNotContaminateTheNextQuery(): void + { + User::where('id', self::$ids[0]); + + try { + self::runQuery(self::BAD_SQL); + $this->fail('a query against a missing table must throw'); + } catch (Exception $e) { + $this->assertStringContainsString('Error at query to database', $e->getMessage()); + } + + // With the default resetQuery: true, the failed run must have left + // the builder as good as new; get() therefore sees all three rows. + $this->assertCount(3, User::get(), 'builder state leaked from the failed query'); + } + + #[Test] + public function failedKeepStateQueryLeavesTheWhereForRetry(): void + { + User::where('username', 'fiona'); + + try { + self::runQuery(self::BAD_SQL, false); + $this->fail('a query against a missing table must throw'); + } catch (Exception $e) { + $this->assertStringContainsString('Error at query to database', $e->getMessage()); + } + + // resetQuery: false contracts to keep the where alive for the + // caller's deliberate retry (also guards against a fix that resets + // unconditionally). + $this->assertStringContainsString('WHERE', self::currentSql()); + + $retry = User::get(); + $this->assertCount(1, $retry, 'retry after failure must reuse the kept where'); + $this->assertSame('fiona', $retry[0]->username); + } +} diff --git a/tests/Integration/ModelTransactionTest.php b/tests/Integration/ModelTransactionTest.php index 249ba39..599e123 100644 --- a/tests/Integration/ModelTransactionTest.php +++ b/tests/Integration/ModelTransactionTest.php @@ -69,6 +69,10 @@ final class ModelTransactionTest extends TestCase User::beginTransaction(); $user = self::seed('wrecked_carol'); + // Dirty the builder BEFORE failing: only a leftover-free result after + // the exception proves query() itself cleaned up (finding F2). + User::where('age', '>', '30'); + try { (new ReflectionMethod(User::class, 'query')) ->invokeArgs(null, ['SELECT * FROM table_that_does_not_exist']); @@ -78,6 +82,14 @@ final class ModelTransactionTest extends TestCase } $this->assertFalse(self::db()->inTransaction(), 'the exception path must close the transaction'); + + $sqlAfterFailure = (new ReflectionMethod(User::class, 'buildQuery'))->invoke(null); + $this->assertSame( + 'SELECT * FROM users', + $sqlAfterFailure, + 'a failed query must leave the builder at its default state' + ); + $this->assertNull(User::where('username', 'wrecked_carol')->getFirst(), 'pending insert must be undone'); } }