235 lines
6.3 KiB
PHP
235 lines
6.3 KiB
PHP
<?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']);
|
|
}
|
|
}
|