From 39a3130bd6013dca21779d759d314e813003d047 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 8 Oct 2025 11:36:38 -0500 Subject: [PATCH 01/68] feat(tests): add InteractWithDatabase trait and corresponding unit tests --- src/Testing/Concerns/InteractWithDatabase.php | 78 +++++++++++++++++++ tests/Unit/InteractWithDatabaseTest.php | 37 +++++++++ 2 files changed, 115 insertions(+) create mode 100644 src/Testing/Concerns/InteractWithDatabase.php create mode 100644 tests/Unit/InteractWithDatabaseTest.php diff --git a/src/Testing/Concerns/InteractWithDatabase.php b/src/Testing/Concerns/InteractWithDatabase.php new file mode 100644 index 00000000..12946e82 --- /dev/null +++ b/src/Testing/Concerns/InteractWithDatabase.php @@ -0,0 +1,78 @@ + $data + */ + public function assertDatabaseHas(string $table, array $data): void + { + [$count, ] = $this->getRecordCount($table, $data); + + Assert::assertGreaterThan(0, $count, 'Failed asserting that table has matching record.'); + } + + /** + * @param array $data + */ + public function assertDatabaseMissing(string $table, array $data): void + { + [$count, ] = $this->getRecordCount($table, $data); + + Assert::assertSame(0, $count, 'Failed asserting that table is missing the provided record.'); + } + + /** + * @param array $data + */ + public function assertDatabaseCount(string $table, int $expected, array $data = []): void + { + [$count, ] = $this->getRecordCount($table, $data); + + Assert::assertSame($expected, $count, 'Failed asserting the expected database record count.'); + } + + /** + * @param array $data + * @return array{0:int,1:string} + */ + protected function getRecordCount(string $table, array $data): array + { + $query = DB::from($table); + + $descriptionParts = []; + + foreach ($data as $column => $value) { + if ($value === null) { + $query->whereNull($column); + $descriptionParts[] = sprintf('%s IS NULL', $column); + + continue; + } + + if (is_bool($value)) { + $value = (int) $value; // normalize boolean to int representation + } + + $query->whereEqual($column, is_int($value) ? $value : (string) $value); + $descriptionParts[] = sprintf('%s = %s', $column, var_export($value, true)); + } + + if ($descriptionParts === []) { + $count = $query->count(); + + return [$count, '']; + } + + $count = $query->count(); + + return [$count, implode(', ', $descriptionParts)]; + } +} diff --git a/tests/Unit/InteractWithDatabaseTest.php b/tests/Unit/InteractWithDatabaseTest.php new file mode 100644 index 00000000..36994d18 --- /dev/null +++ b/tests/Unit/InteractWithDatabaseTest.php @@ -0,0 +1,37 @@ +getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(3)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result([['COUNT(*)' => 1]])), + new Statement(new Result([['COUNT(*)' => 0]])), + new Statement(new Result([['COUNT(*)' => 1]])), + ); + + $this->app->swap(Connection::default(), $connection); + + $this->assertDatabaseHas('users', [ + 'email' => 'test@example.com', + ]); + + $this->assertDatabaseMissing('users', [ + 'email' => 'nonexistent@example.com', + ]); + + $this->assertDatabaseCount('users', 1, [ + 'email' => 'test@example.com', + ]); +}); \ No newline at end of file From af3fa9641b2d1d815f689c604c49f31d668275ed Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 8 Oct 2025 11:38:22 -0500 Subject: [PATCH 02/68] refactor(InteractWithDatabase): simplify record count retrieval and update method signatures --- src/Testing/Concerns/InteractWithDatabase.php | 23 ++++--------------- 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/src/Testing/Concerns/InteractWithDatabase.php b/src/Testing/Concerns/InteractWithDatabase.php index 12946e82..b852cea7 100644 --- a/src/Testing/Concerns/InteractWithDatabase.php +++ b/src/Testing/Concerns/InteractWithDatabase.php @@ -14,7 +14,7 @@ trait InteractWithDatabase */ public function assertDatabaseHas(string $table, array $data): void { - [$count, ] = $this->getRecordCount($table, $data); + $count = $this->getRecordCount($table, $data); Assert::assertGreaterThan(0, $count, 'Failed asserting that table has matching record.'); } @@ -24,7 +24,7 @@ public function assertDatabaseHas(string $table, array $data): void */ public function assertDatabaseMissing(string $table, array $data): void { - [$count, ] = $this->getRecordCount($table, $data); + $count = $this->getRecordCount($table, $data); Assert::assertSame(0, $count, 'Failed asserting that table is missing the provided record.'); } @@ -34,25 +34,21 @@ public function assertDatabaseMissing(string $table, array $data): void */ public function assertDatabaseCount(string $table, int $expected, array $data = []): void { - [$count, ] = $this->getRecordCount($table, $data); + $count = $this->getRecordCount($table, $data); Assert::assertSame($expected, $count, 'Failed asserting the expected database record count.'); } /** * @param array $data - * @return array{0:int,1:string} */ - protected function getRecordCount(string $table, array $data): array + protected function getRecordCount(string $table, array $data): int { $query = DB::from($table); - $descriptionParts = []; - foreach ($data as $column => $value) { if ($value === null) { $query->whereNull($column); - $descriptionParts[] = sprintf('%s IS NULL', $column); continue; } @@ -62,17 +58,8 @@ protected function getRecordCount(string $table, array $data): array } $query->whereEqual($column, is_int($value) ? $value : (string) $value); - $descriptionParts[] = sprintf('%s = %s', $column, var_export($value, true)); } - if ($descriptionParts === []) { - $count = $query->count(); - - return [$count, '']; - } - - $count = $query->count(); - - return [$count, implode(', ', $descriptionParts)]; + return $query->count(); } } From b11471de653120b4f125ee44b0103a335398938b Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 8 Oct 2025 11:52:56 -0500 Subject: [PATCH 03/68] refactor(InteractWithDatabase): update method signatures to accept Closure criteria --- src/Testing/Concerns/InteractWithDatabase.php | 31 ++++++++++++------- tests/Unit/InteractWithDatabaseTest.php | 16 ++++++++++ 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/src/Testing/Concerns/InteractWithDatabase.php b/src/Testing/Concerns/InteractWithDatabase.php index b852cea7..cba900e8 100644 --- a/src/Testing/Concerns/InteractWithDatabase.php +++ b/src/Testing/Concerns/InteractWithDatabase.php @@ -4,49 +4,56 @@ namespace Phenix\Testing\Concerns; +use Closure; use Phenix\Facades\DB; use PHPUnit\Framework\Assert; trait InteractWithDatabase { /** - * @param array $data + * @param Closure|array $criteria */ - public function assertDatabaseHas(string $table, array $data): void + public function assertDatabaseHas(string $table, Closure|array $criteria): void { - $count = $this->getRecordCount($table, $data); + $count = $this->getRecordCount($table, $criteria); Assert::assertGreaterThan(0, $count, 'Failed asserting that table has matching record.'); } /** - * @param array $data + * @param Closure|array $criteria */ - public function assertDatabaseMissing(string $table, array $data): void + public function assertDatabaseMissing(string $table, Closure|array $criteria): void { - $count = $this->getRecordCount($table, $data); + $count = $this->getRecordCount($table, $criteria); Assert::assertSame(0, $count, 'Failed asserting that table is missing the provided record.'); } /** - * @param array $data + * @param Closure|array $criteria */ - public function assertDatabaseCount(string $table, int $expected, array $data = []): void + public function assertDatabaseCount(string $table, int $expected, Closure|array $criteria = []): void { - $count = $this->getRecordCount($table, $data); + $count = $this->getRecordCount($table, $criteria); Assert::assertSame($expected, $count, 'Failed asserting the expected database record count.'); } /** - * @param array $data + * @param Closure|array $criteria */ - protected function getRecordCount(string $table, array $data): int + protected function getRecordCount(string $table, Closure|array $criteria): int { $query = DB::from($table); - foreach ($data as $column => $value) { + if ($criteria instanceof Closure) { + $criteria($query); + + return $query->count(); + } + + foreach ($criteria as $column => $value) { if ($value === null) { $query->whereNull($column); diff --git a/tests/Unit/InteractWithDatabaseTest.php b/tests/Unit/InteractWithDatabaseTest.php index 36994d18..6ee8ed9e 100644 --- a/tests/Unit/InteractWithDatabaseTest.php +++ b/tests/Unit/InteractWithDatabaseTest.php @@ -34,4 +34,20 @@ $this->assertDatabaseCount('users', 1, [ 'email' => 'test@example.com', ]); +}); + +it('supports closure criteria', function (): void { + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(1)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result([['COUNT(*)' => 2]])), + ); + + $this->app->swap(Connection::default(), $connection); + + $this->assertDatabaseCount('users', 2, function ($query) { + $query->whereEqual('active', 1); + }); }); \ No newline at end of file From 6e6a412e65ae32bcad82ce57c9dfb6614a2b5d11 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 8 Oct 2025 16:07:57 -0500 Subject: [PATCH 04/68] feat(tests): add RefreshDatabase trait and corresponding unit tests refactor(TestCase): integrate RefreshDatabase trait for automatic database refresh refactor(functions): add class_uses_recursive function for trait management --- src/Testing/Concerns/RefreshDatabase.php | 126 +++++++++++++++++++++++ src/Testing/TestCase.php | 9 ++ src/functions.php | 25 +++++ tests/Unit/RefreshDatabaseTest.php | 36 +++++++ 4 files changed, 196 insertions(+) create mode 100644 src/Testing/Concerns/RefreshDatabase.php create mode 100644 tests/Unit/RefreshDatabaseTest.php diff --git a/src/Testing/Concerns/RefreshDatabase.php b/src/Testing/Concerns/RefreshDatabase.php new file mode 100644 index 00000000..a9af7411 --- /dev/null +++ b/src/Testing/Concerns/RefreshDatabase.php @@ -0,0 +1,126 @@ +runMigrations(); + + static::$migrated = true; + } + + $this->truncateDatabase(); + } + + protected function runMigrations(): void + { + $defaultConnection = Config::get('database.default'); + $settings = Config::get("database.connections.{$defaultConnection}"); + + $driver = Driver::tryFrom($settings['driver']) ?? Driver::MYSQL; + + $config = new MigrationConfig([ + 'paths' => [ + 'migrations' => Config::get('database.paths.migrations'), + 'seeds' => Config::get('database.paths.seeds'), + ], + 'environments' => [ + 'default_migration_table' => 'migrations', + 'default_environment' => 'default', + 'default' => [ + 'adapter' => $driver->value, + 'host' => $settings['host'] ?? null, + 'name' => $settings['database'] ?? null, + 'user' => $settings['username'] ?? null, + 'pass' => $settings['password'] ?? null, + 'port' => $settings['port'] ?? null, + ], + ], + ]); + + $manager = new Manager($config, new ArrayInput([]), new NullOutput()); + + try { + $manager->migrate('default'); + } catch (Throwable $e) { + report($e); + } + } + + protected function truncateDatabase(): void + { + /** @var SqlCommonConnectionPool $connection */ + $connection = App::make(Connection::default()); + + $defaultConnection = Config::get('database.default'); + $settings = Config::get("database.connections.{$defaultConnection}"); + $driver = Driver::tryFrom($settings['driver']) ?? Driver::MYSQL; + + $tables = []; + + try { + if ($driver === Driver::MYSQL) { + $result = $connection->prepare('SHOW TABLES')->execute(); + foreach ($result as $row) { + $table = array_values($row)[0] ?? null; + if ($table) { + $tables[] = $table; + } + } + } elseif ($driver === Driver::POSTGRESQL) { + $result = $connection->prepare("SELECT tablename FROM pg_tables WHERE schemaname = 'public'")->execute(); + foreach ($result as $row) { + $table = $row['tablename'] ?? null; + if ($table) { + $tables[] = $table; + } + } + } else { + // Unsupported driver for automatic truncation (sqlite, redis, etc.) + return; + } + } catch (Throwable) { + // If we can't list tables (e.g., no real connection in current test) just exit silently. + return; + } + + $tables = array_filter($tables, static fn (string $t): bool => $t !== 'migrations'); + + if (empty($tables)) { + return; // Nothing to truncate + } + + try { + if ($driver === Driver::MYSQL) { + $connection->prepare('SET FOREIGN_KEY_CHECKS=0')->execute(); + foreach ($tables as $table) { + $connection->prepare('TRUNCATE TABLE `'.$table.'`')->execute(); + } + $connection->prepare('SET FOREIGN_KEY_CHECKS=1')->execute(); + } elseif ($driver === Driver::POSTGRESQL) { + $quoted = array_map(static fn (string $t): string => '"' . str_replace('"', '""', $t) . '"', $tables); + $connection->prepare('TRUNCATE TABLE '.implode(', ', $quoted).' RESTART IDENTITY CASCADE')->execute(); + } + } catch (Throwable $e) { + report($e); + } + } +} diff --git a/src/Testing/TestCase.php b/src/Testing/TestCase.php index 7c64c487..12bbf3e1 100644 --- a/src/Testing/TestCase.php +++ b/src/Testing/TestCase.php @@ -10,8 +10,11 @@ use Phenix\AppProxy; use Phenix\Console\Phenix; use Phenix\Testing\Concerns\InteractWithResponses; +use Phenix\Testing\Concerns\RefreshDatabase; use Symfony\Component\Console\Tester\CommandTester; +use function in_array; + abstract class TestCase extends AsyncTestCase { use InteractWithResponses; @@ -29,6 +32,12 @@ protected function setUp(): void $this->app = AppBuilder::build($this->getAppDir(), $this->getEnvFile()); $this->app->enableTestingMode(); } + + $uses = class_uses_recursive($this); + + if (in_array(RefreshDatabase::class, $uses, true)) { + $this->refreshDatabase(); + } } protected function tearDown(): void diff --git a/src/functions.php b/src/functions.php index bc22b596..0f25cf0d 100644 --- a/src/functions.php +++ b/src/functions.php @@ -78,3 +78,28 @@ function trans_choice(string $key, int $number, array $replace = []): string return Translator::choice($key, $number, $replace); } } + +if (! function_exists('class_uses_recursive')) { + function class_uses_recursive(object|string $class): array + { + if (is_object($class)) { + $class = get_class($class); + } + + $results = []; + + do { + $traits = class_uses($class) ?: []; + + foreach ($traits as $trait) { + $results[$trait] = $trait; + + foreach (class_uses_recursive($trait) as $nestedTrait) { + $results[$nestedTrait] = $nestedTrait; + } + } + } while ($class = get_parent_class($class)); + + return array_values($results); + } +} diff --git a/tests/Unit/RefreshDatabaseTest.php b/tests/Unit/RefreshDatabaseTest.php new file mode 100644 index 00000000..32dd7c1c --- /dev/null +++ b/tests/Unit/RefreshDatabaseTest.php @@ -0,0 +1,36 @@ +getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->atLeast(4)) + ->method('prepare') + ->willReturnCallback(function (string $sql) { + if (str_starts_with($sql, 'SHOW TABLES')) { + return new Statement(new Result([ + ['Tables_in_test' => 'users'], + ['Tables_in_test' => 'posts'], + ['Tables_in_test' => 'migrations'], // should be ignored for truncation + ])); + } + + return new Statement(new Result()); + }); + + $this->app->swap(Connection::default(), $connection); + + // Trigger manually + $this->refreshDatabase(); + + $this->assertTrue(true); +}); From f372ef4a1207e3c4370e64175053bc94674eab45 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 8 Oct 2025 16:08:05 -0500 Subject: [PATCH 05/68] fix(config): update application port from 1337 to 1338 --- tests/fixtures/application/config/app.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fixtures/application/config/app.php b/tests/fixtures/application/config/app.php index 732528da..3c8456e5 100644 --- a/tests/fixtures/application/config/app.php +++ b/tests/fixtures/application/config/app.php @@ -6,7 +6,7 @@ 'name' => env('APP_NAME', static fn (): string => 'Phenix'), 'env' => env('APP_ENV', static fn (): string => 'local'), 'url' => env('APP_URL', static fn (): string => 'http://127.0.0.1'), - 'port' => env('APP_PORT', static fn (): int => 1337), + 'port' => env('APP_PORT', static fn (): int => 1338), 'key' => env('APP_KEY'), 'previous_key' => env('APP_PREVIOUS_KEY'), 'debug' => env('APP_DEBUG', static fn (): bool => true), From 3b8ec17408f7d211458b9440bb2e270f0aa22964 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 8 Oct 2025 16:08:11 -0500 Subject: [PATCH 06/68] style: php cs --- tests/Unit/InteractWithDatabaseTest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Unit/InteractWithDatabaseTest.php b/tests/Unit/InteractWithDatabaseTest.php index 6ee8ed9e..83eb80f9 100644 --- a/tests/Unit/InteractWithDatabaseTest.php +++ b/tests/Unit/InteractWithDatabaseTest.php @@ -2,11 +2,11 @@ declare(strict_types=1); -use Tests\Mocks\Database\Result; -use Tests\Mocks\Database\Statement; use Phenix\Database\Constants\Connection; -use Tests\Mocks\Database\MysqlConnectionPool; use Phenix\Testing\Concerns\InteractWithDatabase; +use Tests\Mocks\Database\MysqlConnectionPool; +use Tests\Mocks\Database\Result; +use Tests\Mocks\Database\Statement; uses(InteractWithDatabase::class); @@ -50,4 +50,4 @@ $this->assertDatabaseCount('users', 2, function ($query) { $query->whereEqual('active', 1); }); -}); \ No newline at end of file +}); From 2b9e2d39064a7b6c1512f838fa1e117effc589cd Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 8 Oct 2025 17:15:44 -0500 Subject: [PATCH 07/68] fix(TestCase): ensure refreshDatabase method exists before calling --- src/Testing/TestCase.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Testing/TestCase.php b/src/Testing/TestCase.php index 12bbf3e1..2e6c6c84 100644 --- a/src/Testing/TestCase.php +++ b/src/Testing/TestCase.php @@ -35,7 +35,7 @@ protected function setUp(): void $uses = class_uses_recursive($this); - if (in_array(RefreshDatabase::class, $uses, true)) { + if (in_array(RefreshDatabase::class, $uses, true) && method_exists($this, 'refreshDatabase')) { $this->refreshDatabase(); } } From 4c8a7912d236fb0c55bff15ff8b6587d78d07677 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 8 Oct 2025 17:45:08 -0500 Subject: [PATCH 08/68] refactor(RefreshDatabase): streamline truncateDatabase and improve driver handling --- src/Testing/Concerns/RefreshDatabase.php | 94 +++++++++++++++++------- 1 file changed, 68 insertions(+), 26 deletions(-) diff --git a/src/Testing/Concerns/RefreshDatabase.php b/src/Testing/Concerns/RefreshDatabase.php index a9af7411..1cc9674a 100644 --- a/src/Testing/Concerns/RefreshDatabase.php +++ b/src/Testing/Concerns/RefreshDatabase.php @@ -70,53 +70,95 @@ protected function truncateDatabase(): void /** @var SqlCommonConnectionPool $connection */ $connection = App::make(Connection::default()); + $driver = $this->resolveDriver(); + + try { + $tables = $this->getDatabaseTables($connection, $driver); + } catch (Throwable) { + return; + } + + $tables = $this->filterTruncatableTables($tables); + + if (empty($tables)) { + return; + } + + $this->truncateTables($connection, $driver, $tables); + } + + protected function resolveDriver(): Driver + { $defaultConnection = Config::get('database.default'); $settings = Config::get("database.connections.{$defaultConnection}"); - $driver = Driver::tryFrom($settings['driver']) ?? Driver::MYSQL; + return Driver::tryFrom($settings['driver']) ?? Driver::MYSQL; + } + + /** + * @return array + */ + protected function getDatabaseTables(SqlCommonConnectionPool $connection, Driver $driver): array + { $tables = []; - try { - if ($driver === Driver::MYSQL) { - $result = $connection->prepare('SHOW TABLES')->execute(); - foreach ($result as $row) { - $table = array_values($row)[0] ?? null; - if ($table) { - $tables[] = $table; - } + if ($driver === Driver::MYSQL) { + $result = $connection->prepare('SHOW TABLES')->execute(); + + foreach ($result as $row) { + $table = array_values($row)[0] ?? null; + + if ($table) { + $tables[] = $table; } - } elseif ($driver === Driver::POSTGRESQL) { - $result = $connection->prepare("SELECT tablename FROM pg_tables WHERE schemaname = 'public'")->execute(); - foreach ($result as $row) { - $table = $row['tablename'] ?? null; - if ($table) { - $tables[] = $table; - } + } + } elseif ($driver === Driver::POSTGRESQL) { + $result = $connection->prepare("SELECT tablename FROM pg_tables WHERE schemaname = 'public'")->execute(); + + foreach ($result as $row) { + $table = $row['tablename'] ?? null; + + if ($table) { + $tables[] = $table; } - } else { - // Unsupported driver for automatic truncation (sqlite, redis, etc.) - return; } - } catch (Throwable) { - // If we can't list tables (e.g., no real connection in current test) just exit silently. - return; + } else { + // Unsupported driver (sqlite, etc.) – return empty so caller exits gracefully. + return []; } - $tables = array_filter($tables, static fn (string $t): bool => $t !== 'migrations'); + return $tables; + } - if (empty($tables)) { - return; // Nothing to truncate - } + /** + * @param array $tables + * @return array + */ + protected function filterTruncatableTables(array $tables): array + { + return array_values(array_filter( + $tables, + static fn (string $t): bool => $t !== 'migrations' + )); + } + /** + * @param array $tables + */ + protected function truncateTables(SqlCommonConnectionPool $connection, Driver $driver, array $tables): void + { try { if ($driver === Driver::MYSQL) { $connection->prepare('SET FOREIGN_KEY_CHECKS=0')->execute(); + foreach ($tables as $table) { $connection->prepare('TRUNCATE TABLE `'.$table.'`')->execute(); } + $connection->prepare('SET FOREIGN_KEY_CHECKS=1')->execute(); } elseif ($driver === Driver::POSTGRESQL) { $quoted = array_map(static fn (string $t): string => '"' . str_replace('"', '""', $t) . '"', $tables); + $connection->prepare('TRUNCATE TABLE '.implode(', ', $quoted).' RESTART IDENTITY CASCADE')->execute(); } } catch (Throwable $e) { From 728e93da193a9e9902958f15c32318b4d0442b61 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 9 Oct 2025 08:50:44 -0500 Subject: [PATCH 09/68] feat(tests): add PostgresqlConnectionPool mock and enhance RefreshDatabase tests --- .../Database/PostgresqlConnectionPool.php | 99 +++++++++++++++++++ tests/Unit/RefreshDatabaseTest.php | 33 ++++++- 2 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 tests/Mocks/Database/PostgresqlConnectionPool.php diff --git a/tests/Mocks/Database/PostgresqlConnectionPool.php b/tests/Mocks/Database/PostgresqlConnectionPool.php new file mode 100644 index 00000000..62f5fdcf --- /dev/null +++ b/tests/Mocks/Database/PostgresqlConnectionPool.php @@ -0,0 +1,99 @@ +setFakeResult($result); + + return $pool; + } + + public function setFakeResult(array $result): void + { + $this->fakeResult = new FakeResult($result); + } + + public function throwDatabaseException(Throwable|null $error = null): self + { + $this->fakeError = $error ?? new SqlException('Fail trying database connection'); + + return $this; + } + + public function prepare(string $sql): SqlStatement + { + if (isset($this->fakeError)) { + throw $this->fakeError; + } + + return new FakeStatement($this->fakeResult); + } + + protected function createStatement(SqlStatement $statement, Closure $release): SqlStatement + { + return $statement; + } + + protected function createResult(SqlResult $result, Closure $release): SqlResult + { + return $result; + } + + protected function createStatementPool(string $sql, Closure $prepare): SqlStatement + { + return new FakeStatement($this->fakeResult); + } + + protected function createTransaction(SqlTransaction $transaction, Closure $release): SqlTransaction + { + return $transaction; + } +} diff --git a/tests/Unit/RefreshDatabaseTest.php b/tests/Unit/RefreshDatabaseTest.php index 32dd7c1c..5f48887d 100644 --- a/tests/Unit/RefreshDatabaseTest.php +++ b/tests/Unit/RefreshDatabaseTest.php @@ -3,13 +3,19 @@ declare(strict_types=1); use Phenix\Database\Constants\Connection; +use Phenix\Facades\Config; use Phenix\Testing\Concerns\RefreshDatabase; use Tests\Mocks\Database\MysqlConnectionPool; +use Tests\Mocks\Database\PostgresqlConnectionPool; use Tests\Mocks\Database\Result; use Tests\Mocks\Database\Statement; uses(RefreshDatabase::class); +beforeEach(function (): void { + static::$migrated = false; +}); + it('runs migrations only once and truncates tables between tests', function (): void { $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); @@ -29,7 +35,32 @@ $this->app->swap(Connection::default(), $connection); - // Trigger manually + $this->refreshDatabase(); + + $this->assertTrue(true); +}); + +it('truncates tables for postgresql driver', function (): void { + Config::set('database.default', 'postgresql'); + + $connection = $this->getMockBuilder(PostgresqlConnectionPool::class)->getMock(); + + $connection->expects($this->atLeast(2)) + ->method('prepare') + ->willReturnCallback(function (string $sql) { + if (str_starts_with($sql, 'SELECT tablename FROM pg_tables')) { + return new Statement(new Result([ + ['tablename' => 'users'], + ['tablename' => 'posts'], + ['tablename' => 'migrations'], + ])); + } + + return new Statement(new Result()); + }); + + $this->app->swap(Connection::default(), $connection); + $this->refreshDatabase(); $this->assertTrue(true); From 25fdbe7b70f2053a4c20a832599c1c1a618e4aee Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 9 Oct 2025 12:36:18 -0500 Subject: [PATCH 10/68] feat(tests): add tests for null value and boolean criteria normalization in InteractWithDatabase --- tests/Unit/InteractWithDatabaseTest.php | 37 +++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/Unit/InteractWithDatabaseTest.php b/tests/Unit/InteractWithDatabaseTest.php index 83eb80f9..8f9e0d31 100644 --- a/tests/Unit/InteractWithDatabaseTest.php +++ b/tests/Unit/InteractWithDatabaseTest.php @@ -51,3 +51,40 @@ $query->whereEqual('active', 1); }); }); + +it('supports null value criteria', function (): void { + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(1)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result([[ 'COUNT(*)' => 1 ]])), + ); + + $this->app->swap(Connection::default(), $connection); + + $this->assertDatabaseHas('users', [ + 'deleted_at' => null, + ]); +}); + +it('normalizes boolean criteria to integers', function (): void { + $connection = $this->getMockBuilder(MysqlConnectionPool::class)->getMock(); + + $connection->expects($this->exactly(2)) + ->method('prepare') + ->willReturnOnConsecutiveCalls( + new Statement(new Result([[ 'COUNT(*)' => 1 ]])), // active => true + new Statement(new Result([[ 'COUNT(*)' => 0 ]])), // active => false + ); + + $this->app->swap(Connection::default(), $connection); + + $this->assertDatabaseHas('users', [ + 'active' => true, + ]); + + $this->assertDatabaseMissing('users', [ + 'active' => false, + ]); +}); From 44d3ce0270adc8babe92f6c6b04d4de464ae6b75 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 9 Oct 2025 16:21:29 -0500 Subject: [PATCH 11/68] feat(EventEmitter): add logging and faking capabilities for dispatched events --- src/Events/Contracts/EventEmitter.php | 9 ++++ src/Events/EventEmitter.php | 62 ++++++++++++++++++++++ src/Facades/Event.php | 14 +++++ src/Testing/TestEvents.php | 71 ++++++++++++++++++++++++++ tests/Unit/Events/EventEmitterTest.php | 38 ++++++++++++++ 5 files changed, 194 insertions(+) create mode 100644 src/Testing/TestEvents.php diff --git a/src/Events/Contracts/EventEmitter.php b/src/Events/Contracts/EventEmitter.php index d0444b0b..5d8f0827 100644 --- a/src/Events/Contracts/EventEmitter.php +++ b/src/Events/Contracts/EventEmitter.php @@ -24,4 +24,13 @@ public function getListeners(string $event): array; public function hasListeners(string $event): bool; public function removeAllListeners(): void; + + public function log(): void; + + public function fake(): void; + + /** + * @return array + */ + public function getEventLog(): array; } diff --git a/src/Events/EventEmitter.php b/src/Events/EventEmitter.php index 0b383f43..ac7536dc 100644 --- a/src/Events/EventEmitter.php +++ b/src/Events/EventEmitter.php @@ -6,6 +6,7 @@ use Amp\Future; use Closure; +use Phenix\App; use Phenix\Events\Contracts\Event as EventContract; use Phenix\Events\Contracts\EventEmitter as EventEmitterContract; use Phenix\Events\Contracts\EventListener as EventListenerContract; @@ -37,6 +38,15 @@ class EventEmitter implements EventEmitterContract */ protected bool $emitWarnings = true; + protected bool $logging = false; + + protected bool $faking = false; + + /** + * @var array + */ + protected array $dispatched = []; + public function on(string $event, Closure|EventListenerContract|string $listener, int $priority = 0): void { $eventListener = $this->createEventListener($listener, $priority); @@ -88,6 +98,13 @@ public function off(string $event, Closure|EventListenerContract|string|null $li public function emit(string|EventContract $event, mixed $payload = null): array { $eventObject = $this->createEvent($event, $payload); + + $this->recordDispatched($eventObject); + + if ($this->faking) { + return []; + } + $results = []; $listeners = $this->getListeners($eventObject->getName()); @@ -134,6 +151,13 @@ public function emitAsync(string|EventContract $event, mixed $payload = null): F { return async(function () use ($event, $payload): array { $eventObject = $this->createEvent($event, $payload); + + $this->recordDispatched($eventObject); + + if ($this->faking) { + return []; + } + $listeners = $this->getListeners($eventObject->getName()); $futures = []; @@ -164,6 +188,44 @@ public function emitAsync(string|EventContract $event, mixed $payload = null): F }); } + public function log(): void + { + if (App::isProduction()) { + return; + } + + $this->logging = true; + } + + public function fake(): void + { + if (App::isProduction()) { + return; + } + + $this->logging = true; + $this->faking = true; + } + + public function getEventLog(): array + { + return $this->dispatched; + } + + protected function recordDispatched(EventContract $event): void + { + if (! $this->logging && ! $this->faking) { + return; + } + + $this->dispatched[] = [ + 'name' => $event->getName(), + 'event' => $event, + 'payload' => $event->getPayload(), + 'timestamp' => microtime(true), + ]; + } + protected function handleListenerAsync(EventListenerContract $listener, EventContract $eventObject): Future { return async(function () use ($listener, $eventObject): mixed { diff --git a/src/Facades/Event.php b/src/Facades/Event.php index 66f4954e..f8d51cb6 100644 --- a/src/Facades/Event.php +++ b/src/Facades/Event.php @@ -6,9 +6,11 @@ use Amp\Future; use Closure; +use Phenix\App; use Phenix\Events\Contracts\Event as EventContract; use Phenix\Events\Contracts\EventListener; use Phenix\Runtime\Facade; +use Phenix\Testing\TestEvents; /** * @method static void on(string $event, Closure|EventListener|string $listener, int $priority = 0) @@ -24,6 +26,10 @@ * @method static void setEmitWarnings(bool $emitWarnings) * @method static int getListenerCount(string $event) * @method static array getEventNames() + * @method static void log() + * @method static void fake() + * @method static array getEventLog() + * @method static \Phenix\Testing\TestEvents expect() * * @see \Phenix\Events\EventEmitter */ @@ -33,4 +39,12 @@ public static function getKeyName(): string { return \Phenix\Events\EventEmitter::class; } + + public static function expect(string $event): TestEvents + { + /** @var \Phenix\Events\EventEmitter $emitter */ + $emitter = App::make(self::getKeyName()); + + return new TestEvents($event, $emitter->getEventLog()); + } } diff --git a/src/Testing/TestEvents.php b/src/Testing/TestEvents.php new file mode 100644 index 00000000..ce29806d --- /dev/null +++ b/src/Testing/TestEvents.php @@ -0,0 +1,71 @@ + $log + */ + public function __construct( + protected string $event, + array $log = [] + ) { + $this->log = Collection::fromArray($log); + } + + public function toBeDispatched(Closure|null $closure = null): void + { + $matches = $this->filterByName($this->event); + + if ($closure) { + expect($closure($matches->first()['event'] ?? null))->toBeTrue(); + } else { + expect($matches)->not->toBeEmpty(); + } + } + + public function toNotBeDispatched(Closure|null $closure = null): void + { + $matches = $this->filterByName($this->event); + + if ($closure) { + expect($closure($matches->first()['event'] ?? null))->toBeFalse(); + } else { + expect($matches)->toBeEmpty(); + } + } + + public function toBeDispatchedTimes(int $times): void + { + $matches = $this->filterByName($this->event); + + expect($matches)->toHaveCount($times); + } + + public function toDispatchNothing(): void + { + expect($this->log)->toBeEmpty(); + } + + private function filterByName(string $event): Collection + { + $filtered = []; + + foreach ($this->log as $record) { + if ($record['name'] === $event) { + $filtered[] = $record; + } + } + + return Collection::fromArray($filtered); + } +} diff --git a/tests/Unit/Events/EventEmitterTest.php b/tests/Unit/Events/EventEmitterTest.php index e12259ab..ef1902dc 100644 --- a/tests/Unit/Events/EventEmitterTest.php +++ b/tests/Unit/Events/EventEmitterTest.php @@ -466,3 +466,41 @@ expect($emitter->getListenerCount('warn.event'))->toBe(2); }); + +it('logs dispatched events while still processing listeners', function (): void { + EventFacade::log(); + + $called = false; + EventFacade::on('logged.event', function () use (&$called): void { + $called = true; + }); + + EventFacade::emit('logged.event', 'payload'); + + expect($called)->toBeTrue(); + + EventFacade::expect('logged.event')->toBeDispatched(); + EventFacade::expect('logged.event')->toBeDispatchedTimes(1); +}); + +it('fakes events preventing listener execution', function (): void { + EventFacade::fake(); + + $called = false; + EventFacade::on('fake.event', function () use (&$called): void { + $called = true; + }); + + EventFacade::emit('fake.event', 'payload'); + + expect($called)->toBeFalse(); + + EventFacade::expect('fake.event')->toBeDispatched(); + EventFacade::expect('fake.event')->toBeDispatchedTimes(1); +}); + +it('can assert nothing dispatched', function (): void { + EventFacade::log(); + + EventFacade::expect('any.event')->toDispatchNothing(); +}); From 27b2a99ca2f94db3eb949cf4e3987b2560984258 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 9 Oct 2025 16:37:16 -0500 Subject: [PATCH 12/68] feat(EventEmitterTest): add support for closure predicates in event assertions --- tests/Unit/Events/EventEmitterTest.php | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/Unit/Events/EventEmitterTest.php b/tests/Unit/Events/EventEmitterTest.php index ef1902dc..ba90bd19 100644 --- a/tests/Unit/Events/EventEmitterTest.php +++ b/tests/Unit/Events/EventEmitterTest.php @@ -504,3 +504,27 @@ EventFacade::expect('any.event')->toDispatchNothing(); }); + +it('supports closure predicate', function (): void { + EventFacade::log(); + + EventFacade::emit('closure.event', ['foo' => 'bar']); + + EventFacade::expect('closure.event')->toBeDispatched(function ($event): bool { + return $event !== null && $event->getPayload()['foo'] === 'bar'; + }); +}); + +it('supports closure predicate with existing event', function (): void { + EventFacade::log(); + + EventFacade::emit('neg.event', 'value'); + + EventFacade::expect('neg.event')->toNotBeDispatched(fn ($event): bool => false); +}); + +it('supports closure predicate with absent event', function (): void { + EventFacade::log(); + + EventFacade::expect('absent.event')->toNotBeDispatched(fn ($event): bool => false); +}); From c44e4dd2ec96576eb56c6e8f17065e7cae6bfbef Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 9 Oct 2025 17:29:01 -0500 Subject: [PATCH 13/68] feat(Mail): enhance expect method to accept mailable and improve test assertions --- src/Facades/Mail.php | 5 ++- src/Testing/TestMail.php | 65 ++++++++++++++++++++---------------- tests/Unit/Mail/MailTest.php | 32 +++++++++--------- 3 files changed, 56 insertions(+), 46 deletions(-) diff --git a/src/Facades/Mail.php b/src/Facades/Mail.php index 208c6b3d..3c47fc6d 100644 --- a/src/Facades/Mail.php +++ b/src/Facades/Mail.php @@ -5,6 +5,7 @@ namespace Phenix\Facades; use Phenix\Mail\Constants\MailerType; +use Phenix\Mail\Contracts\Mailable as MailableContract; use Phenix\Mail\MailManager; use Phenix\Runtime\Facade; use Phenix\Testing\TestMail; @@ -15,6 +16,7 @@ * @method static \Phenix\Mail\Contracts\Mailer to(array|string $to) * @method static void send(\Phenix\Mail\Contracts\Mailable $mailable) * @method static \Phenix\Mail\Contracts\Mailer log(\Phenix\Mail\Constants\MailerType|null $mailerType = null) + * @method static TestMail expect(MailableContract|string $mailable, MailerType|null $mailerType = null) * * @see \Phenix\Mail\MailManager */ @@ -25,11 +27,12 @@ public static function getKeyName(): string return MailManager::class; } - public static function expect(MailerType|null $mailerType = null): TestMail + public static function expect(MailableContract|string $mailable, MailerType|null $mailerType = null): TestMail { $mailerType ??= MailerType::from(Config::get('mail.default')); return new TestMail( + $mailable, self::mailer($mailerType)->getSendingLog() ); } diff --git a/src/Testing/TestMail.php b/src/Testing/TestMail.php index 3c4a4299..e67a657b 100644 --- a/src/Testing/TestMail.php +++ b/src/Testing/TestMail.php @@ -7,60 +7,67 @@ use Closure; use Phenix\Data\Collection; use Phenix\Mail\Contracts\Mailable; +use PHPUnit\Framework\Assert; class TestMail { public readonly Collection $log; + protected string $mailable; - public function __construct(array $log = []) - { - $this->log = Collection::fromArray($log); - } - - public function toBeSent(Mailable|string $mailable, Closure|null $closure = null): void + /** + * @param array $log + */ + public function __construct(Mailable|string $mailable, array $log = []) { if ($mailable instanceof Mailable) { $mailable = $mailable::class; } - $matches = $this->log->filter(function (array $mail) use ($mailable): bool { - return $mail['mailable'] === $mailable; - }); + $this->mailable = $mailable; + $this->log = Collection::fromArray($log); + } + + public function toBeSent(Closure|null $closure = null): void + { + $matches = $this->filterByMailable($this->mailable); if ($closure) { - expect($closure($matches->first()))->toBeTrue(); + Assert::assertTrue($closure($matches->first())); } else { - expect($matches)->not->toBeEmpty(); + Assert::assertNotEmpty($matches, "Failed asserting that mailable '{$this->mailable}' was sent at least once."); } } - public function toNotBeSent(Mailable|string $mailable, Closure|null $closure = null): void + public function toNotBeSent(Closure|null $closure = null): void { - if ($mailable instanceof Mailable) { - $mailable = $mailable::class; - } - - $matches = $this->log->filter(function (array $mail) use ($mailable): bool { - return $mail['mailable'] === $mailable; - }); + $matches = $this->filterByMailable($this->mailable); if ($closure) { - expect($closure($matches->first()))->toBeFalse(); + Assert::assertFalse($closure($matches->first())); } else { - expect($matches)->toBeEmpty(); + Assert::assertEmpty($matches, "Failed asserting that mailable '{$this->mailable}' was NOT sent."); } } - public function toBeSentTimes(Mailable|string $mailable, int $times): void + public function toBeSentTimes(int $times): void { - if ($mailable instanceof Mailable) { - $mailable = $mailable::class; - } + $matches = $this->filterByMailable($this->mailable); + + $count = $matches->count(); - $matches = $this->log->filter(function (array $mail) use ($mailable): bool { - return $mail['mailable'] === $mailable; - }); + Assert::assertCount($times, $matches, "Failed asserting that mailable '{$this->mailable}' was sent {$times} times. Actual: {$count}."); + } + + private function filterByMailable(string $mailable): Collection + { + $filtered = []; + + foreach ($this->log as $record) { + if (($record['mailable'] ?? null) === $mailable) { + $filtered[] = $record; + } + } - expect($matches)->toHaveCount($times); + return Collection::fromArray($filtered); } } diff --git a/tests/Unit/Mail/MailTest.php b/tests/Unit/Mail/MailTest.php index c3ef9b7c..fd46df42 100644 --- a/tests/Unit/Mail/MailTest.php +++ b/tests/Unit/Mail/MailTest.php @@ -142,14 +142,14 @@ public function build(): self Mail::to($email)->send($mailable); - Mail::expect()->toBeSent($mailable); + Mail::expect($mailable)->toBeSent(); - Mail::expect()->toBeSent($mailable, function (array $matches): bool { + Mail::expect($mailable)->toBeSent(function (array $matches): bool { return $matches['success'] === true; }); - Mail::expect()->toBeSentTimes($mailable, 1); - Mail::expect()->toNotBeSent($mailable, function (array $matches): bool { + Mail::expect($mailable)->toBeSentTimes(1); + Mail::expect($mailable)->toNotBeSent(function (array $matches): bool { return $matches['success'] === false; }); }); @@ -178,14 +178,14 @@ public function build(): self Mail::to($email)->send($mailable); - Mail::expect()->toBeSent($mailable); + Mail::expect($mailable)->toBeSent(); - Mail::expect()->toBeSent($mailable, function (array $matches): bool { + Mail::expect($mailable)->toBeSent(function (array $matches): bool { return $matches['success'] === true; }); - Mail::expect()->toBeSentTimes($mailable, 1); - Mail::expect()->toNotBeSent($mailable, function (array $matches): bool { + Mail::expect($mailable)->toBeSentTimes(1); + Mail::expect($mailable)->toNotBeSent(function (array $matches): bool { return $matches['success'] === false; }); }); @@ -213,7 +213,7 @@ public function build(): self Mail::send($mailable); - Mail::expect()->toBeSent($mailable); + Mail::expect($mailable)->toBeSent(); }); it('merge sender defined from facade and mailer', function (): void { @@ -241,7 +241,7 @@ public function build(): self Mail::to($email)->send($mailable); - Mail::expect()->toBeSent($mailable, function (array $matches): bool { + Mail::expect($mailable)->toBeSent(function (array $matches): bool { $email = $matches['email'] ?? null; if (! $email) { @@ -282,7 +282,7 @@ public function build(): self ->cc($cc) ->send($mailable); - Mail::expect()->toBeSent($mailable, function (array $matches) use ($cc): bool { + Mail::expect($mailable)->toBeSent(function (array $matches) use ($cc): bool { $email = $matches['email'] ?? null; if (! $email) { @@ -324,7 +324,7 @@ public function build(): self ->bcc($bcc) ->send($mailable); - Mail::expect()->toBeSent($mailable, function (array $matches) use ($bcc): bool { + Mail::expect($mailable)->toBeSent(function (array $matches) use ($bcc): bool { $email = $matches['email'] ?? null; if (! $email) { @@ -365,7 +365,7 @@ public function build(): self Mail::to($to) ->send($mailable); - Mail::expect()->toBeSent($mailable, function (array $matches): bool { + Mail::expect($mailable)->toBeSent(function (array $matches): bool { $email = $matches['email'] ?? null; if (! $email) { @@ -410,7 +410,7 @@ public function build(): self Mail::to($to)->send($mailable); - Mail::expect()->toBeSent($mailable, function (array $matches): bool { + Mail::expect($mailable)->toBeSent(function (array $matches): bool { $email = $matches['email'] ?? null; if (! $email) { return false; @@ -456,7 +456,7 @@ public function build(): self Mail::to($to)->send($mailable); - Mail::expect()->toNotBeSent($mailable); + Mail::expect($mailable)->toNotBeSent(); })->throws(InvalidArgumentException::class); it('run parallel task to send email', function (): void { @@ -530,7 +530,7 @@ public function build(): self Mail::to($to)->send($mailable); - Mail::expect()->toBeSent($mailable, function (array $matches): bool { + Mail::expect($mailable)->toBeSent(function (array $matches): bool { $email = $matches['email'] ?? null; if (! $email) { From bca1e2d01abf2c645d8c716bd02b96c3ab906d5c Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 9 Oct 2025 17:30:47 -0500 Subject: [PATCH 14/68] feat(TestEvents): replace expect assertions with PHPUnit Assert methods for improved clarity --- src/Testing/TestEvents.php | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/Testing/TestEvents.php b/src/Testing/TestEvents.php index ce29806d..4ffb7934 100644 --- a/src/Testing/TestEvents.php +++ b/src/Testing/TestEvents.php @@ -7,6 +7,7 @@ use Closure; use Phenix\Data\Collection; use Phenix\Events\Contracts\Event as EventContract; +use PHPUnit\Framework\Assert; class TestEvents { @@ -27,9 +28,9 @@ public function toBeDispatched(Closure|null $closure = null): void $matches = $this->filterByName($this->event); if ($closure) { - expect($closure($matches->first()['event'] ?? null))->toBeTrue(); + Assert::assertTrue($closure($matches->first()['event'] ?? null)); } else { - expect($matches)->not->toBeEmpty(); + Assert::assertNotEmpty($matches, "Failed asserting that event '{$this->event}' was dispatched at least once."); } } @@ -38,9 +39,9 @@ public function toNotBeDispatched(Closure|null $closure = null): void $matches = $this->filterByName($this->event); if ($closure) { - expect($closure($matches->first()['event'] ?? null))->toBeFalse(); + Assert::assertFalse($closure($matches->first()['event'] ?? null)); } else { - expect($matches)->toBeEmpty(); + Assert::assertEmpty($matches, "Failed asserting that event '{$this->event}' was NOT dispatched."); } } @@ -48,12 +49,12 @@ public function toBeDispatchedTimes(int $times): void { $matches = $this->filterByName($this->event); - expect($matches)->toHaveCount($times); + Assert::assertCount($times, $matches, "Failed asserting that event '{$this->event}' was dispatched {$times} times. Actual: {$matches->count()}."); } public function toDispatchNothing(): void { - expect($this->log)->toBeEmpty(); + Assert::assertEmpty($this->log, "Failed asserting that no events were dispatched."); } private function filterByName(string $event): Collection From 423c561e1621652b44315983121a7326dd83589c Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 9 Oct 2025 18:10:47 -0500 Subject: [PATCH 15/68] feat(TestResponse): replace expect assertions with PHPUnit Assert methods for consistency and clarity --- src/Testing/TestResponse.php | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/Testing/TestResponse.php b/src/Testing/TestResponse.php index b8d971c5..f3ad85c2 100644 --- a/src/Testing/TestResponse.php +++ b/src/Testing/TestResponse.php @@ -6,6 +6,7 @@ use Amp\Http\Client\Response; use Phenix\Http\Constants\HttpStatus; +use PHPUnit\Framework\Assert; class TestResponse { @@ -33,28 +34,28 @@ public function getHeader(string $name): string|null public function assertOk(): self { - expect($this->response->getStatus())->toBe(HttpStatus::OK->value); + Assert::assertEquals(HttpStatus::OK->value, $this->response->getStatus()); return $this; } public function assertNotFound(): self { - expect($this->response->getStatus())->toBe(HttpStatus::NOT_FOUND->value); + Assert::assertEquals(HttpStatus::NOT_FOUND->value, $this->response->getStatus()); return $this; } public function assertNotAcceptable(): self { - expect($this->response->getStatus())->toBe(HttpStatus::NOT_ACCEPTABLE->value); + Assert::assertEquals(HttpStatus::NOT_ACCEPTABLE->value, $this->response->getStatus()); return $this; } public function assertUnprocessableEntity(): self { - expect($this->response->getStatus())->toBe(HttpStatus::UNPROCESSABLE_ENTITY->value); + Assert::assertEquals(HttpStatus::UNPROCESSABLE_ENTITY->value, $this->response->getStatus()); return $this; } @@ -67,7 +68,9 @@ public function assertBodyContains(array|string $needles): self { $needles = (array) $needles; - expect($this->body)->toContain(...$needles); + foreach ($needles as $needle) { + Assert::assertStringContainsString($needle, $this->body); + } return $this; } @@ -77,8 +80,8 @@ public function assertHeaderContains(array $needles): self $needles = (array) $needles; foreach ($needles as $header => $value) { - expect($this->response->getHeader($header))->not->toBeNull(); - expect($this->response->getHeader($header))->toBe($value); + Assert::assertNotNull($this->response->getHeader($header)); + Assert::assertEquals($value, $this->response->getHeader($header)); } return $this; From 1c3c106c3e3272696c9955e2d740ee3eb3af80be Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 9 Oct 2025 18:12:38 -0500 Subject: [PATCH 16/68] chore: rename TestEvents to TestEvent --- src/Facades/Event.php | 8 ++++---- src/Testing/{TestEvents.php => TestEvent.php} | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) rename src/Testing/{TestEvents.php => TestEvent.php} (99%) diff --git a/src/Facades/Event.php b/src/Facades/Event.php index f8d51cb6..73e4d67d 100644 --- a/src/Facades/Event.php +++ b/src/Facades/Event.php @@ -10,7 +10,7 @@ use Phenix\Events\Contracts\Event as EventContract; use Phenix\Events\Contracts\EventListener; use Phenix\Runtime\Facade; -use Phenix\Testing\TestEvents; +use Phenix\Testing\TestEvent; /** * @method static void on(string $event, Closure|EventListener|string $listener, int $priority = 0) @@ -29,7 +29,7 @@ * @method static void log() * @method static void fake() * @method static array getEventLog() - * @method static \Phenix\Testing\TestEvents expect() + * @method static \Phenix\Testing\TestEvent expect() * * @see \Phenix\Events\EventEmitter */ @@ -40,11 +40,11 @@ public static function getKeyName(): string return \Phenix\Events\EventEmitter::class; } - public static function expect(string $event): TestEvents + public static function expect(string $event): TestEvent { /** @var \Phenix\Events\EventEmitter $emitter */ $emitter = App::make(self::getKeyName()); - return new TestEvents($event, $emitter->getEventLog()); + return new TestEvent($event, $emitter->getEventLog()); } } diff --git a/src/Testing/TestEvents.php b/src/Testing/TestEvent.php similarity index 99% rename from src/Testing/TestEvents.php rename to src/Testing/TestEvent.php index 4ffb7934..4145f96a 100644 --- a/src/Testing/TestEvents.php +++ b/src/Testing/TestEvent.php @@ -9,7 +9,7 @@ use Phenix\Events\Contracts\Event as EventContract; use PHPUnit\Framework\Assert; -class TestEvents +class TestEvent { public readonly Collection $log; From 939228805c09adea9a6f47c900f0f5e30ad64ad6 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 9 Oct 2025 18:30:12 -0500 Subject: [PATCH 17/68] feat(Queue): add logging and faking capabilities for task pushes; introduce TestQueue for assertions --- src/Facades/Queue.php | 14 +++++ src/Queue/QueueManager.php | 65 ++++++++++++++++++++ src/Testing/TestQueue.php | 82 ++++++++++++++++++++++++++ tests/Unit/Queue/ParallelQueueTest.php | 48 +++++++++++++++ 4 files changed, 209 insertions(+) create mode 100644 src/Testing/TestQueue.php diff --git a/src/Facades/Queue.php b/src/Facades/Queue.php index 2a7c2aec..efd270f0 100644 --- a/src/Facades/Queue.php +++ b/src/Facades/Queue.php @@ -4,11 +4,13 @@ namespace Phenix\Facades; +use Phenix\App; use Phenix\Queue\Constants\QueueDriver; use Phenix\Queue\Contracts\Queue as QueueContract; use Phenix\Queue\QueueManager; use Phenix\Runtime\Facade; use Phenix\Tasks\QueuableTask; +use Phenix\Testing\TestQueue; /** * @method static void push(QueuableTask $task) @@ -20,6 +22,10 @@ * @method static string getConnectionName() * @method static void setConnectionName(string $name) * @method static QueueContract driver(QueueDriver|null $driverName = null) + * @method static void log() + * @method static void fake() + * @method static array getQueueLog() + * @method static TestQueue expect(string $taskClass) * * @see \Phenix\Queue\QueueManager */ @@ -29,4 +35,12 @@ protected static function getKeyName(): string { return QueueManager::class; } + + public static function expect(string $taskClass): TestQueue + { + /** @var QueueManager $manager */ + $manager = App::make(self::getKeyName()); + + return new TestQueue($taskClass, $manager->getQueueLog()); + } } diff --git a/src/Queue/QueueManager.php b/src/Queue/QueueManager.php index c4ad693c..a7e948f7 100644 --- a/src/Queue/QueueManager.php +++ b/src/Queue/QueueManager.php @@ -17,6 +17,16 @@ class QueueManager protected Config $config; + + protected bool $logging = false; + + protected bool $faking = false; + + /** + * @var array, task: QueuableTask, queue: string|null, connection: string|null, timestamp: float}> + */ + protected array $pushed = []; + public function __construct(Config|null $config = null) { $this->config = $config ?? new Config(); @@ -24,11 +34,24 @@ public function __construct(Config|null $config = null) public function push(QueuableTask $task): void { + $this->recordPush($task); + + if ($this->faking) { + return; + } + $this->driver()->push($task); } public function pushOn(string $queueName, QueuableTask $task): void { + $task->setQueueName($queueName); + $this->recordPush($task); + + if ($this->faking) { + return; + } + $this->driver()->pushOn($queueName, $task); } @@ -72,6 +95,48 @@ public function driver(QueueDriver|null $driverName = null): Queue return $this->drivers[$driverName->value] ??= $this->resolveDriver($driverName); } + public function log(): void + { + if (App::isProduction()) { + return; + } + + $this->logging = true; + } + + public function fake(): void + { + if (App::isProduction()) { + return; + } + + $this->logging = true; + $this->faking = true; + } + + /** + * @return array, task: QueuableTask, queue: string|null, connection: string|null, timestamp: float}> + */ + public function getQueueLog(): array + { + return $this->pushed; + } + + protected function recordPush(QueuableTask $task): void + { + if (! $this->logging && ! $this->faking) { + return; + } + + $this->pushed[] = [ + 'task_class' => $task::class, + 'task' => $task, + 'queue' => $task->getQueueName(), + 'connection' => $task->getConnectionName(), + 'timestamp' => microtime(true), + ]; + } + protected function resolveDriverName(QueueDriver|null $driverName = null): QueueDriver { return $driverName ?? QueueDriver::from($this->config->default()); diff --git a/src/Testing/TestQueue.php b/src/Testing/TestQueue.php new file mode 100644 index 00000000..cab98fe5 --- /dev/null +++ b/src/Testing/TestQueue.php @@ -0,0 +1,82 @@ +, task: QueuableTask, queue: string|null, connection: string|null, timestamp: float}> $log + */ + public function __construct( + protected string $taskClass, + array $log = [] + ) { + $this->log = Collection::fromArray($log); + } + + public function toBePushed(Closure|null $closure = null): void + { + $matches = $this->filterByTaskClass($this->taskClass); + + if ($closure) { + /** @var QueuableTask|null $task */ + $task = $matches->first()['task'] ?? null; + + Assert::assertTrue($closure($task), "Failed asserting that task '{$this->taskClass}' was pushed with given conditions."); + } else { + Assert::assertNotEmpty($matches, "Failed asserting that task '{$this->taskClass}' was pushed at least once."); + } + } + + public function toNotBePushed(Closure|null $closure = null): void + { + $matches = $this->filterByTaskClass($this->taskClass); + + if ($closure) { + /** @var QueuableTask|null $task */ + $task = $matches->first()['task'] ?? null; + + Assert::assertFalse($closure($task), "Failed asserting that task '{$this->taskClass}' was NOT pushed with given conditions."); + } else { + Assert::assertEmpty($matches, "Failed asserting that task '{$this->taskClass}' was NOT pushed."); + } + } + + public function toBePushedTimes(int $times): void + { + $matches = $this->filterByTaskClass($this->taskClass); + + Assert::assertCount( + $times, + $matches, + "Failed asserting that task '{$this->taskClass}' was pushed {$times} times. Actual: {$matches->count()}." + ); + } + + public function toPushNothing(): void + { + Assert::assertEmpty($this->log, 'Failed asserting that no tasks were pushed.'); + } + + private function filterByTaskClass(string $taskClass): Collection + { + $filtered = []; + + foreach ($this->log as $record) { + if ($record['task_class'] === $taskClass) { + $filtered[] = $record; + } + } + + return Collection::fromArray($filtered); + } +} diff --git a/tests/Unit/Queue/ParallelQueueTest.php b/tests/Unit/Queue/ParallelQueueTest.php index 56c81fb7..46adb957 100644 --- a/tests/Unit/Queue/ParallelQueueTest.php +++ b/tests/Unit/Queue/ParallelQueueTest.php @@ -528,3 +528,51 @@ $parallelQueue->clear(); }); + +it('logs pushed tasks when logging is enabled', function (): void { + Queue::log(); + + Queue::push(new BasicQueuableTask()); + + Queue::expect(BasicQueuableTask::class)->toBePushed(); + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(1); +}); + +it('does not log tasks when logging is disabled', function (): void { + Queue::push(new BasicQueuableTask()); + + Queue::expect(BasicQueuableTask::class)->toPushNothing(); +}); + +it('fakes queue pushes and prevents tasks from actually being enqueued', function (): void { + Queue::fake(); + + Queue::push(new BasicQueuableTask()); + Queue::pushOn('custom', new BasicQueuableTask()); + + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(2); + + $this->assertSame(0, Queue::size()); +}); + +it('asserts a task was not pushed', function (): void { + Queue::log(); + + Queue::expect(BasicQueuableTask::class)->toNotBePushed(); +}); + +it('asserts tasks pushed on a custom queue', function (): void { + Queue::fake(); + + Queue::pushOn('emails', new BasicQueuableTask()); + + Queue::expect(BasicQueuableTask::class)->toBePushed(function ($task) { + return $task !== null && $task->getQueueName() === 'emails'; + }); +}); + +it('asserts no tasks were pushed', function (): void { + Queue::log(); + + Queue::expect(BasicQueuableTask::class)->toPushNothing(); +}); From f3254e51900e55161793d6c550554398ff805c8d Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 10 Oct 2025 08:48:49 -0500 Subject: [PATCH 18/68] feat(EventEmitter, Queue): enhance fake methods to support specific events and tasks; add consumption logic for faked items --- src/Events/EventEmitter.php | 42 ++++++++++++++++++++++-- src/Facades/Event.php | 2 +- src/Facades/Queue.php | 2 +- src/Queue/QueueManager.php | 44 ++++++++++++++++++++++++-- tests/Unit/Events/EventEmitterTest.php | 33 +++++++++++++++++++ tests/Unit/Queue/ParallelQueueTest.php | 14 ++++++++ 6 files changed, 129 insertions(+), 8 deletions(-) diff --git a/src/Events/EventEmitter.php b/src/Events/EventEmitter.php index ac7536dc..884e3a81 100644 --- a/src/Events/EventEmitter.php +++ b/src/Events/EventEmitter.php @@ -42,6 +42,13 @@ class EventEmitter implements EventEmitterContract protected bool $faking = false; + protected bool $hasFakeEvents = false; + + /** + * @var array + */ + protected array $fakeEvents = []; + /** * @var array */ @@ -100,8 +107,9 @@ public function emit(string|EventContract $event, mixed $payload = null): array $eventObject = $this->createEvent($event, $payload); $this->recordDispatched($eventObject); + if ($this->shouldFakeEvent($eventObject->getName())) { + $this->consumeFakedEvent($eventObject->getName()); - if ($this->faking) { return []; } @@ -153,8 +161,9 @@ public function emitAsync(string|EventContract $event, mixed $payload = null): F $eventObject = $this->createEvent($event, $payload); $this->recordDispatched($eventObject); + if ($this->shouldFakeEvent($eventObject->getName())) { + $this->consumeFakedEvent($eventObject->getName()); - if ($this->faking) { return []; } @@ -197,7 +206,7 @@ public function log(): void $this->logging = true; } - public function fake(): void + public function fake(string|array|null $events = null): void { if (App::isProduction()) { return; @@ -205,6 +214,13 @@ public function fake(): void $this->logging = true; $this->faking = true; + $this->hasFakeEvents = $events !== null; + + if ($events !== null) { + foreach ((array) $events as $name) { + $this->fakeEvents[$name] = true; + } + } } public function getEventLog(): array @@ -369,4 +385,24 @@ protected function removeListener(string $event, EventListenerContract $listener unset($this->listeners[$event]); } } + + protected function shouldFakeEvent(string $name): bool + { + if (! $this->faking) { + return false; + } + + if ($this->hasFakeEvents) { + return isset($this->fakeEvents[$name]); + } + + return true; + } + + protected function consumeFakedEvent(string $name): void + { + if (isset($this->fakeEvents[$name])) { + unset($this->fakeEvents[$name]); + } + } } diff --git a/src/Facades/Event.php b/src/Facades/Event.php index 73e4d67d..6e1a16ff 100644 --- a/src/Facades/Event.php +++ b/src/Facades/Event.php @@ -27,7 +27,7 @@ * @method static int getListenerCount(string $event) * @method static array getEventNames() * @method static void log() - * @method static void fake() + * @method static void fake(string|array|null $tasks = null) * @method static array getEventLog() * @method static \Phenix\Testing\TestEvent expect() * diff --git a/src/Facades/Queue.php b/src/Facades/Queue.php index efd270f0..589b7fd9 100644 --- a/src/Facades/Queue.php +++ b/src/Facades/Queue.php @@ -23,7 +23,7 @@ * @method static void setConnectionName(string $name) * @method static QueueContract driver(QueueDriver|null $driverName = null) * @method static void log() - * @method static void fake() + * @method static void fake(string|array|null $tasks = null) * @method static array getQueueLog() * @method static TestQueue expect(string $taskClass) * diff --git a/src/Queue/QueueManager.php b/src/Queue/QueueManager.php index a7e948f7..e8226e95 100644 --- a/src/Queue/QueueManager.php +++ b/src/Queue/QueueManager.php @@ -22,6 +22,13 @@ class QueueManager protected bool $faking = false; + protected bool $hasFakeTasks = false; + + /** + * @var array + */ + protected array $fakeTasks = []; + /** * @var array, task: QueuableTask, queue: string|null, connection: string|null, timestamp: float}> */ @@ -36,7 +43,9 @@ public function push(QueuableTask $task): void { $this->recordPush($task); - if ($this->faking) { + if ($this->shouldFakeTask($task)) { + $this->consumeFakedTask($task); + return; } @@ -48,7 +57,9 @@ public function pushOn(string $queueName, QueuableTask $task): void $task->setQueueName($queueName); $this->recordPush($task); - if ($this->faking) { + if ($this->shouldFakeTask($task)) { + $this->consumeFakedTask($task); + return; } @@ -104,7 +115,7 @@ public function log(): void $this->logging = true; } - public function fake(): void + public function fake(string|array|null $tasks = null): void { if (App::isProduction()) { return; @@ -112,6 +123,13 @@ public function fake(): void $this->logging = true; $this->faking = true; + $this->hasFakeTasks = $tasks !== null; + + if ($tasks !== null) { + foreach ((array) $tasks as $name) { + $this->fakeTasks[$name] = true; + } + } } /** @@ -151,6 +169,26 @@ protected function resolveDriver(QueueDriver $driverName): Queue }; } + protected function shouldFakeTask(QueuableTask $task): bool + { + if (! $this->faking) { + return false; + } + + if ($this->hasFakeTasks) { + return isset($this->fakeTasks[$task::class]); + } + + return true; + } + + protected function consumeFakedTask(QueuableTask $task): void + { + if (isset($this->fakeTasks[$task::class])) { + unset($this->fakeTasks[$task::class]); + } + } + protected function createParallelDriver(): Queue { return new ParallelQueue(); diff --git a/tests/Unit/Events/EventEmitterTest.php b/tests/Unit/Events/EventEmitterTest.php index ba90bd19..790885b7 100644 --- a/tests/Unit/Events/EventEmitterTest.php +++ b/tests/Unit/Events/EventEmitterTest.php @@ -528,3 +528,36 @@ EventFacade::expect('absent.event')->toNotBeDispatched(fn ($event): bool => false); }); + +it('fakes only specific events when an array is provided and consumes them after first fake', function (): void { + $calledSpecific = false; + $calledOther = false; + + EventFacade::on('specific.event', function () use (&$calledSpecific): void { + $calledSpecific = true; // Should NOT run because faked + }); + + EventFacade::on('other.event', function () use (&$calledOther): void { + $calledOther = true; // Should run + }); + + EventFacade::fake(['specific.event']); + + EventFacade::emit('specific.event', 'payload-1'); + + $this->assertFalse($calledSpecific); + + EventFacade::expect('specific.event')->toBeDispatchedTimes(1); + + EventFacade::emit('specific.event', 'payload-2'); + + $this->assertTrue($calledSpecific); + + EventFacade::expect('specific.event')->toBeDispatchedTimes(2); + + EventFacade::emit('other.event', 'payload'); + + $this->assertTrue($calledOther); + + EventFacade::expect('other.event')->toBeDispatched(); +}); diff --git a/tests/Unit/Queue/ParallelQueueTest.php b/tests/Unit/Queue/ParallelQueueTest.php index 46adb957..f5e86ca0 100644 --- a/tests/Unit/Queue/ParallelQueueTest.php +++ b/tests/Unit/Queue/ParallelQueueTest.php @@ -576,3 +576,17 @@ Queue::expect(BasicQueuableTask::class)->toPushNothing(); }); + +it('fakes only specific tasks and consumes them after first fake', function (): void { + Queue::fake([BasicQueuableTask::class]); + + Queue::push(new BasicQueuableTask()); // faked + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(1); + + $this->assertSame(0, Queue::size()); + + Queue::push(new BasicQueuableTask()); // now enqueued + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(2); + + $this->assertSame(1, Queue::size()); +}); From 627a213cfcf21e70b5f6737db5bdc1744469b83f Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 10 Oct 2025 21:17:00 -0500 Subject: [PATCH 19/68] feat(EventEmitter, EventFacade): enhance fake method to support event count and infinite faking --- src/Events/Contracts/EventEmitter.php | 2 +- src/Events/EventEmitter.php | 85 ++++++++++++++++++++------ src/Facades/Event.php | 2 +- tests/Unit/Events/EventEmitterTest.php | 77 +++++++++++++++++++++-- 4 files changed, 143 insertions(+), 23 deletions(-) diff --git a/src/Events/Contracts/EventEmitter.php b/src/Events/Contracts/EventEmitter.php index 5d8f0827..2f8b9f45 100644 --- a/src/Events/Contracts/EventEmitter.php +++ b/src/Events/Contracts/EventEmitter.php @@ -27,7 +27,7 @@ public function removeAllListeners(): void; public function log(): void; - public function fake(): void; + public function fake(string|array|null $events = null, int|null $times = null): void; /** * @return array diff --git a/src/Events/EventEmitter.php b/src/Events/EventEmitter.php index 884e3a81..77ed4d57 100644 --- a/src/Events/EventEmitter.php +++ b/src/Events/EventEmitter.php @@ -28,24 +28,21 @@ class EventEmitter implements EventEmitterContract */ protected array $listenerCounts = []; - /** - * Maximum number of listeners per event. - */ protected int $maxListeners = 10; - /** - * Whether to emit warnings for too many listeners. - */ protected bool $emitWarnings = true; protected bool $logging = false; protected bool $faking = false; - protected bool $hasFakeEvents = false; + protected bool $fakeAll = false; /** - * @var array + * null => always fake + * int => number of remaining times to fake; when it reaches 0, it is removed. + * + * @var array */ protected array $fakeEvents = []; @@ -206,7 +203,7 @@ public function log(): void $this->logging = true; } - public function fake(string|array|null $events = null): void + public function fake(string|array|null $events = null, int|null $times = null): void { if (App::isProduction()) { return; @@ -214,13 +211,44 @@ public function fake(string|array|null $events = null): void $this->logging = true; $this->faking = true; - $this->hasFakeEvents = $events !== null; - if ($events !== null) { - foreach ((array) $events as $name) { - $this->fakeEvents[$name] = true; + if ($events === null) { + $this->fakeAll = true; + + return; + } + + $this->fakeAll = false; + + $normalized = []; + + if (is_string($events)) { + $normalized[$events] = $times !== null ? max(0, abs($times)) : null; + } elseif (is_array($events) && array_is_list($events)) { + foreach ($events as $event) { + $normalized[$event] = null; + } + } else { + foreach ($events as $name => $value) { + if (is_int($name)) { + $normalized[(string)$value] = null; + + continue; + } + + $normalized[$name] = is_int($value) ? max(0, abs($value)) : null; + } + } + + foreach ($normalized as $eventName => $count) { + if ($count === 0) { + unset($normalized[$eventName]); } } + + foreach ($normalized as $name => $count) { + $this->fakeEvents[$name] = $count; + } } public function getEventLog(): array @@ -391,18 +419,41 @@ protected function shouldFakeEvent(string $name): bool if (! $this->faking) { return false; } + if ($this->fakeAll) { + return true; + } - if ($this->hasFakeEvents) { - return isset($this->fakeEvents[$name]); + if (empty($this->fakeEvents)) { + return false; } - return true; + if (! array_key_exists($name, $this->fakeEvents)) { + return false; + } + + $remaining = $this->fakeEvents[$name]; + + return $remaining === null || $remaining > 0; } protected function consumeFakedEvent(string $name): void { - if (isset($this->fakeEvents[$name])) { + if (! isset($this->fakeEvents[$name])) { + return; + } + + $remaining = $this->fakeEvents[$name]; + + if ($remaining === null) { + return; + } + + $remaining--; + + if ($remaining <= 0) { unset($this->fakeEvents[$name]); + } else { + $this->fakeEvents[$name] = $remaining; } } } diff --git a/src/Facades/Event.php b/src/Facades/Event.php index 6e1a16ff..835a351b 100644 --- a/src/Facades/Event.php +++ b/src/Facades/Event.php @@ -27,7 +27,7 @@ * @method static int getListenerCount(string $event) * @method static array getEventNames() * @method static void log() - * @method static void fake(string|array|null $tasks = null) + * @method static void fake(string|array|null $events = null, int|null $times = null) * @method static array getEventLog() * @method static \Phenix\Testing\TestEvent expect() * diff --git a/tests/Unit/Events/EventEmitterTest.php b/tests/Unit/Events/EventEmitterTest.php index 790885b7..262290bf 100644 --- a/tests/Unit/Events/EventEmitterTest.php +++ b/tests/Unit/Events/EventEmitterTest.php @@ -541,23 +541,92 @@ $calledOther = true; // Should run }); - EventFacade::fake(['specific.event']); + EventFacade::fake(['specific.event' => 1]); EventFacade::emit('specific.event', 'payload-1'); - $this->assertFalse($calledSpecific); + expect($calledSpecific)->toBeFalse(); EventFacade::expect('specific.event')->toBeDispatchedTimes(1); EventFacade::emit('specific.event', 'payload-2'); - $this->assertTrue($calledSpecific); + expect($calledSpecific)->toBeTrue(); EventFacade::expect('specific.event')->toBeDispatchedTimes(2); EventFacade::emit('other.event', 'payload'); - $this->assertTrue($calledOther); + expect($calledOther)->toBeTrue(); EventFacade::expect('other.event')->toBeDispatched(); }); + +it('supports infinite fake for single event with no times argument', function (): void { + $called = 0; + + EventFacade::on('always.event', function () use (&$called): void { + $called++; + }); + + EventFacade::fake('always.event'); + + EventFacade::emit('always.event'); + EventFacade::emit('always.event'); + EventFacade::emit('always.event'); + + expect($called)->toBe(0); + + EventFacade::expect('always.event')->toBeDispatchedTimes(3); +}); + +it('supports limited fake with times argument then processes listeners', function (): void { + $called = 0; + + EventFacade::on('limited.event', function () use (&$called): void { + $called++; + }); + + EventFacade::fake('limited.event', 2); + + EventFacade::emit('limited.event'); // fake + EventFacade::emit('limited.event'); // fake + EventFacade::emit('limited.event'); // real + EventFacade::emit('limited.event'); // real + + expect($called)->toBe(2); + + EventFacade::expect('limited.event')->toBeDispatchedTimes(4); +}); + +it('supports associative array with mixed counts and infinite entries', function (): void { + $limitedCalled = 0; + $infiniteCalled = 0; + $globalCalled = 0; + + EventFacade::on('assoc.limited', function () use (&$limitedCalled): void { $limitedCalled++; }); + EventFacade::on('assoc.infinite', function () use (&$infiniteCalled): void { $infiniteCalled++; }); + EventFacade::on('assoc.global', function () use (&$globalCalled): void { $globalCalled++; }); + + EventFacade::fake([ + 'assoc.limited' => 1, + 'assoc.infinite' => null, + 'assoc.global', + ]); + + EventFacade::emit('assoc.limited'); // fake + EventFacade::emit('assoc.limited'); // real + EventFacade::emit('assoc.infinite'); // fake + EventFacade::emit('assoc.infinite'); // fake + EventFacade::emit('assoc.global'); // fake + EventFacade::emit('assoc.global'); // fake + EventFacade::emit('assoc.limited'); // real + + expect($limitedCalled)->toBe(2); + expect($infiniteCalled)->toBe(0); + expect($globalCalled)->toBe(0); + + EventFacade::expect('assoc.limited')->toBeDispatchedTimes(3); + EventFacade::expect('assoc.infinite')->toBeDispatchedTimes(2); + EventFacade::expect('assoc.global')->toBeDispatchedTimes(2); +}); From b316753ffd93cd9092c69c8be98d9a81af66c39f Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 15 Oct 2025 12:07:33 -0500 Subject: [PATCH 20/68] feat(EventEmitter): add support for conditional closure based faking in fake method --- src/Events/Contracts/EventEmitter.php | 4 ++++ src/Events/EventEmitter.php | 33 ++++++++++++++++++-------- tests/Unit/Events/EventEmitterTest.php | 29 ++++++++++++++++++++++ 3 files changed, 56 insertions(+), 10 deletions(-) diff --git a/src/Events/Contracts/EventEmitter.php b/src/Events/Contracts/EventEmitter.php index 2f8b9f45..ef415979 100644 --- a/src/Events/Contracts/EventEmitter.php +++ b/src/Events/Contracts/EventEmitter.php @@ -27,6 +27,10 @@ public function removeAllListeners(): void; public function log(): void; + /** + * @param string|array|null $events + * @param int|null $times + */ public function fake(string|array|null $events = null, int|null $times = null): void; /** diff --git a/src/Events/EventEmitter.php b/src/Events/EventEmitter.php index 77ed4d57..0bc68f4b 100644 --- a/src/Events/EventEmitter.php +++ b/src/Events/EventEmitter.php @@ -39,10 +39,7 @@ class EventEmitter implements EventEmitterContract protected bool $fakeAll = false; /** - * null => always fake - * int => number of remaining times to fake; when it reaches 0, it is removed. - * - * @var array + * @var array */ protected array $fakeEvents = []; @@ -236,7 +233,13 @@ public function fake(string|array|null $events = null, int|null $times = null): continue; } - $normalized[$name] = is_int($value) ? max(0, abs($value)) : null; + if (is_int($value)) { + $normalized[$name] = max(0, abs($value)); + } elseif ($value instanceof Closure) { + $normalized[$name] = $value; + } else { + $normalized[$name] = null; + } } } @@ -246,8 +249,8 @@ public function fake(string|array|null $events = null, int|null $times = null): } } - foreach ($normalized as $name => $count) { - $this->fakeEvents[$name] = $count; + foreach ($normalized as $name => $config) { + $this->fakeEvents[$name] = $config; } } @@ -431,9 +434,19 @@ protected function shouldFakeEvent(string $name): bool return false; } - $remaining = $this->fakeEvents[$name]; + $config = $this->fakeEvents[$name]; + + if ($config instanceof Closure) { + try { + return (bool) $config($this->dispatched); + } catch (Throwable $e) { + report($e); + + return false; + } + } - return $remaining === null || $remaining > 0; + return $config === null || $config > 0; } protected function consumeFakedEvent(string $name): void @@ -444,7 +457,7 @@ protected function consumeFakedEvent(string $name): void $remaining = $this->fakeEvents[$name]; - if ($remaining === null) { + if ($remaining === null || $remaining instanceof Closure) { return; } diff --git a/tests/Unit/Events/EventEmitterTest.php b/tests/Unit/Events/EventEmitterTest.php index 262290bf..98835fec 100644 --- a/tests/Unit/Events/EventEmitterTest.php +++ b/tests/Unit/Events/EventEmitterTest.php @@ -630,3 +630,32 @@ EventFacade::expect('assoc.infinite')->toBeDispatchedTimes(2); EventFacade::expect('assoc.global')->toBeDispatchedTimes(2); }); + +it('supports conditional closure based faking', function (): void { + $called = 0; + + EventFacade::on('conditional.event', function () use (&$called): void { $called++; }); + + EventFacade::fake([ + 'conditional.event' => function (array $log): bool { + $count = 0; + + foreach ($log as $entry) { + if (($entry['name'] ?? null) === 'conditional.event') { + $count++; + } + } + + return $count <= 2; + }, + ]); + + EventFacade::emit('conditional.event'); + EventFacade::emit('conditional.event'); + EventFacade::emit('conditional.event'); + EventFacade::emit('conditional.event'); + + expect($called)->toBe(2); + + EventFacade::expect('conditional.event')->toBeDispatchedTimes(4); +}); From 57110afd0071115a81fb1419859fd512895e4753 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 15 Oct 2025 12:20:14 -0500 Subject: [PATCH 21/68] feat(EventEmitter, EventFacade): update fake method to accept Closure as times parameter; enhance tests for conditional closure faking --- src/Events/Contracts/EventEmitter.php | 2 +- src/Events/EventEmitter.php | 10 ++++++-- src/Facades/Event.php | 2 +- tests/Unit/Events/EventEmitterTest.php | 32 +++++++++++++++++++++++--- 4 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/Events/Contracts/EventEmitter.php b/src/Events/Contracts/EventEmitter.php index ef415979..69d7d432 100644 --- a/src/Events/Contracts/EventEmitter.php +++ b/src/Events/Contracts/EventEmitter.php @@ -31,7 +31,7 @@ public function log(): void; * @param string|array|null $events * @param int|null $times */ - public function fake(string|array|null $events = null, int|null $times = null): void; + public function fake(string|array|null $events = null, int|Closure|null $times = null): void; /** * @return array diff --git a/src/Events/EventEmitter.php b/src/Events/EventEmitter.php index 0bc68f4b..c4f47b31 100644 --- a/src/Events/EventEmitter.php +++ b/src/Events/EventEmitter.php @@ -200,7 +200,7 @@ public function log(): void $this->logging = true; } - public function fake(string|array|null $events = null, int|null $times = null): void + public function fake(string|array|null $events = null, int|Closure|null $times = null): void { if (App::isProduction()) { return; @@ -220,7 +220,13 @@ public function fake(string|array|null $events = null, int|null $times = null): $normalized = []; if (is_string($events)) { - $normalized[$events] = $times !== null ? max(0, abs($times)) : null; + if ($times instanceof Closure) { + $normalized[$events] = $times; + } elseif (is_int($times)) { + $normalized[$events] = max(0, abs($times)); + } else { + $normalized[$events] = null; + } } elseif (is_array($events) && array_is_list($events)) { foreach ($events as $event) { $normalized[$event] = null; diff --git a/src/Facades/Event.php b/src/Facades/Event.php index 835a351b..21164234 100644 --- a/src/Facades/Event.php +++ b/src/Facades/Event.php @@ -27,7 +27,7 @@ * @method static int getListenerCount(string $event) * @method static array getEventNames() * @method static void log() - * @method static void fake(string|array|null $events = null, int|null $times = null) + * @method static void fake(string|array|null $events = null, int|Closure|null $times = null) * @method static array getEventLog() * @method static \Phenix\Testing\TestEvent expect() * diff --git a/tests/Unit/Events/EventEmitterTest.php b/tests/Unit/Events/EventEmitterTest.php index 98835fec..c715b793 100644 --- a/tests/Unit/Events/EventEmitterTest.php +++ b/tests/Unit/Events/EventEmitterTest.php @@ -594,7 +594,7 @@ EventFacade::emit('limited.event'); // real EventFacade::emit('limited.event'); // real - expect($called)->toBe(2); + expect($called)->toEqual(2); EventFacade::expect('limited.event')->toBeDispatchedTimes(4); }); @@ -634,8 +634,6 @@ it('supports conditional closure based faking', function (): void { $called = 0; - EventFacade::on('conditional.event', function () use (&$called): void { $called++; }); - EventFacade::fake([ 'conditional.event' => function (array $log): bool { $count = 0; @@ -650,6 +648,8 @@ }, ]); + EventFacade::on('conditional.event', function () use (&$called): void { $called++; }); + EventFacade::emit('conditional.event'); EventFacade::emit('conditional.event'); EventFacade::emit('conditional.event'); @@ -659,3 +659,29 @@ EventFacade::expect('conditional.event')->toBeDispatchedTimes(4); }); + +it('supports single event closure in times parameter for fake', function (): void { + $called = 0; + + EventFacade::fake('single.closure.event', function (array $log): bool { + $count = 0; + foreach ($log as $entry) { + if (($entry['name'] ?? null) === 'single.closure.event') { + $count++; + } + } + + return $count <= 2; + }); + + EventFacade::on('single.closure.event', function () use (&$called): void { $called++; }); + + EventFacade::emit('single.closure.event'); // fake + EventFacade::emit('single.closure.event'); // fake + EventFacade::emit('single.closure.event'); // real + EventFacade::emit('single.closure.event'); // real + + expect($called)->toBe(2); + + EventFacade::expect('single.closure.event')->toBeDispatchedTimes(4); +}); From c4ea6d69da3a120b7c74b5218920ba4b50bb5396 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 15 Oct 2025 12:23:55 -0500 Subject: [PATCH 22/68] feat(EventEmitter): refactor normalizeFakeEvents method to improve handling of event configurations and streamline logic --- src/Events/EventEmitter.php | 73 +++++++++++++++++++++++-------------- 1 file changed, 46 insertions(+), 27 deletions(-) diff --git a/src/Events/EventEmitter.php b/src/Events/EventEmitter.php index c4f47b31..3eba1e8d 100644 --- a/src/Events/EventEmitter.php +++ b/src/Events/EventEmitter.php @@ -217,47 +217,66 @@ public function fake(string|array|null $events = null, int|Closure|null $times = $this->fakeAll = false; - $normalized = []; + $normalized = $this->normalizeFakeEvents($events, $times); + + foreach ($normalized as $name => $config) { + if ($config === 0) { + continue; + } + + $this->fakeEvents[$name] = $config; + } + } + /** + * @param string|array $events + * @param int|Closure|null $times + * @return array + */ + protected function normalizeFakeEvents(string|array $events, int|Closure|null $times): array + { if (is_string($events)) { if ($times instanceof Closure) { - $normalized[$events] = $times; - } elseif (is_int($times)) { - $normalized[$events] = max(0, abs($times)); - } else { - $normalized[$events] = null; + return [$events => $times]; + } + + if (is_int($times)) { + return [$events => max(0, abs($times))]; } - } elseif (is_array($events) && array_is_list($events)) { + + return [$events => null]; + } + + $normalized = []; + + if (array_is_list($events)) { foreach ($events as $event) { $normalized[$event] = null; } - } else { - foreach ($events as $name => $value) { - if (is_int($name)) { - $normalized[(string)$value] = null; - continue; - } + return $normalized; + } - if (is_int($value)) { - $normalized[$name] = max(0, abs($value)); - } elseif ($value instanceof Closure) { - $normalized[$name] = $value; - } else { - $normalized[$name] = null; - } + foreach ($events as $name => $value) { + if (is_int($name)) { + $normalized[(string) $value] = null; + continue; } - } - foreach ($normalized as $eventName => $count) { - if ($count === 0) { - unset($normalized[$eventName]); + if (is_int($value)) { + $normalized[$name] = max(0, abs($value)); + continue; } - } - foreach ($normalized as $name => $config) { - $this->fakeEvents[$name] = $config; + if ($value instanceof Closure) { + $normalized[$name] = $value; + continue; + } + + $normalized[$name] = null; } + + return $normalized; } public function getEventLog(): array From 8b532bf2659ccec1dcb0ef1649cc670e26a71ff5 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 15 Oct 2025 14:15:54 -0500 Subject: [PATCH 23/68] feat(EventEmitter, EventFacade): add resetEventLog method to clear event log for testing purposes --- src/Events/Contracts/EventEmitter.php | 2 ++ src/Events/EventEmitter.php | 9 +++++++++ src/Facades/Event.php | 1 + tests/Unit/Events/EventEmitterTest.php | 9 +++++++++ 4 files changed, 21 insertions(+) diff --git a/src/Events/Contracts/EventEmitter.php b/src/Events/Contracts/EventEmitter.php index 69d7d432..f4dcaeaf 100644 --- a/src/Events/Contracts/EventEmitter.php +++ b/src/Events/Contracts/EventEmitter.php @@ -37,4 +37,6 @@ public function fake(string|array|null $events = null, int|Closure|null $times = * @return array */ public function getEventLog(): array; + + public function resetEventLog(): void; } diff --git a/src/Events/EventEmitter.php b/src/Events/EventEmitter.php index 3eba1e8d..bc14e722 100644 --- a/src/Events/EventEmitter.php +++ b/src/Events/EventEmitter.php @@ -284,6 +284,15 @@ public function getEventLog(): array return $this->dispatched; } + public function resetEventLog(): void + { + if (App::isProduction()) { + return; + } + + $this->dispatched = []; + } + protected function recordDispatched(EventContract $event): void { if (! $this->logging && ! $this->faking) { diff --git a/src/Facades/Event.php b/src/Facades/Event.php index 21164234..973178ce 100644 --- a/src/Facades/Event.php +++ b/src/Facades/Event.php @@ -29,6 +29,7 @@ * @method static void log() * @method static void fake(string|array|null $events = null, int|Closure|null $times = null) * @method static array getEventLog() + * @method static void resetEventLog() * @method static \Phenix\Testing\TestEvent expect() * * @see \Phenix\Events\EventEmitter diff --git a/tests/Unit/Events/EventEmitterTest.php b/tests/Unit/Events/EventEmitterTest.php index c715b793..72d2d156 100644 --- a/tests/Unit/Events/EventEmitterTest.php +++ b/tests/Unit/Events/EventEmitterTest.php @@ -481,6 +481,15 @@ EventFacade::expect('logged.event')->toBeDispatched(); EventFacade::expect('logged.event')->toBeDispatchedTimes(1); + + expect(EventFacade::getEventLog())->toHaveCount(1); + + EventFacade::resetEventLog(); + + expect(EventFacade::getEventLog())->toHaveCount(0); + + EventFacade::emit('logged.event', 'payload-2'); + EventFacade::expect('logged.event')->toBeDispatchedTimes(1); }); it('fakes events preventing listener execution', function (): void { From 5409f96eceb5e66b49116992b69ca9d31b8b5972 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 15 Oct 2025 18:04:23 -0500 Subject: [PATCH 24/68] refactor(EventEmitter): remove redundant comment about one-time listeners in emit method and streamline event normalization logic --- src/Events/EventEmitter.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Events/EventEmitter.php b/src/Events/EventEmitter.php index bc14e722..1dbc792f 100644 --- a/src/Events/EventEmitter.php +++ b/src/Events/EventEmitter.php @@ -124,7 +124,6 @@ public function emit(string|EventContract $event, mixed $payload = null): array $result = $listener->handle($eventObject); $results[] = $result; - // Remove one-time listeners after execution if ($listener->isOnce()) { $this->removeListener($eventObject->getName(), $listener); } @@ -260,16 +259,19 @@ protected function normalizeFakeEvents(string|array $events, int|Closure|null $t foreach ($events as $name => $value) { if (is_int($name)) { $normalized[(string) $value] = null; + continue; } if (is_int($value)) { $normalized[$name] = max(0, abs($value)); + continue; } if ($value instanceof Closure) { $normalized[$name] = $value; + continue; } From 03f383dafc44bd23e8ee3dcb80aaad2e1e300bd3 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 15 Oct 2025 18:05:19 -0500 Subject: [PATCH 25/68] feat(Queue, QueueManager): enhance fake method to support times parameter and add resetQueueLog method --- src/Facades/Queue.php | 4 +- src/Queue/QueueManager.php | 142 ++++++++++++++++++++++--- tests/Unit/Queue/ParallelQueueTest.php | 46 ++++++++ 3 files changed, 177 insertions(+), 15 deletions(-) diff --git a/src/Facades/Queue.php b/src/Facades/Queue.php index 589b7fd9..beaef1ee 100644 --- a/src/Facades/Queue.php +++ b/src/Facades/Queue.php @@ -4,6 +4,7 @@ namespace Phenix\Facades; +use Closure; use Phenix\App; use Phenix\Queue\Constants\QueueDriver; use Phenix\Queue\Contracts\Queue as QueueContract; @@ -23,8 +24,9 @@ * @method static void setConnectionName(string $name) * @method static QueueContract driver(QueueDriver|null $driverName = null) * @method static void log() - * @method static void fake(string|array|null $tasks = null) + * @method static void fake(string|array|null $tasks = null, int|Closure|null $times = null) * @method static array getQueueLog() + * @method static void resetQueueLog() * @method static TestQueue expect(string $taskClass) * * @see \Phenix\Queue\QueueManager diff --git a/src/Queue/QueueManager.php b/src/Queue/QueueManager.php index e8226e95..0b227669 100644 --- a/src/Queue/QueueManager.php +++ b/src/Queue/QueueManager.php @@ -4,12 +4,14 @@ namespace Phenix\Queue; +use Closure; use Phenix\App; use Phenix\Database\Constants\Driver as DatabaseDriver; use Phenix\Queue\Constants\QueueDriver; use Phenix\Queue\Contracts\Queue; use Phenix\Redis\Contracts\Client; use Phenix\Tasks\QueuableTask; +use Throwable; class QueueManager { @@ -22,15 +24,15 @@ class QueueManager protected bool $faking = false; - protected bool $hasFakeTasks = false; + protected bool $fakeAll = false; /** - * @var array + * @var array */ protected array $fakeTasks = []; /** - * @var array, task: QueuableTask, queue: string|null, connection: string|null, timestamp: float}> + * @var array, task: QueuableTask, queue: string|null, connection: string|null, timestamp: float}> */ protected array $pushed = []; @@ -115,7 +117,11 @@ public function log(): void $this->logging = true; } - public function fake(string|array|null $tasks = null): void + /** + * @param string|array, int|Closure|null>|class-string|null $tasks + * @param int|Closure|null $times + */ + public function fake(string|array|null $tasks = null, int|Closure|null $times = null): void { if (App::isProduction()) { return; @@ -123,23 +129,40 @@ public function fake(string|array|null $tasks = null): void $this->logging = true; $this->faking = true; - $this->hasFakeTasks = $tasks !== null; + $this->fakeAll = $tasks === null; + + if ($this->fakeAll) { + return; + } + + $normalized = $this->normalizeFakeTasks($tasks, $times); - if ($tasks !== null) { - foreach ((array) $tasks as $name) { - $this->fakeTasks[$name] = true; + foreach ($normalized as $taskClass => $config) { + if ($config === 0) { + continue; } + + $this->fakeTasks[$taskClass] = $config; } } /** - * @return array, task: QueuableTask, queue: string|null, connection: string|null, timestamp: float}> + * @return array, task: QueuableTask, queue: string|null, connection: string|null, timestamp: float}> */ public function getQueueLog(): array { return $this->pushed; } + public function resetQueueLog(): void + { + if (App::isProduction()) { + return; + } + + $this->pushed = []; + } + protected function recordPush(QueuableTask $task): void { if (! $this->logging && ! $this->faking) { @@ -175,18 +198,109 @@ protected function shouldFakeTask(QueuableTask $task): bool return false; } - if ($this->hasFakeTasks) { - return isset($this->fakeTasks[$task::class]); + if ($this->fakeAll) { + return true; } - return true; + if (empty($this->fakeTasks)) { + return false; + } + + $class = $task::class; + + if (! array_key_exists($class, $this->fakeTasks)) { + return false; + } + + $config = $this->fakeTasks[$class]; + + if ($config instanceof Closure) { + try { + return (bool) $config($this->pushed); + } catch (Throwable $e) { + report($e); + + return false; + } + } + + return $config === null || $config > 0; } protected function consumeFakedTask(QueuableTask $task): void { - if (isset($this->fakeTasks[$task::class])) { - unset($this->fakeTasks[$task::class]); + $class = $task::class; + + if (! array_key_exists($class, $this->fakeTasks)) { + return; } + + $remaining = $this->fakeTasks[$class]; + + if ($remaining === null || $remaining instanceof Closure) { + return; + } + + $remaining--; + if ($remaining <= 0) { + unset($this->fakeTasks[$class]); + } else { + $this->fakeTasks[$class] = $remaining; + } + } + + /** + * @param string|array $tasks + * @param int|Closure|null $times + * @return array + */ + protected function normalizeFakeTasks(string|array $tasks, int|Closure|null $times): array + { + if (is_string($tasks)) { + if ($times instanceof Closure) { + return [$tasks => $times]; + } + + if (is_int($times)) { + return [$tasks => max(0, abs($times))]; + } + + return [$tasks => 1]; + } + + $normalized = []; + + if (array_is_list($tasks)) { + foreach ($tasks as $class) { + $normalized[$class] = 1; + } + + return $normalized; + } + + foreach ($tasks as $class => $value) { + if (is_int($class)) { + $normalized[(string) $value] = 1; + + continue; + } + + if ($value instanceof Closure) { + $normalized[$class] = $value; + + continue; + } + + if (is_int($value)) { + $normalized[$class] = max(0, abs($value)); + + continue; + } + + $normalized[$class] = $value === null ? null : 1; + } + + return $normalized; } protected function createParallelDriver(): Queue diff --git a/tests/Unit/Queue/ParallelQueueTest.php b/tests/Unit/Queue/ParallelQueueTest.php index f5e86ca0..2f1899e9 100644 --- a/tests/Unit/Queue/ParallelQueueTest.php +++ b/tests/Unit/Queue/ParallelQueueTest.php @@ -590,3 +590,49 @@ $this->assertSame(1, Queue::size()); }); + +it('fakes a task multiple times using times parameter', function (): void { + Queue::fake(BasicQueuableTask::class, 2); + + Queue::push(new BasicQueuableTask()); // faked + $this->assertSame(0, Queue::size()); + + Queue::push(new BasicQueuableTask()); // faked + $this->assertSame(0, Queue::size()); + + Queue::push(new BasicQueuableTask()); // real + $this->assertSame(1, Queue::size()); + + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(3); +}); + +it('fakes tasks with per-task counts array', function (): void { + Queue::fake([ + BasicQueuableTask::class => 2, + ]); + + Queue::push(new BasicQueuableTask()); // faked + Queue::push(new BasicQueuableTask()); // faked + $this->assertSame(0, Queue::size()); + + Queue::push(new BasicQueuableTask()); // real + $this->assertSame(1, Queue::size()); + + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(3); +}); + +it('conditionally fakes tasks using a closure configuration', function (): void { + Queue::fake([ + BasicQueuableTask::class => function (array $log): bool { + return count($log) <= 3; + }, + ]); + + for ($i = 0; $i < 5; $i++) { + Queue::push(new BasicQueuableTask()); + } + + $this->assertSame(2, Queue::size()); + + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(5); +}); From 22d167adfc2b0f225d1fc2ad4d08151bc83bcec8 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 15 Oct 2025 18:12:40 -0500 Subject: [PATCH 26/68] fix(EventEmitter): correct condition check for remaining fake events in emit method --- src/Events/EventEmitter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Events/EventEmitter.php b/src/Events/EventEmitter.php index 1dbc792f..8d17fe9b 100644 --- a/src/Events/EventEmitter.php +++ b/src/Events/EventEmitter.php @@ -493,7 +493,7 @@ protected function consumeFakedEvent(string $name): void $remaining = $this->fakeEvents[$name]; - if ($remaining === null || $remaining instanceof Closure) { + if (!$remaining || $remaining instanceof Closure) { return; } From a429f25bc768f9b3be44bb5a5e19d0c860987eb2 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 15 Oct 2025 18:15:42 -0500 Subject: [PATCH 27/68] style: php cs --- src/Events/EventEmitter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Events/EventEmitter.php b/src/Events/EventEmitter.php index 8d17fe9b..16a9d248 100644 --- a/src/Events/EventEmitter.php +++ b/src/Events/EventEmitter.php @@ -493,7 +493,7 @@ protected function consumeFakedEvent(string $name): void $remaining = $this->fakeEvents[$name]; - if (!$remaining || $remaining instanceof Closure) { + if (! $remaining || $remaining instanceof Closure) { return; } From 29d962f13221be90f4c5133faa6827d1ae33da55 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 16 Oct 2025 17:46:54 -0500 Subject: [PATCH 28/68] feat(EventEmitter): split class to CaptureEvents trait --- src/Events/Concerns/CaptureEvents.php | 200 +++++++++++++++++++++ src/Events/EventEmitter.php | 248 +++----------------------- 2 files changed, 228 insertions(+), 220 deletions(-) create mode 100644 src/Events/Concerns/CaptureEvents.php diff --git a/src/Events/Concerns/CaptureEvents.php b/src/Events/Concerns/CaptureEvents.php new file mode 100644 index 00000000..3f313ae9 --- /dev/null +++ b/src/Events/Concerns/CaptureEvents.php @@ -0,0 +1,200 @@ + + */ + protected array $fakeEvents = []; + + /** + * @var array + */ + protected array $dispatched = []; + + public function log(): void + { + if (App::isProduction()) { + return; + } + + $this->logging = true; + } + + public function fake(string|array|null $events = null, int|Closure|null $times = null): void + { + if (App::isProduction()) { + return; + } + + $this->logging = true; + $this->faking = true; + + if ($events === null) { + $this->fakeAll = true; + + return; + } + + $this->fakeAll = false; + + $normalized = $this->normalizeFakeEvents($events, $times); + + foreach ($normalized as $name => $config) { + if ($config === 0) { + continue; + } + + $this->fakeEvents[$name] = $config; + } + } + + public function getEventLog(): array + { + return $this->dispatched; + } + + public function resetEventLog(): void + { + if (App::isProduction()) { + return; + } + + $this->dispatched = []; + } + + /** + * @param string|array $events + * @param int|Closure|null $times + * @return array + */ + protected function normalizeFakeEvents(string|array $events, int|Closure|null $times): array + { + if (is_string($events)) { + if ($times instanceof Closure) { + return [$events => $times]; + } + + if (is_int($times)) { + return [$events => max(0, abs($times))]; + } + + return [$events => null]; + } + + $normalized = []; + + if (array_is_list($events)) { + foreach ($events as $event) { + $normalized[$event] = null; + } + + return $normalized; + } + + foreach ($events as $name => $value) { + if (is_int($name)) { + $normalized[(string) $value] = null; + + continue; + } + + if (is_int($value)) { + $normalized[$name] = max(0, abs($value)); + + continue; + } + + if ($value instanceof Closure) { + $normalized[$name] = $value; + + continue; + } + + $normalized[$name] = null; + } + + return $normalized; + } + + protected function recordDispatched(EventContract $event): void + { + if (! $this->logging && ! $this->faking) { + return; + } + + $this->dispatched[] = [ + 'name' => $event->getName(), + 'event' => $event, + 'payload' => $event->getPayload(), + 'timestamp' => microtime(true), + ]; + } + + protected function shouldFakeEvent(string $name): bool + { + if (! $this->faking) { + return false; + } + if ($this->fakeAll) { + return true; + } + + if (empty($this->fakeEvents)) { + return false; + } + + if (! array_key_exists($name, $this->fakeEvents)) { + return false; + } + + $config = $this->fakeEvents[$name]; + + if ($config instanceof Closure) { + try { + return (bool) $config($this->dispatched); + } catch (Throwable $e) { + report($e); + + return false; + } + } + + return $config === null || $config > 0; + } + + protected function consumeFakedEvent(string $name): void + { + if (! isset($this->fakeEvents[$name])) { + return; + } + + $remaining = $this->fakeEvents[$name]; + + if (! $remaining || $remaining instanceof Closure) { + return; + } + + $remaining--; + + if ($remaining <= 0) { + unset($this->fakeEvents[$name]); + } else { + $this->fakeEvents[$name] = $remaining; + } + } +} diff --git a/src/Events/EventEmitter.php b/src/Events/EventEmitter.php index 16a9d248..778ffac5 100644 --- a/src/Events/EventEmitter.php +++ b/src/Events/EventEmitter.php @@ -6,7 +6,7 @@ use Amp\Future; use Closure; -use Phenix\App; +use Phenix\Events\Concerns\CaptureEvents; use Phenix\Events\Contracts\Event as EventContract; use Phenix\Events\Contracts\EventEmitter as EventEmitterContract; use Phenix\Events\Contracts\EventListener as EventListenerContract; @@ -18,6 +18,8 @@ class EventEmitter implements EventEmitterContract { + use CaptureEvents; + /** * @var array> */ @@ -32,22 +34,6 @@ class EventEmitter implements EventEmitterContract protected bool $emitWarnings = true; - protected bool $logging = false; - - protected bool $faking = false; - - protected bool $fakeAll = false; - - /** - * @var array - */ - protected array $fakeEvents = []; - - /** - * @var array - */ - protected array $dispatched = []; - public function on(string $event, Closure|EventListenerContract|string $listener, int $priority = 0): void { $eventListener = $this->createEventListener($listener, $priority); @@ -190,123 +176,48 @@ public function emitAsync(string|EventContract $event, mixed $payload = null): F }); } - public function log(): void + /** + * @return array + */ + public function getListeners(string $event): array { - if (App::isProduction()) { - return; - } - - $this->logging = true; + return $this->listeners[$event] ?? []; } - public function fake(string|array|null $events = null, int|Closure|null $times = null): void + public function hasListeners(string $event): bool { - if (App::isProduction()) { - return; - } - - $this->logging = true; - $this->faking = true; - - if ($events === null) { - $this->fakeAll = true; - - return; - } - - $this->fakeAll = false; - - $normalized = $this->normalizeFakeEvents($events, $times); - - foreach ($normalized as $name => $config) { - if ($config === 0) { - continue; - } - - $this->fakeEvents[$name] = $config; - } + return isset($this->listeners[$event]) && count($this->listeners[$event]) > 0; } - /** - * @param string|array $events - * @param int|Closure|null $times - * @return array - */ - protected function normalizeFakeEvents(string|array $events, int|Closure|null $times): array + public function removeAllListeners(): void { - if (is_string($events)) { - if ($times instanceof Closure) { - return [$events => $times]; - } - - if (is_int($times)) { - return [$events => max(0, abs($times))]; - } - - return [$events => null]; - } - - $normalized = []; - - if (array_is_list($events)) { - foreach ($events as $event) { - $normalized[$event] = null; - } - - return $normalized; - } - - foreach ($events as $name => $value) { - if (is_int($name)) { - $normalized[(string) $value] = null; - - continue; - } - - if (is_int($value)) { - $normalized[$name] = max(0, abs($value)); - - continue; - } - - if ($value instanceof Closure) { - $normalized[$name] = $value; - - continue; - } - - $normalized[$name] = null; - } - - return $normalized; + $this->listeners = []; + $this->listenerCounts = []; } - public function getEventLog(): array + public function setMaxListeners(int $maxListeners): void { - return $this->dispatched; + $this->maxListeners = $maxListeners; } - public function resetEventLog(): void + public function getMaxListeners(): int { - if (App::isProduction()) { - return; - } + return $this->maxListeners; + } - $this->dispatched = []; + public function setEmitWarnings(bool $emitWarnings): void + { + $this->emitWarnings = $emitWarnings; } - protected function recordDispatched(EventContract $event): void + public function getListenerCount(string $event): int { - if (! $this->logging && ! $this->faking) { - return; - } + return $this->listenerCounts[$event] ?? 0; + } - $this->dispatched[] = [ - 'name' => $event->getName(), - 'event' => $event, - 'payload' => $event->getPayload(), - 'timestamp' => microtime(true), - ]; + public function getEventNames(): array + { + return array_keys($this->listeners); } protected function handleListenerAsync(EventListenerContract $listener, EventContract $eventObject): Future @@ -319,19 +230,13 @@ protected function handleListenerAsync(EventListenerContract $listener, EventCon $result = $listener->handle($eventObject); - // Remove one-time listeners after execution if ($listener->isOnce()) { $this->removeListener($eventObject->getName(), $listener); } return $result; } catch (Throwable $e) { - Log::error('Async event listener error', [ - 'event' => $eventObject->getName(), - 'error' => $e->getMessage(), - 'file' => $e->getFile(), - 'line' => $e->getLine(), - ]); + report($e); if ($this->emitWarnings) { throw new EventException( @@ -346,50 +251,6 @@ protected function handleListenerAsync(EventListenerContract $listener, EventCon }); } - /** - * @return array - */ - public function getListeners(string $event): array - { - return $this->listeners[$event] ?? []; - } - - public function hasListeners(string $event): bool - { - return isset($this->listeners[$event]) && count($this->listeners[$event]) > 0; - } - - public function removeAllListeners(): void - { - $this->listeners = []; - $this->listenerCounts = []; - } - - public function setMaxListeners(int $maxListeners): void - { - $this->maxListeners = $maxListeners; - } - - public function getMaxListeners(): int - { - return $this->maxListeners; - } - - public function setEmitWarnings(bool $emitWarnings): void - { - $this->emitWarnings = $emitWarnings; - } - - public function getListenerCount(string $event): int - { - return $this->listenerCounts[$event] ?? 0; - } - - public function getEventNames(): array - { - return array_keys($this->listeners); - } - protected function createEventListener(Closure|EventListenerContract|string $listener, int $priority): EventListenerContract { if ($listener instanceof EventListenerContract) { @@ -452,57 +313,4 @@ protected function removeListener(string $event, EventListenerContract $listener unset($this->listeners[$event]); } } - - protected function shouldFakeEvent(string $name): bool - { - if (! $this->faking) { - return false; - } - if ($this->fakeAll) { - return true; - } - - if (empty($this->fakeEvents)) { - return false; - } - - if (! array_key_exists($name, $this->fakeEvents)) { - return false; - } - - $config = $this->fakeEvents[$name]; - - if ($config instanceof Closure) { - try { - return (bool) $config($this->dispatched); - } catch (Throwable $e) { - report($e); - - return false; - } - } - - return $config === null || $config > 0; - } - - protected function consumeFakedEvent(string $name): void - { - if (! isset($this->fakeEvents[$name])) { - return; - } - - $remaining = $this->fakeEvents[$name]; - - if (! $remaining || $remaining instanceof Closure) { - return; - } - - $remaining--; - - if ($remaining <= 0) { - unset($this->fakeEvents[$name]); - } else { - $this->fakeEvents[$name] = $remaining; - } - } } From 40a6baaf4259362b842bd66d09b5cfec274907e0 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 16 Oct 2025 17:50:44 -0500 Subject: [PATCH 29/68] refactor(CaptureEvents): simplify normalizeFakeEvents method for better readability and performance --- src/Events/Concerns/CaptureEvents.php | 50 +++++++++++---------------- 1 file changed, 21 insertions(+), 29 deletions(-) diff --git a/src/Events/Concerns/CaptureEvents.php b/src/Events/Concerns/CaptureEvents.php index 3f313ae9..1ef5bfb9 100644 --- a/src/Events/Concerns/CaptureEvents.php +++ b/src/Events/Concerns/CaptureEvents.php @@ -5,6 +5,7 @@ namespace Phenix\Events\Concerns; use Closure; +use Throwable; use Phenix\App; use Phenix\Events\Contracts\Event as EventContract; @@ -84,48 +85,39 @@ public function resetEventLog(): void */ protected function normalizeFakeEvents(string|array $events, int|Closure|null $times): array { - if (is_string($events)) { - if ($times instanceof Closure) { - return [$events => $times]; - } - - if (is_int($times)) { - return [$events => max(0, abs($times))]; - } - - return [$events => null]; - } - $normalized = []; - if (array_is_list($events)) { + if (is_string($events)) { + $normalized[$events] = $times instanceof Closure + ? $times + : (is_int($times) ? max(0, abs($times)) : null); + } elseif (array_is_list($events)) { foreach ($events as $event) { $normalized[$event] = null; } + } else { + foreach ($events as $name => $value) { + if (is_int($name)) { + $normalized[(string) $value] = null; - return $normalized; - } - foreach ($events as $name => $value) { - if (is_int($name)) { - $normalized[(string) $value] = null; + continue; + } - continue; - } + if (is_int($value)) { + $normalized[$name] = max(0, abs($value)); - if (is_int($value)) { - $normalized[$name] = max(0, abs($value)); + continue; + } - continue; - } + if ($value instanceof Closure) { + $normalized[$name] = $value; - if ($value instanceof Closure) { - $normalized[$name] = $value; + continue; + } - continue; + $normalized[$name] = null; } - - $normalized[$name] = null; } return $normalized; From f528b8b4ec8176ade38259f4506d1bbc17ba4faf Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 16 Oct 2025 18:00:53 -0500 Subject: [PATCH 30/68] refactor(CaptureEvents): streamline shouldFakeEvent method for improved clarity and efficiency --- src/Events/Concerns/CaptureEvents.php | 37 ++++++++++++--------------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/src/Events/Concerns/CaptureEvents.php b/src/Events/Concerns/CaptureEvents.php index 1ef5bfb9..5906af86 100644 --- a/src/Events/Concerns/CaptureEvents.php +++ b/src/Events/Concerns/CaptureEvents.php @@ -139,34 +139,31 @@ protected function recordDispatched(EventContract $event): void protected function shouldFakeEvent(string $name): bool { - if (! $this->faking) { - return false; - } - if ($this->fakeAll) { - return true; - } + $result = false; - if (empty($this->fakeEvents)) { - return false; + if (!$this->faking) { + return $result; } - if (! array_key_exists($name, $this->fakeEvents)) { - return false; - } - - $config = $this->fakeEvents[$name]; + if ($this->fakeAll) { + $result = true; + } elseif (! empty($this->fakeEvents) && array_key_exists($name, $this->fakeEvents)) { + $config = $this->fakeEvents[$name]; - if ($config instanceof Closure) { - try { - return (bool) $config($this->dispatched); - } catch (Throwable $e) { - report($e); + if ($config instanceof Closure) { + try { + $result = (bool) $config($this->dispatched); + } catch (Throwable $e) { + report($e); - return false; + $result = false; + } + } else { + $result = $config === null || $config > 0; } } - return $config === null || $config > 0; + return $result; } protected function consumeFakedEvent(string $name): void From 64dbe008a4be4ae2cbfab59fafd9fae030e7d6ff Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 16 Oct 2025 18:42:20 -0500 Subject: [PATCH 31/68] style: php cs --- src/Events/Concerns/CaptureEvents.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Events/Concerns/CaptureEvents.php b/src/Events/Concerns/CaptureEvents.php index 5906af86..4ecc56e2 100644 --- a/src/Events/Concerns/CaptureEvents.php +++ b/src/Events/Concerns/CaptureEvents.php @@ -5,9 +5,9 @@ namespace Phenix\Events\Concerns; use Closure; -use Throwable; use Phenix\App; use Phenix\Events\Contracts\Event as EventContract; +use Throwable; trait CaptureEvents { @@ -141,7 +141,7 @@ protected function shouldFakeEvent(string $name): bool { $result = false; - if (!$this->faking) { + if (! $this->faking) { return $result; } From f9da0645dbbcc575a017320b7a87bcf408aecb88 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 16 Oct 2025 18:44:03 -0500 Subject: [PATCH 32/68] feat(QueueManager): split class to CaptureTasks trait for task logging and faking functionality --- src/Queue/Concerns/CaptureTasks.php | 205 ++++++++++++++++++++++++++++ src/Queue/QueueManager.php | 203 +-------------------------- 2 files changed, 208 insertions(+), 200 deletions(-) create mode 100644 src/Queue/Concerns/CaptureTasks.php diff --git a/src/Queue/Concerns/CaptureTasks.php b/src/Queue/Concerns/CaptureTasks.php new file mode 100644 index 00000000..2eb42164 --- /dev/null +++ b/src/Queue/Concerns/CaptureTasks.php @@ -0,0 +1,205 @@ + + */ + protected array $fakeTasks = []; + + /** + * @var array, task: QueuableTask, queue: string|null, connection: string|null, timestamp: float}> + */ + protected array $pushed = []; + + public function log(): void + { + if (App::isProduction()) { + return; + } + + $this->logging = true; + } + + /** + * @param string|array, int|Closure|null>|class-string|null $tasks + * @param int|Closure|null $times + */ + public function fake(string|array|null $tasks = null, int|Closure|null $times = null): void + { + if (App::isProduction()) { + return; + } + + $this->logging = true; + $this->faking = true; + $this->fakeAll = $tasks === null; + + if ($this->fakeAll) { + return; + } + + $normalized = $this->normalizeFakeTasks($tasks, $times); + + foreach ($normalized as $taskClass => $config) { + if ($config === 0) { + continue; + } + + $this->fakeTasks[$taskClass] = $config; + } + } + + /** + * @return array, task: QueuableTask, queue: string|null, connection: string|null, timestamp: float}> + */ + public function getQueueLog(): array + { + return $this->pushed; + } + + public function resetQueueLog(): void + { + if (App::isProduction()) { + return; + } + + $this->pushed = []; + } + + protected function recordPush(QueuableTask $task): void + { + if (! $this->logging && ! $this->faking) { + return; + } + + $this->pushed[] = [ + 'task_class' => $task::class, + 'task' => $task, + 'queue' => $task->getQueueName(), + 'connection' => $task->getConnectionName(), + 'timestamp' => microtime(true), + ]; + } + + protected function shouldFakeTask(QueuableTask $task): bool + { + if (! $this->faking) { + return false; + } + + $result = false; + + if ($this->fakeAll) { + $result = true; + } else { + $class = $task::class; + + if (! empty($this->fakeTasks) && array_key_exists($class, $this->fakeTasks)) { + $config = $this->fakeTasks[$class]; + + if ($config instanceof Closure) { + try { + $result = (bool) $config($this->pushed); + } catch (Throwable $e) { + report($e); + $result = false; + } + } else { + $result = $config === null || $config > 0; + } + } + } + + return $result; + } + + protected function consumeFakedTask(QueuableTask $task): void + { + $class = $task::class; + + if (! array_key_exists($class, $this->fakeTasks)) { + return; + } + + $remaining = $this->fakeTasks[$class]; + + if ($remaining === null || $remaining instanceof Closure) { + return; + } + + $remaining--; + if ($remaining <= 0) { + unset($this->fakeTasks[$class]); + } else { + $this->fakeTasks[$class] = $remaining; + } + } + + /** + * @param string|array $tasks + * @param int|Closure|null $times + * @return array + */ + protected function normalizeFakeTasks(string|array $tasks, int|Closure|null $times): array + { + $normalized = []; + + if (is_string($tasks)) { + if ($times instanceof Closure) { + $normalized[$tasks] = $times; + } elseif (is_int($times)) { + $normalized[$tasks] = max(0, abs($times)); + } else { + $normalized[$tasks] = 1; + } + + return $normalized; + } + + if (array_is_list($tasks)) { + foreach ($tasks as $class) { + $normalized[$class] = 1; + } + } else { + foreach ($tasks as $class => $value) { + if (is_int($class)) { + $normalized[(string) $value] = 1; + + continue; + } + + if ($value instanceof Closure) { + $normalized[$class] = $value; + + continue; + } + + if (is_int($value)) { + $normalized[$class] = max(0, abs($value)); + + continue; + } + + $normalized[$class] = $value === null ? null : 1; + } + } + + return $normalized; + } +} diff --git a/src/Queue/QueueManager.php b/src/Queue/QueueManager.php index 0b227669..ae6a316a 100644 --- a/src/Queue/QueueManager.php +++ b/src/Queue/QueueManager.php @@ -4,38 +4,22 @@ namespace Phenix\Queue; -use Closure; use Phenix\App; use Phenix\Database\Constants\Driver as DatabaseDriver; +use Phenix\Queue\Concerns\CaptureTasks; use Phenix\Queue\Constants\QueueDriver; use Phenix\Queue\Contracts\Queue; use Phenix\Redis\Contracts\Client; use Phenix\Tasks\QueuableTask; -use Throwable; class QueueManager { + use CaptureTasks; + protected array $drivers = []; protected Config $config; - - protected bool $logging = false; - - protected bool $faking = false; - - protected bool $fakeAll = false; - - /** - * @var array - */ - protected array $fakeTasks = []; - - /** - * @var array, task: QueuableTask, queue: string|null, connection: string|null, timestamp: float}> - */ - protected array $pushed = []; - public function __construct(Config|null $config = null) { $this->config = $config ?? new Config(); @@ -108,76 +92,6 @@ public function driver(QueueDriver|null $driverName = null): Queue return $this->drivers[$driverName->value] ??= $this->resolveDriver($driverName); } - public function log(): void - { - if (App::isProduction()) { - return; - } - - $this->logging = true; - } - - /** - * @param string|array, int|Closure|null>|class-string|null $tasks - * @param int|Closure|null $times - */ - public function fake(string|array|null $tasks = null, int|Closure|null $times = null): void - { - if (App::isProduction()) { - return; - } - - $this->logging = true; - $this->faking = true; - $this->fakeAll = $tasks === null; - - if ($this->fakeAll) { - return; - } - - $normalized = $this->normalizeFakeTasks($tasks, $times); - - foreach ($normalized as $taskClass => $config) { - if ($config === 0) { - continue; - } - - $this->fakeTasks[$taskClass] = $config; - } - } - - /** - * @return array, task: QueuableTask, queue: string|null, connection: string|null, timestamp: float}> - */ - public function getQueueLog(): array - { - return $this->pushed; - } - - public function resetQueueLog(): void - { - if (App::isProduction()) { - return; - } - - $this->pushed = []; - } - - protected function recordPush(QueuableTask $task): void - { - if (! $this->logging && ! $this->faking) { - return; - } - - $this->pushed[] = [ - 'task_class' => $task::class, - 'task' => $task, - 'queue' => $task->getQueueName(), - 'connection' => $task->getConnectionName(), - 'timestamp' => microtime(true), - ]; - } - protected function resolveDriverName(QueueDriver|null $driverName = null): QueueDriver { return $driverName ?? QueueDriver::from($this->config->default()); @@ -192,117 +106,6 @@ protected function resolveDriver(QueueDriver $driverName): Queue }; } - protected function shouldFakeTask(QueuableTask $task): bool - { - if (! $this->faking) { - return false; - } - - if ($this->fakeAll) { - return true; - } - - if (empty($this->fakeTasks)) { - return false; - } - - $class = $task::class; - - if (! array_key_exists($class, $this->fakeTasks)) { - return false; - } - - $config = $this->fakeTasks[$class]; - - if ($config instanceof Closure) { - try { - return (bool) $config($this->pushed); - } catch (Throwable $e) { - report($e); - - return false; - } - } - - return $config === null || $config > 0; - } - - protected function consumeFakedTask(QueuableTask $task): void - { - $class = $task::class; - - if (! array_key_exists($class, $this->fakeTasks)) { - return; - } - - $remaining = $this->fakeTasks[$class]; - - if ($remaining === null || $remaining instanceof Closure) { - return; - } - - $remaining--; - if ($remaining <= 0) { - unset($this->fakeTasks[$class]); - } else { - $this->fakeTasks[$class] = $remaining; - } - } - - /** - * @param string|array $tasks - * @param int|Closure|null $times - * @return array - */ - protected function normalizeFakeTasks(string|array $tasks, int|Closure|null $times): array - { - if (is_string($tasks)) { - if ($times instanceof Closure) { - return [$tasks => $times]; - } - - if (is_int($times)) { - return [$tasks => max(0, abs($times))]; - } - - return [$tasks => 1]; - } - - $normalized = []; - - if (array_is_list($tasks)) { - foreach ($tasks as $class) { - $normalized[$class] = 1; - } - - return $normalized; - } - - foreach ($tasks as $class => $value) { - if (is_int($class)) { - $normalized[(string) $value] = 1; - - continue; - } - - if ($value instanceof Closure) { - $normalized[$class] = $value; - - continue; - } - - if (is_int($value)) { - $normalized[$class] = max(0, abs($value)); - - continue; - } - - $normalized[$class] = $value === null ? null : 1; - } - - return $normalized; - } - protected function createParallelDriver(): Queue { return new ParallelQueue(); From fcb39e74ed886e10ecc11f427f5f23cd13e29799 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 16 Oct 2025 19:21:17 -0500 Subject: [PATCH 33/68] refactor(CaptureEvents): enhance normalizeFakeEvents method for improved clarity and structure --- src/Events/Concerns/CaptureEvents.php | 79 +++++++++++++++++++-------- 1 file changed, 56 insertions(+), 23 deletions(-) diff --git a/src/Events/Concerns/CaptureEvents.php b/src/Events/Concerns/CaptureEvents.php index 4ecc56e2..11e83a31 100644 --- a/src/Events/Concerns/CaptureEvents.php +++ b/src/Events/Concerns/CaptureEvents.php @@ -85,39 +85,72 @@ public function resetEventLog(): void */ protected function normalizeFakeEvents(string|array $events, int|Closure|null $times): array { - $normalized = []; - if (is_string($events)) { - $normalized[$events] = $times instanceof Closure + return $this->normalizeSingleEvent($events, $times); + } + + if (array_is_list($events)) { + return $this->normalizeListEvents($events); + } + + return $this->normalizeMapEvents($events); + } + + /** + * @return array + */ + private function normalizeSingleEvent(string $event, int|Closure|null $times): array + { + return [ + $event => $times instanceof Closure ? $times - : (is_int($times) ? max(0, abs($times)) : null); - } elseif (array_is_list($events)) { - foreach ($events as $event) { - $normalized[$event] = null; - } - } else { - foreach ($events as $name => $value) { - if (is_int($name)) { - $normalized[(string) $value] = null; + : (is_int($times) ? max(0, abs($times)) : null), + ]; + } + + /** + * @param array $events + * @return array + */ + private function normalizeListEvents(array $events): array + { + $normalized = []; + foreach ($events as $event) { + $normalized[$event] = null; + } - continue; - } + return $normalized; + } + + /** + * @param array $events + * @return array + */ + private function normalizeMapEvents(array $events): array + { + $normalized = []; - if (is_int($value)) { - $normalized[$name] = max(0, abs($value)); + foreach ($events as $name => $value) { + if (is_int($name)) { + $normalized[(string) $value] = null; - continue; - } + continue; + } - if ($value instanceof Closure) { - $normalized[$name] = $value; + if ($value instanceof Closure) { + $normalized[$name] = $value; - continue; - } + continue; + } + + if (is_int($value)) { + $normalized[$name] = max(0, abs($value)); - $normalized[$name] = null; + continue; } + + $normalized[$name] = null; } return $normalized; From 2adbbda72c4e45401c9e5f82b0f494b5b6d10b8f Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 16 Oct 2025 19:22:39 -0500 Subject: [PATCH 34/68] refactor(CaptureEvents): improve normalizeSingleEvent method for better clarity and structure --- src/Events/Concerns/CaptureEvents.php | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/Events/Concerns/CaptureEvents.php b/src/Events/Concerns/CaptureEvents.php index 11e83a31..ded4147d 100644 --- a/src/Events/Concerns/CaptureEvents.php +++ b/src/Events/Concerns/CaptureEvents.php @@ -101,11 +101,15 @@ protected function normalizeFakeEvents(string|array $events, int|Closure|null $t */ private function normalizeSingleEvent(string $event, int|Closure|null $times): array { - return [ - $event => $times instanceof Closure - ? $times - : (is_int($times) ? max(0, abs($times)) : null), - ]; + $config = null; + + if ($times instanceof Closure) { + $config = $times; + } elseif (is_int($times)) { + $config = max(0, abs($times)); + } + + return [$event => $config]; } /** From 48738774d4b33099eb4d910a76c074a5711aed21 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 16 Oct 2025 19:26:10 -0500 Subject: [PATCH 35/68] style: php cs --- src/Queue/Concerns/CaptureTasks.php | 90 +++++++++++++++++++---------- 1 file changed, 61 insertions(+), 29 deletions(-) diff --git a/src/Queue/Concerns/CaptureTasks.php b/src/Queue/Concerns/CaptureTasks.php index 2eb42164..88517c89 100644 --- a/src/Queue/Concerns/CaptureTasks.php +++ b/src/Queue/Concerns/CaptureTasks.php @@ -9,6 +9,8 @@ use Phenix\Tasks\QueuableTask; use Throwable; +use function array_is_list; + trait CaptureTasks { protected bool $logging = false; @@ -158,46 +160,76 @@ protected function consumeFakedTask(QueuableTask $task): void */ protected function normalizeFakeTasks(string|array $tasks, int|Closure|null $times): array { - $normalized = []; - if (is_string($tasks)) { - if ($times instanceof Closure) { - $normalized[$tasks] = $times; - } elseif (is_int($times)) { - $normalized[$tasks] = max(0, abs($times)); - } else { - $normalized[$tasks] = 1; - } - - return $normalized; + return $this->normalizeSingleTask($tasks, $times); } if (array_is_list($tasks)) { - foreach ($tasks as $class) { - $normalized[$class] = 1; - } - } else { - foreach ($tasks as $class => $value) { - if (is_int($class)) { - $normalized[(string) $value] = 1; + return $this->normalizeListTasks($tasks); + } - continue; - } + return $this->normalizeMapTasks($tasks); + } - if ($value instanceof Closure) { - $normalized[$class] = $value; + /** + * @return array + */ + private function normalizeSingleTask(string $taskClass, int|Closure|null $times): array + { + $config = 1; - continue; - } + if ($times instanceof Closure) { + $config = $times; + } elseif (is_int($times)) { + $config = max(0, abs($times)); + } - if (is_int($value)) { - $normalized[$class] = max(0, abs($value)); + return [$taskClass => $config]; + } - continue; - } + /** + * @param array $tasks + * @return array + */ + private function normalizeListTasks(array $tasks): array + { + $normalized = []; - $normalized[$class] = $value === null ? null : 1; + foreach ($tasks as $class) { + $normalized[$class] = 1; + } + + return $normalized; + } + + /** + * @param array $tasks + * @return array + */ + private function normalizeMapTasks(array $tasks): array + { + $normalized = []; + + foreach ($tasks as $class => $value) { + if (is_int($class)) { + $normalized[(string) $value] = 1; + + continue; + } + + if ($value instanceof Closure) { + $normalized[$class] = $value; + + continue; } + + if (is_int($value)) { + $normalized[$class] = max(0, abs($value)); + + continue; + } + + $normalized[$class] = ($value === null) ? null : 1; } return $normalized; From 7257741f410d2b492e2e5edbda2412b1fa09c50d Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 17 Oct 2025 18:33:32 -0500 Subject: [PATCH 36/68] test(EventEmitter): add tests for event logging and faking in production environment --- src/Events/Concerns/CaptureEvents.php | 4 -- tests/Unit/Events/EventEmitterTest.php | 87 ++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/src/Events/Concerns/CaptureEvents.php b/src/Events/Concerns/CaptureEvents.php index ded4147d..63868419 100644 --- a/src/Events/Concerns/CaptureEvents.php +++ b/src/Events/Concerns/CaptureEvents.php @@ -71,10 +71,6 @@ public function getEventLog(): array public function resetEventLog(): void { - if (App::isProduction()) { - return; - } - $this->dispatched = []; } diff --git a/tests/Unit/Events/EventEmitterTest.php b/tests/Unit/Events/EventEmitterTest.php index 72d2d156..d2b30f18 100644 --- a/tests/Unit/Events/EventEmitterTest.php +++ b/tests/Unit/Events/EventEmitterTest.php @@ -7,6 +7,7 @@ use Phenix\Events\EventEmitter; use Phenix\Events\Exceptions\EventException; use Phenix\Exceptions\RuntimeError; +use Phenix\Facades\Config; use Phenix\Facades\Event as EventFacade; use Phenix\Facades\Log; use Tests\Unit\Events\Internal\InvalidListener; @@ -694,3 +695,89 @@ EventFacade::expect('single.closure.event')->toBeDispatchedTimes(4); }); + +it('does not log events in production environment', function (): void { + Config::set('app.env', 'production'); + + EventFacade::log(); + + EventFacade::emit('prod.logged.event', 'payload'); + + expect(EventFacade::getEventLog())->toHaveCount(0); + + Config::set('app.env', 'local'); +}); + +it('does not fake events in production environment', function (): void { + Config::set('app.env', 'production'); + + $called = false; + EventFacade::on('prod.fake.event', function () use (&$called): void { + $called = true; + }); + + EventFacade::fake('prod.fake.event'); + + EventFacade::emit('prod.fake.event', 'payload'); + + expect($called)->toBeTrue(); + + Config::set('app.env', 'local'); +}); + +it('fakes all events provided as a list array', function (): void { + EventFacade::on('list.one', function (): never { throw new RuntimeError('Should not run'); }); + EventFacade::on('list.two', function (): never { throw new RuntimeError('Should not run'); }); + + $executedThree = false; + + EventFacade::on('list.three', function () use (&$executedThree): void { $executedThree = true; }); + + EventFacade::fake(['list.one', 'list.two']); + + EventFacade::emit('list.one'); + EventFacade::emit('list.one'); + EventFacade::emit('list.two'); + EventFacade::emit('list.two'); + + EventFacade::emit('list.three'); + + expect($executedThree)->toEqual(true); + + EventFacade::expect('list.one')->toBeDispatchedTimes(2); + EventFacade::expect('list.two')->toBeDispatchedTimes(2); + EventFacade::expect('list.three')->toBeDispatchedTimes(1); +}); + +it('ignores events configured with zero count', function (): void { + $executed = 0; + + EventFacade::on('zero.count.event', function () use (&$executed): void { $executed++; }); + + EventFacade::fake(['zero.count.event' => 0]); + + EventFacade::emit('zero.count.event'); + EventFacade::emit('zero.count.event'); + + expect($executed)->toEqual(2); + + EventFacade::expect('zero.count.event')->toBeDispatchedTimes(2); +}); + +it('does not fake when closure throws exception', function (): void { + $executed = false; + + EventFacade::on('closure.exception.event', function () use (&$executed): void { $executed = true; }); + + EventFacade::fake([ + 'closure.exception.event' => function (): never { + throw new RuntimeError('Predicate error'); + }, + ]); + + EventFacade::emit('closure.exception.event'); + + expect($executed)->toEqual(true); + + EventFacade::expect('closure.exception.event')->toBeDispatchedTimes(1); +}); From ceb0816b4424f088a0faaf7a23e7c2c6331482b0 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 17 Oct 2025 18:53:38 -0500 Subject: [PATCH 37/68] test(EventEmitter): add test for correct behavior of async event faking --- tests/Unit/Events/EventEmitterTest.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/Unit/Events/EventEmitterTest.php b/tests/Unit/Events/EventEmitterTest.php index d2b30f18..53e465bc 100644 --- a/tests/Unit/Events/EventEmitterTest.php +++ b/tests/Unit/Events/EventEmitterTest.php @@ -781,3 +781,21 @@ EventFacade::expect('closure.exception.event')->toBeDispatchedTimes(1); }); + +it('fakes async emits correctly', function (): void { + EventFacade::fake(); + + $called = false; + + EventFacade::on('async.fake.event', function () use (&$called): void { + $called = true; + }); + + $future = EventFacade::emitAsync('async.fake.event', 'payload'); + + $future->await(); + + expect($called)->toBeFalse(); + + EventFacade::expect('async.fake.event')->toBeDispatched(); +}); From c270301f8c8d104b267e2e1d1c534ccd240ae62b Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 17 Oct 2025 18:53:45 -0500 Subject: [PATCH 38/68] refactor(CaptureTasks): remove production check from resetQueueLog method --- src/Queue/Concerns/CaptureTasks.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/Queue/Concerns/CaptureTasks.php b/src/Queue/Concerns/CaptureTasks.php index 88517c89..c74143d9 100644 --- a/src/Queue/Concerns/CaptureTasks.php +++ b/src/Queue/Concerns/CaptureTasks.php @@ -77,10 +77,6 @@ public function getQueueLog(): array public function resetQueueLog(): void { - if (App::isProduction()) { - return; - } - $this->pushed = []; } From b180e888688568ab0e74692ad504eda55832c042 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 17 Oct 2025 18:54:44 -0500 Subject: [PATCH 39/68] test(ParallelQueue): add tests for logging and faking behavior in production environment --- tests/Unit/Queue/ParallelQueueTest.php | 56 +++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/tests/Unit/Queue/ParallelQueueTest.php b/tests/Unit/Queue/ParallelQueueTest.php index 2f1899e9..4bb49a36 100644 --- a/tests/Unit/Queue/ParallelQueueTest.php +++ b/tests/Unit/Queue/ParallelQueueTest.php @@ -538,6 +538,34 @@ Queue::expect(BasicQueuableTask::class)->toBePushedTimes(1); }); +it('does not log pushes in production environment', function (): void { + Config::set('app.env', 'production'); + + Queue::log(); + + Queue::push(new BasicQueuableTask()); + + Queue::expect(BasicQueuableTask::class)->toPushNothing(); + + Config::set('app.env', 'local'); +}); + +it('does not fake tasks in production environment', function (): void { + Config::set('app.env', 'production'); + + Queue::fake(BasicQueuableTask::class); + + Queue::push(new BasicQueuableTask()); + Queue::push(new BasicQueuableTask()); + + $this->assertSame(2, Queue::size()); + + Queue::expect(BasicQueuableTask::class)->toPushNothing(); + + Config::set('app.env', 'local'); + Queue::clear(); +}); + it('does not log tasks when logging is disabled', function (): void { Queue::push(new BasicQueuableTask()); @@ -621,7 +649,7 @@ Queue::expect(BasicQueuableTask::class)->toBePushedTimes(3); }); -it('conditionally fakes tasks using a closure configuration', function (): void { +it('conditionally fakes tasks using array and a closure configuration', function (): void { Queue::fake([ BasicQueuableTask::class => function (array $log): bool { return count($log) <= 3; @@ -636,3 +664,29 @@ Queue::expect(BasicQueuableTask::class)->toBePushedTimes(5); }); + +it('conditionally fakes tasks using only a closure configuration', function (): void { + Queue::fake(BasicQueuableTask::class, function (array $log): bool { + return count($log) <= 2; + }); + + for ($i = 0; $i < 4; $i++) { + Queue::push(new BasicQueuableTask()); + } + + $this->assertSame(2, Queue::size()); + + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(4); +}); + +it('does not fake when closure throws an exception', function (): void { + Queue::fake(BasicQueuableTask::class, function (array $log): bool { + throw new RuntimeException('Closure exception'); + }); + + Queue::push(new BasicQueuableTask()); + + $this->assertSame(1, Queue::size()); + + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(1); +}); From 9b835d6034dbf533324ed9364dea1ddfd64e0a00 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Fri, 17 Oct 2025 18:57:36 -0500 Subject: [PATCH 40/68] test(ParallelQueue): enhance assertion for tasks not pushed to include queue name check --- tests/Unit/Queue/ParallelQueueTest.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/Unit/Queue/ParallelQueueTest.php b/tests/Unit/Queue/ParallelQueueTest.php index 4bb49a36..e39d20ff 100644 --- a/tests/Unit/Queue/ParallelQueueTest.php +++ b/tests/Unit/Queue/ParallelQueueTest.php @@ -587,6 +587,9 @@ Queue::log(); Queue::expect(BasicQueuableTask::class)->toNotBePushed(); + Queue::expect(BasicQueuableTask::class)->toNotBePushed(function ($task) { + return $task !== null && $task->getQueueName() === 'default'; + }); }); it('asserts tasks pushed on a custom queue', function (): void { From 317ed8b7c7183febb8514aaef0548d295ebf635b Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Tue, 21 Oct 2025 18:15:13 -0500 Subject: [PATCH 41/68] refactor(EventEmitter): remove unused logging and event management methods --- src/Events/Contracts/EventEmitter.php | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/src/Events/Contracts/EventEmitter.php b/src/Events/Contracts/EventEmitter.php index f4dcaeaf..d0444b0b 100644 --- a/src/Events/Contracts/EventEmitter.php +++ b/src/Events/Contracts/EventEmitter.php @@ -24,19 +24,4 @@ public function getListeners(string $event): array; public function hasListeners(string $event): bool; public function removeAllListeners(): void; - - public function log(): void; - - /** - * @param string|array|null $events - * @param int|null $times - */ - public function fake(string|array|null $events = null, int|Closure|null $times = null): void; - - /** - * @return array - */ - public function getEventLog(): array; - - public function resetEventLog(): void; } From 47a7c2085f2907bd700438ea2a685506c9f0bf84 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Tue, 21 Oct 2025 18:17:40 -0500 Subject: [PATCH 42/68] fix(EventEmitter): ensure event dispatching records are created for faked events --- src/Events/EventEmitter.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Events/EventEmitter.php b/src/Events/EventEmitter.php index 778ffac5..0077dd25 100644 --- a/src/Events/EventEmitter.php +++ b/src/Events/EventEmitter.php @@ -87,6 +87,7 @@ public function emit(string|EventContract $event, mixed $payload = null): array $eventObject = $this->createEvent($event, $payload); $this->recordDispatched($eventObject); + if ($this->shouldFakeEvent($eventObject->getName())) { $this->consumeFakedEvent($eventObject->getName()); @@ -140,6 +141,7 @@ public function emitAsync(string|EventContract $event, mixed $payload = null): F $eventObject = $this->createEvent($event, $payload); $this->recordDispatched($eventObject); + if ($this->shouldFakeEvent($eventObject->getName())) { $this->consumeFakedEvent($eventObject->getName()); From 7fad2babf951d1ae800c1120c3ef85781b919e49 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Tue, 21 Oct 2025 18:32:51 -0500 Subject: [PATCH 43/68] refactor(CaptureEvents): streamline faking logic and enhance event logging structure --- src/Events/Concerns/CaptureEvents.php | 182 +++++++++++-------------- src/Facades/Event.php | 12 +- src/Testing/Constants/FakeMode.php | 14 ++ src/Testing/TestCase.php | 3 + src/Testing/TestEvent.php | 23 +--- tests/Unit/Events/EventEmitterTest.php | 89 ++++++------ 6 files changed, 154 insertions(+), 169 deletions(-) create mode 100644 src/Testing/Constants/FakeMode.php diff --git a/src/Events/Concerns/CaptureEvents.php b/src/Events/Concerns/CaptureEvents.php index 63868419..f93d1584 100644 --- a/src/Events/Concerns/CaptureEvents.php +++ b/src/Events/Concerns/CaptureEvents.php @@ -6,16 +6,17 @@ use Closure; use Phenix\App; +use Phenix\Data\Collection; use Phenix\Events\Contracts\Event as EventContract; +use Phenix\Testing\Constants\FakeMode; +use Phenix\Util\Date; use Throwable; trait CaptureEvents { protected bool $logging = false; - protected bool $faking = false; - - protected bool $fakeAll = false; + protected FakeMode $fakeMode = FakeMode::NONE; /** * @var array @@ -23,9 +24,9 @@ trait CaptureEvents protected array $fakeEvents = []; /** - * @var array + * @var Collection */ - protected array $dispatched = []; + protected Collection $dispatched; public function log(): void { @@ -33,154 +34,121 @@ public function log(): void return; } - $this->logging = true; + $this->enableLog(); } - public function fake(string|array|null $events = null, int|Closure|null $times = null): void + public function fake(): void { if (App::isProduction()) { return; } - $this->logging = true; - $this->faking = true; - - if ($events === null) { - $this->fakeAll = true; + $this->enableFake(FakeMode::ALL); + } + public function fakeWhen(string $event, Closure $callback): void + { + if (App::isProduction()) { return; } - $this->fakeAll = false; + $this->enableFake(FakeMode::SCOPED); - $normalized = $this->normalizeFakeEvents($events, $times); - - foreach ($normalized as $name => $config) { - if ($config === 0) { - continue; - } - - $this->fakeEvents[$name] = $config; - } + $this->fakeEvents[$event] = $callback; } - public function getEventLog(): array + public function fakeTimes(string $event, int $times): void { - return $this->dispatched; - } + if (App::isProduction()) { + return; + } - public function resetEventLog(): void - { - $this->dispatched = []; + $this->enableFake(FakeMode::SCOPED); + + $this->fakeEvents[$event] = $times; } - /** - * @param string|array $events - * @param int|Closure|null $times - * @return array - */ - protected function normalizeFakeEvents(string|array $events, int|Closure|null $times): array + public function fakeOnce(string $event): void { - if (is_string($events)) { - return $this->normalizeSingleEvent($events, $times); + if (App::isProduction()) { + return; } - if (array_is_list($events)) { - return $this->normalizeListEvents($events); - } + $this->enableFake(FakeMode::SCOPED); - return $this->normalizeMapEvents($events); + $this->fakeEvents[$event] = 1; } - /** - * @return array - */ - private function normalizeSingleEvent(string $event, int|Closure|null $times): array + public function fakeOnly(string $event): void { - $config = null; - - if ($times instanceof Closure) { - $config = $times; - } elseif (is_int($times)) { - $config = max(0, abs($times)); + if (App::isProduction()) { + return; } - return [$event => $config]; + $this->enableFake(FakeMode::SCOPED); + + $this->fakeEvents = [ + $event => null, + ]; } - /** - * @param array $events - * @return array - */ - private function normalizeListEvents(array $events): array + public function fakeExcept(string $event): void { - $normalized = []; - - foreach ($events as $event) { - $normalized[$event] = null; + if (App::isProduction()) { + return; } - return $normalized; + $this->enableFake(FakeMode::SCOPED); + + $this->fakeEvents = [ + $event => fn (Collection $log): bool => $log->filter(fn (array $entry): bool => $entry['name'] === $event)->isEmpty(), + ]; } - /** - * @param array $events - * @return array - */ - private function normalizeMapEvents(array $events): array + public function getEventLog(): Collection { - $normalized = []; - - foreach ($events as $name => $value) { - if (is_int($name)) { - $normalized[(string) $value] = null; - - continue; - } - - if ($value instanceof Closure) { - $normalized[$name] = $value; - - continue; - } - - if (is_int($value)) { - $normalized[$name] = max(0, abs($value)); + if (! isset($this->dispatched)) { + $this->dispatched = Collection::fromArray([]); + } - continue; - } + return $this->dispatched; + } - $normalized[$name] = null; - } + public function resetEventLog(): void + { + $this->dispatched = Collection::fromArray([]); + } - return $normalized; + public function resetFaking(): void + { + $this->logging = false; + $this->fakeMode = FakeMode::NONE; + $this->fakeEvents = []; + $this->dispatched = Collection::fromArray([]); } protected function recordDispatched(EventContract $event): void { - if (! $this->logging && ! $this->faking) { + if (! $this->logging) { return; } - $this->dispatched[] = [ + $this->dispatched->add([ 'name' => $event->getName(), 'event' => $event, - 'payload' => $event->getPayload(), - 'timestamp' => microtime(true), - ]; + 'timestamp' => Date::now(), + ]); } protected function shouldFakeEvent(string $name): bool { - $result = false; - - if (! $this->faking) { - return $result; + if ($this->fakeMode === FakeMode::ALL) { + return true; } - if ($this->fakeAll) { - $result = true; - } elseif (! empty($this->fakeEvents) && array_key_exists($name, $this->fakeEvents)) { + $result = false; + + if (! empty($this->fakeEvents) && array_key_exists($name, $this->fakeEvents)) { $config = $this->fakeEvents[$name]; if ($config instanceof Closure) { @@ -219,4 +187,18 @@ protected function consumeFakedEvent(string $name): void $this->fakeEvents[$name] = $remaining; } } + + protected function enableLog(): void + { + if (! $this->logging) { + $this->logging = true; + $this->dispatched = Collection::fromArray([]); + } + } + + protected function enableFake(FakeMode $fakeMode): void + { + $this->enableLog(); + $this->fakeMode = $fakeMode; + } } diff --git a/src/Facades/Event.php b/src/Facades/Event.php index 973178ce..a4cde3af 100644 --- a/src/Facades/Event.php +++ b/src/Facades/Event.php @@ -7,6 +7,7 @@ use Amp\Future; use Closure; use Phenix\App; +use Phenix\Data\Collection; use Phenix\Events\Contracts\Event as EventContract; use Phenix\Events\Contracts\EventListener; use Phenix\Runtime\Facade; @@ -27,10 +28,15 @@ * @method static int getListenerCount(string $event) * @method static array getEventNames() * @method static void log() - * @method static void fake(string|array|null $events = null, int|Closure|null $times = null) - * @method static array getEventLog() + * @method static void fake() + * @method static void fakeWhen(string $event, Closure $callback) + * @method static void fakeTimes(string $event, int $times) + * @method static void fakeOnce(string $event) + * @method static void fakeOnly(string $event) + * @method static void fakeExcept(string $event) + * @method static Collection getEventLog() * @method static void resetEventLog() - * @method static \Phenix\Testing\TestEvent expect() + * @method static void resetFaking() * * @see \Phenix\Events\EventEmitter */ diff --git a/src/Testing/Constants/FakeMode.php b/src/Testing/Constants/FakeMode.php new file mode 100644 index 00000000..ddb5d66c --- /dev/null +++ b/src/Testing/Constants/FakeMode.php @@ -0,0 +1,14 @@ +app = null; } diff --git a/src/Testing/TestEvent.php b/src/Testing/TestEvent.php index 4145f96a..7eb9d777 100644 --- a/src/Testing/TestEvent.php +++ b/src/Testing/TestEvent.php @@ -6,21 +6,14 @@ use Closure; use Phenix\Data\Collection; -use Phenix\Events\Contracts\Event as EventContract; use PHPUnit\Framework\Assert; class TestEvent { - public readonly Collection $log; - - /** - * @param array $log - */ public function __construct( - protected string $event, - array $log = [] + public readonly string $event, + public readonly Collection $log ) { - $this->log = Collection::fromArray($log); } public function toBeDispatched(Closure|null $closure = null): void @@ -59,14 +52,8 @@ public function toDispatchNothing(): void private function filterByName(string $event): Collection { - $filtered = []; - - foreach ($this->log as $record) { - if ($record['name'] === $event) { - $filtered[] = $record; - } - } - - return Collection::fromArray($filtered); + return $this->log->filter(function (array $record) use ($event): bool { + return $record['name'] === $event; + }); } } diff --git a/tests/Unit/Events/EventEmitterTest.php b/tests/Unit/Events/EventEmitterTest.php index 53e465bc..52540b7c 100644 --- a/tests/Unit/Events/EventEmitterTest.php +++ b/tests/Unit/Events/EventEmitterTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use Phenix\Data\Collection; use Phenix\Events\Contracts\Event as EventContract; use Phenix\Events\Event; use Phenix\Events\EventEmitter; @@ -483,11 +484,11 @@ EventFacade::expect('logged.event')->toBeDispatched(); EventFacade::expect('logged.event')->toBeDispatchedTimes(1); - expect(EventFacade::getEventLog())->toHaveCount(1); + expect(EventFacade::getEventLog()->count())->toEqual(1); EventFacade::resetEventLog(); - expect(EventFacade::getEventLog())->toHaveCount(0); + expect(EventFacade::getEventLog()->count())->toEqual(0); EventFacade::emit('logged.event', 'payload-2'); EventFacade::expect('logged.event')->toBeDispatchedTimes(1); @@ -498,6 +499,7 @@ $called = false; EventFacade::on('fake.event', function () use (&$called): void { + dump('FAILING'); $called = true; }); @@ -539,7 +541,7 @@ EventFacade::expect('absent.event')->toNotBeDispatched(fn ($event): bool => false); }); -it('fakes only specific events when an array is provided and consumes them after first fake', function (): void { +it('fakes only specific events when a single event is provided and consumes it after first fake', function (): void { $calledSpecific = false; $calledOther = false; @@ -551,7 +553,7 @@ $calledOther = true; // Should run }); - EventFacade::fake(['specific.event' => 1]); + EventFacade::fakeTimes('specific.event', 1); EventFacade::emit('specific.event', 'payload-1'); @@ -579,7 +581,7 @@ $called++; }); - EventFacade::fake('always.event'); + EventFacade::fakeOnly('always.event'); EventFacade::emit('always.event'); EventFacade::emit('always.event'); @@ -597,7 +599,7 @@ $called++; }); - EventFacade::fake('limited.event', 2); + EventFacade::fakeTimes('limited.event', 2); EventFacade::emit('limited.event'); // fake EventFacade::emit('limited.event'); // fake @@ -609,54 +611,46 @@ EventFacade::expect('limited.event')->toBeDispatchedTimes(4); }); -it('supports associative array with mixed counts and infinite entries', function (): void { +it('supports limited fake then switching to only one infinite event', function (): void { $limitedCalled = 0; - $infiniteCalled = 0; - $globalCalled = 0; + $onlyCalled = 0; EventFacade::on('assoc.limited', function () use (&$limitedCalled): void { $limitedCalled++; }); - EventFacade::on('assoc.infinite', function () use (&$infiniteCalled): void { $infiniteCalled++; }); - EventFacade::on('assoc.global', function () use (&$globalCalled): void { $globalCalled++; }); + EventFacade::on('assoc.only', function () use (&$onlyCalled): void { $onlyCalled++; }); - EventFacade::fake([ - 'assoc.limited' => 1, - 'assoc.infinite' => null, - 'assoc.global', - ]); + EventFacade::fakeTimes('assoc.limited', 1); // fake first occurrence only EventFacade::emit('assoc.limited'); // fake EventFacade::emit('assoc.limited'); // real - EventFacade::emit('assoc.infinite'); // fake - EventFacade::emit('assoc.infinite'); // fake - EventFacade::emit('assoc.global'); // fake - EventFacade::emit('assoc.global'); // fake + + EventFacade::fakeOnly('assoc.only'); + + EventFacade::emit('assoc.only'); // fake + EventFacade::emit('assoc.only'); // fake + EventFacade::emit('assoc.limited'); // real expect($limitedCalled)->toBe(2); - expect($infiniteCalled)->toBe(0); - expect($globalCalled)->toBe(0); + expect($onlyCalled)->toBe(0); - EventFacade::expect('assoc.limited')->toBeDispatchedTimes(3); - EventFacade::expect('assoc.infinite')->toBeDispatchedTimes(2); - EventFacade::expect('assoc.global')->toBeDispatchedTimes(2); + EventFacade::expect('assoc.limited')->toBeDispatchedTimes(3); // recorded 3 emits + EventFacade::expect('assoc.only')->toBeDispatchedTimes(2); // recorded but never executed }); it('supports conditional closure based faking', function (): void { $called = 0; - EventFacade::fake([ - 'conditional.event' => function (array $log): bool { - $count = 0; - - foreach ($log as $entry) { - if (($entry['name'] ?? null) === 'conditional.event') { - $count++; - } + EventFacade::log(); + EventFacade::fakeWhen('conditional.event', function (Collection $log): bool { + $count = 0; + foreach ($log as $entry) { + if (($entry['name'] ?? null) === 'conditional.event') { + $count++; } + } - return $count <= 2; - }, - ]); + return $count <= 2; + }); EventFacade::on('conditional.event', function () use (&$called): void { $called++; }); @@ -670,10 +664,10 @@ EventFacade::expect('conditional.event')->toBeDispatchedTimes(4); }); -it('supports single event closure in times parameter for fake', function (): void { +it('supports single event closure predicate faking', function (): void { $called = 0; - EventFacade::fake('single.closure.event', function (array $log): bool { + EventFacade::fakeWhen('single.closure.event', function (Collection $log): bool { $count = 0; foreach ($log as $entry) { if (($entry['name'] ?? null) === 'single.closure.event') { @@ -703,7 +697,7 @@ EventFacade::emit('prod.logged.event', 'payload'); - expect(EventFacade::getEventLog())->toHaveCount(0); + expect(EventFacade::getEventLog()->count())->toEqual(0); Config::set('app.env', 'local'); }); @@ -716,7 +710,7 @@ $called = true; }); - EventFacade::fake('prod.fake.event'); + EventFacade::fakeOnly('prod.fake.event'); EventFacade::emit('prod.fake.event', 'payload'); @@ -725,7 +719,7 @@ Config::set('app.env', 'local'); }); -it('fakes all events provided as a list array', function (): void { +it('fakes multiple events provided sequentially', function (): void { EventFacade::on('list.one', function (): never { throw new RuntimeError('Should not run'); }); EventFacade::on('list.two', function (): never { throw new RuntimeError('Should not run'); }); @@ -733,7 +727,8 @@ EventFacade::on('list.three', function () use (&$executedThree): void { $executedThree = true; }); - EventFacade::fake(['list.one', 'list.two']); + EventFacade::fakeOnly('list.one'); + EventFacade::fakeTimes('list.two', PHP_INT_MAX); EventFacade::emit('list.one'); EventFacade::emit('list.one'); @@ -754,7 +749,7 @@ EventFacade::on('zero.count.event', function () use (&$executed): void { $executed++; }); - EventFacade::fake(['zero.count.event' => 0]); + EventFacade::fakeTimes('zero.count.event', 0); EventFacade::emit('zero.count.event'); EventFacade::emit('zero.count.event'); @@ -769,11 +764,9 @@ EventFacade::on('closure.exception.event', function () use (&$executed): void { $executed = true; }); - EventFacade::fake([ - 'closure.exception.event' => function (): never { - throw new RuntimeError('Predicate error'); - }, - ]); + EventFacade::fakeWhen('closure.exception.event', function (Collection $log): bool { + throw new RuntimeError('Predicate error'); + }); EventFacade::emit('closure.exception.event'); From 0ef464b37a245b52236552def030faca5d88cc53 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Tue, 21 Oct 2025 20:24:39 -0500 Subject: [PATCH 44/68] refactor(Queue): enhance faking methods and improve logging structure --- src/Facades/Queue.php | 12 +- src/Queue/Concerns/CaptureTasks.php | 227 +++++++++++-------------- src/Testing/TestQueue.php | 21 +-- tests/Unit/Queue/ParallelQueueTest.php | 24 ++- 4 files changed, 128 insertions(+), 156 deletions(-) diff --git a/src/Facades/Queue.php b/src/Facades/Queue.php index beaef1ee..f3899f8d 100644 --- a/src/Facades/Queue.php +++ b/src/Facades/Queue.php @@ -6,6 +6,7 @@ use Closure; use Phenix\App; +use Phenix\Data\Collection; use Phenix\Queue\Constants\QueueDriver; use Phenix\Queue\Contracts\Queue as QueueContract; use Phenix\Queue\QueueManager; @@ -24,10 +25,15 @@ * @method static void setConnectionName(string $name) * @method static QueueContract driver(QueueDriver|null $driverName = null) * @method static void log() - * @method static void fake(string|array|null $tasks = null, int|Closure|null $times = null) - * @method static array getQueueLog() + * @method static void fake() + * @method static void fakeWhen(string $taskClass, Closure $callback) + * @method static void fakeTimes(string $taskClass, int $times) + * @method static void fakeOnce(string $taskClass) + * @method static void fakeOnly(string $taskClass) + * @method static void fakeExcept(string $taskClass) + * @method static Collection getQueueLog() * @method static void resetQueueLog() - * @method static TestQueue expect(string $taskClass) + * @method static void resetFaking() * * @see \Phenix\Queue\QueueManager */ diff --git a/src/Queue/Concerns/CaptureTasks.php b/src/Queue/Concerns/CaptureTasks.php index c74143d9..bdfde027 100644 --- a/src/Queue/Concerns/CaptureTasks.php +++ b/src/Queue/Concerns/CaptureTasks.php @@ -6,18 +6,16 @@ use Closure; use Phenix\App; +use Phenix\Data\Collection; use Phenix\Tasks\QueuableTask; +use Phenix\Testing\Constants\FakeMode; use Throwable; -use function array_is_list; - trait CaptureTasks { protected bool $logging = false; - protected bool $faking = false; - - protected bool $fakeAll = false; + protected FakeMode $fakeMode = FakeMode::NONE; /** * @var array @@ -25,9 +23,9 @@ trait CaptureTasks protected array $fakeTasks = []; /** - * @var array, task: QueuableTask, queue: string|null, connection: string|null, timestamp: float}> + * @var Collection, task: QueuableTask, queue: string|null, connection: string|null, timestamp: float}> */ - protected array $pushed = []; + protected Collection $pushed; public function log(): void { @@ -35,92 +33,136 @@ public function log(): void return; } - $this->logging = true; + $this->enableLog(); } - /** - * @param string|array, int|Closure|null>|class-string|null $tasks - * @param int|Closure|null $times - */ - public function fake(string|array|null $tasks = null, int|Closure|null $times = null): void + public function fake(): void { if (App::isProduction()) { return; } - $this->logging = true; - $this->faking = true; - $this->fakeAll = $tasks === null; + $this->enableFake(FakeMode::ALL); + } - if ($this->fakeAll) { + public function fakeWhen(string $taskClass, Closure $callback): void + { + if (App::isProduction()) { return; } - $normalized = $this->normalizeFakeTasks($tasks, $times); + $this->enableFake(FakeMode::SCOPED); - foreach ($normalized as $taskClass => $config) { - if ($config === 0) { - continue; - } + $this->fakeTasks[$taskClass] = $callback; + } - $this->fakeTasks[$taskClass] = $config; + public function fakeTimes(string $taskClass, int $times): void + { + if (App::isProduction()) { + return; } + + $this->enableFake(FakeMode::SCOPED); + + $this->fakeTasks[$taskClass] = $times; } - /** - * @return array, task: QueuableTask, queue: string|null, connection: string|null, timestamp: float}> - */ - public function getQueueLog(): array + public function fakeOnce(string $taskClass): void + { + if (App::isProduction()) { + return; + } + + $this->enableFake(FakeMode::SCOPED); + + $this->fakeTasks[$taskClass] = 1; + } + + public function fakeOnly(string $taskClass): void + { + if (App::isProduction()) { + return; + } + + $this->enableFake(FakeMode::SCOPED); + + $this->fakeTasks = [ + $taskClass => null, + ]; + } + + public function fakeExcept(string $taskClass): void { + if (App::isProduction()) { + return; + } + + $this->enableFake(FakeMode::SCOPED); + + $this->fakeTasks = [ + $taskClass => fn (Collection $log): bool => $log->filter(fn (array $entry): bool => $entry['task_class'] === $taskClass)->isEmpty(), + ]; + } + + public function getQueueLog(): Collection + { + if (! isset($this->pushed)) { + $this->pushed = Collection::fromArray([]); + } + return $this->pushed; } public function resetQueueLog(): void { - $this->pushed = []; + $this->pushed = Collection::fromArray([]); + } + + public function resetFaking(): void + { + $this->logging = false; + $this->fakeMode = FakeMode::NONE; + $this->fakeTasks = []; + $this->pushed = Collection::fromArray([]); } protected function recordPush(QueuableTask $task): void { - if (! $this->logging && ! $this->faking) { + if (! $this->logging) { return; } - $this->pushed[] = [ + $this->pushed->add([ 'task_class' => $task::class, 'task' => $task, 'queue' => $task->getQueueName(), 'connection' => $task->getConnectionName(), 'timestamp' => microtime(true), - ]; + ]); } protected function shouldFakeTask(QueuableTask $task): bool { - if (! $this->faking) { - return false; + if ($this->fakeMode === FakeMode::ALL) { + return true; } $result = false; + $class = $task::class; - if ($this->fakeAll) { - $result = true; - } else { - $class = $task::class; - - if (! empty($this->fakeTasks) && array_key_exists($class, $this->fakeTasks)) { - $config = $this->fakeTasks[$class]; - - if ($config instanceof Closure) { - try { - $result = (bool) $config($this->pushed); - } catch (Throwable $e) { - report($e); - $result = false; - } - } else { - $result = $config === null || $config > 0; + if (! empty($this->fakeTasks) && array_key_exists($class, $this->fakeTasks)) { + $config = $this->fakeTasks[$class]; + + if ($config instanceof Closure) { + try { + $result = (bool) $config($this->pushed); + } catch (Throwable $e) { + report($e); + + $result = false; } + } else { + $result = $config === null || $config > 0; } } @@ -137,11 +179,12 @@ protected function consumeFakedTask(QueuableTask $task): void $remaining = $this->fakeTasks[$class]; - if ($remaining === null || $remaining instanceof Closure) { + if (! $remaining || $remaining instanceof Closure) { return; } $remaining--; + if ($remaining <= 0) { unset($this->fakeTasks[$class]); } else { @@ -149,85 +192,17 @@ protected function consumeFakedTask(QueuableTask $task): void } } - /** - * @param string|array $tasks - * @param int|Closure|null $times - * @return array - */ - protected function normalizeFakeTasks(string|array $tasks, int|Closure|null $times): array - { - if (is_string($tasks)) { - return $this->normalizeSingleTask($tasks, $times); - } - - if (array_is_list($tasks)) { - return $this->normalizeListTasks($tasks); - } - - return $this->normalizeMapTasks($tasks); - } - - /** - * @return array - */ - private function normalizeSingleTask(string $taskClass, int|Closure|null $times): array - { - $config = 1; - - if ($times instanceof Closure) { - $config = $times; - } elseif (is_int($times)) { - $config = max(0, abs($times)); - } - - return [$taskClass => $config]; - } - - /** - * @param array $tasks - * @return array - */ - private function normalizeListTasks(array $tasks): array + protected function enableLog(): void { - $normalized = []; - - foreach ($tasks as $class) { - $normalized[$class] = 1; + if (! $this->logging) { + $this->logging = true; + $this->pushed = Collection::fromArray([]); } - - return $normalized; } - /** - * @param array $tasks - * @return array - */ - private function normalizeMapTasks(array $tasks): array + protected function enableFake(FakeMode $fakeMode): void { - $normalized = []; - - foreach ($tasks as $class => $value) { - if (is_int($class)) { - $normalized[(string) $value] = 1; - - continue; - } - - if ($value instanceof Closure) { - $normalized[$class] = $value; - - continue; - } - - if (is_int($value)) { - $normalized[$class] = max(0, abs($value)); - - continue; - } - - $normalized[$class] = ($value === null) ? null : 1; - } - - return $normalized; + $this->enableLog(); + $this->fakeMode = $fakeMode; } } diff --git a/src/Testing/TestQueue.php b/src/Testing/TestQueue.php index cab98fe5..c954ff49 100644 --- a/src/Testing/TestQueue.php +++ b/src/Testing/TestQueue.php @@ -11,16 +11,14 @@ class TestQueue { - public readonly Collection $log; - /** - * @param array, task: QueuableTask, queue: string|null, connection: string|null, timestamp: float}> $log + * @param class-string $taskClass + * @param Collection, task: QueuableTask, queue: string|null, connection: string|null, timestamp: float}> $log */ public function __construct( protected string $taskClass, - array $log = [] + public readonly Collection $log ) { - $this->log = Collection::fromArray($log); } public function toBePushed(Closure|null $closure = null): void @@ -69,14 +67,11 @@ public function toPushNothing(): void private function filterByTaskClass(string $taskClass): Collection { - $filtered = []; - - foreach ($this->log as $record) { - if ($record['task_class'] === $taskClass) { - $filtered[] = $record; - } - } + /** @var Collection, task: QueuableTask, queue: string|null, connection: string|null, timestamp: float}> $filtered */ + $filtered = $this->log->filter(function (array $record) use ($taskClass): bool { + return $record['task_class'] === $taskClass; + }); - return Collection::fromArray($filtered); + return $filtered; } } diff --git a/tests/Unit/Queue/ParallelQueueTest.php b/tests/Unit/Queue/ParallelQueueTest.php index e39d20ff..9df98df8 100644 --- a/tests/Unit/Queue/ParallelQueueTest.php +++ b/tests/Unit/Queue/ParallelQueueTest.php @@ -553,7 +553,7 @@ it('does not fake tasks in production environment', function (): void { Config::set('app.env', 'production'); - Queue::fake(BasicQueuableTask::class); + Queue::fake(); Queue::push(new BasicQueuableTask()); Queue::push(new BasicQueuableTask()); @@ -609,7 +609,7 @@ }); it('fakes only specific tasks and consumes them after first fake', function (): void { - Queue::fake([BasicQueuableTask::class]); + Queue::fakeOnce(BasicQueuableTask::class); Queue::push(new BasicQueuableTask()); // faked Queue::expect(BasicQueuableTask::class)->toBePushedTimes(1); @@ -623,7 +623,7 @@ }); it('fakes a task multiple times using times parameter', function (): void { - Queue::fake(BasicQueuableTask::class, 2); + Queue::fakeTimes(BasicQueuableTask::class, 2); Queue::push(new BasicQueuableTask()); // faked $this->assertSame(0, Queue::size()); @@ -638,9 +638,7 @@ }); it('fakes tasks with per-task counts array', function (): void { - Queue::fake([ - BasicQueuableTask::class => 2, - ]); + Queue::fakeTimes(BasicQueuableTask::class, 2); Queue::push(new BasicQueuableTask()); // faked Queue::push(new BasicQueuableTask()); // faked @@ -653,11 +651,9 @@ }); it('conditionally fakes tasks using array and a closure configuration', function (): void { - Queue::fake([ - BasicQueuableTask::class => function (array $log): bool { - return count($log) <= 3; - }, - ]); + Queue::fakeWhen(BasicQueuableTask::class, function ($log) { + return $log->count() <= 3; + }); for ($i = 0; $i < 5; $i++) { Queue::push(new BasicQueuableTask()); @@ -669,8 +665,8 @@ }); it('conditionally fakes tasks using only a closure configuration', function (): void { - Queue::fake(BasicQueuableTask::class, function (array $log): bool { - return count($log) <= 2; + Queue::fakeWhen(BasicQueuableTask::class, function ($log) { + return $log->count() <= 2; }); for ($i = 0; $i < 4; $i++) { @@ -683,7 +679,7 @@ }); it('does not fake when closure throws an exception', function (): void { - Queue::fake(BasicQueuableTask::class, function (array $log): bool { + Queue::fakeWhen(BasicQueuableTask::class, function ($log) { throw new RuntimeException('Closure exception'); }); From 8c5c0c1d5270abdd1051d0015ac75deb9be3a9d5 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Tue, 21 Oct 2025 20:32:27 -0500 Subject: [PATCH 45/68] refactor(Mail): rename log method to fake for clarity and consistency --- src/Facades/Mail.php | 2 +- src/Mail/MailManager.php | 2 +- tests/Unit/Mail/MailTest.php | 20 ++++++++++---------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Facades/Mail.php b/src/Facades/Mail.php index 3c47fc6d..48c48fb5 100644 --- a/src/Facades/Mail.php +++ b/src/Facades/Mail.php @@ -15,7 +15,7 @@ * @method static \Phenix\Mail\Contracts\Mailer using(MailerType $mailerType) * @method static \Phenix\Mail\Contracts\Mailer to(array|string $to) * @method static void send(\Phenix\Mail\Contracts\Mailable $mailable) - * @method static \Phenix\Mail\Contracts\Mailer log(\Phenix\Mail\Constants\MailerType|null $mailerType = null) + * @method static \Phenix\Mail\Contracts\Mailer fake(\Phenix\Mail\Constants\MailerType|null $mailerType = null) * @method static TestMail expect(MailableContract|string $mailable, MailerType|null $mailerType = null) * * @see \Phenix\Mail\MailManager diff --git a/src/Mail/MailManager.php b/src/Mail/MailManager.php index 1311c4f1..4c7c93f9 100644 --- a/src/Mail/MailManager.php +++ b/src/Mail/MailManager.php @@ -49,7 +49,7 @@ public function send(Mailable $mailable): void $this->mailer()->send($mailable); } - public function log(MailerType|null $mailerType = null): void + public function fake(MailerType|null $mailerType = null): void { $mailerType ??= MailerType::from($this->config->default()); diff --git a/tests/Unit/Mail/MailTest.php b/tests/Unit/Mail/MailTest.php index fd46df42..58a54ce1 100644 --- a/tests/Unit/Mail/MailTest.php +++ b/tests/Unit/Mail/MailTest.php @@ -128,7 +128,7 @@ 'password' => 'password', ]); - Mail::log(); + Mail::fake(); $email = faker()->freeEmail(); @@ -164,7 +164,7 @@ public function build(): self 'password' => 'password', ]); - Mail::log(); + Mail::fake(); $email = faker()->freeEmail(); @@ -200,7 +200,7 @@ public function build(): self 'password' => 'password', ]); - Mail::log(); + Mail::fake(); $mailable = new class () extends Mailable { public function build(): self @@ -226,7 +226,7 @@ public function build(): self 'password' => 'password', ]); - Mail::log(); + Mail::fake(); $email = faker()->freeEmail(); @@ -265,7 +265,7 @@ public function build(): self 'password' => 'password', ]); - Mail::log(); + Mail::fake(); $to = faker()->freeEmail(); $cc = faker()->freeEmail(); @@ -307,7 +307,7 @@ public function build(): self 'password' => 'password', ]); - Mail::log(); + Mail::fake(); $to = faker()->freeEmail(); $bcc = faker()->freeEmail(); @@ -349,7 +349,7 @@ public function build(): self 'password' => 'password', ]); - Mail::log(); + Mail::fake(); $to = faker()->freeEmail(); @@ -389,7 +389,7 @@ public function build(): self 'password' => 'password', ]); - Mail::log(); + Mail::fake(); $to = faker()->freeEmail(); $mailable = new class () extends Mailable { @@ -442,7 +442,7 @@ public function build(): self 'password' => 'password', ]); - Mail::log(); + Mail::fake(); $to = faker()->freeEmail(); $mailable = new class () extends Mailable { @@ -511,7 +511,7 @@ public function build(): self 'password' => 'password', ]); - Mail::log(); + Mail::fake(); $to = faker()->freeEmail(); From 1f39dd1b4243dfc324879bd967f6f77d6f624691 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Tue, 21 Oct 2025 20:32:34 -0500 Subject: [PATCH 46/68] refactor(TestEvent, TestQueue): simplify filtering logic using arrow functions --- src/Testing/TestEvent.php | 7 ++++--- src/Testing/TestQueue.php | 4 +--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/Testing/TestEvent.php b/src/Testing/TestEvent.php index 7eb9d777..32dc06c2 100644 --- a/src/Testing/TestEvent.php +++ b/src/Testing/TestEvent.php @@ -52,8 +52,9 @@ public function toDispatchNothing(): void private function filterByName(string $event): Collection { - return $this->log->filter(function (array $record) use ($event): bool { - return $record['name'] === $event; - }); + /** @var Collection $filtered */ + $filtered = $this->log->filter(fn (array $record) => $record['name'] === $event); + + return $filtered; } } diff --git a/src/Testing/TestQueue.php b/src/Testing/TestQueue.php index c954ff49..74d75241 100644 --- a/src/Testing/TestQueue.php +++ b/src/Testing/TestQueue.php @@ -68,9 +68,7 @@ public function toPushNothing(): void private function filterByTaskClass(string $taskClass): Collection { /** @var Collection, task: QueuableTask, queue: string|null, connection: string|null, timestamp: float}> $filtered */ - $filtered = $this->log->filter(function (array $record) use ($taskClass): bool { - return $record['task_class'] === $taskClass; - }); + $filtered = $this->log->filter(fn (array $record) => $record['task_class'] === $taskClass); return $filtered; } From 135b1c56fff4962fb40728511f89755bfff6f022 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 22 Oct 2025 15:26:21 -0500 Subject: [PATCH 47/68] refactor(Collection): enhance collection methods with filter, map, where, sort, diff, intersect, and merge functionalities --- src/Data/Collection.php | 191 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) diff --git a/src/Data/Collection.php b/src/Data/Collection.php index a3a2200d..6ab83799 100644 --- a/src/Data/Collection.php +++ b/src/Data/Collection.php @@ -4,12 +4,30 @@ namespace Phenix\Data; +use Closure; use Phenix\Contracts\Arrayable; use Ramsey\Collection\Collection as GenericCollection; +use Ramsey\Collection\CollectionInterface; +use Ramsey\Collection\Exception\CollectionMismatchException; +use Ramsey\Collection\Sort; use SplFixedArray; +use function array_filter; use function array_key_first; +use function array_map; +use function array_merge; +use function array_udiff; +use function array_uintersect; +use function is_int; +use function is_object; +use function spl_object_id; +use function sprintf; +use function usort; +/** + * @template T + * @extends GenericCollection + */ class Collection extends GenericCollection implements Arrayable { public static function fromArray(array $data): self @@ -34,4 +52,177 @@ public function first(): mixed return $this->data[$firstIndex]; } + + /** + * @param callable(T): bool $callback + * + * @return self + */ + public function filter(callable $callback): self + { + $collection = clone $this; + $collection->data = array_merge([], array_filter($collection->data, $callback)); + + return $collection; + } + + /** + * @param callable(T): TCallbackReturn $callback + * + * @return self + * + * @template TCallbackReturn + */ + public function map(callable $callback): self + { + return new self('mixed', array_map($callback, $this->data)); + } + + /** + * @param string|null $propertyOrMethod + * @param mixed $value + * + * @return self + */ + public function where(string|null $propertyOrMethod, mixed $value): self + { + return $this->filter( + fn (mixed $item): bool => $this->extractValue($item, $propertyOrMethod) === $value, + ); + } + + /** + * @param string|null $propertyOrMethod + * @param Sort $order + * + * @return self + */ + public function sort(string|null $propertyOrMethod = null, Sort $order = Sort::Ascending): self + { + $collection = clone $this; + + usort( + $collection->data, + function (mixed $a, mixed $b) use ($propertyOrMethod, $order): int { + $aValue = $this->extractValue($a, $propertyOrMethod); + $bValue = $this->extractValue($b, $propertyOrMethod); + + return ($aValue <=> $bValue) * ($order === Sort::Descending ? -1 : 1); + }, + ); + + return $collection; + } + + /** + * @param CollectionInterface $other + * + * @return self + */ + public function diff(CollectionInterface $other): self + { + $this->compareCollectionTypes($other); + + $diffAtoB = array_udiff($this->data, $other->toArray(), $this->getComparator()); + $diffBtoA = array_udiff($other->toArray(), $this->data, $this->getComparator()); + + $collection = clone $this; + $collection->data = array_merge($diffAtoB, $diffBtoA); + + return $collection; + } + + /** + * @param CollectionInterface $other + * + * @return self + */ + public function intersect(CollectionInterface $other): self + { + $this->compareCollectionTypes($other); + + $collection = clone $this; + $collection->data = array_uintersect($this->data, $other->toArray(), $this->getComparator()); + + return $collection; + } + + /** + * @param CollectionInterface ...$collections + * + * @return self + */ + public function merge(CollectionInterface ...$collections): self + { + $mergedCollection = clone $this; + + foreach ($collections as $index => $collection) { + if (! $collection instanceof static) { + throw new CollectionMismatchException( + sprintf('Collection with index %d must be of type %s', $index, static::class), + ); + } + + if ($this->getUniformType($collection) !== $this->getUniformType($this)) { + throw new CollectionMismatchException( + sprintf( + 'Collection items in collection with index %d must be of type %s', + $index, + $this->getType(), + ), + ); + } + + foreach ($collection as $key => $value) { + if (is_int($key)) { + $mergedCollection[] = $value; + } else { + $mergedCollection[$key] = $value; + } + } + } + + return $mergedCollection; + } + + /** + * @param CollectionInterface $other + * + * @throws CollectionMismatchException + */ + private function compareCollectionTypes(CollectionInterface $other): void + { + if (! $other instanceof static) { + throw new CollectionMismatchException('Collection must be of type ' . static::class); + } + + if ($this->getUniformType($other) !== $this->getUniformType($this)) { + throw new CollectionMismatchException('Collection items must be of type ' . $this->getType()); + } + } + + private function getComparator(): Closure + { + return function (mixed $a, mixed $b): int { + if (is_object($a) && is_object($b)) { + $a = spl_object_id($a); + $b = spl_object_id($b); + } + + return $a === $b ? 0 : ($a < $b ? 1 : -1); + }; + } + + /** + * @param CollectionInterface $collection + */ + private function getUniformType(CollectionInterface $collection): string + { + return match ($collection->getType()) { + 'integer' => 'int', + 'boolean' => 'bool', + 'double' => 'float', + default => $collection->getType(), + }; + } } From e290d7b6045da251fe33dae75ef696464808e223 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 22 Oct 2025 15:26:42 -0500 Subject: [PATCH 48/68] refactor(DatabaseQueryBuilder, QueryBuilder): update collection type annotations for clarity --- .../Models/QueryBuilders/DatabaseQueryBuilder.php | 14 +++++++------- src/Database/QueryBuilder.php | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php index cb4df29a..cd534151 100644 --- a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php +++ b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php @@ -139,7 +139,7 @@ public function with(array|string $relationships): self } /** - * @return Collection + * @return Collection */ public function get(): Collection { @@ -215,7 +215,7 @@ protected function resolveRelationships(Collection $collection): void } /** - * @param Collection $models + * @param Collection $models * @param BelongsTo $relationship * @param Closure $closure */ @@ -226,7 +226,7 @@ protected function resolveBelongsToRelationship( ): void { $closure($relationship); - /** @var Collection $records */ + /** @var Collection $records */ $records = $relationship->query() ->whereIn($relationship->getForeignKey()->getColumnName(), $models->modelKeys()) ->get(); @@ -243,7 +243,7 @@ protected function resolveBelongsToRelationship( } /** - * @param Collection $models + * @param Collection $models * @param HasMany $relationship * @param Closure $closure */ @@ -254,7 +254,7 @@ protected function resolveHasManyRelationship( ): void { $closure($relationship); - /** @var Collection $children */ + /** @var Collection $children */ $children = $relationship->query() ->whereIn($relationship->getProperty()->getAttribute()->foreignKey, $models->modelKeys()) ->get(); @@ -284,7 +284,7 @@ protected function resolveHasManyRelationship( } /** - * @param Collection $models + * @param Collection $models * @param BelongsToMany $relationship * @param Closure $closure */ @@ -297,7 +297,7 @@ protected function resolveBelongsToManyRelationship( $attr = $relationship->getProperty()->getAttribute(); - /** @var Collection $related */ + /** @var Collection $related */ $related = $relationship->query() ->addSelect($relationship->getColumns()) ->innerJoin($attr->table, function (Join $join) use ($attr): void { diff --git a/src/Database/QueryBuilder.php b/src/Database/QueryBuilder.php index e03408e5..54fdc4ae 100644 --- a/src/Database/QueryBuilder.php +++ b/src/Database/QueryBuilder.php @@ -68,7 +68,7 @@ public function connection(SqlCommonConnectionPool|string $connection): self } /** - * @return Collection + * @return Collection> */ public function get(): Collection { From 5fb592c9c1526d8c493d9e3a8f654268da56e7cb Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 22 Oct 2025 15:26:55 -0500 Subject: [PATCH 49/68] refactor(CaptureEvents, CaptureTasks): update timestamp type annotations to use Date instead of float --- src/Events/Concerns/CaptureEvents.php | 2 +- src/Queue/Concerns/CaptureTasks.php | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Events/Concerns/CaptureEvents.php b/src/Events/Concerns/CaptureEvents.php index f93d1584..356ae545 100644 --- a/src/Events/Concerns/CaptureEvents.php +++ b/src/Events/Concerns/CaptureEvents.php @@ -24,7 +24,7 @@ trait CaptureEvents protected array $fakeEvents = []; /** - * @var Collection + * @var Collection */ protected Collection $dispatched; diff --git a/src/Queue/Concerns/CaptureTasks.php b/src/Queue/Concerns/CaptureTasks.php index bdfde027..185af22b 100644 --- a/src/Queue/Concerns/CaptureTasks.php +++ b/src/Queue/Concerns/CaptureTasks.php @@ -9,6 +9,7 @@ use Phenix\Data\Collection; use Phenix\Tasks\QueuableTask; use Phenix\Testing\Constants\FakeMode; +use Phenix\Util\Date; use Throwable; trait CaptureTasks @@ -23,7 +24,7 @@ trait CaptureTasks protected array $fakeTasks = []; /** - * @var Collection, task: QueuableTask, queue: string|null, connection: string|null, timestamp: float}> + * @var Collection, task: QueuableTask, queue: string|null, connection: string|null, timestamp: Date}> */ protected Collection $pushed; @@ -137,7 +138,7 @@ protected function recordPush(QueuableTask $task): void 'task' => $task, 'queue' => $task->getQueueName(), 'connection' => $task->getConnectionName(), - 'timestamp' => microtime(true), + 'timestamp' => Date::now(), ]); } From 89a5596fce9c7599116ab9022b1a813d20e794e6 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 22 Oct 2025 15:27:10 -0500 Subject: [PATCH 50/68] refactor(TestQueue): update timestamp type annotations to use float instead of Date for consistency --- src/Testing/TestEvent.php | 5 +---- src/Testing/TestQueue.php | 7 ++----- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/Testing/TestEvent.php b/src/Testing/TestEvent.php index 32dc06c2..18490826 100644 --- a/src/Testing/TestEvent.php +++ b/src/Testing/TestEvent.php @@ -52,9 +52,6 @@ public function toDispatchNothing(): void private function filterByName(string $event): Collection { - /** @var Collection $filtered */ - $filtered = $this->log->filter(fn (array $record) => $record['name'] === $event); - - return $filtered; + return $this->log->filter(fn (array $record) => $record['name'] === $event); } } diff --git a/src/Testing/TestQueue.php b/src/Testing/TestQueue.php index 74d75241..d8b93bf8 100644 --- a/src/Testing/TestQueue.php +++ b/src/Testing/TestQueue.php @@ -13,7 +13,7 @@ class TestQueue { /** * @param class-string $taskClass - * @param Collection, task: QueuableTask, queue: string|null, connection: string|null, timestamp: float}> $log + * @param Collection, task: QueuableTask, queue: string|null, connection: string|null, timestamp: float}> $log */ public function __construct( protected string $taskClass, @@ -67,9 +67,6 @@ public function toPushNothing(): void private function filterByTaskClass(string $taskClass): Collection { - /** @var Collection, task: QueuableTask, queue: string|null, connection: string|null, timestamp: float}> $filtered */ - $filtered = $this->log->filter(fn (array $record) => $record['task_class'] === $taskClass); - - return $filtered; + return $this->log->filter(fn (array $record) => $record['task_class'] === $taskClass); } } From 9e886fe9d29b55ce6086e3b9fa1acfbc0562bdee Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 22 Oct 2025 17:32:24 -0500 Subject: [PATCH 51/68] refactor(Collection): simplify fromArray method and enhance getDataType for type detection --- src/Data/Collection.php | 34 ++++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/src/Data/Collection.php b/src/Data/Collection.php index 6ab83799..436455fc 100644 --- a/src/Data/Collection.php +++ b/src/Data/Collection.php @@ -32,12 +32,8 @@ class Collection extends GenericCollection implements Arrayable { public static function fromArray(array $data): self { - $data = SplFixedArray::fromArray($data); - $collection = new self('array'); - - foreach ($data as $value) { - $collection->add($value); - } + $collection = new self(self::getDataType($data)); + $collection->data = $data; return $collection; } @@ -225,4 +221,30 @@ private function getUniformType(CollectionInterface $collection): string default => $collection->getType(), }; } + + /** + * @param array $data + * + * @return string + */ + private static function getDataType(array $data): string + { + if (empty($data)) { + return 'mixed'; + } + + $firstType = gettype(reset($data)); + + if (count($data) === 1) { + return $firstType; + } + + foreach ($data as $item) { + if (gettype($item) !== $firstType) { + return 'mixed'; + } + } + + return $firstType; + } } From 8a29e9a4d9479749038ad0c35b837ecc2bf6ccbd Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 22 Oct 2025 17:34:09 -0500 Subject: [PATCH 52/68] tests: add comprehensive tests for filter, map, where, sort, diff, intersect, and merge functionalities --- tests/Unit/Data/CollectionTest.php | 327 +++++++++++++++++++++++++++++ 1 file changed, 327 insertions(+) diff --git a/tests/Unit/Data/CollectionTest.php b/tests/Unit/Data/CollectionTest.php index 2ecdde2f..88815b35 100644 --- a/tests/Unit/Data/CollectionTest.php +++ b/tests/Unit/Data/CollectionTest.php @@ -3,6 +3,8 @@ declare(strict_types=1); use Phenix\Data\Collection; +use Ramsey\Collection\Exception\CollectionMismatchException; +use Ramsey\Collection\Sort; it('creates collection from array', function () { $collection = Collection::fromArray([['name' => 'John']]); @@ -22,3 +24,328 @@ expect($collection->first())->toBeNull(); }); + +it('filters items based on callback', function () { + $collection = Collection::fromArray([ + ['name' => 'John', 'age' => 25], + ['name' => 'Jane', 'age' => 30], + ['name' => 'Bob', 'age' => 20], + ]); + + $filtered = $collection->filter(fn (array $item) => $item['age'] >= 25); + + expect($filtered)->toBeInstanceOf(Collection::class); + expect($filtered->count())->toBe(2); + expect($filtered->first()['name'])->toBe('John'); +}); + +it('filter returns empty collection when no items match', function () { + $collection = Collection::fromArray([ + ['name' => 'John', 'age' => 25], + ['name' => 'Jane', 'age' => 30], + ]); + + $filtered = $collection->filter(fn (array $item) => $item['age'] > 50); + + expect($filtered)->toBeInstanceOf(Collection::class); + expect($filtered->isEmpty())->toBe(true); +}); + +it('filter returns new collection instance', function () { + $collection = Collection::fromArray([['name' => 'John']]); + $filtered = $collection->filter(fn (array $item) => true); + + expect($filtered)->toBeInstanceOf(Collection::class); + expect($filtered)->not()->toBe($collection); +}); + +it('transforms items based on callback', function () { + $collection = Collection::fromArray([ + ['name' => 'John', 'age' => 25], + ['name' => 'Jane', 'age' => 30], + ]); + + $mapped = $collection->map(fn (array $item) => $item['name']); + + expect($mapped)->toBeInstanceOf(Collection::class); + expect($mapped->count())->toBe(2); + expect($mapped->first())->toBe('John'); +}); + +it('map can transform to different types', function () { + $collection = Collection::fromArray([1, 2, 3]); + $mapped = $collection->map(fn (int $num) => ['value' => $num * 2]); + + expect($mapped)->toBeInstanceOf(Collection::class); + expect($mapped->first())->toBe(['value' => 2]); +}); + +it('map returns new collection instance', function () { + $collection = Collection::fromArray([1, 2, 3]); + $mapped = $collection->map(fn (int $num) => $num); + + expect($mapped)->toBeInstanceOf(Collection::class); + expect($mapped)->not()->toBe($collection); +}); + +it('filters by property value using where', function () { + $collection = Collection::fromArray([ + ['name' => 'John', 'role' => 'admin'], + ['name' => 'Jane', 'role' => 'user'], + ['name' => 'Bob', 'role' => 'admin'], + ]); + + $admins = $collection->where('role', 'admin'); + + expect($admins)->toBeInstanceOf(Collection::class); + expect($admins->count())->toBe(2); + expect($admins->first()['name'])->toBe('John'); +}); + +it('where returns empty collection when no matches', function () { + $collection = Collection::fromArray([ + ['name' => 'John', 'role' => 'admin'], + ['name' => 'Jane', 'role' => 'user'], + ]); + + $guests = $collection->where('role', 'guest'); + + expect($guests)->toBeInstanceOf(Collection::class); + expect($guests->isEmpty())->toBe(true); +}); + +it('where returns new collection instance', function () { + $collection = Collection::fromArray([['name' => 'John', 'role' => 'admin']]); + $filtered = $collection->where('role', 'admin'); + + expect($filtered)->toBeInstanceOf(Collection::class); + expect($filtered)->not()->toBe($collection); +}); + +it('sorts items by property in ascending order', function () { + $collection = Collection::fromArray([ + ['name' => 'John', 'age' => 30], + ['name' => 'Jane', 'age' => 25], + ['name' => 'Bob', 'age' => 35], + ]); + + $sorted = $collection->sort('age'); + + expect($sorted)->toBeInstanceOf(Collection::class); + expect($sorted->first()['name'])->toBe('Jane'); + expect($sorted->last()['name'])->toBe('Bob'); +}); + +it('sorts items by property in descending order', function () { + $collection = Collection::fromArray([ + ['name' => 'John', 'age' => 30], + ['name' => 'Jane', 'age' => 25], + ['name' => 'Bob', 'age' => 35], + ]); + + $sorted = $collection->sort('age', Sort::Descending); + + expect($sorted)->toBeInstanceOf(Collection::class); + expect($sorted->first()['name'])->toBe('Bob'); + expect($sorted->last()['name'])->toBe('Jane'); +}); + +it('sorts items without property when comparing elements directly', function () { + $collection = new Collection('integer', [3, 1, 4, 1, 5, 9, 2, 6]); + $sorted = $collection->sort(); + + expect($sorted)->toBeInstanceOf(Collection::class); + expect($sorted->first())->toBe(1); + expect($sorted->last())->toBe(9); +}); + +it('sort returns new collection instance', function () { + $collection = new Collection('integer', [3, 1, 2]); + $sorted = $collection->sort(); + + expect($sorted)->toBeInstanceOf(Collection::class); + expect($sorted)->not()->toBe($collection); +}); + +it('returns divergent items between collections', function () { + $collection1 = Collection::fromArray([1, 2, 3, 4]); + $collection2 = Collection::fromArray([3, 4, 5, 6]); + + $diff = $collection1->diff($collection2); + + expect($diff)->toBeInstanceOf(Collection::class); + expect($diff->count())->toBe(4); // 1, 2, 5, 6 + expect($diff->contains(1))->toBe(true); + expect($diff->contains(2))->toBe(true); + expect($diff->contains(5))->toBe(true); + expect($diff->contains(6))->toBe(true); +}); + +it('diff returns empty collection when collections are identical', function () { + $collection1 = Collection::fromArray([1, 2, 3]); + $collection2 = Collection::fromArray([1, 2, 3]); + + $diff = $collection1->diff($collection2); + + expect($diff)->toBeInstanceOf(Collection::class); + expect($diff->isEmpty())->toBe(true); +}); + +it('diff returns new collection instance', function () { + $collection1 = Collection::fromArray([1, 2, 3]); + $collection2 = Collection::fromArray([2, 3, 4]); + + $diff = $collection1->diff($collection2); + + expect($diff)->toBeInstanceOf(Collection::class); + expect($diff)->not()->toBe($collection1); + expect($diff)->not()->toBe($collection2); +}); + +// Intersect tests +it('returns intersecting items between collections', function () { + $collection1 = new Collection('integer', [1, 2, 3, 4]); + $collection2 = new Collection('integer', [3, 4, 5, 6]); + + $intersect = $collection1->intersect($collection2); + + expect($intersect)->toBeInstanceOf(Collection::class); + expect($intersect->count())->toBe(2); // 3, 4 + expect($intersect->contains(3))->toBe(true); + expect($intersect->contains(4))->toBe(true); +}); + +it('intersect returns empty collection when no intersection exists', function () { + $collection1 = new Collection('integer', [1, 2, 3]); + $collection2 = new Collection('integer', [4, 5, 6]); + + $intersect = $collection1->intersect($collection2); + + expect($intersect)->toBeInstanceOf(Collection::class); + expect($intersect->isEmpty())->toBe(true); +}); + +it('intersect returns new collection instance', function () { + $collection1 = new Collection('integer', [1, 2, 3]); + $collection2 = new Collection('integer', [2, 3, 4]); + + $intersect = $collection1->intersect($collection2); + + expect($intersect)->toBeInstanceOf(Collection::class); + expect($intersect)->not()->toBe($collection1); + expect($intersect)->not()->toBe($collection2); +}); + +it('merges multiple collections', function () { + $collection1 = Collection::fromArray([1, 2, 3]); + $collection2 = Collection::fromArray([4, 5]); + $collection3 = Collection::fromArray([6, 7]); + + $merged = $collection1->merge($collection2, $collection3); + + expect($merged)->toBeInstanceOf(Collection::class); + expect($merged->count())->toBe(7); + expect($merged->contains(1))->toBe(true); + expect($merged->contains(7))->toBe(true); +}); + +it('merges collections with array keys', function () { + $collection1 = new Collection('array', ['a' => ['name' => 'John']]); + $collection2 = new Collection('array', ['b' => ['name' => 'Jane']]); + + $merged = $collection1->merge($collection2); + + expect($merged)->toBeInstanceOf(Collection::class); + expect($merged->count())->toBe(2); + expect($merged->offsetExists('a'))->toBe(true); + expect($merged->offsetExists('b'))->toBe(true); +}); + +it('merge returns new collection instance', function () { + $collection1 = Collection::fromArray([1, 2]); + $collection2 = Collection::fromArray([3, 4]); + + $merged = $collection1->merge($collection2); + + expect($merged)->toBeInstanceOf(Collection::class); + expect($merged)->not()->toBe($collection1); + expect($merged)->not()->toBe($collection2); +}); + +it('merge throws exception when merging incompatible collection types', function () { + $collection1 = Collection::fromArray([1, 2, 3]); + $collection2 = new Collection('string', ['a', 'b', 'c']); + + $collection1->merge($collection2); +})->throws(CollectionMismatchException::class); + +it('allows fluent method chaining', function () { + $collection = Collection::fromArray([ + ['name' => 'John', 'age' => 30, 'role' => 'admin'], + ['name' => 'Jane', 'age' => 25, 'role' => 'user'], + ['name' => 'Bob', 'age' => 35, 'role' => 'admin'], + ['name' => 'Alice', 'age' => 28, 'role' => 'user'], + ]); + + $result = $collection + ->filter(fn (array $item) => $item['age'] >= 28) + ->where('role', 'admin') + ->sort('age', Sort::Descending) + ->map(fn (array $item) => $item['name']); + + expect($result)->toBeInstanceOf(Collection::class); + expect($result->count())->toBe(2); + expect($result->first())->toBe('Bob'); +}); + +it('efficiently detects homogeneous array types', function () { + $largeArray = array_fill(0, 10000, ['key' => 'value']); + + $start = microtime(true); + $collection = Collection::fromArray($largeArray); + $duration = microtime(true) - $start; + + expect($collection)->toBeInstanceOf(Collection::class); + expect($collection->getType())->toBe('array'); + expect($duration)->toBeLessThan(0.5); // Should complete in less than 500ms +}); + +it('efficiently detects mixed array types', function () { + $mixedArray = [1, 'string', 3.14, true, ['array']]; + + $collection = Collection::fromArray($mixedArray); + + expect($collection)->toBeInstanceOf(Collection::class); + expect($collection->getType())->toBe('mixed'); +}); + +it('handles empty arrays efficiently', function () { + $collection = Collection::fromArray([]); + + expect($collection)->toBeInstanceOf(Collection::class); + expect($collection->isEmpty())->toBe(true); + expect($collection->getType())->toBe('mixed'); +}); + +it('detects type from single element', function () { + $collection = Collection::fromArray([42]); + + expect($collection)->toBeInstanceOf(Collection::class); + expect($collection->getType())->toBe('integer'); + expect($collection->count())->toBe(1); +}); + +it('stops checking types early when mixed is detected', function () { + $array = [1, 'two']; + for ($i = 0; $i < 10000; $i++) { + $array[] = $i; + } + + $start = microtime(true); + $collection = Collection::fromArray($array); + $duration = microtime(true) - $start; + + expect($collection->getType())->toBe('mixed'); + expect($duration)->toBeLessThan(0.1); +}); + From 4b85057a6339ed19237bcba389a36d6f56df727a Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 22 Oct 2025 17:42:41 -0500 Subject: [PATCH 53/68] tests: add logging verification for pushed tasks in the parallel queue --- tests/Unit/Queue/ParallelQueueTest.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/Unit/Queue/ParallelQueueTest.php b/tests/Unit/Queue/ParallelQueueTest.php index 9df98df8..16043755 100644 --- a/tests/Unit/Queue/ParallelQueueTest.php +++ b/tests/Unit/Queue/ParallelQueueTest.php @@ -536,6 +536,22 @@ Queue::expect(BasicQueuableTask::class)->toBePushed(); Queue::expect(BasicQueuableTask::class)->toBePushedTimes(1); + + expect(Queue::getQueueLog()->count())->toBe(1); + + Queue::resetQueueLog(); + + expect(Queue::getQueueLog()->count())->toBe(0); + + Queue::push(new BasicQueuableTask()); + + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(1); + + Queue::resetFaking(); + + Queue::push(new BasicQueuableTask()); + + expect(Queue::getQueueLog()->count())->toBe(1); }); it('does not log pushes in production environment', function (): void { From 064547f09e126ecef815a40ffde23feb2681c80c Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Wed, 22 Oct 2025 17:47:57 -0500 Subject: [PATCH 54/68] tests: add fake queue expectations for BasicQueuableTask and BadTask --- tests/Unit/Queue/ParallelQueueTest.php | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/Unit/Queue/ParallelQueueTest.php b/tests/Unit/Queue/ParallelQueueTest.php index 16043755..b5e049f9 100644 --- a/tests/Unit/Queue/ParallelQueueTest.php +++ b/tests/Unit/Queue/ParallelQueueTest.php @@ -578,6 +578,26 @@ Queue::expect(BasicQueuableTask::class)->toPushNothing(); + Queue::fakeOnce(BasicQueuableTask::class); + + Queue::push(new BasicQueuableTask()); + + Queue::expect(BasicQueuableTask::class)->toPushNothing(); + + Queue::fakeOnly(BasicQueuableTask::class); + + Queue::push(new BasicQueuableTask()); + + Queue::expect(BasicQueuableTask::class)->toPushNothing(); + + Queue::fakeExcept(BasicQueuableTask::class); + + Queue::push(new BadTask()); + Queue::push(new BasicQueuableTask()); + + Queue::expect(BadTask::class)->toPushNothing(); + Queue::expect(BasicQueuableTask::class)->toPushNothing(); + Config::set('app.env', 'local'); Queue::clear(); }); From da7fcc2bd437efd071e88b543a80ff6d68b99dd4 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 23 Oct 2025 11:49:34 -0500 Subject: [PATCH 55/68] tests: enhance task faking methods in ParallelQueueTest for improved specificity --- tests/Unit/Queue/ParallelQueueTest.php | 33 ++++++++++++++++++++------ 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/tests/Unit/Queue/ParallelQueueTest.php b/tests/Unit/Queue/ParallelQueueTest.php index b5e049f9..002c9ba5 100644 --- a/tests/Unit/Queue/ParallelQueueTest.php +++ b/tests/Unit/Queue/ParallelQueueTest.php @@ -644,18 +644,37 @@ Queue::expect(BasicQueuableTask::class)->toPushNothing(); }); -it('fakes only specific tasks and consumes them after first fake', function (): void { - Queue::fakeOnce(BasicQueuableTask::class); +it('fakeOnly fakes only the specified task class', function (): void { + Queue::fakeOnly(BasicQueuableTask::class); - Queue::push(new BasicQueuableTask()); // faked + Queue::push(new BasicQueuableTask()); Queue::expect(BasicQueuableTask::class)->toBePushedTimes(1); - $this->assertSame(0, Queue::size()); + expect(Queue::size())->toBe(0); - Queue::push(new BasicQueuableTask()); // now enqueued - Queue::expect(BasicQueuableTask::class)->toBePushedTimes(2); + Queue::push(new BadTask()); + Queue::expect(BadTask::class)->toBePushedTimes(1); - $this->assertSame(1, Queue::size()); + expect(Queue::size())->toBe(1); + + Queue::push(new DelayableTask(1)); + Queue::expect(DelayableTask::class)->toBePushedTimes(1); + + expect(Queue::size())->toBe(2); +}); + +it('fakeExcept fakes the specified task until it appears in the log', function (): void { + Queue::fakeExcept(BasicQueuableTask::class); + + Queue::push(new BasicQueuableTask()); + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(1); + + expect(Queue::size())->toBe(1); + + Queue::push(new BadTask()); + Queue::expect(BadTask::class)->toBePushedTimes(1); + + expect(Queue::size())->toBe(1); }); it('fakes a task multiple times using times parameter', function (): void { From 3015ffa03d5f338877a578a6e10e208249085f3e Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 23 Oct 2025 11:49:42 -0500 Subject: [PATCH 56/68] tests: update logging expectation for pushed tasks in ParallelQueueTest --- tests/Unit/Queue/ParallelQueueTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Unit/Queue/ParallelQueueTest.php b/tests/Unit/Queue/ParallelQueueTest.php index 002c9ba5..e135890f 100644 --- a/tests/Unit/Queue/ParallelQueueTest.php +++ b/tests/Unit/Queue/ParallelQueueTest.php @@ -551,7 +551,7 @@ Queue::push(new BasicQueuableTask()); - expect(Queue::getQueueLog()->count())->toBe(1); + expect(Queue::getQueueLog()->count())->toBe(0); }); it('does not log pushes in production environment', function (): void { From a21ef2dbafb57c0db1b24c0e2c0cd35f3a7181ff Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 23 Oct 2025 12:07:19 -0500 Subject: [PATCH 57/68] feat: add new except mode --- src/Queue/Concerns/CaptureTasks.php | 22 ++++-- src/Testing/Constants/FakeMode.php | 2 + tests/Unit/Queue/ParallelQueueTest.php | 94 ++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 7 deletions(-) diff --git a/src/Queue/Concerns/CaptureTasks.php b/src/Queue/Concerns/CaptureTasks.php index 185af22b..e84abb5d 100644 --- a/src/Queue/Concerns/CaptureTasks.php +++ b/src/Queue/Concerns/CaptureTasks.php @@ -23,6 +23,11 @@ trait CaptureTasks */ protected array $fakeTasks = []; + /** + * @var array + */ + protected array $fakeExceptTasks = []; + /** * @var Collection, task: QueuableTask, queue: string|null, connection: string|null, timestamp: Date}> */ @@ -98,11 +103,10 @@ public function fakeExcept(string $taskClass): void return; } - $this->enableFake(FakeMode::SCOPED); + $this->enableFake(FakeMode::EXCEPT); - $this->fakeTasks = [ - $taskClass => fn (Collection $log): bool => $log->filter(fn (array $entry): bool => $entry['task_class'] === $taskClass)->isEmpty(), - ]; + $this->fakeExceptTasks[] = $taskClass; + $this->fakeTasks = []; } public function getQueueLog(): Collection @@ -124,6 +128,7 @@ public function resetFaking(): void $this->logging = false; $this->fakeMode = FakeMode::NONE; $this->fakeTasks = []; + $this->fakeExceptTasks = []; $this->pushed = Collection::fromArray([]); } @@ -148,11 +153,14 @@ protected function shouldFakeTask(QueuableTask $task): bool return true; } + if ($this->fakeMode === FakeMode::EXCEPT) { + return ! in_array($task::class, $this->fakeExceptTasks, true); + } + $result = false; - $class = $task::class; - if (! empty($this->fakeTasks) && array_key_exists($class, $this->fakeTasks)) { - $config = $this->fakeTasks[$class]; + if (! empty($this->fakeTasks) && array_key_exists($task::class, $this->fakeTasks)) { + $config = $this->fakeTasks[$task::class]; if ($config instanceof Closure) { try { diff --git a/src/Testing/Constants/FakeMode.php b/src/Testing/Constants/FakeMode.php index ddb5d66c..87151da2 100644 --- a/src/Testing/Constants/FakeMode.php +++ b/src/Testing/Constants/FakeMode.php @@ -11,4 +11,6 @@ enum FakeMode case ALL; case SCOPED; + + case EXCEPT; } diff --git a/tests/Unit/Queue/ParallelQueueTest.php b/tests/Unit/Queue/ParallelQueueTest.php index e135890f..35d7d7ad 100644 --- a/tests/Unit/Queue/ParallelQueueTest.php +++ b/tests/Unit/Queue/ParallelQueueTest.php @@ -744,3 +744,97 @@ Queue::expect(BasicQueuableTask::class)->toBePushedTimes(1); }); + +it('fakes only the specified task class', function (): void { + Queue::fakeOnly(BasicQueuableTask::class); + + Queue::push(new BasicQueuableTask()); + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(1); + + expect(Queue::size())->toBe(0); + + Queue::push(new BadTask()); + Queue::expect(BadTask::class)->toBePushedTimes(1); + + expect(Queue::size())->toBe(1); + + Queue::push(new DelayableTask(1)); + Queue::expect(DelayableTask::class)->toBePushedTimes(1); + + expect(Queue::size())->toBe(2); +}); + +it('fakes all tasks except the specified class', function (): void { + Queue::fakeExcept(BasicQueuableTask::class); + + Queue::push(new BasicQueuableTask()); + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(1); + + expect(Queue::size())->toBe(1); + + Queue::push(new BadTask()); + Queue::expect(BadTask::class)->toBePushedTimes(1); + + expect(Queue::size())->toBe(1); + + Queue::push(new DelayableTask(1)); + Queue::expect(DelayableTask::class)->toBePushedTimes(1); + + expect(Queue::size())->toBe(1); +}); + +it('fakeOnly resets previous fake configurations', function (): void { + Queue::fakeTimes(BadTask::class, 2); + Queue::fakeTimes(DelayableTask::class, 1); + + Queue::fakeOnly(BasicQueuableTask::class); + + Queue::push(new BasicQueuableTask()); + + expect(Queue::size())->toBe(0); + + Queue::push(new BadTask()); + Queue::push(new DelayableTask(1)); + + expect(Queue::size())->toBe(2); + + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(1); + Queue::expect(BadTask::class)->toBePushedTimes(1); + Queue::expect(DelayableTask::class)->toBePushedTimes(1); +}); + +it('fakeExcept resets previous fake configurations', function (): void { + Queue::fakeTimes(BasicQueuableTask::class, 1); + Queue::fakeTimes(DelayableTask::class, 1); + + Queue::fakeExcept(BasicQueuableTask::class); + + Queue::push(new BasicQueuableTask()); + + expect(Queue::size())->toBe(1); + + Queue::push(new BadTask()); + Queue::push(new DelayableTask(1)); + + expect(Queue::size())->toBe(1); + + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(1); + Queue::expect(DelayableTask::class)->toBePushedTimes(1); + Queue::expect(BadTask::class)->toBePushedTimes(1); +}); + +it('fakeOnly continues to fake the same task multiple times', function (): void { + Queue::fakeOnly(BasicQueuableTask::class); + + for ($i = 0; $i < 5; $i++) { + Queue::push(new BasicQueuableTask()); + } + + expect(Queue::size())->toBe(0); + + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(5); + + Queue::push(new BadTask()); + + expect(Queue::size())->toBe(1); +}); From 82cfcffd073b46a2c076e760a15c8048b700f46a Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 23 Oct 2025 12:08:34 -0500 Subject: [PATCH 58/68] style: php cs --- src/Data/Collection.php | 1 - tests/Unit/Data/CollectionTest.php | 1 - 2 files changed, 2 deletions(-) diff --git a/src/Data/Collection.php b/src/Data/Collection.php index 436455fc..5134f7bd 100644 --- a/src/Data/Collection.php +++ b/src/Data/Collection.php @@ -10,7 +10,6 @@ use Ramsey\Collection\CollectionInterface; use Ramsey\Collection\Exception\CollectionMismatchException; use Ramsey\Collection\Sort; -use SplFixedArray; use function array_filter; use function array_key_first; diff --git a/tests/Unit/Data/CollectionTest.php b/tests/Unit/Data/CollectionTest.php index 88815b35..d880c055 100644 --- a/tests/Unit/Data/CollectionTest.php +++ b/tests/Unit/Data/CollectionTest.php @@ -348,4 +348,3 @@ expect($collection->getType())->toBe('mixed'); expect($duration)->toBeLessThan(0.1); }); - From 8ba2b6e4c53a6b3cdd843f3f2d4f84a83e14e9e6 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 23 Oct 2025 12:09:03 -0500 Subject: [PATCH 59/68] feat: add fake except mode for events --- src/Events/Concerns/CaptureEvents.php | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/Events/Concerns/CaptureEvents.php b/src/Events/Concerns/CaptureEvents.php index 356ae545..b3549db5 100644 --- a/src/Events/Concerns/CaptureEvents.php +++ b/src/Events/Concerns/CaptureEvents.php @@ -23,6 +23,11 @@ trait CaptureEvents */ protected array $fakeEvents = []; + /** + * @var array + */ + protected array $fakeExceptEvents = []; + /** * @var Collection */ @@ -98,11 +103,9 @@ public function fakeExcept(string $event): void return; } - $this->enableFake(FakeMode::SCOPED); + $this->enableFake(FakeMode::EXCEPT); - $this->fakeEvents = [ - $event => fn (Collection $log): bool => $log->filter(fn (array $entry): bool => $entry['name'] === $event)->isEmpty(), - ]; + $this->fakeExceptEvents[] = $event; } public function getEventLog(): Collection @@ -124,6 +127,7 @@ public function resetFaking(): void $this->logging = false; $this->fakeMode = FakeMode::NONE; $this->fakeEvents = []; + $this->fakeExceptEvents = []; $this->dispatched = Collection::fromArray([]); } @@ -146,6 +150,10 @@ protected function shouldFakeEvent(string $name): bool return true; } + if ($this->fakeMode === FakeMode::EXCEPT) { + return ! in_array($name, $this->fakeExceptEvents, true); + } + $result = false; if (! empty($this->fakeEvents) && array_key_exists($name, $this->fakeEvents)) { From 30b42129a3e8175a4c965fad47570defd65cf443 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 23 Oct 2025 15:55:22 -0500 Subject: [PATCH 60/68] refactor: improve code to solve sonar issues --- src/Data/Collection.php | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/Data/Collection.php b/src/Data/Collection.php index 5134f7bd..46c7523b 100644 --- a/src/Data/Collection.php +++ b/src/Data/Collection.php @@ -204,7 +204,15 @@ private function getComparator(): Closure $b = spl_object_id($b); } - return $a === $b ? 0 : ($a < $b ? 1 : -1); + if ($a === $b) { + return 0; + } + + if ($a < $b) { + return 1; + } + + return -1; }; } @@ -234,10 +242,6 @@ private static function getDataType(array $data): string $firstType = gettype(reset($data)); - if (count($data) === 1) { - return $firstType; - } - foreach ($data as $item) { if (gettype($item) !== $firstType) { return 'mixed'; From 5c3cfc59febd486d1d5d79be1a0ff9074a16e71a Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 23 Oct 2025 16:11:24 -0500 Subject: [PATCH 61/68] feat: add tests for EventFacade fake methods and their behavior --- tests/Unit/Events/EventEmitterTest.php | 77 ++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/tests/Unit/Events/EventEmitterTest.php b/tests/Unit/Events/EventEmitterTest.php index 52540b7c..41cf5344 100644 --- a/tests/Unit/Events/EventEmitterTest.php +++ b/tests/Unit/Events/EventEmitterTest.php @@ -710,12 +710,44 @@ $called = true; }); + EventFacade::fake(); + + EventFacade::emit('prod.fake.event', 'payload'); + + expect($called)->toBeTrue(); + EventFacade::fakeOnly('prod.fake.event'); EventFacade::emit('prod.fake.event', 'payload'); expect($called)->toBeTrue(); + EventFacade::fakeWhen('prod.fake.event', function (): bool { + return true; + }); + + EventFacade::emit('prod.fake.event', 'payload'); + + expect($called)->toBeTrue(); + + EventFacade::fakeTimes('prod.fake.event', 10); + + EventFacade::emit('prod.fake.event', 'payload'); + + expect($called)->toBeTrue(); + + EventFacade::fakeOnce('prod.fake.event'); + + EventFacade::emit('prod.fake.event', 'payload'); + + expect($called)->toBeTrue(); + + EventFacade::fakeExcept('prod.fake.event'); + + EventFacade::emit('prod.fake.event', 'payload'); + + expect($called)->toBeTrue(); + Config::set('app.env', 'local'); }); @@ -792,3 +824,48 @@ EventFacade::expect('async.fake.event')->toBeDispatched(); }); + +it('fakes once correctly', function (): void { + $called = 0; + + EventFacade::on('fake.once.event', function () use (&$called): void { + $called++; + }); + + EventFacade::fakeOnce('fake.once.event'); + + EventFacade::emit('fake.once.event'); + EventFacade::emit('fake.once.event'); + EventFacade::emit('fake.once.event'); + + expect($called)->toBe(2); + + EventFacade::expect('fake.once.event')->toBeDispatchedTimes(3); +}); + +it('fakes all except specified events', function (): void { + $calledFaked = 0; + $calledNotFaked = 0; + + EventFacade::on('not.faked.event', function () use (&$calledNotFaked): void { + $calledNotFaked++; + }); + + EventFacade::on('faked.event', function () use (&$calledFaked): void { + $calledFaked++; + }); + + EventFacade::fakeExcept('not.faked.event'); + + EventFacade::emit('faked.event'); + EventFacade::emit('faked.event'); + + EventFacade::emit('not.faked.event'); + EventFacade::emit('not.faked.event'); + + expect($calledFaked)->toBe(0); + expect($calledNotFaked)->toBe(2); + + EventFacade::expect('faked.event')->toBeDispatchedTimes(2); + EventFacade::expect('not.faked.event')->toBeDispatchedTimes(2); +}); From 352f348a9f8a0d9b69fb7179504bd4fe7d53780f Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 23 Oct 2025 16:13:15 -0500 Subject: [PATCH 62/68] feat: add negative assertions for event dispatching and email sending --- tests/Unit/Events/EventEmitterTest.php | 1 + tests/Unit/Mail/MailTest.php | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/Unit/Events/EventEmitterTest.php b/tests/Unit/Events/EventEmitterTest.php index 41cf5344..a31a57be 100644 --- a/tests/Unit/Events/EventEmitterTest.php +++ b/tests/Unit/Events/EventEmitterTest.php @@ -532,6 +532,7 @@ EventFacade::emit('neg.event', 'value'); + EventFacade::expect('neg.event')->toNotBeDispatched(); EventFacade::expect('neg.event')->toNotBeDispatched(fn ($event): bool => false); }); diff --git a/tests/Unit/Mail/MailTest.php b/tests/Unit/Mail/MailTest.php index 58a54ce1..3aff9d15 100644 --- a/tests/Unit/Mail/MailTest.php +++ b/tests/Unit/Mail/MailTest.php @@ -149,6 +149,7 @@ public function build(): self }); Mail::expect($mailable)->toBeSentTimes(1); + Mail::expect($mailable)->toNotBeSent(); Mail::expect($mailable)->toNotBeSent(function (array $matches): bool { return $matches['success'] === false; }); From 192ed5047b30e82d6d27d19f65a636d71a3802e1 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 23 Oct 2025 16:44:05 -0500 Subject: [PATCH 63/68] fix: update TestMail class for improved mailable handling --- src/Testing/TestMail.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Testing/TestMail.php b/src/Testing/TestMail.php index e67a657b..d8cf98f5 100644 --- a/src/Testing/TestMail.php +++ b/src/Testing/TestMail.php @@ -45,6 +45,8 @@ public function toNotBeSent(Closure|null $closure = null): void if ($closure) { Assert::assertFalse($closure($matches->first())); } else { + $matches = $matches->filter(fn (array $item): bool => $item['success'] === false); + Assert::assertEmpty($matches, "Failed asserting that mailable '{$this->mailable}' was NOT sent."); } } From f9ff45320b0a50c43174f6dbfb26221fbee5a358 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 23 Oct 2025 17:15:56 -0500 Subject: [PATCH 64/68] fix: remove unnecessary event emission in closure predicate test --- tests/Unit/Events/EventEmitterTest.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/Unit/Events/EventEmitterTest.php b/tests/Unit/Events/EventEmitterTest.php index a31a57be..3e850427 100644 --- a/tests/Unit/Events/EventEmitterTest.php +++ b/tests/Unit/Events/EventEmitterTest.php @@ -530,8 +530,6 @@ it('supports closure predicate with existing event', function (): void { EventFacade::log(); - EventFacade::emit('neg.event', 'value'); - EventFacade::expect('neg.event')->toNotBeDispatched(); EventFacade::expect('neg.event')->toNotBeDispatched(fn ($event): bool => false); }); @@ -539,6 +537,7 @@ it('supports closure predicate with absent event', function (): void { EventFacade::log(); + EventFacade::expect('absent.event')->toNotBeDispatched(); EventFacade::expect('absent.event')->toNotBeDispatched(fn ($event): bool => false); }); From 02f903577b904640fa2b0a50438756d21ec3f829 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 23 Oct 2025 17:35:22 -0500 Subject: [PATCH 65/68] test: update DelayableTask duration in processing skip test --- tests/Unit/Queue/ParallelQueueTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Unit/Queue/ParallelQueueTest.php b/tests/Unit/Queue/ParallelQueueTest.php index 35d7d7ad..58f70b79 100644 --- a/tests/Unit/Queue/ParallelQueueTest.php +++ b/tests/Unit/Queue/ParallelQueueTest.php @@ -239,7 +239,7 @@ $parallelQueue = new ParallelQueue('test-skip-processing'); // Add initial task that will take 6 seconds to process - $parallelQueue->push(new DelayableTask(3)); + $parallelQueue->push(new DelayableTask(6)); $this->assertTrue($parallelQueue->isProcessing()); From e1caad86238a274d03551db43c5c2befe643b2f3 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 23 Oct 2025 17:35:39 -0500 Subject: [PATCH 66/68] test: add fake queue behavior for BasicQueuableTask in local environment --- tests/Unit/Queue/ParallelQueueTest.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/Unit/Queue/ParallelQueueTest.php b/tests/Unit/Queue/ParallelQueueTest.php index 58f70b79..a0614e8e 100644 --- a/tests/Unit/Queue/ParallelQueueTest.php +++ b/tests/Unit/Queue/ParallelQueueTest.php @@ -598,6 +598,20 @@ Queue::expect(BadTask::class)->toPushNothing(); Queue::expect(BasicQueuableTask::class)->toPushNothing(); + Queue::fakeWhen(BasicQueuableTask::class, function ($log) { + return true; + }); + + Queue::push(new BasicQueuableTask()); + + Queue::expect(BasicQueuableTask::class)->toPushNothing(); + + Queue::fakeTimes(BasicQueuableTask::class, 2); + + Queue::push(new BasicQueuableTask()); + + Queue::expect(BasicQueuableTask::class)->toPushNothing(); + Config::set('app.env', 'local'); Queue::clear(); }); From fd4facd254cabeb8e63011c53bc8dc1540592c63 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 23 Oct 2025 17:35:59 -0500 Subject: [PATCH 67/68] test: add tests for parallel queue processing and task management --- tests/Unit/Queue/ParallelQueueTest.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/Unit/Queue/ParallelQueueTest.php b/tests/Unit/Queue/ParallelQueueTest.php index a0614e8e..5f492347 100644 --- a/tests/Unit/Queue/ParallelQueueTest.php +++ b/tests/Unit/Queue/ParallelQueueTest.php @@ -852,3 +852,17 @@ expect(Queue::size())->toBe(1); }); + +it('fake once fakes only the next push of the specified task class', function (): void { + Queue::fakeOnce(BasicQueuableTask::class); + + Queue::push(new BasicQueuableTask()); // faked + + expect(Queue::size())->toBe(0); + + Queue::push(new BasicQueuableTask()); // real + + expect(Queue::size())->toBe(1); + + Queue::expect(BasicQueuableTask::class)->toBePushedTimes(2); +}); From 2abdf51b5f7db018876dbb2c70815cd8d7b8b5c5 Mon Sep 17 00:00:00 2001 From: barbosa89 Date: Thu, 23 Oct 2025 19:16:38 -0500 Subject: [PATCH 68/68] fix: reset queue faking in tearDown method [skip ci] --- src/Testing/TestCase.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Testing/TestCase.php b/src/Testing/TestCase.php index d77981dc..1df28909 100644 --- a/src/Testing/TestCase.php +++ b/src/Testing/TestCase.php @@ -10,6 +10,7 @@ use Phenix\AppProxy; use Phenix\Console\Phenix; use Phenix\Facades\Event; +use Phenix\Facades\Queue; use Phenix\Testing\Concerns\InteractWithResponses; use Phenix\Testing\Concerns\RefreshDatabase; use Symfony\Component\Console\Tester\CommandTester; @@ -46,6 +47,7 @@ protected function tearDown(): void parent::tearDown(); Event::resetFaking(); + Queue::resetFaking(); $this->app = null; }