From d0c4b3c503dd6a33fd65f283ac2ef230ada5d3fe Mon Sep 17 00:00:00 2001 From: kj Date: Sat, 5 Sep 2026 16:15:28 -0300 Subject: [PATCH] feat(test): add multi-engine integration matrix --- Makefile | 116 +++++++++- phpunit.xml | 3 + tests/Integration/ModelCrudTest.php | 234 ++++++++++++++++++++ tests/Integration/ModelFilterTest.php | 245 +++++++++++++++++++++ tests/Integration/ModelJoinTest.php | 145 ++++++++++++ tests/Integration/ModelSearchTest.php | 130 +++++++++++ tests/Integration/ModelTransactionTest.php | 83 +++++++ tests/Integration/SchemaSmokeTest.php | 91 ++++++++ tests/Models/Post.php | 20 ++ tests/Models/User.php | 26 +++ tests/TestCase.php | 89 +++++++- tests/Unit/BootstrapTest.php | 12 +- tests/Unit/NeuronTest.php | 20 +- tests/Unit/ValidatorTest.php | 52 +++-- tests/bootstrap.php | 50 ++++- 15 files changed, 1278 insertions(+), 38 deletions(-) create mode 100644 tests/Integration/ModelCrudTest.php create mode 100644 tests/Integration/ModelFilterTest.php create mode 100644 tests/Integration/ModelJoinTest.php create mode 100644 tests/Integration/ModelSearchTest.php create mode 100644 tests/Integration/ModelTransactionTest.php create mode 100644 tests/Integration/SchemaSmokeTest.php create mode 100644 tests/Models/Post.php create mode 100644 tests/Models/User.php diff --git a/Makefile b/Makefile index a99fca2..8128dd6 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,26 @@ # network; installs dev dependencies first if missing) # ...then develop on develop and commit there as usual. # +# Multi-engine regression net (run it whenever Model/Database change): +# make integration full matrix: brings the test services up, +# runs the Integration suite on sqlite, mysql +# and pgsql, then takes the services down even +# if a leg fails or you interrupt it +# make integration-engine ENGINE=mysql +# single leg (assumes services are already up); +# the fastest inner loop while iterating +# make db-up / make db-down manage the rootless podman test containers +# (mariadb:11, postgres:16-alpine); idempotent +# +# Hard port constraint: the services must bind default ports 3306/5432 on +# 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 @@ -35,10 +55,13 @@ SHELL := /bin/bash -.PHONY: test publish +.PHONY: test publish db-up db-up-mariadb db-up-postgres db-down integration integration-engine PHPUNIT := vendor/bin/phpunit +# Engine for single-leg integration runs (services must already be up). +ENGINE ?= sqlite + # --- publish configuration ------------------------------------------------- WHITELIST := src config.php index.php autoload.php .htaccess readme.org BLACKLIST := @@ -49,11 +72,22 @@ MASTER_REF := refs/heads/$(MASTER_BRANCH) LAST_SYNC_TAG := last-sync MSG ?= sync: $(shell date +%Y-%m-%d) -test: $(PHPUNIT) - ./$(PHPUNIT) +# --- integration database services (podman rootless) ----------------------- +# Default ports are non-negotiable: Database.php's DSN carries no port field, +# so the engines must answer on 3306/5432. If a local server owns those +# ports, stop it before `make db-up`. +MARIADB_NAME := duckbrain-test-mariadb +POSTGRES_NAME := duckbrain-test-postgres +MARIADB_IMAGE := docker.io/library/mariadb:11 +POSTGRES_IMAGE := docker.io/library/postgres:16-alpine +TEST_DB_NAME := duckbrain_test +TEST_DB_USER := duckbrain +TEST_DB_PASS := duckbrain +DB_READY_SECS := 240 -$(PHPUNIT): composer.json composer.lock - composer install --no-interaction +test: + @test -x $(PHPUNIT) || composer install --no-interaction + ./$(PHPUNIT) --testsuite Unit publish: @if [ "$$(git branch --show-current)" != "$(DEVELOP_BRANCH)" ]; then \ @@ -94,3 +128,75 @@ publish: @echo "root entries left behind in $(DEVELOP_BRANCH) (add to WHITELIST if they belong to the artifact):"; \ comm -23 <(git ls-tree --name-only $(DEVELOP_REF) | sort) \ <(for x in $(WHITELIST) $(BLACKLIST); do echo $$x; done | sort) | sed 's/^/ - /' + +db-up: db-up-mariadb db-up-postgres + +db-up-mariadb: + @if podman ps -a --format '{{.Names}}' | grep -qx $(MARIADB_NAME); then \ + echo "reusing $(MARIADB_NAME)"; podman start $(MARIADB_NAME) >/dev/null; \ + else \ + (echo > /dev/tcp/127.0.0.1/3306) 2>/dev/null && { \ + echo "ABORT: port 3306 already in use; stop the local MySQL/MariaDB server or free the port before db-up"; exit 1; } || true; \ + podman run -d --name $(MARIADB_NAME) \ + -e MARIADB_ROOT_PASSWORD=$(TEST_DB_PASS) \ + -e MARIADB_DATABASE=$(TEST_DB_NAME) \ + -e MARIADB_USER=$(TEST_DB_USER) \ + -e MARIADB_PASSWORD=$(TEST_DB_PASS) \ + -p 127.0.0.1:3306:3306 $(MARIADB_IMAGE) >/dev/null; \ + fi + @echo "waiting for $(MARIADB_NAME) to accept connections..."; \ + for i in $$(seq 1 $(DB_READY_SECS)); do \ + podman exec $(MARIADB_NAME) mariadb-admin ping -h 127.0.0.1 -u $(TEST_DB_USER) -p$(TEST_DB_PASS) --silent >/dev/null 2>&1 \ + && echo "$(MARIADB_NAME) ready" && exit 0; \ + sleep 2; \ + done; \ + echo "TIMEOUT: $(MARIADB_NAME) never became ready; inspect: podman logs $(MARIADB_NAME)"; exit 1 + +db-up-postgres: + @if podman ps -a --format '{{.Names}}' | grep -qx $(POSTGRES_NAME); then \ + echo "reusing $(POSTGRES_NAME)"; podman start $(POSTGRES_NAME) >/dev/null; \ + else \ + (echo > /dev/tcp/127.0.0.1/5432) 2>/dev/null && { \ + echo "ABORT: port 5432 already in use; stop the local PostgreSQL server or free the port before db-up"; exit 1; } || true; \ + podman run -d --name $(POSTGRES_NAME) \ + -e POSTGRES_DB=$(TEST_DB_NAME) \ + -e POSTGRES_USER=$(TEST_DB_USER) \ + -e POSTGRES_PASSWORD=$(TEST_DB_PASS) \ + -p 127.0.0.1:5432:5432 $(POSTGRES_IMAGE) >/dev/null; \ + fi + @echo "waiting for $(POSTGRES_NAME) to accept connections..."; \ + for i in $$(seq 1 $(DB_READY_SECS)); do \ + podman exec $(POSTGRES_NAME) pg_isready -U $(TEST_DB_USER) -d $(TEST_DB_NAME) >/dev/null 2>&1 \ + && echo "$(POSTGRES_NAME) ready" && exit 0; \ + sleep 2; \ + done; \ + echo "TIMEOUT: $(POSTGRES_NAME) never became ready; inspect: podman logs $(POSTGRES_NAME)"; exit 1 + +db-down: + @for c in $(MARIADB_NAME) $(POSTGRES_NAME); do \ + if podman ps -a --format '{{.Names}}' | grep -qx $$c; then \ + podman stop $$c >/dev/null 2>&1; podman rm $$c >/dev/null 2>&1; echo "removed $$c"; \ + else \ + echo "$$c not present"; \ + fi; \ + done + +integration: + @bash -c ' \ + trap "$(MAKE) db-down" EXIT; \ + trap "exit 130" INT; \ + trap "exit 143" TERM; \ + $(MAKE) --no-print-directory db-up || exit 1; \ + rc=0; \ + for e in sqlite mysql pgsql; do \ + echo "============ leg: $$e ============"; \ + $(MAKE) --no-print-directory integration-engine ENGINE=$$e || rc=1; \ + done; \ + echo "=================================="; \ + if [ $$rc -eq 0 ]; then echo "integration: all legs green"; else echo "integration: at least one leg FAILED (rc=1)"; fi; \ + exit $$rc \ + ' + +integration-engine: + @test -x $(PHPUNIT) || composer install --no-interaction + DUCKBRAIN_TEST_DB=$(ENGINE) ./$(PHPUNIT) --testsuite Integration --cache-directory .phpunit.cache/$(ENGINE) diff --git a/phpunit.xml b/phpunit.xml index 3d7cd25..553557d 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -13,6 +13,9 @@ tests/Unit + + tests/Integration + diff --git a/tests/Integration/ModelCrudTest.php b/tests/Integration/ModelCrudTest.php new file mode 100644 index 0000000..e593d0f --- /dev/null +++ b/tests/Integration/ModelCrudTest.php @@ -0,0 +1,234 @@ + 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']); + } +} diff --git a/tests/Integration/ModelFilterTest.php b/tests/Integration/ModelFilterTest.php new file mode 100644 index 0000000..7e1da57 --- /dev/null +++ b/tests/Integration/ModelFilterTest.php @@ -0,0 +1,245 @@ + 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 */ + 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()); + } +} diff --git a/tests/Integration/ModelJoinTest.php b/tests/Integration/ModelJoinTest.php new file mode 100644 index 0000000..6f7e50c --- /dev/null +++ b/tests/Integration/ModelJoinTest.php @@ -0,0 +1,145 @@ + 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 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'); + } +} diff --git a/tests/Integration/ModelSearchTest.php b/tests/Integration/ModelSearchTest.php new file mode 100644 index 0000000..0f6aacf --- /dev/null +++ b/tests/Integration/ModelSearchTest.php @@ -0,0 +1,130 @@ + */ + 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 */ + 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'); + } +} diff --git a/tests/Integration/ModelTransactionTest.php b/tests/Integration/ModelTransactionTest.php new file mode 100644 index 0000000..249ba39 --- /dev/null +++ b/tests/Integration/ModelTransactionTest.php @@ -0,0 +1,83 @@ + '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'); + } +} diff --git a/tests/Integration/SchemaSmokeTest.php b/tests/Integration/SchemaSmokeTest.php new file mode 100644 index 0000000..efa0b0b --- /dev/null +++ b/tests/Integration/SchemaSmokeTest.php @@ -0,0 +1,91 @@ + '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']); + } +} diff --git a/tests/Models/Post.php b/tests/Models/Post.php new file mode 100644 index 0000000..3e5dc63 --- /dev/null +++ b/tests/Models/Post.php @@ -0,0 +1,20 @@ +invoke(null); + } + /** * Returns the PDO connection to the in-memory test database. * @@ -32,21 +48,80 @@ abstract class TestCase extends FrameworkTestCase } /** - * Creates a table in the test database and registers it for cleanup. + * DDL fragments per engine for each logical column type. + * + * @var array> + */ + private const TYPE_DDL = [ + 'sqlite' => [ + 'pk' => 'INTEGER PRIMARY KEY AUTOINCREMENT', + 'string' => 'VARCHAR(255)', + 'text' => 'TEXT', + 'number' => 'REAL', + 'bool' => 'INTEGER', + 'timestamp' => 'TEXT', + ], + 'mysql' => [ + 'pk' => 'INT AUTO_INCREMENT PRIMARY KEY', + 'string' => 'VARCHAR(255)', + 'text' => 'TEXT', + 'number' => 'DOUBLE', + 'bool' => 'TINYINT(1)', + 'timestamp' => 'DATETIME', + ], + 'pgsql' => [ + 'pk' => 'SERIAL PRIMARY KEY', + 'string' => 'VARCHAR(255)', + 'text' => 'TEXT', + 'number' => 'DOUBLE PRECISION', + 'bool' => 'BOOLEAN', + 'timestamp' => 'TIMESTAMP', + ], + ]; + + /** + * Creates a table in the test database from logical column types and + * registers it for cleanup. The DDL is translated per engine, so the + * same declaration works on sqlite, MySQL/MariaDB and PostgreSQL. * * @param string $name - * Table name. + * Table name (avoid SQL reserved words). * - * @param string $columns - * Raw column definition, as accepted by the test - * engine (sqlite): "id INTEGER PRIMARY KEY, x TEXT". + * @param array $columns + * Map of column name to logical type: + * pk|string|text|number|bool|timestamp. */ - protected static function createTable(string $name, string $columns): void + protected static function createTable(string $name, array $columns): void { - static::db()->exec("CREATE TABLE IF NOT EXISTS {$name} ({$columns})"); + $types = self::TYPE_DDL[DB_TYPE] ?? throw new \InvalidArgumentException( + 'No type translation for DB_TYPE ' . DB_TYPE + ); + + $ddl = []; + foreach ($columns as $column => $type) { + $fragment = $types[$type] ?? throw new \InvalidArgumentException( + "Unknown logical type '{$type}' for column '{$column}'; " + . 'expected: ' . implode(', ', array_keys($types)) + ); + $ddl[] = $column . ' ' . $fragment; + } + + static::db()->exec("CREATE TABLE IF NOT EXISTS {$name} (" . implode(', ', $ddl) . ')'); static::$tables[] = $name; } + /** + * Never let an aborted test leak an open transaction into the next one. + */ + protected function tearDown(): void + { + if (static::db()->inTransaction()) { + static::db()->rollBack(); + } + + parent::tearDown(); + } + /** * Drops every table registered through createTable() during this class. */ diff --git a/tests/Unit/BootstrapTest.php b/tests/Unit/BootstrapTest.php index daa08ec..d74e657 100644 --- a/tests/Unit/BootstrapTest.php +++ b/tests/Unit/BootstrapTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit; +use PHPUnit\Framework\Attributes\Test; use Libs\Neuron; use PDO; use Tests\TestCase; @@ -15,7 +16,8 @@ use Tests\TestCase; */ final class BootstrapTest extends TestCase { - public function testFrameworkClassesAreAutoloaded(): void + #[Test] + public function frameworkClassesAreAutoloaded(): void { $this->assertTrue( class_exists(Neuron::class), @@ -27,7 +29,9 @@ final class BootstrapTest extends TestCase $this->assertNull($neuron->doesNotExist); } - public function testDatabaseConstantsPointToInMemorySqlite(): void + #[Test] + + public function databaseConstantsPointToInMemorySqlite(): void { $this->assertSame( 'sqlite', @@ -37,7 +41,9 @@ final class BootstrapTest extends TestCase $this->assertSame(':memory:', DB_NAME, 'DB_NAME should be :memory: so tests never touch disks or servers'); } - public function testTestDatabaseIsUsable(): void + #[Test] + + public function testDatabaseIsUsable(): void { $db = self::db(); $this->assertInstanceOf(PDO::class, $db, 'Could not obtain the test PDO connection'); diff --git a/tests/Unit/NeuronTest.php b/tests/Unit/NeuronTest.php index 299d2eb..185ab4b 100644 --- a/tests/Unit/NeuronTest.php +++ b/tests/Unit/NeuronTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit; +use PHPUnit\Framework\Attributes\Test; use Libs\Neuron; use Tests\TestCase; @@ -13,7 +14,8 @@ use Tests\TestCase; */ final class NeuronTest extends TestCase { - public function testConstructFromAssociativeArray(): void + #[Test] + public function constructFromAssociativeArray(): void { $n = new Neuron(['username' => 'kj', 'level' => 3]); @@ -21,7 +23,9 @@ final class NeuronTest extends TestCase $this->assertSame(3, $n->level); } - public function testConstructFromObjectCopiesPublicProperties(): void + #[Test] + + public function constructFromObjectCopiesPublicProperties(): void { $source = new \stdClass(); $source->id = 7; @@ -33,14 +37,18 @@ final class NeuronTest extends TestCase $this->assertSame('kj@example.com', $n->email); } - public function testUndefinedPropertyIsNullWithoutNotice(): void + #[Test] + + public function undefinedPropertyIsNullWithoutNotice(): void { $n = new Neuron(); $this->assertNull($n->thisDoesNotExist); } - public function testDynamicPropertiesCanBeAssignedAndRead(): void + #[Test] + + public function dynamicPropertiesCanBeAssignedAndRead(): void { $n = new Neuron(); $n->fresh = ['a', 'b']; @@ -48,7 +56,9 @@ final class NeuronTest extends TestCase $this->assertSame(['a', 'b'], $n->fresh); } - public function testNestedValuesArePreservedVerbatim(): void + #[Test] + + public function nestedValuesArePreservedVerbatim(): void { $payload = ['meta' => ['tags' => ['x', 'y'], 'n' => null]]; $n = new Neuron($payload); diff --git a/tests/Unit/ValidatorTest.php b/tests/Unit/ValidatorTest.php index c14ef7c..e456053 100644 --- a/tests/Unit/ValidatorTest.php +++ b/tests/Unit/ValidatorTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit; +use PHPUnit\Framework\Attributes\Test; use Libs\Neuron; use Libs\Validator; use PHPUnit\Framework\Attributes\DataProvider; @@ -43,19 +44,24 @@ final class ValidatorTest extends TestCase } #[DataProvider('scalarRuleProvider')] - public function testScalarRules(string $rule, mixed $value, bool $expected): void + #[Test] + public function scalarRules(string $rule, mixed $value, bool $expected): void { $this->assertSame($expected, Validator::checkRule($value, $rule), "rule {$rule} with " . var_export($value, true)); } - public function testExistsAndRequiredDisagreeOnEmptyString(): void + #[Test] + + public function existsAndRequiredDisagreeOnEmptyString(): void { $this->assertTrue(Validator::checkRule('', 'exists')); $this->assertFalse(Validator::checkRule('', 'required')); $this->assertFalse(Validator::checkRule(null, 'exists')); } - public function testSizeRulesMeasureStringNumberAndArray(): void + #[Test] + + public function sizeRulesMeasureStringNumberAndArray(): void { $this->assertTrue(Validator::checkRule('abc', 'min:3')); $this->assertFalse(Validator::checkRule('abc', 'min:4')); @@ -65,32 +71,42 @@ final class ValidatorTest extends TestCase $this->assertFalse(Validator::checkRule('hey', 'size:4')); } - public function testRegexKeepsColonsAndCommasInsidePattern(): void + #[Test] + + public function regexKeepsColonsAndCommasInsidePattern(): void { $this->assertTrue(Validator::checkRule('a,b', 'regex:/^a,b$/')); $this->assertFalse(Validator::checkRule('a;b', 'regex:/^a,b$/')); } - public function testEnumIsLooseComparison(): void + #[Test] + + public function enumIsLooseComparison(): void { $this->assertTrue(Validator::checkRule('1', 'enum:1,2,3')); $this->assertFalse(Validator::checkRule('9', 'enum:1,2,3')); } - public function testNotNegatesNextRule(): void + #[Test] + + public function notNegatesNextRule(): void { $this->assertTrue(Validator::checkRule('3.5', 'not:int')); $this->assertFalse(Validator::checkRule('42', 'not:int')); } - public function testParseRule(): void + #[Test] + + public function parseRule(): void { $this->assertSame(['required', ''], Validator::parseRule('required')); $this->assertSame(['enum', 'a,b'], Validator::parseRule('enum:a,b')); $this->assertSame(['regex', '/^a,b$/'], Validator::parseRule('regex:/^a,b$/')); } - public function testValidateListPassesFullyValidBatch(): void + #[Test] + + public function validateListPassesFullyValidBatch(): void { $data = new Neuron(['username' => 'kj', 'email' => 'kj@duckbrain.dev']); @@ -101,7 +117,9 @@ final class ValidatorTest extends TestCase $this->assertSame('', Validator::$lastFailed); } - public function testValidateListStopsAtFirstFailure(): void + #[Test] + + public function validateListStopsAtFirstFailure(): void { $data = new Neuron(['email' => 'not-an-email', 'name' => null]); @@ -112,7 +130,9 @@ final class ValidatorTest extends TestCase $this->assertSame('email.email', Validator::$lastFailed); } - public function testValidateListNullableSkipsRestWhenEmpty(): void + #[Test] + + public function validateListNullableSkipsRestWhenEmpty(): void { $rules = ['bio' => 'nullable|min:10']; @@ -120,7 +140,9 @@ final class ValidatorTest extends TestCase $this->assertFalse(Validator::validateList($rules, new Neuron(['bio' => 'too-short']))); } - public function testValidateListConfirmedUsesSiblingField(): void + #[Test] + + public function validateListConfirmedUsesSiblingField(): void { $rules = ['password' => 'required|confirmed']; @@ -132,7 +154,9 @@ final class ValidatorTest extends TestCase $this->assertSame('password.confirmed', Validator::$lastFailed); } - public function testMessageBuildsHumanTextFromLastFailed(): void + #[Test] + + public function messageBuildsHumanTextFromLastFailed(): void { $this->assertSame( 'The email must be a valid email address.', @@ -152,7 +176,9 @@ final class ValidatorTest extends TestCase ); } - public function testMessageRespectsAttributesAndOverrides(): void + #[Test] + + public function messageRespectsAttributesAndOverrides(): void { $this->assertSame( 'The user name field is required.', diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 131ab9a..9c01352 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -5,12 +5,52 @@ require_once __DIR__ . '/../vendor/autoload.php'; +// Engine selection: DUCKBRAIN_TEST_DB=sqlite|mysql|pgsql (default: sqlite). +// sqlite uses an in-memory database, so the unit suite never needs services. +$engine = strtolower(getenv('DUCKBRAIN_TEST_DB') ?: 'sqlite'); + +$profiles = [ + 'sqlite' => [ + 'type' => 'sqlite', + 'host' => 'localhost', + 'name' => ':memory:', + 'user' => '', + 'pass' => '', + ], + 'mysql' => [ + 'type' => 'mysql', + 'host' => '127.0.0.1', + 'name' => 'duckbrain_test', + 'user' => 'duckbrain', + 'pass' => 'duckbrain', + ], + 'pgsql' => [ + 'type' => 'pgsql', + 'host' => '127.0.0.1', + 'name' => 'duckbrain_test', + 'user' => 'duckbrain', + 'pass' => 'duckbrain', + ], +]; + +if (!isset($profiles[$engine])) { + fwrite( + STDERR, + "Invalid DUCKBRAIN_TEST_DB '{$engine}'. Expected one of: " + . implode(', ', array_keys($profiles)) . "\n" + ); + exit(1); +} + // Define DB constants BEFORE config.php so the test database wins over the // real configuration (config.php's define() warnings are suppressed by @). -define('DB_TYPE', 'sqlite'); -define('DB_HOST', 'localhost'); -define('DB_NAME', ':memory:'); -define('DB_USER', ''); -define('DB_PASS', ''); +// Each field can be overridden without losing the engine's other defaults. +$profile = $profiles[$engine]; + +define('DB_TYPE', $profile['type']); +define('DB_HOST', getenv('DUCKBRAIN_TEST_HOST') ?: $profile['host']); +define('DB_NAME', getenv('DUCKBRAIN_TEST_NAME') ?: $profile['name']); +define('DB_USER', getenv('DUCKBRAIN_TEST_USER') ?: $profile['user']); +define('DB_PASS', getenv('DUCKBRAIN_TEST_PASS') ?: $profile['pass']); @require_once __DIR__ . '/../autoload.php';