From df027be1c8161161bdc362084fbb57557f2a28f5 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:46:37 +0000 Subject: [PATCH 1/5] docs: track URL-aware parallel database isolation Record that persistent connections configured only through a URL currently bypass ParaTest worker database rewriting and can therefore be shared across workers. Capture the required design constraints: normalize configuration before deciding ownership, preserve process-local in-memory SQLite behavior, and either rewrite supported persistent URLs per worker or fail clearly when automatic isolation is not possible. --- docs/todo.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/todo.md b/docs/todo.md index 3f5f03920..c49f8af1b 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -30,6 +30,7 @@ ## Testing +- Make parallel database isolation URL-aware. A persistent connection configured only with `url` currently skips worker-database rewriting, so ParaTest workers share one database. Normalize the connection before deciding whether it can be managed, keep in-memory SQLite process-local, and either rewrite supported persistent URLs per worker or fail with a clear error when automatic isolation is impossible. - Port current Laravel's complete `tests/Support/SupportTestingEventFakeTest.php`, preserving Hypervel-specific EventFake coverage and coroutine-safe test behavior. - Complete Testing assertion coverage: port the remaining current Laravel `TestResponseTest` cases through the incremental upstream-update workflow, and add focused coverage for `TestView`'s public assertion and string surface where Laravel has no equivalent suite. - Add the repository-required `: void` return type to the remaining untyped HTTP test methods: 176 in `tests/Http/HttpClientTest.php`, 30 in `tests/Http/HttpRequestTrustedStateTest.php`, and 4 in `tests/Http/HttpRequestTrustedStateCoroutineTest.php`. Verify each file after the mechanical conversion. From c938aed3c89543eb5a98b006216467978c50b135 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:47:44 +0000 Subject: [PATCH 2/5] fix(database): normalize in-memory SQLite configuration Add one SQLite-owned classifier that normalizes connection URLs before deciding whether a database is in memory. This handles supported URL-only configurations and respects URL values that override discrete driver and database keys. Use the classifier from RefreshDatabase and Testbench so every database testing lifecycle makes the same decision. Keep missing connection records on their existing failure path and let malformed URLs fail through the configuration parser. Cover discrete, URL-only, overriding, incomplete, and non-SQLite configurations at the helper and consumer boundaries. --- src/database/src/SQLiteDatabase.php | 20 ++++++++++++++++ .../src/Testing/RefreshDatabase.php | 7 +++--- .../src/Concerns/HandlesDatabases.php | 10 +++----- tests/Database/SQLiteDatabaseTest.php | 24 +++++++++++++++++++ .../Testing/RefreshDatabaseTest.php | 12 ++++++---- tests/Testbench/DefaultConfigurationTest.php | 4 ++++ 6 files changed, 62 insertions(+), 15 deletions(-) diff --git a/src/database/src/SQLiteDatabase.php b/src/database/src/SQLiteDatabase.php index 46775c9ef..3e0771f2b 100644 --- a/src/database/src/SQLiteDatabase.php +++ b/src/database/src/SQLiteDatabase.php @@ -4,6 +4,8 @@ namespace Hypervel\Database; +use InvalidArgumentException; + class SQLiteDatabase { /** @@ -41,4 +43,22 @@ public static function isInMemory(string $database): bool return ($parameters['mode'] ?? null) === 'memory'; } + + /** + * Determine if a connection configuration resolves to an in-memory SQLite database. + * + * Discrete configurations pass through normalization unchanged. Malformed URLs + * intentionally fail with the database configuration parser's exception. + * + * @throws InvalidArgumentException + */ + public static function isInMemoryConfiguration(array $configuration): bool + { + $configuration = (new ConfigurationUrlParser)->parseConfiguration($configuration); + $database = $configuration['database'] ?? null; + + return ($configuration['driver'] ?? null) === 'sqlite' + && is_string($database) + && static::isInMemory($database); + } } diff --git a/src/foundation/src/Testing/RefreshDatabase.php b/src/foundation/src/Testing/RefreshDatabase.php index c2d4fe042..d462744e7 100644 --- a/src/foundation/src/Testing/RefreshDatabase.php +++ b/src/foundation/src/Testing/RefreshDatabase.php @@ -93,11 +93,10 @@ protected function usingInMemoryDatabase(?string $name = null): bool { $config = $this->app->make('config'); $name ??= $this->getRefreshConnection(); + $configuration = $config->get("database.connections.{$name}"); - // All supported SQLite memory URI forms need the same refresh lifecycle. - return SQLiteDatabase::isInMemory( - $config->string("database.connections.{$name}.database") - ); + return is_array($configuration) + && SQLiteDatabase::isInMemoryConfiguration($configuration); } /** diff --git a/src/testbench/src/Concerns/HandlesDatabases.php b/src/testbench/src/Concerns/HandlesDatabases.php index 945a4f19c..82a0caf2d 100644 --- a/src/testbench/src/Concerns/HandlesDatabases.php +++ b/src/testbench/src/Concerns/HandlesDatabases.php @@ -77,14 +77,10 @@ protected function usesSqliteInMemoryDatabaseConnection(?string $connection = nu $connection ??= $config->get('database.default'); - /** @var null|array{driver: string, database: string} $database */ - $database = $config->get("database.connections.{$connection}"); + $configuration = $config->get("database.connections.{$connection}"); - if ($database === null || $database['driver'] !== 'sqlite') { - return false; - } - - return SQLiteDatabase::isInMemory($database['database']); + return is_array($configuration) + && SQLiteDatabase::isInMemoryConfiguration($configuration); } /** diff --git a/tests/Database/SQLiteDatabaseTest.php b/tests/Database/SQLiteDatabaseTest.php index 8fb740fe9..24ccc3a73 100644 --- a/tests/Database/SQLiteDatabaseTest.php +++ b/tests/Database/SQLiteDatabaseTest.php @@ -61,4 +61,28 @@ public static function memoryProvider(): array 'mixed-case mode value is not memory' => ['file:database?mode=MEMORY', false], ]; } + + #[DataProvider('configurationProvider')] + public function testItClassifiesInMemoryConnectionConfigurations(array $configuration, bool $expected): void + { + $this->assertSame($expected, SQLiteDatabase::isInMemoryConfiguration($configuration)); + } + + /** + * @return array, bool}> + */ + public static function configurationProvider(): array + { + return [ + 'discrete SQLite memory' => [['driver' => 'sqlite', 'database' => ':memory:'], true], + 'SQLite memory URL' => [['url' => 'sqlite:///:memory:'], true], + 'URL overrides discrete values' => [[ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'url' => 'mysql://root:secret@database/app', + ], false], + 'non-SQLite memory name' => [['driver' => 'mysql', 'database' => ':memory:'], false], + 'incomplete configuration' => [['driver' => 'sqlite'], false], + ]; + } } diff --git a/tests/Foundation/Testing/RefreshDatabaseTest.php b/tests/Foundation/Testing/RefreshDatabaseTest.php index 1d469a18d..3e8060af2 100644 --- a/tests/Foundation/Testing/RefreshDatabaseTest.php +++ b/tests/Foundation/Testing/RefreshDatabaseTest.php @@ -254,7 +254,7 @@ public function testBeginDatabaseTransactionWorkSetsMigratedAndCachesPdoTogether 'database' => [ 'default' => 'default', 'connections' => [ - 'default' => ['database' => ':memory:'], + 'default' => ['driver' => 'sqlite', 'database' => ':memory:'], ], ], ])); @@ -291,7 +291,7 @@ public function testRestoreInMemoryDatabaseUsesResolvedDefaultConnectionName(): 'database' => [ 'default' => 'default', 'connections' => [ - 'default' => ['database' => ':memory:'], + 'default' => ['driver' => 'sqlite', 'database' => ':memory:'], ], ], ])); @@ -309,10 +309,12 @@ public function testInMemoryClassificationUsesTheNamedConnectionAndLiveDefault() 'default' => 'file', 'connections' => [ 'file' => [ + 'driver' => 'sqlite', 'database' => ParallelTesting::tempDir('RefreshDatabaseTest') . '/database.sqlite', ], - 'memory' => ['database' => 'file::memory:?cache=shared'], + 'memory' => ['driver' => 'sqlite', 'database' => 'file::memory:?cache=shared'], + 'url_memory' => ['url' => 'sqlite:///:memory:'], ], ], ])); @@ -320,6 +322,7 @@ public function testInMemoryClassificationUsesTheNamedConnectionAndLiveDefault() $this->assertFalse($this->usingInMemoryDatabase()); $this->assertFalse($this->usingInMemoryDatabase('file')); $this->assertTrue($this->usingInMemoryDatabase('memory')); + $this->assertTrue($this->usingInMemoryDatabase('url_memory')); $this->connectionsToTransact = ['file', 'memory']; @@ -362,10 +365,11 @@ public function testBeginDatabaseTransactionWorkCachesOnlyNamedInMemoryConnectio 'default' => 'file', 'connections' => [ 'file' => [ + 'driver' => 'sqlite', 'database' => ParallelTesting::tempDir('RefreshDatabaseTest') . '/database.sqlite', ], - 'memory' => ['database' => 'file::memory:?cache=shared'], + 'memory' => ['driver' => 'sqlite', 'database' => 'file::memory:?cache=shared'], ], ], ])); diff --git a/tests/Testbench/DefaultConfigurationTest.php b/tests/Testbench/DefaultConfigurationTest.php index e9b097db3..2c7e1721f 100644 --- a/tests/Testbench/DefaultConfigurationTest.php +++ b/tests/Testbench/DefaultConfigurationTest.php @@ -86,9 +86,13 @@ public function itUsesTheCanonicalSqliteMemoryClassification(): void 'driver' => 'sqlite', 'database' => 'file:database?mode=memory&mode=rwc', ]); + $config->set('database.connections.url_memory', [ + 'url' => 'sqlite:///:memory:', + ]); $this->assertTrue($this->usesSqliteInMemoryDatabaseConnection('uri_memory')); $this->assertFalse($this->usesSqliteInMemoryDatabaseConnection('uri_file')); + $this->assertTrue($this->usesSqliteInMemoryDatabaseConnection('url_memory')); } #[Test] From 70f5eba274f4012cf7386f13edeacbf63ceea637 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:47:58 +0000 Subject: [PATCH 3/5] fix(testing): retain truncated in-memory databases Make DatabaseTruncation participate in parallel database setup and preserve each configured in-memory SQLite PDO after the initial migration. Restore retained connections before truncation hooks run so per-test application recreation does not discard the schema. Rebind the current application's event dispatcher when restoring a PDO, use canonical connection names for the cache, and leave persistent database runs on an allocation-free empty-cache return path. Document the concern's mutual exclusivity with the other migration concerns and cover default, named, file-backed, dispatcher-rebinding, and empty-cache behavior. --- .../InteractsWithParallelDatabase.php | 3 +- .../src/Testing/DatabaseTruncation.php | 82 +++++++++++++++++++ .../Testing/DatabaseTruncationTest.php | 74 +++++++++++++++++ 3 files changed, 158 insertions(+), 1 deletion(-) diff --git a/src/foundation/src/Testing/Concerns/InteractsWithParallelDatabase.php b/src/foundation/src/Testing/Concerns/InteractsWithParallelDatabase.php index c89ec0447..492e7ea63 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithParallelDatabase.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithParallelDatabase.php @@ -78,7 +78,8 @@ protected function configureParallelDatabaseName($app): void * Ensure the per-worker database exists, creating it if needed. * * Called from database testing traits (RefreshDatabase, DatabaseMigrations, - * DatabaseTransactions) after the app is booted and connections are available. + * DatabaseTruncation, DatabaseTransactions) after the app is booted and + * connections are available. * The config has already been rewritten by configureParallelDatabaseName(). * * No-op when not running in parallel or when using in-memory SQLite. diff --git a/src/foundation/src/Testing/DatabaseTruncation.php b/src/foundation/src/Testing/DatabaseTruncation.php index c8ce9a8a0..ecc142403 100644 --- a/src/foundation/src/Testing/DatabaseTruncation.php +++ b/src/foundation/src/Testing/DatabaseTruncation.php @@ -5,14 +5,21 @@ namespace Hypervel\Foundation\Testing; use Hypervel\Contracts\Console\Kernel; +use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Database\ConnectionInterface; +use Hypervel\Database\SQLiteDatabase; +use Hypervel\Foundation\Testing\Concerns\InteractsWithParallelDatabase; use Hypervel\Foundation\Testing\Traits\CanConfigureMigrationCommands; use Hypervel\Support\Arr; use Hypervel\Support\Collection; +/** + * This concern is mutually exclusive with RefreshDatabase and DatabaseMigrations. + */ trait DatabaseTruncation { use CanConfigureMigrationCommands; + use InteractsWithParallelDatabase; /** * The cached names of the database tables for each connection. @@ -24,6 +31,9 @@ trait DatabaseTruncation */ protected function truncateDatabaseTables(): void { + $this->ensureParallelDatabaseExists(); + $this->restoreInMemoryDatabases(); + $this->beforeTruncatingDatabase(); // Migrate and seed the database on first run... @@ -32,6 +42,8 @@ protected function truncateDatabaseTables(): void $this->app->make(Kernel::class)->setArtisan(null); + $this->cacheInMemoryDatabases(); + RefreshDatabaseState::$migrated = true; return; @@ -51,6 +63,76 @@ protected function truncateDatabaseTables(): void $this->afterTruncatingDatabase(); } + /** + * Restore the in-memory databases between tests. + */ + protected function restoreInMemoryDatabases(): void + { + if (RefreshDatabaseState::$inMemoryConnections === []) { + return; + } + + $database = $this->app->make('db'); + $defaultConnection = $this->app->make('config')->string('database.default'); + + foreach ($this->connectionsToTruncate() as $name) { + $connectionName = $name ?? $defaultConnection; + + if (isset(RefreshDatabaseState::$inMemoryConnections[$connectionName])) { + // The PDO outlives its original application; the dispatcher must not. + $database->connection($name) + ->setPdo(RefreshDatabaseState::$inMemoryConnections[$connectionName]) + ->setEventDispatcher($this->app->make(Dispatcher::class)); + } + } + } + + /** + * Cache the in-memory databases after migration. + */ + protected function cacheInMemoryDatabases(): void + { + $database = $this->app->make('db'); + $config = $this->app->make('config'); + $defaultConnection = $config->string('database.default'); + + foreach ($this->connectionsToTruncate() as $name) { + if ($this->usingInMemoryDatabaseForTruncation($name)) { + $connectionName = $name ?? $defaultConnection; + + RefreshDatabaseState::$inMemoryConnections[$connectionName] + = $database->connection($name)->getPdo(); + } + } + } + + /** + * Determine if any connection being truncated uses an in-memory database. + */ + protected function usingInMemoryDatabasesForTruncation(): bool + { + foreach ($this->connectionsToTruncate() as $name) { + if ($this->usingInMemoryDatabaseForTruncation($name)) { + return true; + } + } + + return false; + } + + /** + * Determine if the given connection uses an in-memory database. + */ + protected function usingInMemoryDatabaseForTruncation(?string $name): bool + { + $config = $this->app->make('config'); + $name ??= $config->string('database.default'); + $configuration = $config->get("database.connections.{$name}"); + + return is_array($configuration) + && SQLiteDatabase::isInMemoryConfiguration($configuration); + } + /** * Truncate the database tables for all configured connections. */ diff --git a/tests/Foundation/Testing/DatabaseTruncationTest.php b/tests/Foundation/Testing/DatabaseTruncationTest.php index 790657717..0a5a61697 100644 --- a/tests/Foundation/Testing/DatabaseTruncationTest.php +++ b/tests/Foundation/Testing/DatabaseTruncationTest.php @@ -8,12 +8,15 @@ use Hypervel\Container\Container; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Database\Connection; +use Hypervel\Database\DatabaseManager; use Hypervel\Database\Query\Builder as QueryBuilder; use Hypervel\Database\Schema\Builder; use Hypervel\Database\Schema\PostgresBuilder; use Hypervel\Foundation\Testing\DatabaseTruncation; +use Hypervel\Foundation\Testing\RefreshDatabaseState; use Hypervel\Tests\TestCase; use Mockery as m; +use PDO; class DatabaseTruncationTest extends TestCase { @@ -25,6 +28,8 @@ class DatabaseTruncationTest extends TestCase private ?array $exceptTables = null; + private array $connectionsToTruncate = [null]; + protected function setUp(): void { parent::setUp(); @@ -44,8 +49,10 @@ protected function tearDown(): void { $this->app = null; static::$allTables = []; + RefreshDatabaseState::$inMemoryConnections = []; $this->tablesToTruncate = null; $this->exceptTables = null; + $this->connectionsToTruncate = [null]; parent::tearDown(); } @@ -181,6 +188,73 @@ public function testTruncateTablesOnPgsqlWithSearchPath() $this->assertEquals(['public.foo', 'public.bar', 'my_schema.foo', 'my_schema.baz'], $truncatedTables); } + public function testRestoreSkipsDatabaseResolutionWhenNoInMemoryConnectionIsCached(): void + { + $this->restoreInMemoryDatabases(); + + $this->assertFalse($this->app->resolved('db')); + } + + public function testCachesAndRestoresConfiguredInMemoryConnections(): void + { + $defaultPdo = m::mock(PDO::class); + $namedPdo = m::mock(PDO::class); + $sourceDefault = m::mock(Connection::class); + $sourceNamed = m::mock(Connection::class); + $sourceDefault->shouldReceive('getPdo')->once()->andReturn($defaultPdo); + $sourceNamed->shouldReceive('getPdo')->once()->andReturn($namedPdo); + + $sourceDatabase = m::mock(DatabaseManager::class); + $sourceDatabase->shouldReceive('connection')->once()->with(null)->andReturn($sourceDefault); + $sourceDatabase->shouldReceive('connection')->once()->with('named')->andReturn($sourceNamed); + $sourceDatabase->shouldNotReceive('connection')->with('file'); + + $this->app->instance('config', new Repository([ + 'database' => [ + 'default' => 'default', + 'connections' => [ + 'default' => ['driver' => 'sqlite', 'database' => ':memory:'], + 'named' => ['driver' => 'sqlite', 'database' => 'file::memory:?cache=shared'], + 'file' => ['driver' => 'sqlite', 'database' => '/tmp/database.sqlite'], + ], + ], + ])); + $this->app->instance('db', $sourceDatabase); + $this->connectionsToTruncate = [null, 'named', 'file']; + + $this->cacheInMemoryDatabases(); + + $dispatcher = m::mock(Dispatcher::class); + $restoredDefault = m::mock(Connection::class); + $restoredNamed = m::mock(Connection::class); + $restoredDefault->shouldReceive('setPdo')->once()->with($defaultPdo)->andReturnSelf(); + $restoredDefault->shouldReceive('setEventDispatcher')->once()->with($dispatcher)->andReturnSelf(); + $restoredNamed->shouldReceive('setPdo')->once()->with($namedPdo)->andReturnSelf(); + $restoredNamed->shouldReceive('setEventDispatcher')->once()->with($dispatcher)->andReturnSelf(); + + $restoredDatabase = m::mock(DatabaseManager::class); + $restoredDatabase->shouldReceive('connection')->once()->with(null)->andReturn($restoredDefault); + $restoredDatabase->shouldReceive('connection')->once()->with('named')->andReturn($restoredNamed); + $restoredDatabase->shouldNotReceive('connection')->with('file'); + + $this->app->instance('db', $restoredDatabase); + $this->app->instance(Dispatcher::class, $dispatcher); + + $this->restoreInMemoryDatabases(); + + $this->connectionsToTruncate = ['named', 'file']; + + $this->assertTrue($this->usingInMemoryDatabasesForTruncation()); + + $this->connectionsToTruncate = ['missing']; + + $this->assertFalse($this->usingInMemoryDatabasesForTruncation()); + $this->assertSame([ + 'default' => $defaultPdo, + 'named' => $namedPdo, + ], RefreshDatabaseState::$inMemoryConnections); + } + private function arrangeConnection( ?array &$actual, array $allTables, From cd6d080d1012c4c15eed46727f5c0220a3289704 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:48:16 +0000 Subject: [PATCH 4/5] fix(testbench): run the database truncation lifecycle Invoke DatabaseTruncation from Testbench's database concern setup after requirements and migration attributes have registered their configuration. This restores the Foundation testing contract that Testbench's custom setup order previously omitted. Register default migration paths for the initial and retained in-memory truncation lifecycles while keeping persistent, already-migrated databases on the rollback-capable migration processor path. Share that decision with Hypervel migrations and retain every cached in-memory connection until the class boundary. Reset retained schema state around method-level WithMigration attributes so temporary migration sets cannot leak into sibling tests. Cover first and later application lifecycles, persistent existing databases, and restoration of the class migration set after a method-specific schema. --- .../src/Concerns/InteractsWithMigrations.php | 68 +++++++++++---- .../src/Concerns/WithHypervelMigrations.php | 19 +---- src/testbench/src/TestCase.php | 35 ++++++++ ...DatabaseTruncationExistingDatabaseTest.php | 68 +++++++++++++++ ...abaseTruncationWithMethodMigrationTest.php | 49 +++++++++++ ...lMigrationsUsingDatabaseTruncationTest.php | 84 +++++++++++++++++++ 6 files changed, 289 insertions(+), 34 deletions(-) create mode 100644 tests/Testbench/Databases/DatabaseTruncationExistingDatabaseTest.php create mode 100644 tests/Testbench/Databases/DatabaseTruncationWithMethodMigrationTest.php create mode 100644 tests/Testbench/Databases/MigrateWithHypervelMigrationsUsingDatabaseTruncationTest.php diff --git a/src/testbench/src/Concerns/InteractsWithMigrations.php b/src/testbench/src/Concerns/InteractsWithMigrations.php index e5d69a0fc..15880df8c 100644 --- a/src/testbench/src/Concerns/InteractsWithMigrations.php +++ b/src/testbench/src/Concerns/InteractsWithMigrations.php @@ -9,6 +9,7 @@ use Hypervel\Database\Migrations\Migrator; use Hypervel\Foundation\Console\Kernel as FoundationConsoleKernel; use Hypervel\Foundation\Testing\DatabaseMigrations; +use Hypervel\Foundation\Testing\DatabaseTruncation; use Hypervel\Foundation\Testing\RefreshDatabaseState; use Hypervel\Support\Arr; use Hypervel\Testbench\Attributes\ResetRefreshDatabaseState; @@ -31,7 +32,7 @@ trait InteractsWithMigrations protected function setUpInteractsWithMigrations(): void { - if ($this->usesSqliteInMemoryDatabaseConnection()) { + if ($this->usesInMemoryDatabaseForMigrationState()) { $this->afterApplicationCreated(static function (): void { static::usesTestingFeature(new ResetRefreshDatabaseState); }); @@ -41,6 +42,9 @@ protected function setUpInteractsWithMigrations(): void protected function tearDownInteractsWithMigrations(): void { $hasInMemoryConnections = ! empty(RefreshDatabaseState::$inMemoryConnections); + $preservesInMemoryDatabase = static::usesTestingConcern(DatabaseTruncation::class) + && ! static::usesTestingConcern(DatabaseMigrations::class) + && ! static::usesRefreshDatabaseTestingConcern(); $processors = $this->cachedTestMigratorProcessors; $this->cachedTestMigratorProcessors = []; $failure = null; @@ -48,7 +52,11 @@ protected function tearDownInteractsWithMigrations(): void try { if ( (count($processors) > 0 && static::usesRefreshDatabaseTestingConcern()) - || ($hasInMemoryConnections && $this->usesSqliteInMemoryDatabaseConnection()) + || ( + ! $preservesInMemoryDatabase + && $hasInMemoryConnections + && $this->usesInMemoryDatabaseForMigrationState() + ) ) { ResetRefreshDatabaseState::run(); } @@ -79,9 +87,37 @@ protected function loadMigrationsFrom(array|string $paths): void /** @var ApplicationContract $app */ $app = $this->app; + if ( + (is_string($paths) || Arr::isList($paths)) + && $this->shouldRegisterMigrationPaths() + ) { + /** @var list|string $paths */ + load_migration_paths($app, $paths); + + return; + } + + /** @var array|string $paths */ + $this->runMigrationProcessor($app, $this->resolvePackageMigrationsOptions($paths)); + } + + /** + * Determine whether migration paths should be registered for an upcoming migration. + */ + protected function shouldRegisterMigrationPaths(): bool + { $migrateRefresh = property_exists($this, 'migrateRefresh') && (bool) $this->migrateRefresh; - $refreshesDatabase = static::usesTestingConcern(DatabaseMigrations::class) + + // List and string paths target the default connection; named options use a processor. + return static::usesTestingConcern(DatabaseMigrations::class) + || ( + static::usesTestingConcern(DatabaseTruncation::class) + && ( + RefreshDatabaseState::$migrated === false + || $this->usesSqliteInMemoryDatabaseConnection() + ) + ) || ( static::usesRefreshDatabaseTestingConcern() && ( @@ -92,19 +128,6 @@ protected function loadMigrationsFrom(array|string $paths): void ) ) ); - - if ( - (is_string($paths) || Arr::isList($paths)) - && $refreshesDatabase - ) { - /** @var list|string $paths */ - load_migration_paths($app, $paths); - - return; - } - - /** @var array|string $paths */ - $this->runMigrationProcessor($app, $this->resolvePackageMigrationsOptions($paths)); } /** @@ -201,6 +224,19 @@ protected function runMigrationProcessor(ApplicationContract $app, array $option $this->resetApplicationArtisanCommands($app); } + /** + * Determine whether the active database concern retains in-memory state. + */ + protected function usesInMemoryDatabaseForMigrationState(): bool + { + if (static::usesTestingConcern(DatabaseTruncation::class)) { + // Every cached truncation PDO needs the same class-boundary state owner. + return $this->usingInMemoryDatabasesForTruncation(); /* @phpstan-ignore method.notFound */ + } + + return $this->usesSqliteInMemoryDatabaseConnection(); + } + protected function resetApplicationArtisanCommands(ApplicationContract $app): void { $kernel = $app->make(ConsoleKernelContract::class); diff --git a/src/testbench/src/Concerns/WithHypervelMigrations.php b/src/testbench/src/Concerns/WithHypervelMigrations.php index 6e8086680..45074101a 100644 --- a/src/testbench/src/Concerns/WithHypervelMigrations.php +++ b/src/testbench/src/Concerns/WithHypervelMigrations.php @@ -4,9 +4,6 @@ namespace Hypervel\Testbench\Concerns; -use Hypervel\Foundation\Testing\DatabaseMigrations; -use Hypervel\Foundation\Testing\RefreshDatabaseState; - use function Hypervel\Testbench\after_resolving; use function Hypervel\Testbench\default_migration_path; @@ -25,21 +22,7 @@ protected function setUpWithHypervelMigrations(): void return; } - $migrateRefresh = property_exists($this, 'migrateRefresh') - && (bool) $this->migrateRefresh; - $refreshesDatabase = static::usesTestingConcern(DatabaseMigrations::class) - || ( - static::usesRefreshDatabaseTestingConcern() - && ( - $migrateRefresh - || ( - RefreshDatabaseState::$migrated === false - && RefreshDatabaseState::$lazilyRefreshed === false - ) - ) - ); - - if ($refreshesDatabase) { + if ($this->shouldRegisterMigrationPaths()) { after_resolving($this->app, 'migrator', static function ($migrator, $app): void { $migrator->path(default_migration_path()); }); diff --git a/src/testbench/src/TestCase.php b/src/testbench/src/TestCase.php index 9f0fe7945..fa8ecc3c6 100644 --- a/src/testbench/src/TestCase.php +++ b/src/testbench/src/TestCase.php @@ -10,8 +10,12 @@ use Hypervel\Filesystem\Filesystem; use Hypervel\Foundation\Testing\DatabaseMigrations; use Hypervel\Foundation\Testing\DatabaseTransactions; +use Hypervel\Foundation\Testing\DatabaseTruncation; use Hypervel\Foundation\Testing\RefreshDatabase; use Hypervel\Foundation\Testing\TestCase as BaseTestCase; +use Hypervel\Testbench\Attributes\ResetRefreshDatabaseState; +use Hypervel\Testbench\Attributes\WithMigration; +use ReflectionMethod; use RuntimeException; use Swoole\Timer; use Throwable; @@ -24,6 +28,7 @@ * * @method void refreshDatabase() * @method void runDatabaseMigrations() + * @method void truncateDatabaseTables() * @method void beginDatabaseTransaction() * @method void disableMiddlewareForAllTests() * @method void disableEventsForAllTests() @@ -133,6 +138,9 @@ protected function preservePackageManifestCache(): void */ protected function setUpDatabaseTraits(array $uses): void { + // Reset before database attributes register paths against retained schema state. + $this->prepareDatabaseTruncationForMethod($uses); + $this->setUpDatabaseRequirements(function () use ($uses): void { if (isset($uses[RefreshDatabase::class])) { $this->refreshDatabase(); @@ -141,6 +149,10 @@ protected function setUpDatabaseTraits(array $uses): void if (isset($uses[DatabaseMigrations::class])) { $this->runDatabaseMigrations(); } + + if (isset($uses[DatabaseTruncation::class])) { + $this->truncateDatabaseTables(); + } }); if (isset($uses[DatabaseTransactions::class])) { @@ -148,6 +160,29 @@ protected function setUpDatabaseTraits(array $uses): void } } + /** + * Isolate method-specific migration sets from the retained truncation schema. + */ + protected function prepareDatabaseTruncationForMethod(array $uses): void + { + if (! isset($uses[DatabaseTruncation::class])) { + return; + } + + $method = $this->resolvePhpUnitTestMethodName(); + + if ($method === null + || (new ReflectionMethod(static::class, $method))->getAttributes(WithMigration::class) === []) { + return; + } + + ResetRefreshDatabaseState::run(); + + $this->beforeApplicationDestroyed(static function (): void { + ResetRefreshDatabaseState::run(); + }); + } + /** * Refresh the application instance. */ diff --git a/tests/Testbench/Databases/DatabaseTruncationExistingDatabaseTest.php b/tests/Testbench/Databases/DatabaseTruncationExistingDatabaseTest.php new file mode 100644 index 000000000..88287dd84 --- /dev/null +++ b/tests/Testbench/Databases/DatabaseTruncationExistingDatabaseTest.php @@ -0,0 +1,68 @@ +ensureDirectoryExists(self::$databaseDirectory); + $files->put(self::$databaseDirectory . '/database.sqlite', ''); + + $app->make('config')->set( + 'database.connections.testing.database', + self::$databaseDirectory . '/database.sqlite', + ); + } + + #[Override] + protected function defineDatabaseMigrations(): void + { + RefreshDatabaseState::$migrated = true; + + $this->loadMigrationsFrom(workbench_path('database/migrations')); + } + + #[Test] + public function itRunsNewMigrationPathsWhenThePersistentDatabaseWasAlreadyMigrated(): void + { + $this->assertCount(1, $this->cachedTestMigratorProcessors); + $this->assertTrue(Schema::hasTable('testbench_users')); + } + + #[Override] + protected function tearDown(): void + { + try { + parent::tearDown(); + + $this->assertTrue(RefreshDatabaseState::$migrated); + } finally { + (new Filesystem)->deleteDirectory(self::$databaseDirectory); + } + } +} diff --git a/tests/Testbench/Databases/DatabaseTruncationWithMethodMigrationTest.php b/tests/Testbench/Databases/DatabaseTruncationWithMethodMigrationTest.php new file mode 100644 index 000000000..8a19b4fae --- /dev/null +++ b/tests/Testbench/Databases/DatabaseTruncationWithMethodMigrationTest.php @@ -0,0 +1,49 @@ +loadMigrationsFrom(workbench_path('database/migrations')); + } + + #[Test] + #[WithMigration('notifications')] + public function itIsolatesMethodSpecificMigrationSets(): void + { + $this->assertTrue(Schema::hasTable('notifications')); + } + + #[Test] + #[Depends('itIsolatesMethodSpecificMigrationSets')] + public function itRestoresTheClassSchemaAfterAMethodSpecificMigrationSet(): void + { + $this->assertTrue(Schema::hasTable('testbench_users')); + $this->assertFalse(Schema::hasTable('notifications')); + } +} diff --git a/tests/Testbench/Databases/MigrateWithHypervelMigrationsUsingDatabaseTruncationTest.php b/tests/Testbench/Databases/MigrateWithHypervelMigrationsUsingDatabaseTruncationTest.php new file mode 100644 index 000000000..f02dfb408 --- /dev/null +++ b/tests/Testbench/Databases/MigrateWithHypervelMigrationsUsingDatabaseTruncationTest.php @@ -0,0 +1,84 @@ +loadMigrationsFrom(workbench_path('database/migrations')); + } + + protected function afterTruncatingDatabase(): void + { + $this->truncated = true; + } + + #[Test] + #[DataProvider('applicationLifecycles')] + public function itMigratesOnceAndThenTruncatesAcrossApplicationLifecycles(int $lifecycle): void + { + $this->assertSame([], $this->cachedTestMigratorProcessors); + $this->assertTrue(RefreshDatabaseState::$migrated); + $this->assertSame($lifecycle === 2, $this->truncated); + $this->assertTrue(Schema::hasTable('users')); + $this->assertTrue(Schema::hasTable('testbench_users')); + $this->assertSame(0, DB::connection()->transactionLevel()); + $this->assertSame( + 0, + DB::table('testbench_users')->where('email', 'truncation@example.com')->count(), + ); + + DB::table('testbench_users')->insert([ + 'email' => 'truncation@example.com', + 'password' => (string) $lifecycle, + ]); + } + + public static function applicationLifecycles(): array + { + // The second case exercises database state retained by the first lifecycle. + return [ + 'first application' => [1], + 'second application' => [2], + ]; + } +} From 7ccb6d92944a17801074adf52d1c1789150513d9 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:12:21 +0000 Subject: [PATCH 5/5] test(database): type refresh regression method Add the repository-required void return type to the changed RefreshDatabase regression test. The other methods identified by the review already carry the correct return type. --- tests/Foundation/Testing/RefreshDatabaseTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Foundation/Testing/RefreshDatabaseTest.php b/tests/Foundation/Testing/RefreshDatabaseTest.php index 3e8060af2..9df6390f0 100644 --- a/tests/Foundation/Testing/RefreshDatabaseTest.php +++ b/tests/Foundation/Testing/RefreshDatabaseTest.php @@ -224,7 +224,7 @@ public function testRefreshTestDatabaseRestoresMockConsoleOutputAfterMigrationFa } } - public function testBeginDatabaseTransactionWorkSetsMigratedAndCachesPdoTogether() + public function testBeginDatabaseTransactionWorkSetsMigratedAndCachesPdoTogether(): void { // Regression test for the RefreshDatabase + RunTestsInCoroutine + // mid-setUp skip bug. The invariant the fix establishes is that