test(integration): cover query failure state cleanup
This commit is contained in:
5
Makefile
5
Makefile
@@ -25,11 +25,6 @@
|
|||||||
# localhost because Database.php's DSN has no port field; stop any local
|
# localhost because Database.php's DSN has no port field; stop any local
|
||||||
# server on those ports first (db-up aborts naming the port).
|
# 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):
|
# Publishing ritual (human only — AI agents must never run it; it commits):
|
||||||
# 1. make publish MSG="feat: ..." (MSG optional, defaults to "sync: <date>")
|
# 1. make publish MSG="feat: ..." (MSG optional, defaults to "sync: <date>")
|
||||||
# 2. review the new master commit (git log -1 refs/heads/master); if the
|
# 2. review the new master commit (git log -1 refs/heads/master); if the
|
||||||
|
|||||||
100
tests/Integration/ModelFailureTest.php
Normal file
100
tests/Integration/ModelFailureTest.php
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Integration;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
use PHPUnit\Framework\Attributes\Group;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use ReflectionMethod;
|
||||||
|
use Tests\Models\User;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ModelFailureTest - DuckBrain integration matrix
|
||||||
|
*
|
||||||
|
* Family: query-failure state handling (findings F2 / spec model).
|
||||||
|
*
|
||||||
|
* All contamination is provoked and observed INSIDE a single test: the
|
||||||
|
* harness-wide resetQuery guard in Tests\TestCase::setUp() protects the
|
||||||
|
* next test, never the current one, so these assertions depend solely on
|
||||||
|
* Model::query()'s own bookkeeping.
|
||||||
|
*/
|
||||||
|
#[Group('integration')]
|
||||||
|
final class ModelFailureTest extends TestCase
|
||||||
|
{
|
||||||
|
/** @var list<int> */
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,10 @@ final class ModelTransactionTest extends TestCase
|
|||||||
User::beginTransaction();
|
User::beginTransaction();
|
||||||
$user = self::seed('wrecked_carol');
|
$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 {
|
try {
|
||||||
(new ReflectionMethod(User::class, 'query'))
|
(new ReflectionMethod(User::class, 'query'))
|
||||||
->invokeArgs(null, ['SELECT * FROM table_that_does_not_exist']);
|
->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');
|
$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');
|
$this->assertNull(User::where('username', 'wrecked_carol')->getFirst(), 'pending insert must be undone');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user