feat(test): add multi-engine integration matrix

This commit is contained in:
kj
2026-09-05 16:15:28 -03:00
parent 48d3ed6b3f
commit d0c4b3c503
15 changed files with 1278 additions and 38 deletions

View File

@@ -0,0 +1,234 @@
<?php
namespace Tests\Integration;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;
use DateTime;
use Libs\Database;
use PDO;
use ReflectionMethod;
use Tests\Models\User;
use Tests\TestCase;
/**
* ModelCrudTest - DuckBrain integration matrix
*
* Family: db, getInstance, getVars, className, table, add, save (new vs
* existing), update, delete, getById, getFirst, all, markAsSaved.
*
* Engine caveats respected here: all()/getFirst() carry no ORDER BY, so
* every assertion targets a specific id or a tally of rows created by this
* class. Rows accumulate across tests on persistent engines; nothing may
* assume an empty table.
*/
#[Group('integration')]
final class ModelCrudTest extends TestCase
{
/** @var list<int> Ids created by this test class (tally for counts). */
private static array $saved = [];
private static function schema(): void
{
self::createTable('users', [
'id' => 'pk',
'username' => 'string',
'email' => 'string',
'bio' => 'text',
'age' => 'number',
'is_active' => 'bool',
'last_login' => 'timestamp',
]);
}
private static function freshUser(): User
{
$n = count(self::$saved) + 1;
$user = new User();
$user->username = 'user' . $n;
$user->email = 'user' . $n . '@duckbrain.dev';
$user->bio = 'bio ' . $n;
$user->age = 20 + $n;
$user->isActive = true;
$user->lastLogin = new DateTime('2026-09-05 10:00:00');
$user->save();
self::$saved[] = $user->id;
return $user;
}
private static function call(mixed $scope, string $method, array $args = []): mixed
{
return (new ReflectionMethod($scope, $method))->invokeArgs(is_object($scope) ? $scope : null, $args);
}
/** @return list<int> */
private static function idsOf(array $instances): array
{
$ids = array_map(static fn (User $u): int => (int) $u->id, $instances);
sort($ids);
return $ids;
}
#[Test]
public function classNameAndTableAreDerivedFromTheClass(): void
{
self::schema();
$this->assertSame('User', User::className());
$this->assertSame('users', self::call(User::class, 'table'));
}
#[Test]
public function dbIsTheSharedSingletonConnection(): void
{
self::schema();
$pdo = self::call(User::class, 'db');
$this->assertInstanceOf(PDO::class, $pdo);
$this->assertSame($pdo, self::call(User::class, 'db'));
}
#[Test]
public function saveInsertsThenUpdatesWithoutDuplicating(): void
{
self::schema();
$user = self::freshUser();
$this->assertGreaterThan(0, $user->id);
$this->assertCount(1, User::where('id', $user->id)->get());
$user->email = 'changed@duckbrain.dev';
$user->age = 40;
$user->save();
$found = User::getById($user->id);
$this->assertSame('changed@duckbrain.dev', $found->email);
$this->assertSame(40, $found->age);
$this->assertCount(1, User::where('id', $user->id)->get(), 'second save() must UPDATE, not INSERT');
}
#[Test]
public function typedPropertiesSurviveTheRoundTrip(): void
{
self::schema();
$user = self::freshUser();
$user->email = null;
$user->save();
$found = User::getById($user->id);
$this->assertIsInt($found->id);
$this->assertNull($found->email, 'nullable typed property must hydrate back as null');
$this->assertIsInt($found->age);
$this->assertInstanceOf(DateTime::class, $found->lastLogin);
$this->assertTrue($found->isActive);
}
#[Test]
public function getByIdReturnsNullForMissingRows(): void
{
self::schema();
$this->assertNull(User::getById(99999));
}
#[Test]
public function getFirstRespectsTheWhereCondition(): void
{
self::schema();
$user = self::freshUser();
$found = User::where('id', $user->id)->getFirst();
$this->assertInstanceOf(User::class, $found);
$this->assertSame($user->id, $found->id);
$this->assertNull(User::where('id', 99999)->getFirst());
}
#[Test]
public function allReturnsEveryInstanceOfThisClass(): void
{
self::schema();
$a = self::freshUser();
$b = self::freshUser();
$all = self::idsOf(User::all());
$this->assertContains($a->id, $all);
$this->assertContains($b->id, $all);
// Defect-tolerant tally: rows deleted by other tests must not count.
$mine = [];
foreach (self::$saved as $id) {
if (User::getById($id) !== null) {
$mine[] = $id;
}
}
sort($mine);
$this->assertSame($mine, $all, 'all() must match exactly the live rows created by this class');
}
#[Test]
public function deleteRemovesTheRow(): void
{
self::schema();
$user = self::freshUser();
$user->delete();
$this->assertNull(User::getById($user->id));
$this->assertNotContains($user->id, self::idsOf(User::all()));
}
#[Test]
public function markAsSavedTurnsTheNextSaveIntoAnUpdate(): void
{
self::schema();
self::freshUser();
$before = count(User::all());
$ghost = new User();
$ghost->id = 900;
$ghost->username = 'ghost';
$ghost->markAsSaved();
$ghost->save();
$this->assertNull(User::getById(900), 'markAsSaved() must prevent INSERT even with a manual id');
$this->assertCount($before, User::all());
}
#[Test]
public function getVarsExposesSnakeCasedCastColumns(): void
{
self::schema();
$user = self::freshUser();
$vars = self::call($user, 'getVars');
$this->assertSame(
['age', 'bio', 'email', 'id', 'is_active', 'last_login', 'username'],
(static function (array $keys): array {
sort($keys);
return $keys;
})(array_keys($vars))
);
$this->assertSame('2026-09-05 10:00:00', $vars['last_login']);
$this->assertIsBool($vars['is_active']);
}
}

View File

@@ -0,0 +1,245 @@
<?php
namespace Tests\Integration;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;
use Libs\Model;
use Tests\Models\User;
use Tests\TestCase;
/**
* ModelFilterTest - DuckBrain integration matrix
*
* Family: select, from, where, and, or, whereIn, whereNotIn, whereNull,
* whereNotNull, whereExists, whereNotExists, limit, orderBy, count, plus
* resetQuery/buildQuery observed through behavior (state must not leak
* between executed queries).
*/
#[Group('integration')]
final class ModelFilterTest extends TestCase
{
/** @var list<int> ids of alice, bob, carol, dave */
private static array $ids = [];
public static function setUpBeforeClass(): void
{
self::createTable('users', [
'id' => 'pk',
'username' => 'string',
'email' => 'string',
'bio' => 'text',
'age' => 'number',
'is_active' => 'bool',
'last_login' => 'timestamp',
]);
self::createTable('posts', [
'id' => 'pk',
'user_id' => 'number',
'title' => 'string',
'content' => 'text',
'rating' => 'number',
]);
$rows = [
['alice', 'alice@duckbrain.dev', 'first', 20, true],
['bob', 'bob@duckbrain.dev', 'second', 30, true],
['carol', null, 'third', 40, false],
['dave', 'dave@duckbrain.dev', null, 50, true],
];
foreach ($rows as [$username, $email, $bio, $age, $active]) {
$user = new User();
$user->username = $username;
$user->email = $email;
$user->bio = $bio;
$user->age = $age;
$user->isActive = $active;
$user->save();
self::$ids[] = (int) $user->id;
}
// A post owned by alice only, for the EXISTS tests.
$post = new \Tests\Models\Post();
$post->userId = self::$ids[0];
$post->title = 'hello world';
$post->content = 'body';
$post->rating = 4.5;
$post->save();
}
/** @return list<int> */
private static function sortedIds(array $instances): array
{
$ids = array_map(static fn (Model $m): int => (int) $m->id, $instances);
sort($ids);
return $ids;
}
#[Test]
public function whereEqualityAndOperatorForms(): void
{
$this->assertSame(
[self::$ids[1]],
self::sortedIds(User::where('username', 'bob')->get())
);
$this->assertSame(
[self::$ids[2], self::$ids[3]],
self::sortedIds(User::where('age', '>', '35')->get())
);
}
#[Test]
public function andNestsConditions(): void
{
$this->assertSame(
[self::$ids[3]],
self::sortedIds(User::where('age', '>', '25')->and('username', 'dave')->get())
);
}
#[Test]
public function orCombinesAlternativeConditions(): void
{
$this->assertSame(
[self::$ids[0], self::$ids[3]],
self::sortedIds(User::where('username', 'alice')->or('username', 'dave')->get())
);
}
#[Test]
public function whereInAndWhereNotIn(): void
{
$in = self::sortedIds(User::whereIn('username', ['alice', 'carol'])->get());
$this->assertSame([self::$ids[0], self::$ids[2]], $in);
$notIn = self::sortedIds(User::whereNotIn('username', ['alice', 'carol'])->get());
$this->assertSame([self::$ids[1], self::$ids[3]], $notIn);
}
#[Test]
public function whereNullAndWhereNotNull(): void
{
$this->assertSame(
[self::$ids[3]],
self::sortedIds(User::whereNull('bio')->get())
);
$this->assertSame(
[self::$ids[2]],
self::sortedIds(User::whereNull('email')->get())
);
$this->assertCount(3, User::whereNotNull('email')->get());
}
#[Test]
public function whereExistsAndWhereNotExists(): void
{
$correlated = 'SELECT 1 FROM posts WHERE posts.user_id = users.id';
$this->assertSame(
[self::$ids[0]],
self::sortedIds(User::whereExists($correlated)->get())
);
$this->assertSame(
[self::$ids[1], self::$ids[2], self::$ids[3]],
self::sortedIds(User::whereNotExists($correlated)->get())
);
}
#[Test]
public function selectAndFromRestrictTheProjection(): void
{
$rows = User::select('username')->from('users')->where('username', 'bob')->get();
$this->assertCount(1, $rows);
$this->assertSame('bob', $rows[0]->username);
}
#[Test]
public function limitSingleAndOffsetForms(): void
{
$firstTwo = User::orderBy('id', 'ASC')->limit(2)->get();
$this->assertSame(
[self::$ids[0], self::$ids[1]],
array_map(static fn (Model $m): int => (int) $m->id, $firstTwo)
);
// limit(offset, quantity) -> skip the first two.
$lastTwo = User::orderBy('id', 'ASC')->limit(2, 2)->get();
$this->assertSame(
[self::$ids[2], self::$ids[3]],
array_map(static fn (Model $m): int => (int) $m->id, $lastTwo)
);
}
#[Test]
public function orderByColumnAscendingAndDescending(): void
{
$asc = array_map(static fn (Model $m): string => (string) $m->age, User::orderBy('age', 'ASC')->get());
$this->assertSame(['20', '30', '40', '50'], array_map('strval', array_map('intval', $asc)));
$descFirst = User::orderBy('age', 'DESC')->getFirst();
$this->assertSame('dave', $descFirst->username);
}
#[Test]
public function countWithAndWithoutConditions(): void
{
$this->assertSame(4, User::count());
$this->assertSame(3, User::where('age', '>', '25')->count());
}
#[Test]
public function countWithUseLimit(): void
{
$this->assertSame(2, User::orderBy('id', 'ASC')->limit(2)->count(true, true));
}
#[Test]
public function countWithoutResetKeepsTheOriginalQuery(): void
{
User::where('age', '>', '25');
$count = User::count(false);
$this->assertSame(3, $count);
$this->assertSame(
[self::$ids[1], self::$ids[2], self::$ids[3]],
self::sortedIds(User::get()),
'count(false) must leave the where active; get() consumes it'
);
}
#[Test]
public function queryStateResetsAfterExecution(): void
{
$one = User::where('id', self::$ids[0])->getFirst();
$this->assertSame('alice', $one->username);
$this->assertCount(4, User::all(), 'where must not leak into the next query');
$this->assertCount(4, User::get());
}
#[Test]
public function orderByRandReturnsEveryRow(): void
{
// The readme matrix promises orderBy ok on the three engines.
// Model emits literal RAND(); sqlite and PostgreSQL know RANDOM().
// This test documents the promise; failures are findings, not noise.
$this->assertCount(4, User::orderBy('RAND')->get());
}
}

View File

@@ -0,0 +1,145 @@
<?php
namespace Tests\Integration;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;
use Tests\Models\Post;
use Tests\Models\User;
use Tests\TestCase;
/**
* ModelJoinTest - DuckBrain integration matrix
*
* Family: innerJoin, leftJoin, rightJoin, crossJoin, groupBy.
*
* Data has orphans on both sides (carol/dave have no posts; no post lacks an
* owner) so join cardinality actually discriminates. Projections are always
* qualified and limited to driver-model columns: a bare SELECT * on
* rightJoin differs between engines BY DESIGN (the sqlite branch rewrites
* into a swapped LEFT JOIN, see readme "fixed"); asserting on ambiguous
* duplicate columns would test that rewrite detail, not the join promise.
*/
#[Group('integration')]
final class ModelJoinTest extends TestCase
{
/** @var array<string,int> username => id */
private static array $ids = [];
public static function setUpBeforeClass(): void
{
self::createTable('users', [
'id' => 'pk',
'username' => 'string',
'email' => 'string',
'bio' => 'text',
'age' => 'number',
'is_active' => 'bool',
'last_login' => 'timestamp',
]);
self::createTable('posts', [
'id' => 'pk',
'user_id' => 'number',
'title' => 'string',
'content' => 'text',
'rating' => 'number',
]);
foreach ([['alice', true], ['bob', true], ['carol', false], ['dave', true]] as $row) {
$user = new User();
$user->username = $row[0];
$user->email = $row[0] . '@duckbrain.dev';
$user->age = 30;
$user->isActive = $row[1];
$user->save();
self::$ids[$row[0]] = (int) $user->id;
}
foreach ([['p1', 'alice'], ['p2', 'alice'], ['p3', 'bob']] as [$title, $owner]) {
$post = new Post();
$post->title = $title;
$post->userId = self::$ids[$owner];
$post->save();
}
}
/** @return list<string> sorted usernames of the result rows */
private static function names(array $rows): array
{
$names = array_map(static fn (User $u): string => (string) $u->username, $rows);
sort($names);
return $names;
}
#[Test]
public function leftJoinKeepsUsersWithoutPosts(): void
{
$rows = User::select('users.id', 'users.username')
->leftJoin('posts', 'users.id', '=', 'posts.user_id')
->get();
$this->assertSame(
['alice', 'alice', 'bob', 'carol', 'dave'],
self::names($rows),
'LEFT JOIN must keep orphan users exactly once each'
);
}
#[Test]
public function innerJoinDropsBothOrphans(): void
{
$rows = User::select('users.id', 'users.username')
->innerJoin('posts', 'users.id', '=', 'posts.user_id')
->get();
$this->assertSame(['alice', 'alice', 'bob'], self::names($rows));
}
#[Test]
public function rightJoinKeepsEveryRowOfTheJoinedTable(): void
{
// sqlite takes the swapped-LEFT-JOIN rewrite; mysql/pgsql take the
// native RIGHT JOIN branch. Both must produce the same observable set.
$rows = User::select('users.id', 'users.username')
->rightJoin('posts', 'users.id', '=', 'posts.user_id')
->get();
$names = self::names($rows);
$this->assertCount(3, $rows, 'posts are the preserved side: 3 rows');
$this->assertSame(['alice', 'alice', 'bob'], $names);
$this->assertNotContains('carol', $names, 'users without posts vanish from a RIGHT JOIN over posts');
}
#[Test]
public function crossJoinProducesTheCartesianProduct(): void
{
$rows = User::select('users.id', 'posts.id')
->crossJoin('posts')
->get();
$this->assertCount(12, $rows, '4 users x 3 posts');
// Qualified both-id columns: the last one wins on every engine
// (users.* comes first), so the surviving id set is 1..3.
$surviving = array_unique(array_map(static fn (User $u): int => (int) $u->id, $rows));
sort($surviving);
$this->assertSame([1, 2, 3], $surviving);
}
#[Test]
public function groupByCollapsesRowsPerGroup(): void
{
$statuses = User::select('users.is_active')->groupBy('users.is_active')->get();
$this->assertCount(2, $statuses, 'active and inactive groups');
$owners = User::select('users.id', 'users.username')
->innerJoin('posts', 'users.id', '=', 'posts.user_id')
->groupBy('users.id')
->get();
$names = self::names($owners);
sort($names);
$this->assertSame(['alice', 'bob'], $names, 'one row per user that owns posts');
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace Tests\Integration;
use DateTime;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;
use Tests\Models\User;
use Tests\TestCase;
/**
* ModelSearchTest - DuckBrain integration matrix
*
* Family: search (mysql CONCAT branch vs sqlite/pgsql CAST||LIKE branch),
* plus filters that bind every supported column type and must agree on
* matches across engines. Search terms are chosen case-insensitively stable
* (MySQL LIKE is case-insensitive by collation, PostgreSQL's is not).
*/
#[Group('integration')]
final class ModelSearchTest extends TestCase
{
/** @var array<string,int> */
private static array $ids = [];
public static function setUpBeforeClass(): void
{
self::createTable('users', [
'id' => 'pk',
'username' => 'string',
'email' => 'string',
'bio' => 'text',
'age' => 'number',
'is_active' => 'bool',
'last_login' => 'timestamp',
]);
$seed = [
['alice', 'alice@duckbrain.dev', 'alpha keeper', 20, true],
['bob', 'bob@duckbrain.dev', 'beta reader', 30, true],
['carol', 'carol@duckbrain.dev', 'gamma dreamer', 40, false],
['dave', 'dave@duckbrain.dev', 'delta builder', 50, true],
];
foreach ($seed as [$username, $email, $bio, $age, $active]) {
$user = new User();
$user->username = $username;
$user->email = $email;
$user->bio = $bio;
$user->age = $age;
$user->isActive = $active;
$user->lastLogin = new DateTime('2026-09-05 10:00:00');
$user->save();
self::$ids[$username] = (int) $user->id;
}
}
/** @return list<int> */
private static function idsOf(array $rows): array
{
$ids = array_map(static fn (User $u): int => (int) $u->id, $rows);
sort($ids);
return $ids;
}
#[Test]
public function searchMatchesAcrossAllDefaultColumns(): void
{
// 'ali' occurs only in alice's username/bio space.
$this->assertSame(
[self::$ids['alice']],
self::idsOf(User::search('ali')->get())
);
}
#[Test]
public function searchFindsNumericValueThroughTextCasting(): void
{
// The number column must be matched by its textual form: sqlite
// casts REAL to '30.0' while mysql/pgsql yield '30'; both contain 30.
$this->assertSame(
[self::$ids['bob']],
self::idsOf(User::search('30')->get())
);
}
#[Test]
public function searchHonorsExplicitColumnList(): void
{
$this->assertSame(
[self::$ids['alice']],
self::idsOf(User::search('lpha', ['bio'])->get())
);
$this->assertSame(
[self::$ids['carol']],
self::idsOf(User::search('arol', ['username', 'email'])->get())
);
}
#[Test]
public function searchCombinesWithWhere(): void
{
$this->assertSame(
[self::$ids['alice']],
self::idsOf(User::where('is_active', '1')->search('li')->get())
);
}
#[Test]
public function everyColumnTypeFiltersConsistently(): void
{
$this->assertSame([self::$ids['alice']], self::idsOf(User::where('id', self::$ids['alice'])->get()));
$this->assertSame([self::$ids['bob']], self::idsOf(User::where('email', 'bob@duckbrain.dev')->get()));
$this->assertSame([self::$ids['carol']], self::idsOf(User::where('is_active', '0')->get()));
$this->assertCount(4, User::where('last_login', '>', '2026-09-05 09:00:00')->get());
$this->assertSame([self::$ids['dave']], self::idsOf(User::where('age', '>=', '50')->get()));
}
#[Test]
public function booleanFalseSurvivesTheRoundTrip(): void
{
// Promise of the matrix: typed properties hydrate faithfully on the
// three engines. PostgreSQL reports boolean false as 'f' and the
// core casts nothing -> this is the R2 probe.
$carol = User::getById(self::$ids['carol']);
$this->assertNotNull($carol);
$this->assertFalse($carol->isActive, 'inactive user must hydrate as bool false');
}
}

View File

@@ -0,0 +1,83 @@
<?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;
/**
* ModelTransactionTest - DuckBrain integration matrix
*
* Family: beginTransaction, commit, rollBack, plus the implicit rollBack
* that Model.php performs when a query fails mid-transaction (and the
* Exception it rethrows wrapping the PDO error).
*/
#[Group('integration')]
final class ModelTransactionTest extends TestCase
{
public static function setUpBeforeClass(): void
{
self::createTable('users', [
'id' => 'pk',
'username' => 'string',
'email' => 'string',
'bio' => 'text',
'age' => 'number',
'is_active' => 'bool',
'last_login' => 'timestamp',
]);
}
private static function seed(string $username): User
{
$user = new User();
$user->username = $username;
$user->email = $username . '@duckbrain.dev';
$user->age = 25;
$user->save();
return $user;
}
#[Test]
public function commitMakesPendingWritesVisible(): void
{
User::beginTransaction();
$user = self::seed('committed_alice');
$this->assertTrue(User::commit());
$this->assertNotNull(User::getById($user->id));
}
#[Test]
public function rollBackDiscardsPendingWrites(): void
{
User::beginTransaction();
$user = self::seed('rolled_bob');
$this->assertTrue(User::rollBack());
$this->assertNull(User::getById($user->id));
}
#[Test]
public function aFailingQueryRollsBackTheOpenTransaction(): void
{
User::beginTransaction();
$user = self::seed('wrecked_carol');
try {
(new ReflectionMethod(User::class, 'query'))
->invokeArgs(null, ['SELECT * FROM table_that_does_not_exist']);
$this->fail('a failing query inside a transaction must throw');
} catch (Exception $e) {
$this->assertStringContainsString('Error at query to database', $e->getMessage());
}
$this->assertFalse(self::db()->inTransaction(), 'the exception path must close the transaction');
$this->assertNull(User::where('username', 'wrecked_carol')->getFirst(), 'pending insert must be undone');
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace Tests\Integration;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;
use DateTime;
use InvalidArgumentException;
use Tests\Models\User;
use Tests\TestCase;
/**
* SchemaSmokeTest - DuckBrain test harness
*
* Integration smoke test: the six logical column types must translate into
* valid DDL for the current engine and round-trip a row. Runs on all three
* legs (sqlite, mysql, pgsql) unchanged.
*/
#[Group('integration')]
final class SchemaSmokeTest extends TestCase
{
#[Test]
public function allLogicalTypesCreateAndRoundTrip(): void
{
self::createTable('smoke_types', [
'id' => 'pk',
'label' => 'string',
'body' => 'text',
'amount' => 'number',
'active' => 'bool',
'created_at' => 'timestamp',
]);
$insert = self::db()->prepare(
'INSERT INTO smoke_types (label, body, amount, active, created_at) VALUES (?, ?, ?, ?, ?)'
);
$insert->execute(['widget', 'a longer free-form body', 12.5, true, '2026-09-05 12:34:56']);
$row = self::db()->query('SELECT * FROM smoke_types')->fetch();
$this->assertSame(1, (int) $row['id']);
$this->assertSame('widget', $row['label']);
$this->assertSame('a longer free-form body', $row['body']);
$this->assertEquals(12.5, (float) $row['amount']);
$this->assertTrue(filter_var($row['active'], FILTER_VALIDATE_BOOLEAN));
$this->assertStringStartsWith('2026-09-05 12:34:56', (string) $row['created_at']);
}
#[Test]
public function saveAssignsPrimaryKeyAndGetByIdRoundTrips(): void
{
self::createTable('users', [
'id' => 'pk',
'username' => 'string',
'email' => 'string',
'bio' => 'text',
'age' => 'number',
'is_active' => 'bool',
'last_login' => 'timestamp',
]);
$user = new User();
$user->username = 'keyjay';
$user->email = 'kj@duckbrain.dev';
$user->bio = 'framework keeper';
$user->age = 29;
$user->isActive = true;
$user->lastLogin = new DateTime('2026-09-05 10:00:00');
$user->save();
$this->assertSame(1, $user->id, 'save() should assign the generated primary key');
$found = User::getById(1);
$this->assertNotNull($found);
$this->assertSame('keyjay', $found->username);
$this->assertSame(29, $found->age);
$this->assertTrue($found->isActive);
$this->assertInstanceOf(DateTime::class, $found->lastLogin);
$this->assertSame('2026-09-05 10:00:00', $found->lastLogin->format('Y-m-d H:i:s'));
}
#[Test]
public function unknownLogicalTypeFails(): void
{
$this->expectException(InvalidArgumentException::class);
self::createTable('smoke_bad', ['x' => 'money']);
}
}