From d594aa3ec1011577d6ac6d333b4fe2b2650bee5f Mon Sep 17 00:00:00 2001 From: kj Date: Sat, 5 Sep 2026 13:43:35 -0300 Subject: [PATCH] test(unit): Add Neuron and Validator regression tests --- tests/Unit/BootstrapTest.php | 20 ++--- tests/Unit/NeuronTest.php | 59 +++++++++++++ tests/Unit/ValidatorTest.php | 166 +++++++++++++++++++++++++++++++++++ 3 files changed, 235 insertions(+), 10 deletions(-) create mode 100644 tests/Unit/NeuronTest.php create mode 100644 tests/Unit/ValidatorTest.php diff --git a/tests/Unit/BootstrapTest.php b/tests/Unit/BootstrapTest.php index 114e159..daa08ec 100644 --- a/tests/Unit/BootstrapTest.php +++ b/tests/Unit/BootstrapTest.php @@ -19,12 +19,12 @@ final class BootstrapTest extends TestCase { $this->assertTrue( class_exists(Neuron::class), - 'FALTA AUTOLOAD: autoload.php no resolvio Libs\Neuron; revisa ROOT_CORE y ejecuta desde la raiz' + 'MISSING AUTOLOAD: autoload.php did not resolve Libs\Neuron; check ROOT_CORE and run from the project root' ); - $neuron = new Neuron(['clave' => 'valor']); - $this->assertSame('valor', $neuron->clave); - $this->assertNull($neuron->inexistente); + $neuron = new Neuron(['key' => 'value']); + $this->assertSame('value', $neuron->key); + $this->assertNull($neuron->doesNotExist); } public function testDatabaseConstantsPointToInMemorySqlite(): void @@ -32,20 +32,20 @@ final class BootstrapTest extends TestCase $this->assertSame( 'sqlite', DB_TYPE, - 'FALTA OVERRIDE: DB_TYPE viene de config.php; el bootstrap debe definir las constantes ANTES de require autoload.php' + 'MISSING OVERRIDE: DB_TYPE comes from config.php; the bootstrap must define the DB_* constants BEFORE requiring autoload.php' ); - $this->assertSame(':memory:', DB_NAME, 'DB_NAME deberia ser :memory: para no tocar discos ni servidores'); + $this->assertSame(':memory:', DB_NAME, 'DB_NAME should be :memory: so tests never touch disks or servers'); } public function testTestDatabaseIsUsable(): void { $db = self::db(); - $this->assertInstanceOf(PDO::class, $db, 'No se pudo obtener la PDO de prueba'); - $this->assertSame($db, self::db(), 'Database::getInstance deberia devolver el mismo singleton'); + $this->assertInstanceOf(PDO::class, $db, 'Could not obtain the test PDO connection'); + $this->assertSame($db, self::db(), 'Database::getInstance should return the same singleton'); $db->exec('CREATE TABLE smoke (id INTEGER PRIMARY KEY, v TEXT)'); - $db->prepare('INSERT INTO smoke (v) VALUES (?)')->execute(['cuac']); - $this->assertSame('cuac', $db->query('SELECT v FROM smoke')->fetchColumn()); + $db->prepare('INSERT INTO smoke (v) VALUES (?)')->execute(['quack']); + $this->assertSame('quack', $db->query('SELECT v FROM smoke')->fetchColumn()); $db->exec('DROP TABLE smoke'); } } diff --git a/tests/Unit/NeuronTest.php b/tests/Unit/NeuronTest.php new file mode 100644 index 0000000..299d2eb --- /dev/null +++ b/tests/Unit/NeuronTest.php @@ -0,0 +1,59 @@ + 'kj', 'level' => 3]); + + $this->assertSame('kj', $n->username); + $this->assertSame(3, $n->level); + } + + public function testConstructFromObjectCopiesPublicProperties(): void + { + $source = new \stdClass(); + $source->id = 7; + $source->email = 'kj@example.com'; + + $n = new Neuron($source); + + $this->assertSame(7, $n->id); + $this->assertSame('kj@example.com', $n->email); + } + + public function testUndefinedPropertyIsNullWithoutNotice(): void + { + $n = new Neuron(); + + $this->assertNull($n->thisDoesNotExist); + } + + public function testDynamicPropertiesCanBeAssignedAndRead(): void + { + $n = new Neuron(); + $n->fresh = ['a', 'b']; + + $this->assertSame(['a', 'b'], $n->fresh); + } + + public function testNestedValuesArePreservedVerbatim(): void + { + $payload = ['meta' => ['tags' => ['x', 'y'], 'n' => null]]; + $n = new Neuron($payload); + + $this->assertSame($payload['meta'], $n->meta); + $this->assertNull($n->meta['n']); + } +} diff --git a/tests/Unit/ValidatorTest.php b/tests/Unit/ValidatorTest.php new file mode 100644 index 0000000..c14ef7c --- /dev/null +++ b/tests/Unit/ValidatorTest.php @@ -0,0 +1,166 @@ + ['email', 'kj@duckbrain.dev', true], + 'invalid email' => ['email', 'not-an-email', false], + 'valid url' => ['url', 'https://kj2.me', true], + 'invalid url' => ['url', 'kj2.me', false], + 'int from string' => ['int', '42', true], + 'int with decimals' => ['int', '42.5', false], + 'float' => ['float', '3.14', true], + 'number e-notation' => ['number', '1e3', true], + 'bool yes' => ['bool', 'yes', true], + 'bool garbage' => ['bool', 'quizas', false], + 'strict string' => ['string', '42', true], + 'string rejects int' => ['string', 42, false], + 'array' => ['array', [1], true], + 'array rejects string' => ['array', 'x', false], + ]; + } + + #[DataProvider('scalarRuleProvider')] + public function testScalarRules(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 + { + $this->assertTrue(Validator::checkRule('', 'exists')); + $this->assertFalse(Validator::checkRule('', 'required')); + $this->assertFalse(Validator::checkRule(null, 'exists')); + } + + public function testSizeRulesMeasureStringNumberAndArray(): void + { + $this->assertTrue(Validator::checkRule('abc', 'min:3')); + $this->assertFalse(Validator::checkRule('abc', 'min:4')); + $this->assertTrue(Validator::checkRule(10, 'max:10')); + $this->assertTrue(Validator::checkRule([1, 2, 3], 'between:2,3')); + $this->assertTrue(Validator::checkRule('hey', 'size:3')); + $this->assertFalse(Validator::checkRule('hey', 'size:4')); + } + + public function testRegexKeepsColonsAndCommasInsidePattern(): void + { + $this->assertTrue(Validator::checkRule('a,b', 'regex:/^a,b$/')); + $this->assertFalse(Validator::checkRule('a;b', 'regex:/^a,b$/')); + } + + public function testEnumIsLooseComparison(): void + { + $this->assertTrue(Validator::checkRule('1', 'enum:1,2,3')); + $this->assertFalse(Validator::checkRule('9', 'enum:1,2,3')); + } + + public function testNotNegatesNextRule(): void + { + $this->assertTrue(Validator::checkRule('3.5', 'not:int')); + $this->assertFalse(Validator::checkRule('42', 'not:int')); + } + + public function testParseRule(): 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 + { + $data = new Neuron(['username' => 'kj', 'email' => 'kj@duckbrain.dev']); + + $this->assertTrue(Validator::validateList( + ['username' => 'required|string|min:2', 'email' => 'required|email'], + $data + )); + $this->assertSame('', Validator::$lastFailed); + } + + public function testValidateListStopsAtFirstFailure(): void + { + $data = new Neuron(['email' => 'not-an-email', 'name' => null]); + + $this->assertFalse(Validator::validateList( + ['email' => 'required|email', 'name' => 'required'], + $data + )); + $this->assertSame('email.email', Validator::$lastFailed); + } + + public function testValidateListNullableSkipsRestWhenEmpty(): void + { + $rules = ['bio' => 'nullable|min:10']; + + $this->assertTrue(Validator::validateList($rules, new Neuron(['bio' => null]))); + $this->assertFalse(Validator::validateList($rules, new Neuron(['bio' => 'too-short']))); + } + + public function testValidateListConfirmedUsesSiblingField(): void + { + $rules = ['password' => 'required|confirmed']; + + $ok = new Neuron(['password' => 'secret', 'password_confirmation' => 'secret']); + $bad = new Neuron(['password' => 'secret', 'password_confirmation' => 'different']); + + $this->assertTrue(Validator::validateList($rules, $ok)); + $this->assertFalse(Validator::validateList($rules, $bad)); + $this->assertSame('password.confirmed', Validator::$lastFailed); + } + + public function testMessageBuildsHumanTextFromLastFailed(): void + { + $this->assertSame( + 'The email must be a valid email address.', + Validator::message('email.email') + ); + $this->assertSame( + 'The age must be between 5 and 10.', + Validator::message('age.between:5,10') + ); + $this->assertSame( + 'The selected status is invalid. Allowed: a, b, c.', + Validator::message('status.enum:a,b,c') + ); + $this->assertSame( + 'The reason field is required when mode is other.', + Validator::message('reason.required_if:mode,other') + ); + } + + public function testMessageRespectsAttributesAndOverrides(): void + { + $this->assertSame( + 'The user name field is required.', + Validator::message('username.required', [], ['username' => 'user name']) + ); + $this->assertSame( + 'custom', + Validator::message('username.min:3', ['username.min:3' => 'custom']) + ); + } +}