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 */ 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']); } }