From 1397d574c62f0d642936d25f737e27ff15043fa4 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:44:31 +0000 Subject: [PATCH 1/8] Keep mixed mail recipient lists and plain-only view pairs working Message::to(), cc(), bcc() and replyTo() spread a recipient array into Symfony's variadic address methods. In a list that mixes named and unnamed addresses, such as ['taylor@example.test' => 'Taylor', 'foo@example.test'], PHP treats the string key as a named argument, so the unnamed address after it throws an Error. The addresses are now spread as a list. Mailer::parseView() detected a [html, plain] view pair with isset($view[0]), so [null, 'plain'] fell through to the keyed-array branch and the plain view was dropped. It now checks array_key_exists(), so the message is sent with only its plain body. Laravel has both problems at the 13.x revision 5d7797528723107cd4f495326838a7d7d67fbb20. They came up while porting laravel/framework#61739, whose notification tests cover these cases with mocks. Verified with php-cs-fixer, PHPStan and the mail test files. --- src/mail/src/Mailer.php | 2 +- src/mail/src/Message.php | 3 ++- tests/Mail/MailMailerTest.php | 14 ++++++++++++++ tests/Mail/MailMessageTest.php | 10 ++++++++++ 4 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/mail/src/Mailer.php b/src/mail/src/Mailer.php index 3f3e12c5ab..b1a2ee2b6f 100644 --- a/src/mail/src/Mailer.php +++ b/src/mail/src/Mailer.php @@ -315,7 +315,7 @@ protected function parseView(array|Closure|string $view): array // If the given view is an array with numeric keys, we will just assume that // both a "pretty" and "plain" view were provided, so we will return this // array as is, since it should contain both views with numerical keys. - if (isset($view[0])) { + if (array_key_exists(0, $view)) { return [$view[0], $view[1], null]; } diff --git a/src/mail/src/Message.php b/src/mail/src/Message.php index e855c62500..43a03669ef 100644 --- a/src/mail/src/Message.php +++ b/src/mail/src/Message.php @@ -190,7 +190,8 @@ protected function addAddresses(array|string $address, ?string $name, string $ty return $this->ensureAddressIsSafe($address); })->all(); - $this->message->{"{$type}"}(...$addresses); + // Spread a list: string keys become named arguments, which makes any later positional address fail. + $this->message->{"{$type}"}(...array_values($addresses)); } else { $this->message->{"add{$type}"}($this->createAddress($address, (string) $name)); } diff --git a/tests/Mail/MailMailerTest.php b/tests/Mail/MailMailerTest.php index 38081f48b2..3e0a77cba5 100644 --- a/tests/Mail/MailMailerTest.php +++ b/tests/Mail/MailMailerTest.php @@ -206,6 +206,20 @@ public function testMailerSendSendsMessageWithProperPlainViewContentWhenExplicit $this->assertStringContainsString($expected, $sentMessage->toString()); } + public function testMailerSendSendsOnlyPlainContentWhenViewPairHasNoHtmlView(): void + { + $view = $this->viewFactory('rendered.plain'); + + $mailer = new Mailer('array', $view, new ArrayTransport); + + $sentMessage = $mailer->send([null, 'bar'], ['data'], function (Message $message): void { + $message->to('taylor@hypervel.org')->from('hello@hypervel.org'); + }); + + $this->assertSame('rendered.plain', $sentMessage->getOriginalMessage()->getTextBody()); + $this->assertNull($sentMessage->getOriginalMessage()->getHtmlBody()); + } + public function testToAllowsEmailAndName(): void { $view = $this->viewFactory('rendered.view'); diff --git a/tests/Mail/MailMessageTest.php b/tests/Mail/MailMessageTest.php index 7e0e9b6781..0c8e724c22 100644 --- a/tests/Mail/MailMessageTest.php +++ b/tests/Mail/MailMessageTest.php @@ -79,6 +79,16 @@ public function testToMethod(): void $this->assertEquals(new Address('another@example.test'), $this->message->getSymfonyMessage()->getTo()[0]); } + public function testToMethodAcceptsNamedAddressesBeforeUnnamedAddresses(): void + { + $this->message->to(['taylor@example.test' => 'Taylor', 'foo@example.test']); + + $this->assertEquals([ + new Address('taylor@example.test', 'Taylor'), + new Address('foo@example.test'), + ], $this->message->getSymfonyMessage()->getTo()); + } + public function testToMethodWithOverride(): void { $this->assertInstanceOf(Message::class, $message = $this->message->to('foo@bar.baz', 'Foo', true)); From 55e39ae2bd100d00e2a6636819465fdda0c8baf7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:44:50 +0000 Subject: [PATCH 2/8] Match Orchestra's Testbench teardown order and application reloads Testbench's beforeApplicationDestroyed() appended callbacks, so cleanup registered in setUp() ran before cleanup registered later in the test. Orchestra Testbench runs these callbacks newest first, and tests converted in laravel/framework#61739 rely on that order. Testbench now overrides beforeApplicationDestroyed() as a public method that runs callbacks newest first, so later setup is cleaned up before the setup it depends on. Foundation's base test case keeps its existing order. reloadApplication() called the test case's own tearDown() and setUp(), so every reload reran test-specific teardown and setup. It now tears down and sets up only the Testbench environment, through tearDownTheTestbenchEnvironment() and setUpTheTestbenchEnvironment(), as Orchestra does. Setup that a reloaded application needs again can be registered with afterApplicationRefreshed(), ported from Orchestra Testbench. Its callbacks run before the test case's traits are set up, are kept across reloads and run again for each reloaded application. The Testbench documentation describes it. Tests: - ApplicationTestingHooksTest and ReloadApplicationTest cover the destroy order, the refreshed callbacks and reloads. - The Sentry cache and classification tests register their reload-sensitive setup with afterApplicationRefreshed(). - DefineCacheRoutesTest's failed reload tears down only the Testbench environment. - RemoteCommandTest checks the restored package manifest after the test case is torn down, since the restore registered in setUp() now runs after callbacks registered in the test. Verified with php-cs-fixer, PHPStan, the full test suite and the Testbench package and dogfood suites. --- src/docs/testbench.md | 13 ++++ .../InteractsWithTestCaseLifecycle.php | 32 +++++++++ src/testbench/src/Concerns/Testing.php | 4 +- src/testbench/src/TestCase.php | 34 +++++++++ .../Sentry/Features/CacheIntegrationTest.php | 8 ++- .../ClassificationIntegrationTest.php | 5 +- .../Testbench/ApplicationTestingHooksTest.php | 70 +++++++++++++++++++ .../Concerns/DefineCacheRoutesTest.php | 2 +- .../Foundation/Process/RemoteCommandTest.php | 25 +++++-- tests/Testbench/ReloadApplicationTest.php | 67 ++++++++++++++++++ 10 files changed, 249 insertions(+), 11 deletions(-) create mode 100644 tests/Testbench/ApplicationTestingHooksTest.php create mode 100644 tests/Testbench/ReloadApplicationTest.php diff --git a/src/docs/testbench.md b/src/docs/testbench.md index c75e72469c..82f2b03c7b 100644 --- a/src/docs/testbench.md +++ b/src/docs/testbench.md @@ -934,6 +934,19 @@ PHP); The `defineCacheRoutes` method writes a temporary route file into the runtime skeleton, runs `route:cache`, reloads the application, and removes the generated files during teardown. +Reloading creates a new application without running your test's `setUp` method again. If the reloaded application also needs setup from that method, register it with `afterApplicationRefreshed` before calling `parent::setUp()`. These callbacks run before the test case's traits are set up, and again each time the application is reloaded: + +```php +use Hypervel\Support\Facades\Http; + +protected function setUp(): void +{ + $this->afterApplicationRefreshed(fn () => Http::preventStrayRequests()); + + parent::setUp(); +} +``` + If you need to define routes through the same temporary route-file mechanism without route caching, use `defineStashRoutes`: ```php diff --git a/src/foundation/src/Testing/Concerns/InteractsWithTestCaseLifecycle.php b/src/foundation/src/Testing/Concerns/InteractsWithTestCaseLifecycle.php index 1d113547c0..657fb59267 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithTestCaseLifecycle.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithTestCaseLifecycle.php @@ -39,6 +39,11 @@ trait InteractsWithTestCaseLifecycle */ protected array $afterApplicationCreatedCallbacks = []; + /** + * The callbacks that should be run after the application is refreshed. + */ + protected array $afterApplicationRefreshedCallbacks = []; + /** * The callbacks that should be run before the application is destroyed. */ @@ -54,6 +59,11 @@ trait InteractsWithTestCaseLifecycle */ protected bool $setUpHasRun = false; + /** + * Indicates if the application refreshed callbacks have started running. + */ + protected bool $afterApplicationRefreshedCallbacksStarted = false; + /** * Set up the test environment. * @@ -74,6 +84,12 @@ protected function setUpTheTestEnvironment(): void DatabaseConnectionResolver::resetCachedConnections(); $this->runInCoroutine(function () { + $this->afterApplicationRefreshedCallbacksStarted = true; + + foreach ($this->afterApplicationRefreshedCallbacks as $callback) { + $callback(); + } + $this->setUpTraits(); // Preserve transaction manager context for the test coroutine. @@ -151,6 +167,8 @@ protected function tearDownTheTestEnvironment(): void $this->beforeApplicationDestroyedCallbacks = []; $this->callbackException = null; $this->setUpHasRun = false; + // Refreshed callbacks are kept so a reloaded application runs them again. + $this->afterApplicationRefreshedCallbacksStarted = false; try { $this->flushState(); @@ -286,6 +304,20 @@ public static function tearDownAfterClassUsingTestCase(): void { } + /** + * Register a callback to be run after the application is refreshed. + * + * The callbacks run before the test case's traits are set up, and again for each reloaded application. + */ + public function afterApplicationRefreshed(callable $callback): void + { + $this->afterApplicationRefreshedCallbacks[] = $callback; + + if ($this->afterApplicationRefreshedCallbacksStarted) { + $callback(); + } + } + /** * Register a callback to be run after the application is created. */ diff --git a/src/testbench/src/Concerns/Testing.php b/src/testbench/src/Concerns/Testing.php index 48fabf6511..1cbe3ee068 100644 --- a/src/testbench/src/Concerns/Testing.php +++ b/src/testbench/src/Concerns/Testing.php @@ -31,7 +31,7 @@ trait Testing */ protected function reloadApplication(): void { - $this->tearDown(); - $this->setUp(); + $this->tearDownTheTestbenchEnvironment(); + $this->setUpTheTestbenchEnvironment(); } } diff --git a/src/testbench/src/TestCase.php b/src/testbench/src/TestCase.php index c8fb34958e..6aace84042 100644 --- a/src/testbench/src/TestCase.php +++ b/src/testbench/src/TestCase.php @@ -54,6 +54,18 @@ class TestCase extends BaseTestCase implements Contracts\TestCase * Setup the test environment. */ protected function setUp(): void + { + $this->setUpTheTestbenchEnvironment(); + } + + /** + * Set up the Testbench environment. + * + * Application reloads call this directly, so the test case's own setUp() runs only once. + * + * @internal + */ + protected function setUpTheTestbenchEnvironment(): void { if ($this->withoutBootingFramework()) { parent::setUp(); @@ -231,6 +243,18 @@ protected function refreshApplication(): void * Clean up the testing environment before the next test. */ protected function tearDown(): void + { + $this->tearDownTheTestbenchEnvironment(); + } + + /** + * Clean up the Testbench environment. + * + * Application reloads call this directly, so the test case's own tearDown() runs only once. + * + * @internal + */ + protected function tearDownTheTestbenchEnvironment(): void { if ($this->withoutBootingFramework()) { parent::tearDown(); @@ -290,6 +314,16 @@ protected function tearDown(): void } } + /** + * Register a callback to be run before the application is destroyed. + * + * Callbacks run newest first, so later setup is cleaned up before the setup it depends on. + */ + public function beforeApplicationDestroyed(callable $callback): void + { + array_unshift($this->beforeApplicationDestroyedCallbacks, $callback); + } + /** * Prepare the testing environment before the running the test case. */ diff --git a/tests/Sentry/Features/CacheIntegrationTest.php b/tests/Sentry/Features/CacheIntegrationTest.php index 15a774e542..80df893dda 100644 --- a/tests/Sentry/Features/CacheIntegrationTest.php +++ b/tests/Sentry/Features/CacheIntegrationTest.php @@ -57,10 +57,12 @@ class CacheIntegrationTest extends TestCase #[Override] protected function setUp(): void { - parent::setUp(); - // Ensure that session keys can be detected in the tests that are running from the console - $this->app->make(CacheIntegration::class)->detectSessionKeyOnConsole = true; + $this->afterApplicationRefreshed(function (): void { + $this->app->make(CacheIntegration::class)->detectSessionKeyOnConsole = true; + }); + + parent::setUp(); } public function testCacheBreadcrumbForWriteAndHitIsRecorded(): void diff --git a/tests/Sentry/Features/ClassificationIntegrationTest.php b/tests/Sentry/Features/ClassificationIntegrationTest.php index c4f18a4919..7bdc1b13b7 100644 --- a/tests/Sentry/Features/ClassificationIntegrationTest.php +++ b/tests/Sentry/Features/ClassificationIntegrationTest.php @@ -83,8 +83,11 @@ protected function getPackageProviders(Application $app): array */ protected function setUp(): void { + $this->afterApplicationRefreshed(function (): void { + Http::preventStrayRequests(); + }); + parent::setUp(); - Http::preventStrayRequests(); } public function testEvaluateSpanIsRecorded(): void diff --git a/tests/Testbench/ApplicationTestingHooksTest.php b/tests/Testbench/ApplicationTestingHooksTest.php new file mode 100644 index 0000000000..e6d20d6b1a --- /dev/null +++ b/tests/Testbench/ApplicationTestingHooksTest.php @@ -0,0 +1,70 @@ +hooks[] = 'trait'; + + $this->afterApplicationRefreshed(function (): void { + $this->hooks[] = 'trait refreshed'; + }); + } +} + +class ApplicationTestingHooksTest extends TestCase +{ + use RegistersRefreshedCallbackDuringSetUp; + + /** + * @var list + */ + protected array $hooks = []; + + /** + * Register the application callbacks before the application is created. + */ + protected function setUp(): void + { + $this->afterApplicationRefreshed(function (): void { + $this->hooks[] = 'refreshed'; + }); + + $this->afterApplicationCreated(function (): void { + $this->hooks[] = 'created'; + }); + + parent::setUp(); + } + + public function testRefreshedCallbacksRunBeforeTraitsAndCreatedCallbacks(): void + { + $this->assertSame(['refreshed', 'trait', 'trait refreshed', 'created'], $this->hooks); + } + + public function testDestroyedCallbacksRunNewestFirst(): void + { + $destroyed = []; + + $this->beforeApplicationDestroyed(function () use (&$destroyed): void { + $this->assertSame(['newest', 'middle'], $destroyed); + }); + + $this->beforeApplicationDestroyed(function () use (&$destroyed): void { + $destroyed[] = 'middle'; + }); + + $this->beforeApplicationDestroyed(function () use (&$destroyed): void { + $destroyed[] = 'newest'; + }); + } +} diff --git a/tests/Testbench/Concerns/DefineCacheRoutesTest.php b/tests/Testbench/Concerns/DefineCacheRoutesTest.php index a500f45572..cb529efe3d 100644 --- a/tests/Testbench/Concerns/DefineCacheRoutesTest.php +++ b/tests/Testbench/Concerns/DefineCacheRoutesTest.php @@ -27,7 +27,7 @@ protected function reloadApplication(): void return; } - $this->tearDown(); + $this->tearDownTheTestbenchEnvironment(); throw new RuntimeException('Application reload failed.'); } diff --git a/tests/Testbench/Foundation/Process/RemoteCommandTest.php b/tests/Testbench/Foundation/Process/RemoteCommandTest.php index e7e580da3b..03cd866707 100644 --- a/tests/Testbench/Foundation/Process/RemoteCommandTest.php +++ b/tests/Testbench/Foundation/Process/RemoteCommandTest.php @@ -28,6 +28,23 @@ class RemoteCommandTest extends TestCase { use InteractsWithSqliteDatabaseFile; + /** + * The assertions to run once the test case has been torn down. + */ + private ?Closure $afterTearDown = null; + + /** + * Clean up the test environment. + */ + protected function tearDown(): void + { + parent::tearDown(); + + if ($this->afterTearDown !== null) { + ($this->afterTearDown)(); + } + } + #[Test] public function itCanCallRemoteAndGetCurrentVersion(): void { @@ -155,16 +172,16 @@ public function itRestoresThePackageManifestAfterRemoteCommands(): void $existed = $files->isFile($path); $contents = $existed ? $files->get($path) : ''; - // The base TestCase registered its restoration callback during setUp, - // so this later callback observes the already-restored manifest. - $this->beforeApplicationDestroyed(function () use ($files, $path, $existed, $contents): void { + // The base TestCase restores the manifest while the application is + // destroyed, so check it once teardown has finished. + $this->afterTearDown = function () use ($files, $path, $existed, $contents): void { if ($existed) { $this->assertFileExists($path); $this->assertSame($contents, $files->get($path)); } else { $this->assertFileDoesNotExist($path); } - }); + }; // Force the child to build the manifest instead of reusing the baseline cache. if ($files->isFile($path)) { diff --git a/tests/Testbench/ReloadApplicationTest.php b/tests/Testbench/ReloadApplicationTest.php new file mode 100644 index 0000000000..5ff638813c --- /dev/null +++ b/tests/Testbench/ReloadApplicationTest.php @@ -0,0 +1,67 @@ + + */ + protected array $refreshed = []; + + /** + * Register a refreshed callback before the application is created. + */ + protected function setUp(): void + { + ++$this->setUpCalls; + + $this->afterApplicationRefreshed(function (): void { + $this->refreshed[] = ['setUp', $this->app]; + }); + + parent::setUp(); + } + + /** + * Count the test case's own teardown calls. + */ + protected function tearDown(): void + { + ++$this->tearDownCalls; + + parent::tearDown(); + } + + public function testReloadRunsRefreshedCallbacksAgainWithoutRerunningTheTestCaseLifecycle(): void + { + $original = $this->app; + + $this->afterApplicationRefreshed(function (): void { + $this->refreshed[] = ['body', $this->app]; + }); + + $this->assertSame([['setUp', $original], ['body', $original]], $this->refreshed); + + $this->reloadApplication(); + + $this->assertNotSame($original, $this->app); + $this->assertSame([ + ['setUp', $original], + ['body', $original], + ['setUp', $this->app], + ['body', $this->app], + ], $this->refreshed); + $this->assertSame(1, $this->setUpCalls); + $this->assertSame(0, $this->tearDownCalls); + } +} From 4c0c37dc7cc5b715d9bae5b2e001ec3a3fce27ec Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:45:16 +0000 Subject: [PATCH 3/8] Finish porting Laravel's real-object test conversions Laravel's final pass replacing test mocks with real objects, fakes and in-memory connections (laravel/framework#61739) was partly ported with #61663. This ports the remaining files at the 13.x revision 5d7797528723107cd4f495326838a7d7d67fbb20. Same-shape Hypervel-only tests are converted alongside; distinct Hypervel tests keep their mocks. Hunks that later pull requests changed again stay with those ports. Hypervel adaptations: - Keep resolver mocks where upstream builds Laravel's array-backed ConnectionResolver, since Hypervel's resolver is the pooled one built by the container. The database README and Laravel porting guide now describe the difference. - Use the session store where the memoized store tests used APC, which Hypervel does not support. - Record setArtisan() calls in the console kernel spy, so the setArtisan(null) checks survive, and add replace() and a read-only chmod() to the env command test filesystem for Hypervel's atomic, permission-preserving writes. - Stub usesBackslashEscapes() on the schema builder test connections and read metadata through selectFromWriteConnection(). - Fake the exception handler in the scheduled event ping test instead of an anonymous handler. - Send every mail notification test through a real array mailer, including the named-address and plain-only view cases upstream keeps on mocks. - Keep the mock in the deprecation logging-failure test, because the real log manager absorbs creator failures. Coverage kept or corrected: - Check that creating or deleting a password reset token removes only that user's tokens. - Check that cache() stores with the given lifetime, that the email verification listener sends exactly one notification and that Fluent::toJson() encodes the toArray() result. - Keep exact ordered event lists in the cache event and sync queue tests, and the scheduling mutex lifetime as a whole-second check. - Keep Hypervel's file mode assertions and corrected decrypt test names in the env command tests. Not ported: the Laravel Cloud tests, the deferred-provider manifest test and the cache-based session handler test that Hypervel already removed. Verified with php-cs-fixer, PHPStan and the full test suite. --- src/database/README.md | 1 + src/docs/porting-from-laravel.md | 2 + .../Auth/AuthDatabaseTokenRepositoryTest.php | 167 ++++---- ...ficationNotificationHandleFunctionTest.php | 97 ++++- tests/Auth/AuthPasswordBrokerTest.php | 32 +- tests/Broadcasting/BroadcasterTest.php | 10 +- tests/Cache/CacheEventsTest.php | 311 +++++++------- tests/Cache/CacheFileStoreTest.php | 49 +-- tests/Cache/CacheMemoizedStoreTest.php | 5 +- tests/Console/CacheCommandMutexTest.php | 59 +-- tests/Console/CommandMutexTest.php | 73 ++-- .../Console/Concerns/InteractsWithIOTest.php | 24 +- tests/Console/Fixtures/FakeCacheFactory.php | 34 ++ .../Scheduling/CacheEventMutexTest.php | 67 ++-- .../Scheduling/CacheSchedulingMutexTest.php | 71 ++-- tests/Console/Scheduling/EventTest.php | 90 ++--- tests/Console/Scheduling/ScheduleTest.php | 20 +- tests/Console/View/ComponentsTest.php | 28 +- tests/Container/ContainerCallTest.php | 13 +- tests/Container/ContainerTest.php | 6 + ...tBelongsToManyWithCastedAttributesTest.php | 3 +- ...BelongsToManyWithDefaultAttributesTest.php | 3 +- .../DatabaseEloquentMorphToManyTest.php | 5 +- .../DatabaseMariaDbSchemaBuilderTest.php | 22 +- .../DatabaseMySQLSchemaBuilderTest.php | 22 +- .../Database/DatabasePostgresBuilderTest.php | 80 ++-- .../DatabasePostgresSchemaBuilderTest.php | 16 +- tests/Database/DatabaseSQLiteBuilderTest.php | 10 +- .../DatabaseSQLiteQueryGrammarTest.php | 5 +- tests/Database/DatabaseSchemaBuilderTest.php | 28 +- tests/Events/EventsDispatcherTest.php | 19 +- .../Bootstrap/HandleExceptionsTest.php | 216 ++++------ .../Exceptions/Renderer/FrameTest.php | 4 +- .../Foundation/FoundationDevCommandsTest.php | 38 +- .../FoundationExceptionsHandlerTest.php | 245 +++++++++--- .../Foundation/FoundationFormRequestTest.php | 43 +- tests/Foundation/FoundationHelpersTest.php | 31 +- .../FoundationInteractsWithDatabaseTest.php | 157 +++----- .../Concerns/InteractsWithContainerTest.php | 12 +- .../Testing/DatabaseMigrationsTest.php | 126 +++--- .../Testing/Fixtures/ConsoleKernelSpy.php | 148 +++++++ .../Testing/LazilyRefreshDatabaseTest.php | 49 +-- .../Testing/RefreshDatabaseTest.php | 71 ++-- .../Console/EnvironmentDecryptCommandTest.php | 202 ++++------ .../Console/EnvironmentEncryptCommandTest.php | 258 ++++-------- ...vironmentEncryptIncrementalCommandTest.php | 192 ++++----- .../Console/Fixtures/ArrayFilesystem.php | 133 ++++++ .../Console/Scheduling/EventPingTest.php | 9 +- .../DatabaseEloquentBroadcastingTest.php | 92 ++++- tests/Integration/Events/ListenerTest.php | 27 +- .../Fixtures/mail/my-custom-theme.blade.php | 1 + .../SendingMailNotificationsTest.php | 378 +++++++----------- tests/Queue/QueueBeanstalkdJobTest.php | 15 +- tests/Queue/QueueClearCommandTest.php | 57 ++- tests/Queue/QueueSqsJobTest.php | 5 - tests/Queue/QueueSyncQueueTest.php | 18 +- tests/Routing/RouteRegistrarTest.php | 5 +- tests/Support/SupportFacadeTest.php | 11 +- tests/Support/SupportFluentTest.php | 14 +- tests/View/ViewTest.php | 11 +- 60 files changed, 2156 insertions(+), 1784 deletions(-) create mode 100644 tests/Console/Fixtures/FakeCacheFactory.php create mode 100644 tests/Foundation/Testing/Fixtures/ConsoleKernelSpy.php create mode 100644 tests/Integration/Console/Fixtures/ArrayFilesystem.php create mode 100644 tests/Integration/Notifications/Fixtures/mail/my-custom-theme.blade.php diff --git a/src/database/README.md b/src/database/README.md index 05fa854a3d..a88ad86afc 100644 --- a/src/database/README.md +++ b/src/database/README.md @@ -20,6 +20,7 @@ Documentation: https://hypervel.org/docs/database - Laravel's `migrate:fresh` command wipes only its selected connection. Hypervel discovers the connection declared by each migration and wipes every resolved target before rebuilding the schema. - Laravel's deprecated database-inspection forwarding helpers are intentionally not ported. Extensions can call `ConnectionInterface::getDriverTitle()` and `threadCount()` directly. - Laravel's remaining directly deprecated Database compatibility forwarders are intentionally not ported. Use the current class-keyed factory resolver, schema blueprint and grammar APIs, and correctly named PostgreSQL truncation method instead. +- Laravel's `ConnectionResolver` is a simple registry of connection instances. Hypervel's `ConnectionResolver` is the pooled resolver built by the container, so it has no array constructor or `addConnection()` method. Tests that inject existing connections can mock `ConnectionResolverInterface`, while tests that need real configured connections can use the Capsule manager. - Laravel's Capsule manager exposes a `setFetchMode()` method that writes configuration its connections do not read. Hypervel omits this ineffective connection-wide setter; use `Query\Builder::fetchUsing()` for each query that needs a custom row shape. - `make:migration` omits Laravel's deprecated `--fullpath` option and obsolete Composer constructor dependency because migration creation no longer dumps autoload files. - `Blueprint::dropForeign()` widens Laravel's method signature with an optional constraint name when columns are supplied, allowing explicitly named foreign keys to be dropped portably across SQLite and the server databases. Custom `Blueprint` subclasses that override this method must accept the optional second argument. diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index 6dade8895a..67f81270c1 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -809,6 +809,8 @@ If your extension inspects transaction records or registers callbacks directly w When a package constructs `DatabaseStore`, `DatabaseSessionHandler`, `DatabaseQueue`, or `DatabaseBatchRepository` directly, pass the database connection resolver and configured connection name instead of retaining a resolved connection. Framework-configured drivers already use this form. +Laravel's `ConnectionResolver` can be built from an array of connection instances, but Hypervel's `ConnectionResolver` is the pooled resolver created by the container. Where package tests build Laravel's resolver directly, mock `ConnectionResolverInterface` to inject existing connections, or use the Capsule manager for real configured connections. + Laravel's base `Connection` class exposes PDO methods. Hypervel's base `Connection` is driver-neutral, while its built-in SQL connections extend `PdoConnection`. Ported code that calls `getPdo`, `getReadPdo`, or another PDO-specific method should accept or narrow to `PdoConnection`. See [extending database connections](/docs/{{version}}/database#extending-database-connections) when porting a custom driver. Custom base query builders overriding `newQuery`, `forNestedWhere`, or `cloneForPaginationCount` must declare `static` returns and preserve the concrete builder class. Keep `forSubQuery` separate: join subqueries return the parent query builder. See the [database extension guide](/docs/{{version}}/database#extending-database-connections) for these return contracts. diff --git a/tests/Auth/AuthDatabaseTokenRepositoryTest.php b/tests/Auth/AuthDatabaseTokenRepositoryTest.php index 83e33d4348..f209fe43d3 100755 --- a/tests/Auth/AuthDatabaseTokenRepositoryTest.php +++ b/tests/Auth/AuthDatabaseTokenRepositoryTest.php @@ -10,53 +10,44 @@ use Hypervel\Database\ConnectionInterface; use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Database\Query\Builder; +use Hypervel\Database\Schema\Blueprint; +use Hypervel\Database\SQLiteConnection; +use Hypervel\Hashing\BcryptHasher; use Hypervel\Support\CarbonImmutable; use Hypervel\Tests\TestCase; use Mockery as m; +use PDO; class AuthDatabaseTokenRepositoryTest extends TestCase { public function testCreateInsertsNewRecordIntoTable(): void { $repo = $this->getRepo(); - $repo->getHasher()->expects('make')->andReturn('hashed-token'); - $query = m::mock(Builder::class); - $repo->getConnection()->expects('table')->times(2)->with('table')->andReturn($query); - $query->expects('where')->with('email', 'email')->andReturn($query); - $query->expects('delete'); - $query->expects('insert'); - $user = m::mock(CanResetPassword::class); - $user->expects('getEmailForPasswordReset')->times(2)->andReturn('email'); + $user = $this->getUser('email'); + $this->insertToken($repo, 'email', 'old-token', CarbonImmutable::now()); + $this->insertToken($repo, 'other@example.com', 'other-token', CarbonImmutable::now()); - $results = $repo->create($user); + $token = $repo->create($user); - $this->assertIsString($results); - $this->assertGreaterThan(1, strlen($results)); + $this->assertIsString($token); + $this->assertGreaterThan(1, strlen($token)); + $this->assertTrue($repo->exists($user, $token)); + $this->assertSame(1, $repo->getConnection()->table('table')->where('email', 'email')->count()); + $this->assertTrue($repo->exists($this->getUser('other@example.com'), 'other-token')); } public function testExistReturnsFalseIfNoRowFoundForUser(): void { $repo = $this->getRepo(); - $query = m::mock(Builder::class); - $repo->getConnection()->expects('table')->with('table')->andReturn($query); - $query->expects('where')->with('email', 'email')->andReturn($query); - $query->expects('first')->andReturn(null); - $user = m::mock(CanResetPassword::class); - $user->expects('getEmailForPasswordReset')->andReturn('email'); - $this->assertFalse($repo->exists($user, 'token')); + $this->assertFalse($repo->exists($this->getUser('email'), 'token')); } public function testExistReturnsFalseIfRecordIsExpired(): void { $repo = $this->getRepo(); - $query = m::mock(Builder::class); - $repo->getConnection()->expects('table')->with('table')->andReturn($query); - $query->expects('where')->with('email', 'email')->andReturn($query); - $date = CarbonImmutable::now()->subSeconds(300000)->toDateTimeString(); - $query->expects('first')->andReturn((object) ['created_at' => $date, 'token' => 'hashed-token']); - $user = m::mock(CanResetPassword::class); - $user->expects('getEmailForPasswordReset')->andReturn('email'); + $user = $this->getUser('email'); + $this->insertToken($repo, 'email', 'token', CarbonImmutable::now()->subSeconds(300000)); $this->assertFalse($repo->exists($user, 'token')); } @@ -64,14 +55,8 @@ public function testExistReturnsFalseIfRecordIsExpired(): void public function testExistReturnsTrueIfValidRecordExists(): void { $repo = $this->getRepo(); - $repo->getHasher()->expects('check')->with('token', 'hashed-token')->andReturn(true); - $query = m::mock(Builder::class); - $repo->getConnection()->expects('table')->with('table')->andReturn($query); - $query->expects('where')->with('email', 'email')->andReturn($query); - $date = CarbonImmutable::now()->subMinutes(10)->toDateTimeString(); - $query->expects('first')->andReturn((object) ['created_at' => $date, 'token' => 'hashed-token']); - $user = m::mock(CanResetPassword::class); - $user->expects('getEmailForPasswordReset')->andReturn('email'); + $user = $this->getUser('email'); + $this->insertToken($repo, 'email', 'token', CarbonImmutable::now()->subMinutes(10)); $this->assertTrue($repo->exists($user, 'token')); } @@ -79,14 +64,8 @@ public function testExistReturnsTrueIfValidRecordExists(): void public function testExistReturnsFalseIfInvalidToken(): void { $repo = $this->getRepo(); - $repo->getHasher()->expects('check')->with('wrong-token', 'hashed-token')->andReturn(false); - $query = m::mock(Builder::class); - $repo->getConnection()->expects('table')->with('table')->andReturn($query); - $query->expects('where')->with('email', 'email')->andReturn($query); - $date = CarbonImmutable::now()->subMinutes(10)->toDateTimeString(); - $query->expects('first')->andReturn((object) ['created_at' => $date, 'token' => 'hashed-token']); - $user = m::mock(CanResetPassword::class); - $user->expects('getEmailForPasswordReset')->andReturn('email'); + $user = $this->getUser('email'); + $this->insertToken($repo, 'email', 'token', CarbonImmutable::now()->subMinutes(10)); $this->assertFalse($repo->exists($user, 'wrong-token')); } @@ -94,14 +73,8 @@ public function testExistReturnsFalseIfInvalidToken(): void public function testRecentlyCreatedReturnsFalseIfNoRowFoundForUser(): void { $repo = $this->getRepo(); - $query = m::mock(Builder::class); - $repo->getConnection()->expects('table')->with('table')->andReturn($query); - $query->expects('where')->with('email', 'email')->andReturn($query); - $query->expects('first')->andReturn(null); - $user = m::mock(CanResetPassword::class); - $user->expects('getEmailForPasswordReset')->andReturn('email'); - $this->assertFalse($repo->recentlyCreatedToken($user)); + $this->assertFalse($repo->recentlyCreatedToken($this->getUser('email'))); } public function testRecentlyCreatedReturnsTrueIfRecordIsRecentlyCreated(): void @@ -109,13 +82,8 @@ public function testRecentlyCreatedReturnsTrueIfRecordIsRecentlyCreated(): void CarbonImmutable::setTestNow($now = CarbonImmutable::now()); $repo = $this->getRepo(); - $query = m::mock(Builder::class); - $repo->getConnection()->expects('table')->with('table')->andReturn($query); - $query->expects('where')->with('email', 'email')->andReturn($query); - $date = $now->subSeconds(59)->toDateTimeString(); - $query->expects('first')->andReturn((object) ['created_at' => $date, 'token' => 'hashed-token']); - $user = m::mock(CanResetPassword::class); - $user->expects('getEmailForPasswordReset')->andReturn('email'); + $user = $this->getUser('email'); + $this->insertToken($repo, 'email', 'token', $now->subSeconds(59)); $this->assertTrue($repo->recentlyCreatedToken($user)); } @@ -125,13 +93,8 @@ public function testRecentlyCreatedReturnsFalseIfValidRecordExists(): void CarbonImmutable::setTestNow($now = CarbonImmutable::now()); $repo = $this->getRepo(); - $query = m::mock(Builder::class); - $repo->getConnection()->expects('table')->with('table')->andReturn($query); - $query->expects('where')->with('email', 'email')->andReturn($query); - $date = $now->subSeconds(61)->toDateTimeString(); - $query->expects('first')->andReturn((object) ['created_at' => $date, 'token' => 'hashed-token']); - $user = m::mock(CanResetPassword::class); - $user->expects('getEmailForPasswordReset')->andReturn('email'); + $user = $this->getUser('email'); + $this->insertToken($repo, 'email', 'token', $now->subSeconds(61)); $this->assertFalse($repo->recentlyCreatedToken($user)); } @@ -139,25 +102,26 @@ public function testRecentlyCreatedReturnsFalseIfValidRecordExists(): void public function testDeleteMethodDeletesByToken(): void { $repo = $this->getRepo(); - $query = m::mock(Builder::class); - $repo->getConnection()->expects('table')->with('table')->andReturn($query); - $query->expects('where')->with('email', 'email')->andReturn($query); - $query->expects('delete'); - $user = m::mock(CanResetPassword::class); - $user->expects('getEmailForPasswordReset')->andReturn('email'); + $user = $this->getUser('email'); + $this->insertToken($repo, 'email', 'token', CarbonImmutable::now()); + $this->insertToken($repo, 'other@example.com', 'other-token', CarbonImmutable::now()); $repo->delete($user); + + $this->assertFalse($repo->exists($user, 'token')); + $this->assertTrue($repo->exists($this->getUser('other@example.com'), 'other-token')); } public function testDeleteExpiredMethodDeletesExpiredTokens(): void { $repo = $this->getRepo(); - $query = m::mock(Builder::class); - $repo->getConnection()->expects('table')->with('table')->andReturn($query); - $query->expects('where')->with('created_at', '<', m::any())->andReturn($query); - $query->expects('delete'); + $this->insertToken($repo, 'expired@example.com', 'expired-token', CarbonImmutable::now()->subSeconds(3700)); + $this->insertToken($repo, 'recent@example.com', 'recent-token', CarbonImmutable::now()->subSeconds(10)); $repo->deleteExpired(); + + $rows = $repo->getConnection()->table('table')->pluck('email')->all(); + $this->assertSame(['recent@example.com'], $rows); } public function testResolverBackedRepositoryResolvesTheConfiguredConnectionForEveryOperation(): void @@ -218,15 +182,60 @@ public function testResolverContractTakesPrecedenceForObjectsImplementingBothCon } /** - * Create a token repository with mocked dependencies. + * Create a token repository backed by an in-memory SQLite table. */ protected function getRepo(): DatabaseTokenRepository { - return new DatabaseTokenRepository( - m::mock(ConnectionInterface::class), - m::mock(Hasher::class), - 'table', - 'key' - ); + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); + + $connection->getSchemaBuilder()->create('table', function (Blueprint $table): void { + $table->string('email')->index(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + + return new DatabaseTokenRepository($connection, new BcryptHasher, 'table', 'key'); + } + + /** + * Insert a hashed token record for the given email. + */ + protected function insertToken(DatabaseTokenRepository $repo, string $email, string $token, CarbonImmutable $createdAt): void + { + $repo->getConnection()->table('table')->insert([ + 'email' => $email, + 'token' => $repo->getHasher()->make($token), + 'created_at' => $createdAt, + ]); + } + + /** + * Create a password reset user with the given email. + */ + protected function getUser(string $email): CanResetPassword + { + return new class($email) implements CanResetPassword { + /** + * Create a user with the given email. + */ + public function __construct(protected string $email) + { + } + + /** + * Get the e-mail address where password reset links are sent. + */ + public function getEmailForPasswordReset(): string + { + return $this->email; + } + + /** + * Send the password reset notification. + */ + public function sendPasswordResetNotification(string $token): void + { + } + }; } } diff --git a/tests/Auth/AuthListenersSendEmailVerificationNotificationHandleFunctionTest.php b/tests/Auth/AuthListenersSendEmailVerificationNotificationHandleFunctionTest.php index ef0d765416..a3c28fa0d8 100644 --- a/tests/Auth/AuthListenersSendEmailVerificationNotificationHandleFunctionTest.php +++ b/tests/Auth/AuthListenersSendEmailVerificationNotificationHandleFunctionTest.php @@ -5,44 +5,119 @@ namespace Hypervel\Tests\Auth; use Hypervel\Auth\Events\Registered; +use Hypervel\Auth\GenericUser; use Hypervel\Auth\Listeners\SendEmailVerificationNotification; -use Hypervel\Contracts\Auth\Authenticatable; use Hypervel\Contracts\Auth\MustVerifyEmail; -use Hypervel\Foundation\Auth\User; use Hypervel\Tests\TestCase; -use Mockery as m; class AuthListenersSendEmailVerificationNotificationHandleFunctionTest extends TestCase { public function testWillExecuted(): void { - $user = m::mock(Authenticatable::class, MustVerifyEmail::class); - $user->shouldReceive('hasVerifiedEmail')->andReturn(false); - $user->expects('sendEmailVerificationNotification'); + $user = $this->unverifiedUser(); $listener = new SendEmailVerificationNotification; $listener->handle(new Registered($user)); + + $this->assertSame(1, $user->notificationsSent); } public function testUserIsNotInstanceOfMustVerifyEmail(): void { - $user = m::mock(User::class); - $user->shouldNotReceive('sendEmailVerificationNotification'); + // Has the verification methods but doesn't implement MustVerifyEmail. + $user = new class([]) extends GenericUser { + public int $notificationsSent = 0; + + /** + * Determine if the user has verified their email address. + */ + public function hasVerifiedEmail(): bool + { + return false; + } + + /** + * Send the email verification notification. + */ + public function sendEmailVerificationNotification(): void + { + ++$this->notificationsSent; + } + }; $listener = new SendEmailVerificationNotification; $listener->handle(new Registered($user)); + + $this->assertSame(0, $user->notificationsSent); } public function testHasVerifiedEmailAsTrue(): void { - $user = m::mock(Authenticatable::class, MustVerifyEmail::class); - $user->shouldReceive('hasVerifiedEmail')->andReturn(true); - $user->shouldNotReceive('sendEmailVerificationNotification'); + $user = $this->unverifiedUser(); + $user->verified = true; $listener = new SendEmailVerificationNotification; $listener->handle(new Registered($user)); + + $this->assertSame(0, $user->notificationsSent); + } + + /** + * Create an unverified user that counts verification notifications. + */ + protected function unverifiedUser(): GenericUser&MustVerifyEmail + { + return new class([]) extends GenericUser implements MustVerifyEmail { + public bool $verified = false; + + public int $notificationsSent = 0; + + /** + * Determine if the user has verified their email address. + */ + public function hasVerifiedEmail(): bool + { + return $this->verified; + } + + /** + * Mark the given user's email as verified. + */ + public function markEmailAsVerified(): bool + { + $this->verified = true; + + return true; + } + + /** + * Mark the given user's email as unverified. + */ + public function markEmailAsUnverified(): bool + { + $this->verified = false; + + return true; + } + + /** + * Send the email verification notification. + */ + public function sendEmailVerificationNotification(): void + { + ++$this->notificationsSent; + } + + /** + * Get the email address that should be used for verification. + */ + public function getEmailForVerification(): string + { + return 'test@example.com'; + } + }; } } diff --git a/tests/Auth/AuthPasswordBrokerTest.php b/tests/Auth/AuthPasswordBrokerTest.php index c106ebc968..b2b304e9fb 100755 --- a/tests/Auth/AuthPasswordBrokerTest.php +++ b/tests/Auth/AuthPasswordBrokerTest.php @@ -13,6 +13,7 @@ use Hypervel\Contracts\Auth\PasswordBroker as PasswordBrokerContract; use Hypervel\Contracts\Auth\UserProvider; use Hypervel\Contracts\Events\Dispatcher; +use Hypervel\Foundation\Auth\User; use Hypervel\Support\Arr; use Hypervel\Tests\TestCase; use Mockery as m; @@ -22,18 +23,16 @@ class AuthPasswordBrokerTest extends TestCase { public function testIfUserIsNotFoundErrorRedirectIsReturned(): void { - $mocks = $this->getMocks(); - $broker = m::mock(PasswordBroker::class, array_values($mocks))->makePartial(); - $broker->expects('getUser')->andReturnNull(); + $broker = $this->getBroker($mocks = $this->getMocks()); + $mocks['users']->expects('retrieveByCredentials')->with(['credentials'])->andReturnNull(); $this->assertSame(PasswordBrokerContract::INVALID_USER, $broker->sendResetLink(['credentials'])); } public function testIfTokenIsRecentlyCreated(): void { - $mocks = $this->getMocks(); - $broker = m::mock(PasswordBroker::class, array_values($mocks))->makePartial(); - $user = m::mock(Authenticatable::class, CanResetPassword::class); + $broker = $this->getBroker($mocks = $this->getMocks()); + $user = new User; $mocks['users']->expects('retrieveByCredentials')->with(['foo'])->andReturn($user); $mocks['tokens']->expects('recentlyCreatedToken')->with($user)->andReturn(true); @@ -53,7 +52,7 @@ public function testGetUserThrowsExceptionIfUserDoesntImplementCanResetPassword( public function testUserIsRetrievedByCredentials(): void { $broker = $this->getBroker($mocks = $this->getMocks()); - $user = m::mock(Authenticatable::class, CanResetPassword::class); + $user = new User; $mocks['users']->expects('retrieveByCredentials')->with(['foo'])->andReturn($user); $this->assertEquals($user, $broker->getUser(['foo'])); @@ -61,8 +60,7 @@ public function testUserIsRetrievedByCredentials(): void public function testBrokerCreatesTokenAndRedirectsWithoutError(): void { - $mocks = $this->getMocks(); - $broker = m::mock(PasswordBroker::class, array_values($mocks))->makePartial(); + $broker = $this->getBroker($mocks = $this->getMocks()); $user = m::mock(Authenticatable::class, CanResetPassword::class); $mocks['users']->expects('retrieveByCredentials')->with(['foo'])->andReturn($user); $mocks['tokens']->expects('recentlyCreatedToken')->with($user)->andReturn(false); @@ -138,7 +136,7 @@ public function testRedirectReturnedByRemindWhenRecordDoesntExistInTable(): void { $credentials = ['token' => 'token']; $broker = $this->getBroker($mocks = $this->getMocks()); - $user = m::mock(Authenticatable::class, CanResetPassword::class); + $user = new User; $mocks['users']->expects('retrieveByCredentials')->with(Arr::except($credentials, ['token']))->andReturn($user); $mocks['tokens']->expects('exists')->with($user, 'token')->andReturn(false); @@ -149,10 +147,10 @@ public function testRedirectReturnedByRemindWhenRecordDoesntExistInTable(): void public function testResetRemovesRecordOnReminderTableAndCallsCallback(): void { $resetArguments = null; - $mocks = $this->getMocks(); - $broker = m::mock(PasswordBroker::class, array_values($mocks))->makePartial()->shouldAllowMockingProtectedMethods(); - $user = m::mock(Authenticatable::class, CanResetPassword::class); - $broker->expects('validateReset')->andReturn($user); + $broker = $this->getBroker($mocks = $this->getMocks()); + $user = new User; + $mocks['users']->expects('retrieveByCredentials')->with(['password' => 'password'])->andReturn($user); + $mocks['tokens']->expects('exists')->with($user, 'token')->andReturn(true); $mocks['tokens']->expects('delete')->with($user); $callback = function (CanResetPassword $user, string $password) use (&$resetArguments): string { $resetArguments = ['user' => $user, 'password' => $password]; @@ -172,13 +170,11 @@ public function testExecutesCallbackInsteadOfSendingNotification(): void $executed = true; }; - $mocks = $this->getMocks(); - $broker = m::mock(PasswordBroker::class, array_values($mocks))->makePartial(); - $user = m::mock(Authenticatable::class, CanResetPassword::class); + $broker = $this->getBroker($mocks = $this->getMocks()); + $user = new User; $mocks['users']->expects('retrieveByCredentials')->with(['foo'])->andReturn($user); $mocks['tokens']->expects('recentlyCreatedToken')->with($user)->andReturn(false); $mocks['tokens']->expects('create')->with($user)->andReturn('token'); - $user->shouldNotReceive('sendPasswordResetNotification'); $this->assertEquals(PasswordBrokerContract::RESET_LINK_SENT, $broker->sendResetLink(['foo'], $closure)); diff --git a/tests/Broadcasting/BroadcasterTest.php b/tests/Broadcasting/BroadcasterTest.php index fc8a6da050..06064fde5e 100644 --- a/tests/Broadcasting/BroadcasterTest.php +++ b/tests/Broadcasting/BroadcasterTest.php @@ -147,6 +147,9 @@ public function testCanRegisterChannelsAsClasses(): void }); $this->broadcaster->channel('somethingelse', DummyBroadcastingChannel::class); + + $this->assertSame([], $this->broadcaster->retrieveChannelOptions('something')); + $this->assertSame([], $this->broadcaster->retrieveChannelOptions('somethingelse')); } public function testNotFoundThrowsHttpException(): void @@ -162,13 +165,8 @@ public function testCanRegisterChannelsWithoutOptions(): void { $this->broadcaster->channel('somechannel', function (): void { }); - } - public function testCanRegisterChannelsWithOptions(): void - { - $options = ['a' => ['b', 'c']]; - $this->broadcaster->channel('somechannel', function (): void { - }, $options); + $this->assertSame([], $this->broadcaster->retrieveChannelOptions('somechannel')); } public function testCanRetrieveChannelsOptions(): void diff --git a/tests/Cache/CacheEventsTest.php b/tests/Cache/CacheEventsTest.php index 640016f4fe..58990560c7 100644 --- a/tests/Cache/CacheEventsTest.php +++ b/tests/Cache/CacheEventsTest.php @@ -29,11 +29,10 @@ use Hypervel\Cache\VersionedTagSet; use Hypervel\Contracts\Cache\CanFlushLocks; use Hypervel\Contracts\Cache\Store; -use Hypervel\Contracts\Events\Dispatcher; +use Hypervel\Contracts\Events\Dispatcher as DispatcherContract; +use Hypervel\Events\Dispatcher; use Hypervel\Tests\TestCase; use Mockery as m; -use Mockery\Matcher\Closure as ClosureMatcher; -use Mockery\MockInterface; use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; use Swoole\Coroutine\CanceledException; @@ -55,7 +54,7 @@ protected function event(object $event): void parent::event($event); } }; - $dispatcher = m::mock(Dispatcher::class); + $dispatcher = m::mock(DispatcherContract::class); $dispatcher->shouldReceive('hasListeners')->withAnyArgs()->andReturnFalse(); $dispatcher->shouldNotReceive('dispatch'); $repository->setEventDispatcher($dispatcher); @@ -66,12 +65,14 @@ protected function event(object $event): void $this->assertTrue($repository->clear()); $this->assertSame(0, $repository->eventCalls); - $dispatcher = $this->getDispatcher(); - $dispatcher->shouldReceive('dispatch')->twice(); - $repository->setEventDispatcher($dispatcher); + $repository->setEventDispatcher($this->getDispatcher()); $this->assertTrue($repository->put('key', 'value', 60)); $this->assertSame(2, $repository->eventCalls); + $this->assertEventDispatched(WritingKey::class, ['key' => 'key']); + $this->assertEventDispatched(KeyWritten::class, ['key' => 'key']); + + $this->assertSame([], $this->dispatchedEvents); } public function testNamespacedTaggedCacheSkipsEventDispatchSeamWithoutListenersAndEntersItWithListeners(): void @@ -90,7 +91,7 @@ protected function event(object $event): void parent::event($event); } }; - $dispatcher = m::mock(Dispatcher::class); + $dispatcher = m::mock(DispatcherContract::class); $dispatcher->shouldReceive('hasListeners')->withAnyArgs()->andReturnFalse(); $dispatcher->shouldNotReceive('dispatch'); $repository->setEventDispatcher($dispatcher); @@ -101,12 +102,14 @@ protected function event(object $event): void $this->assertTrue($repository->flush()); $this->assertSame(0, $repository->eventCalls); - $dispatcher = $this->getDispatcher(); - $dispatcher->shouldReceive('dispatch')->twice(); - $repository->setEventDispatcher($dispatcher); + $repository->setEventDispatcher($this->getDispatcher()); $this->assertTrue($repository->put('key', 'value', 60)); $this->assertSame(2, $repository->eventCalls); + $this->assertEventDispatched(WritingKey::class, ['key' => 'key', 'tags' => ['tag']]); + $this->assertEventDispatched(KeyWritten::class, ['key' => 'key', 'tags' => ['tag']]); + + $this->assertSame([], $this->dispatchedEvents); } public function testHasTriggersEvents(): void @@ -114,21 +117,23 @@ public function testHasTriggersEvents(): void $dispatcher = $this->getDispatcher(); $repository = $this->getRepository($dispatcher); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(RetrievingKey::class, ['storeName' => 'array', 'key' => 'foo'])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(CacheMissed::class, ['storeName' => 'array', 'key' => 'foo'])); $this->assertFalse($repository->has('foo')); + $this->assertEventDispatched(RetrievingKey::class, ['storeName' => 'array', 'key' => 'foo']); + $this->assertEventDispatched(CacheMissed::class, ['storeName' => 'array', 'key' => 'foo']); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(RetrievingKey::class, ['storeName' => 'array', 'key' => 'baz'])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(CacheHit::class, ['storeName' => 'array', 'key' => 'baz', 'value' => 'qux'])); $this->assertTrue($repository->has('baz')); + $this->assertEventDispatched(RetrievingKey::class, ['storeName' => 'array', 'key' => 'baz']); + $this->assertEventDispatched(CacheHit::class, ['storeName' => 'array', 'key' => 'baz', 'value' => 'qux']); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(RetrievingKey::class, ['storeName' => 'array', 'key' => 'foo', 'tags' => ['taylor']])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(CacheMissed::class, ['storeName' => 'array', 'key' => 'foo', 'tags' => ['taylor']])); $this->assertFalse($repository->tags('taylor')->has('foo')); + $this->assertEventDispatched(RetrievingKey::class, ['storeName' => 'array', 'key' => 'foo', 'tags' => ['taylor']]); + $this->assertEventDispatched(CacheMissed::class, ['storeName' => 'array', 'key' => 'foo', 'tags' => ['taylor']]); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(RetrievingKey::class, ['storeName' => 'array', 'key' => 'baz', 'tags' => ['taylor']])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(CacheHit::class, ['storeName' => 'array', 'key' => 'baz', 'value' => 'qux', 'tags' => ['taylor']])); $this->assertTrue($repository->tags('taylor')->has('baz')); + $this->assertEventDispatched(RetrievingKey::class, ['storeName' => 'array', 'key' => 'baz', 'tags' => ['taylor']]); + $this->assertEventDispatched(CacheHit::class, ['storeName' => 'array', 'key' => 'baz', 'value' => 'qux', 'tags' => ['taylor']]); + + $this->assertSame([], $this->dispatchedEvents); } public function testGetTriggersEvents(): void @@ -136,26 +141,28 @@ public function testGetTriggersEvents(): void $dispatcher = $this->getDispatcher(); $repository = $this->getRepository($dispatcher); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(RetrievingKey::class, ['storeName' => 'array', 'key' => 'foo'])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(CacheMissed::class, ['storeName' => 'array', 'key' => 'foo'])); $this->assertNull($repository->get('foo')); + $this->assertEventDispatched(RetrievingKey::class, ['storeName' => 'array', 'key' => 'foo']); + $this->assertEventDispatched(CacheMissed::class, ['storeName' => 'array', 'key' => 'foo']); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(RetrievingManyKeys::class, ['storeName' => 'array', 'keys' => ['foo', 'bar']])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(CacheMissed::class, ['storeName' => 'array', 'key' => 'foo'])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(CacheMissed::class, ['storeName' => 'array', 'key' => 'bar'])); $this->assertSame(['foo' => null, 'bar' => null], $repository->get(['foo', 'bar'])); + $this->assertEventDispatched(RetrievingManyKeys::class, ['storeName' => 'array', 'keys' => ['foo', 'bar']]); + $this->assertEventDispatched(CacheMissed::class, ['storeName' => 'array', 'key' => 'foo']); + $this->assertEventDispatched(CacheMissed::class, ['storeName' => 'array', 'key' => 'bar']); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(RetrievingKey::class, ['storeName' => 'array', 'key' => 'baz'])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(CacheHit::class, ['storeName' => 'array', 'key' => 'baz', 'value' => 'qux'])); $this->assertSame('qux', $repository->get('baz')); + $this->assertEventDispatched(RetrievingKey::class, ['storeName' => 'array', 'key' => 'baz']); + $this->assertEventDispatched(CacheHit::class, ['storeName' => 'array', 'key' => 'baz', 'value' => 'qux']); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(RetrievingKey::class, ['storeName' => 'array', 'key' => 'foo', 'tags' => ['taylor']])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(CacheMissed::class, ['storeName' => 'array', 'key' => 'foo', 'tags' => ['taylor']])); $this->assertNull($repository->tags('taylor')->get('foo')); + $this->assertEventDispatched(RetrievingKey::class, ['storeName' => 'array', 'key' => 'foo', 'tags' => ['taylor']]); + $this->assertEventDispatched(CacheMissed::class, ['storeName' => 'array', 'key' => 'foo', 'tags' => ['taylor']]); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(RetrievingKey::class, ['storeName' => 'array', 'key' => 'baz', 'tags' => ['taylor']])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(CacheHit::class, ['storeName' => 'array', 'key' => 'baz', 'value' => 'qux', 'tags' => ['taylor']])); $this->assertSame('qux', $repository->tags('taylor')->get('baz')); + $this->assertEventDispatched(RetrievingKey::class, ['storeName' => 'array', 'key' => 'baz', 'tags' => ['taylor']]); + $this->assertEventDispatched(CacheHit::class, ['storeName' => 'array', 'key' => 'baz', 'value' => 'qux', 'tags' => ['taylor']]); + + $this->assertSame([], $this->dispatchedEvents); } public function testTaggedManyTriggersManyEvents(): void @@ -163,24 +170,15 @@ public function testTaggedManyTriggersManyEvents(): void $dispatcher = $this->getDispatcher(); $repository = $this->getRepository($dispatcher); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(RetrievingManyKeys::class, [ - 'keys' => ['baz', 'foo'], - 'tags' => ['taylor'], - ])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(CacheHit::class, [ - 'key' => 'baz', - 'value' => 'qux', - 'tags' => ['taylor'], - ])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(CacheMissed::class, [ - 'key' => 'foo', - 'tags' => ['taylor'], - ])); - $this->assertSame([ 'baz' => 'qux', 'foo' => null, ], $repository->tags('taylor')->many(['baz', 'foo'])); + $this->assertEventDispatched(RetrievingManyKeys::class, ['keys' => ['baz', 'foo'], 'tags' => ['taylor']]); + $this->assertEventDispatched(CacheHit::class, ['key' => 'baz', 'value' => 'qux', 'tags' => ['taylor']]); + $this->assertEventDispatched(CacheMissed::class, ['key' => 'foo', 'tags' => ['taylor']]); + + $this->assertSame([], $this->dispatchedEvents); } public function testGetDispatchesFailureEventWhenTheStoreThrows(): void @@ -420,11 +418,13 @@ public function testPullTriggersEvents(): void $dispatcher = $this->getDispatcher(); $repository = $this->getRepository($dispatcher); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(RetrievingKey::class, ['storeName' => 'array', 'key' => 'baz'])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(CacheHit::class, ['storeName' => 'array', 'key' => 'baz', 'value' => 'qux'])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(ForgettingKey::class, ['storeName' => 'array', 'key' => 'baz'])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(KeyForgotten::class, ['storeName' => 'array', 'key' => 'baz'])); $this->assertSame('qux', $repository->pull('baz')); + $this->assertEventDispatched(RetrievingKey::class, ['storeName' => 'array', 'key' => 'baz']); + $this->assertEventDispatched(CacheHit::class, ['storeName' => 'array', 'key' => 'baz', 'value' => 'qux']); + $this->assertEventDispatched(ForgettingKey::class, ['storeName' => 'array', 'key' => 'baz']); + $this->assertEventDispatched(KeyForgotten::class, ['storeName' => 'array', 'key' => 'baz']); + + $this->assertSame([], $this->dispatchedEvents); } public function testPullTriggersEventsUsingTags(): void @@ -432,11 +432,13 @@ public function testPullTriggersEventsUsingTags(): void $dispatcher = $this->getDispatcher(); $repository = $this->getRepository($dispatcher); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(RetrievingKey::class, ['storeName' => 'array', 'key' => 'baz', 'tags' => ['taylor']])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(CacheHit::class, ['storeName' => 'array', 'key' => 'baz', 'value' => 'qux', 'tags' => ['taylor']])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(ForgettingKey::class, ['storeName' => 'array', 'key' => 'baz', 'tags' => ['taylor']])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(KeyForgotten::class, ['storeName' => 'array', 'key' => 'baz', 'tags' => ['taylor']])); $this->assertSame('qux', $repository->tags('taylor')->pull('baz')); + $this->assertEventDispatched(RetrievingKey::class, ['storeName' => 'array', 'key' => 'baz', 'tags' => ['taylor']]); + $this->assertEventDispatched(CacheHit::class, ['storeName' => 'array', 'key' => 'baz', 'value' => 'qux', 'tags' => ['taylor']]); + $this->assertEventDispatched(ForgettingKey::class, ['storeName' => 'array', 'key' => 'baz', 'tags' => ['taylor']]); + $this->assertEventDispatched(KeyForgotten::class, ['storeName' => 'array', 'key' => 'baz', 'tags' => ['taylor']]); + + $this->assertSame([], $this->dispatchedEvents); } public function testPutTriggersEvents(): void @@ -444,18 +446,20 @@ public function testPutTriggersEvents(): void $dispatcher = $this->getDispatcher(); $repository = $this->getRepository($dispatcher); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(WritingKey::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => 99])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(KeyWritten::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => 99])); $repository->put('foo', 'bar', 99); + $this->assertEventDispatched(WritingKey::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => 99]); + $this->assertEventDispatched(KeyWritten::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => 99]); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(WritingManyKeys::class, ['storeName' => 'array', 'keys' => ['foo', 'baz'], 'values' => ['bar', 'qux'], 'seconds' => 99])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(KeyWritten::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => 99])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(KeyWritten::class, ['storeName' => 'array', 'key' => 'baz', 'value' => 'qux', 'seconds' => 99])); $repository->putMany(['foo' => 'bar', 'baz' => 'qux'], 99); + $this->assertEventDispatched(WritingManyKeys::class, ['storeName' => 'array', 'keys' => ['foo', 'baz'], 'values' => ['bar', 'qux'], 'seconds' => 99]); + $this->assertEventDispatched(KeyWritten::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => 99]); + $this->assertEventDispatched(KeyWritten::class, ['storeName' => 'array', 'key' => 'baz', 'value' => 'qux', 'seconds' => 99]); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(WritingKey::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => 99, 'tags' => ['taylor']])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(KeyWritten::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => 99, 'tags' => ['taylor']])); $repository->tags('taylor')->put('foo', 'bar', 99); + $this->assertEventDispatched(WritingKey::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => 99, 'tags' => ['taylor']]); + $this->assertEventDispatched(KeyWritten::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => 99, 'tags' => ['taylor']]); + + $this->assertSame([], $this->dispatchedEvents); } public function testAddTriggersEvents(): void @@ -463,17 +467,19 @@ public function testAddTriggersEvents(): void $dispatcher = $this->getDispatcher(); $repository = $this->getRepository($dispatcher); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(RetrievingKey::class, ['storeName' => 'array', 'key' => 'foo'])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(CacheMissed::class, ['storeName' => 'array', 'key' => 'foo'])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(WritingKey::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => 99])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(KeyWritten::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => 99])); $this->assertTrue($repository->add('foo', 'bar', 99)); + $this->assertEventDispatched(RetrievingKey::class, ['storeName' => 'array', 'key' => 'foo']); + $this->assertEventDispatched(CacheMissed::class, ['storeName' => 'array', 'key' => 'foo']); + $this->assertEventDispatched(WritingKey::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => 99]); + $this->assertEventDispatched(KeyWritten::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => 99]); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(RetrievingKey::class, ['storeName' => 'array', 'key' => 'foo', 'tags' => ['taylor']])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(CacheMissed::class, ['storeName' => 'array', 'key' => 'foo', 'tags' => ['taylor']])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(WritingKey::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => 99, 'tags' => ['taylor']])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(KeyWritten::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => 99, 'tags' => ['taylor']])); $this->assertTrue($repository->tags('taylor')->add('foo', 'bar', 99)); + $this->assertEventDispatched(RetrievingKey::class, ['storeName' => 'array', 'key' => 'foo', 'tags' => ['taylor']]); + $this->assertEventDispatched(CacheMissed::class, ['storeName' => 'array', 'key' => 'foo', 'tags' => ['taylor']]); + $this->assertEventDispatched(WritingKey::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => 99, 'tags' => ['taylor']]); + $this->assertEventDispatched(KeyWritten::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => 99, 'tags' => ['taylor']]); + + $this->assertSame([], $this->dispatchedEvents); } public function testForeverTriggersEvents(): void @@ -481,13 +487,15 @@ public function testForeverTriggersEvents(): void $dispatcher = $this->getDispatcher(); $repository = $this->getRepository($dispatcher); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(WritingKey::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => null])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(KeyWritten::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => null])); $repository->forever('foo', 'bar'); + $this->assertEventDispatched(WritingKey::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => null]); + $this->assertEventDispatched(KeyWritten::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => null]); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(WritingKey::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => null, 'tags' => ['taylor']])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(KeyWritten::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => null, 'tags' => ['taylor']])); $repository->tags('taylor')->forever('foo', 'bar'); + $this->assertEventDispatched(WritingKey::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => null, 'tags' => ['taylor']]); + $this->assertEventDispatched(KeyWritten::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => null, 'tags' => ['taylor']]); + + $this->assertSame([], $this->dispatchedEvents); } public function testRememberTriggersEvents(): void @@ -495,21 +503,23 @@ public function testRememberTriggersEvents(): void $dispatcher = $this->getDispatcher(); $repository = $this->getRepository($dispatcher); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(RetrievingKey::class, ['storeName' => 'array', 'key' => 'foo'])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(CacheMissed::class, ['storeName' => 'array', 'key' => 'foo'])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(WritingKey::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => 99])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(KeyWritten::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => 99])); $this->assertSame('bar', $repository->remember('foo', 99, function (): string { return 'bar'; })); + $this->assertEventDispatched(RetrievingKey::class, ['storeName' => 'array', 'key' => 'foo']); + $this->assertEventDispatched(CacheMissed::class, ['storeName' => 'array', 'key' => 'foo']); + $this->assertEventDispatched(WritingKey::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => 99]); + $this->assertEventDispatched(KeyWritten::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => 99]); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(RetrievingKey::class, ['storeName' => 'array', 'key' => 'foo', 'tags' => ['taylor']])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(CacheMissed::class, ['storeName' => 'array', 'key' => 'foo', 'tags' => ['taylor']])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(WritingKey::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => 99, 'tags' => ['taylor']])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(KeyWritten::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => 99, 'tags' => ['taylor']])); $this->assertSame('bar', $repository->tags('taylor')->remember('foo', 99, function (): string { return 'bar'; })); + $this->assertEventDispatched(RetrievingKey::class, ['storeName' => 'array', 'key' => 'foo', 'tags' => ['taylor']]); + $this->assertEventDispatched(CacheMissed::class, ['storeName' => 'array', 'key' => 'foo', 'tags' => ['taylor']]); + $this->assertEventDispatched(WritingKey::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => 99, 'tags' => ['taylor']]); + $this->assertEventDispatched(KeyWritten::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => 99, 'tags' => ['taylor']]); + + $this->assertSame([], $this->dispatchedEvents); } public function testRememberForeverTriggersEvents(): void @@ -517,21 +527,23 @@ public function testRememberForeverTriggersEvents(): void $dispatcher = $this->getDispatcher(); $repository = $this->getRepository($dispatcher); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(RetrievingKey::class, ['storeName' => 'array', 'key' => 'foo'])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(CacheMissed::class, ['storeName' => 'array', 'key' => 'foo'])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(WritingKey::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => null])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(KeyWritten::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => null])); $this->assertSame('bar', $repository->rememberForever('foo', function (): string { return 'bar'; })); + $this->assertEventDispatched(RetrievingKey::class, ['storeName' => 'array', 'key' => 'foo']); + $this->assertEventDispatched(CacheMissed::class, ['storeName' => 'array', 'key' => 'foo']); + $this->assertEventDispatched(WritingKey::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => null]); + $this->assertEventDispatched(KeyWritten::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => null]); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(RetrievingKey::class, ['storeName' => 'array', 'key' => 'foo', 'tags' => ['taylor']])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(CacheMissed::class, ['storeName' => 'array', 'key' => 'foo', 'tags' => ['taylor']])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(WritingKey::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => null, 'tags' => ['taylor']])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(KeyWritten::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => null, 'tags' => ['taylor']])); $this->assertSame('bar', $repository->tags('taylor')->rememberForever('foo', function (): string { return 'bar'; })); + $this->assertEventDispatched(RetrievingKey::class, ['storeName' => 'array', 'key' => 'foo', 'tags' => ['taylor']]); + $this->assertEventDispatched(CacheMissed::class, ['storeName' => 'array', 'key' => 'foo', 'tags' => ['taylor']]); + $this->assertEventDispatched(WritingKey::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => null, 'tags' => ['taylor']]); + $this->assertEventDispatched(KeyWritten::class, ['storeName' => 'array', 'key' => 'foo', 'value' => 'bar', 'seconds' => null, 'tags' => ['taylor']]); + + $this->assertSame([], $this->dispatchedEvents); } public function testForgetTriggersEvents(): void @@ -539,13 +551,15 @@ public function testForgetTriggersEvents(): void $dispatcher = $this->getDispatcher(); $repository = $this->getRepository($dispatcher); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(ForgettingKey::class, ['storeName' => 'array', 'key' => 'baz'])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(KeyForgotten::class, ['storeName' => 'array', 'key' => 'baz'])); $this->assertTrue($repository->forget('baz')); + $this->assertEventDispatched(ForgettingKey::class, ['storeName' => 'array', 'key' => 'baz']); + $this->assertEventDispatched(KeyForgotten::class, ['storeName' => 'array', 'key' => 'baz']); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(ForgettingKey::class, ['storeName' => 'array', 'key' => 'baz', 'tags' => ['taylor']])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(KeyForgotten::class, ['storeName' => 'array', 'key' => 'baz', 'tags' => ['taylor']])); $this->assertTrue($repository->tags('taylor')->forget('baz')); + $this->assertEventDispatched(ForgettingKey::class, ['storeName' => 'array', 'key' => 'baz', 'tags' => ['taylor']]); + $this->assertEventDispatched(KeyForgotten::class, ['storeName' => 'array', 'key' => 'baz', 'tags' => ['taylor']]); + + $this->assertSame([], $this->dispatchedEvents); } public function testForgetDoesTriggerFailedEventOnFailure(): void @@ -556,13 +570,11 @@ public function testForgetDoesTriggerFailedEventOnFailure(): void $repository = new Repository($store); $repository->setEventDispatcher($dispatcher); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(ForgettingKey::class, ['key' => 'baz'])); - $dispatcher->expects('dispatch')->with($this->assertEventMatches(KeyForgetFailed::class, [ - 'key' => 'baz', - 'exception' => null, - ])); - $dispatcher->shouldReceive('dispatch')->never()->with($this->assertEventMatches(KeyForgotten::class, ['key' => 'baz'])); $this->assertFalse($repository->forget('baz')); + $this->assertEventDispatched(ForgettingKey::class, ['key' => 'baz']); + $this->assertEventDispatched(KeyForgetFailed::class, ['key' => 'baz', 'exception' => null]); + + $this->assertSame([], $this->dispatchedEvents); } public function testFlushTriggersEvents(): void @@ -570,18 +582,11 @@ public function testFlushTriggersEvents(): void $dispatcher = $this->getDispatcher(); $repository = $this->getRepository($dispatcher); - $dispatcher->expects('dispatch')->with( - $this->assertEventMatches(CacheFlushing::class, [ - 'storeName' => 'array', - ]) - ); - - $dispatcher->expects('dispatch')->with( - $this->assertEventMatches(CacheFlushed::class, [ - 'storeName' => 'array', - ]) - ); $this->assertTrue($repository->clear()); + $this->assertEventDispatched(CacheFlushing::class, ['storeName' => 'array']); + $this->assertEventDispatched(CacheFlushed::class, ['storeName' => 'array']); + + $this->assertSame([], $this->dispatchedEvents); } public function testEmptyTaggedFlushIsIdempotentAndDispatchesSuccessEvents(): void @@ -625,18 +630,11 @@ public function testFlushLocksTriggersEvents(): void $dispatcher = $this->getDispatcher(); $repository = $this->getRepository($dispatcher); - $dispatcher->expects('dispatch')->with( - $this->assertEventMatches(CacheLocksFlushing::class, [ - 'storeName' => 'array', - ]) - ); - - $dispatcher->expects('dispatch')->with( - $this->assertEventMatches(CacheLocksFlushed::class, [ - 'storeName' => 'array', - ]) - ); $this->assertTrue($repository->flushLocks()); + $this->assertEventDispatched(CacheLocksFlushing::class, ['storeName' => 'array']); + $this->assertEventDispatched(CacheLocksFlushed::class, ['storeName' => 'array']); + + $this->assertSame([], $this->dispatchedEvents); } public function testFlushLocksDispatchesFailureEventWithExactException(): void @@ -674,19 +672,11 @@ public function testFlushFailureDoesDispatchEvent(): void $repository = new Repository($failingStore, ['store' => 'array']); $repository->setEventDispatcher($dispatcher); - $dispatcher->expects('dispatch')->with( - $this->assertEventMatches(CacheFlushing::class, [ - 'storeName' => 'array', - ]) - ); - - $dispatcher->expects('dispatch')->with( - $this->assertEventMatches(CacheFlushFailed::class, [ - 'storeName' => 'array', - 'exception' => null, - ]) - ); $this->assertFalse($repository->clear()); + $this->assertEventDispatched(CacheFlushing::class, ['storeName' => 'array']); + $this->assertEventDispatched(CacheFlushFailed::class, ['storeName' => 'array', 'exception' => null]); + + $this->assertSame([], $this->dispatchedEvents); } public function testFlushLocksFailureDoesDispatchEvent(): void @@ -701,61 +691,50 @@ public function testFlushLocksFailureDoesDispatchEvent(): void $repository = new Repository($failingStore, ['store' => 'array']); $repository->setEventDispatcher($dispatcher); - $dispatcher->expects('dispatch')->with( - $this->assertEventMatches(CacheLocksFlushing::class, [ - 'storeName' => 'array', - ]) - ); - - $dispatcher->expects('dispatch')->with( - $this->assertEventMatches(CacheLocksFlushFailed::class, [ - 'storeName' => 'array', - 'exception' => null, - ]) - ); $this->assertFalse($repository->flushLocks()); + $this->assertEventDispatched(CacheLocksFlushing::class, ['storeName' => 'array']); + $this->assertEventDispatched(CacheLocksFlushFailed::class, ['storeName' => 'array', 'exception' => null]); + + $this->assertSame([], $this->dispatchedEvents); } + protected array $dispatchedEvents = []; + /** - * Match an event by its class and property values. + * Assert that the next recorded event has the given class and property values. */ - protected function assertEventMatches(string $eventClass, array $properties = []): ClosureMatcher + protected function assertEventDispatched(string $eventClass, array $properties = []): void { - return m::on(function (mixed $event) use ($eventClass, $properties): bool { - if (! $event instanceof $eventClass) { - return false; - } + $event = array_shift($this->dispatchedEvents); - // Keep this loop to avoid an extra callback per property comparison. - foreach ($properties as $name => $value) { - if ($value !== $event->{$name}) { - return false; - } - } + $this->assertInstanceOf($eventClass, $event); - return true; - }); + foreach ($properties as $name => $value) { + $this->assertSame($value, $event->{$name}, "Event property [{$name}] does not match."); + } } /** - * Create a dispatcher with event listeners enabled. + * Create a dispatcher that records cache events in dispatchedEvents. */ - protected function getDispatcher(): Dispatcher&MockInterface + protected function getDispatcher(): Dispatcher { - $dispatcher = m::mock(Dispatcher::class); - $dispatcher->shouldReceive('hasListeners')->withAnyArgs()->andReturn(true); + $this->dispatchedEvents = []; - return $dispatcher; + return $this->getCapturingDispatcher($this->dispatchedEvents); } /** - * Create a dispatcher that records each emitted event. + * Create a dispatcher that records each emitted cache event. + * + * The listener targets the cache event namespace because Hypervel treats a + * bare '*' listener as a passive observer, which hasListeners() guards ignore. */ protected function getCapturingDispatcher(array &$events): Dispatcher { - $dispatcher = $this->getDispatcher(); - $dispatcher->shouldReceive('dispatch')->andReturnUsing(function (object $event) use (&$events): void { - $events[] = $event; + $dispatcher = new Dispatcher; + $dispatcher->listen('Hypervel\Cache\Events\*', function (string $event, array $payload) use (&$events): void { + $events[] = $payload[0]; }); return $dispatcher; diff --git a/tests/Cache/CacheFileStoreTest.php b/tests/Cache/CacheFileStoreTest.php index bc653eace4..5fec88d6f3 100644 --- a/tests/Cache/CacheFileStoreTest.php +++ b/tests/Cache/CacheFileStoreTest.php @@ -9,7 +9,6 @@ use Hypervel\Cache\FileStore; use Hypervel\Cache\Repository; use Hypervel\Cache\SerializableClassPolicy; -use Hypervel\Contracts\Filesystem\FileNotFoundException; use Hypervel\Filesystem\Filesystem; use Hypervel\Filesystem\LockableFile; use Hypervel\Support\CarbonImmutable; @@ -54,9 +53,7 @@ protected function tearDown(): void public function testNullIsReturnedIfFileDoesntExist(): void { - $files = $this->mockFilesystem(); - $files->expects($this->once())->method('get')->willThrowException(new FileNotFoundException); - $store = new FileStore($files, __DIR__); + $store = new FileStore($this->filesystem, $this->tempDir); $value = $store->get('foo'); $this->assertNull($value); } @@ -165,21 +162,22 @@ public function testPutWillConsiderBigValuesAsEternalTime(): void public function testExpiredItemsReturnNullAndGetDeleted(): void { - $files = $this->mockFilesystem(); - $contents = '0000000000'; - $files->expects($this->once())->method('get')->willReturn($contents); - $store = $this->getMockBuilder(FileStore::class)->onlyMethods(['forget'])->setConstructorArgs([$files, __DIR__])->getMock(); - $store->expects($this->once())->method('forget'); + $store = new FileStore($this->filesystem, $this->tempDir); + $path = $store->path('foo'); + $this->filesystem->makeDirectory(dirname($path), 0777, true, true); + file_put_contents($path, '0000000000'); + $value = $store->get('foo'); + $this->assertNull($value); + $this->assertFileDoesNotExist($path); } public function testValidItemReturnsContents(): void { - $files = $this->mockFilesystem(); - $contents = '9999999999' . serialize('Hello World'); - $files->expects($this->once())->method('get')->willReturn($contents); - $store = new FileStore($files, __DIR__); + $store = new FileStore($this->filesystem, $this->tempDir); + $store->put('foo', 'Hello World', 10); + $this->assertSame('Hello World', $store->get('foo')); } @@ -284,10 +282,12 @@ public function testGetPayloadReadsZeroPaddedTimestampsCorrectly(): void { CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC(990464400)); - $files = $this->mockFilesystem(); - $files->expects($this->once())->method('get')->willReturn('0990464403' . serialize('Hello World')); + $store = new FileStore($this->filesystem, $this->tempDir); + $path = $store->path('foo'); + $this->filesystem->makeDirectory(dirname($path), 0777, true, true); + file_put_contents($path, '0990464403' . serialize('Hello World')); - $this->assertSame('Hello World', (new FileStore($files, __DIR__))->get('foo')); + $this->assertSame('Hello World', $store->get('foo')); } public function testFractionalSecondWritesPreserveTheRequestedValueAndLockLifetime(): void @@ -544,6 +544,9 @@ public function testForeversAreNotRemovedOnIncrement(): void $this->assertTrue($store->forever('counter', 1)); $this->assertSame(2, $store->increment('counter')); $this->assertStringStartsWith('9999999999', file_get_contents($store->path('counter'))); + + CarbonImmutable::setTestNow(CarbonImmutable::now()->addYears(100)); + $this->assertSame(2, $store->get('counter')); } @@ -739,7 +742,7 @@ public function testFlushingLocksIgnoreNonExistingDirectory(): void public function testHasSeparateLockStoreReturnsTrueWhenLockDirectoryDiffers(): void { - $store = new FileStore(new Filesystem, __DIR__); + $store = new FileStore(new Filesystem, $this->tempDir); $store->setLockDirectory('/locks'); $this->assertTrue($store->hasSeparateLockStore()); @@ -747,15 +750,15 @@ public function testHasSeparateLockStoreReturnsTrueWhenLockDirectoryDiffers(): v public function testHasSeparateLockStoreReturnsFalseWhenLockDirectoryIsSame(): void { - $store = new FileStore(new Filesystem, __DIR__); - $store->setLockDirectory(__DIR__); + $store = new FileStore(new Filesystem, $this->tempDir); + $store->setLockDirectory($this->tempDir); $this->assertFalse($store->hasSeparateLockStore()); } public function testHasSeparateLockStoreReturnsFalseWhenLockDirectoryIsNull(): void { - $store = new FileStore(new Filesystem, __DIR__); + $store = new FileStore(new Filesystem, $this->tempDir); $store->setLockDirectory(null); $this->assertFalse($store->hasSeparateLockStore()); @@ -763,7 +766,7 @@ public function testHasSeparateLockStoreReturnsFalseWhenLockDirectoryIsNull(): v public function testSupportsFlushingLocksRequiresSeparateLockDirectory(): void { - $store = new FileStore(new Filesystem, __DIR__); + $store = new FileStore(new Filesystem, $this->tempDir); $this->assertFalse($store->supportsFlushingLocks()); @@ -774,8 +777,8 @@ public function testSupportsFlushingLocksRequiresSeparateLockDirectory(): void public function testFlushLocksThrowsExceptionWhenLockDirectoryIsSame(): void { - $store = new FileStore(new Filesystem, __DIR__); - $store->setLockDirectory(__DIR__); + $store = new FileStore(new Filesystem, $this->tempDir); + $store->setLockDirectory($this->tempDir); $this->expectException(RuntimeException::class); diff --git a/tests/Cache/CacheMemoizedStoreTest.php b/tests/Cache/CacheMemoizedStoreTest.php index f44acb27c1..ea87cf27ea 100644 --- a/tests/Cache/CacheMemoizedStoreTest.php +++ b/tests/Cache/CacheMemoizedStoreTest.php @@ -13,12 +13,15 @@ use Hypervel\Cache\NullSentinel; use Hypervel\Cache\NullStore; use Hypervel\Cache\Repository; +use Hypervel\Cache\SessionStore; use Hypervel\Cache\StackStore; use Hypervel\Cache\StackStoreProxy; use Hypervel\Cache\TagMode; use Hypervel\Contracts\Cache\CanFlushLocks; use Hypervel\Contracts\Cache\Store; use Hypervel\Contracts\Events\Dispatcher; +use Hypervel\Session\ArraySessionHandler; +use Hypervel\Session\Store as Session; use Hypervel\Support\CarbonImmutable; use Hypervel\Tests\TestCase; use Mockery as m; @@ -410,7 +413,7 @@ public function testFlushLocksDelegatesToUnderlyingStore(): void public function testFlushLocksThrowsWhenUnderlyingStoreDoesNotSupportIt(): void { - $store = m::mock(Store::class); + $store = new SessionStore(new Session('test', new ArraySessionHandler(10))); $this->expectException(BadMethodCallException::class); $this->expectExceptionMessageIs(sprintf( diff --git a/tests/Console/CacheCommandMutexTest.php b/tests/Console/CacheCommandMutexTest.php index e11c610c98..a205bbf983 100644 --- a/tests/Console/CacheCommandMutexTest.php +++ b/tests/Console/CacheCommandMutexTest.php @@ -8,8 +8,6 @@ use Hypervel\Console\CacheCommandMutex; use Hypervel\Console\Command; use Hypervel\Contracts\Cache\Factory; -use Hypervel\Contracts\Cache\Lock; -use Hypervel\Contracts\Cache\LockProvider; use Hypervel\Contracts\Cache\Repository; use Hypervel\Contracts\Cache\Store; use Hypervel\Testbench\TestCase; @@ -65,32 +63,28 @@ public function testCanCreateMutexWithCustomConnection(): void $this->cacheRepository->expects('add')->andReturn(false); $this->mutex->useStore('test'); - $this->mutex->create($this->command); + $actual = $this->mutex->create($this->command); + + $this->assertFalse($actual); } public function testCanCreateMutexWithLockProvider(): void { - $lock = $this->mockUsingLockProvider(); - $this->acquireLockExpectations($lock, true); + $this->cacheFactory->expects('store')->with(null)->andReturn($this->cacheRepository); + $this->cacheRepository->expects('getStore')->andReturn(new ArrayStore); $actual = $this->mutex->create($this->command); $this->assertTrue($actual); } - public function testCanCreateMutexWithCustomLockProviderConnection(): void + public function testCannotCreateMutexIfAlreadyExistWithLockProvider(): void { - $this->mockUsingCacheStore('test'); - $this->cacheRepository->expects('add')->andReturn(false); - $this->mutex->useStore('test'); + $store = new ArrayStore; + $this->cacheFactory->expects('store')->twice()->with(null)->andReturn($this->cacheRepository); + $this->cacheRepository->expects('getStore')->twice()->andReturn($store); $this->mutex->create($this->command); - } - - public function testCannotCreateMutexIfAlreadyExistWithLockProvider(): void - { - $lock = $this->mockUsingLockProvider(); - $this->acquireLockExpectations($lock, false); $actual = $this->mutex->create($this->command); $this->assertFalse($actual); @@ -121,14 +115,13 @@ public function testCanForgetMutexWithLockProvider(): void public function testCanCreateMutexWithCustomConnectionWithLockProvider(): void { - $lock = m::mock(Store::class, LockProvider::class); $this->cacheFactory->expects('store')->with('test')->andReturn($this->cacheRepository); - $this->cacheRepository->expects('getStore')->andReturn($lock); - - $this->acquireLockExpectations($lock, true); + $this->cacheRepository->expects('getStore')->andReturn(new ArrayStore); $this->mutex->useStore('test'); - $this->mutex->create($this->command); + $actual = $this->mutex->create($this->command); + + $this->assertTrue($actual); } /** @@ -140,32 +133,6 @@ private function mockUsingCacheStore(?string $store = null): void $this->cacheRepository->expects('getStore')->andReturn(m::mock(Store::class)); } - /** - * Expect a cache store with native locks. - */ - private function mockUsingLockProvider(): MockInterface - { - $lock = m::mock(Store::class, LockProvider::class); - $this->cacheFactory->expects('store')->andReturn($this->cacheRepository); - $this->cacheRepository->expects('getStore')->andReturn($lock); - - return $lock; - } - - /** - * Expect creation and acquisition of a cache lock. - */ - private function acquireLockExpectations(MockInterface $lock, bool $acquiresSuccessfully): void - { - $lockInstance = m::mock(Lock::class); - $lockInstance->expects('get') - ->andReturns($acquiresSuccessfully); - - $lock->expects('lock') - ->with(m::type('string'), m::type('int')) - ->andReturns($lockInstance); - } - public function testCommandMutexNameWithoutIsolatedMutexNameMethod(): void { $this->mockUsingCacheStore(); diff --git a/tests/Console/CommandMutexTest.php b/tests/Console/CommandMutexTest.php index 14a8a790f7..7fa51197c6 100644 --- a/tests/Console/CommandMutexTest.php +++ b/tests/Console/CommandMutexTest.php @@ -4,6 +4,9 @@ namespace Hypervel\Tests\Console; +use Hypervel\Cache\Repository; +use Hypervel\Cache\StorageStore; +use Hypervel\Console\CacheCommandMutex; use Hypervel\Console\Command; use Hypervel\Console\CommandMutex; use Hypervel\Console\Events\AfterExecute; @@ -12,7 +15,10 @@ use Hypervel\Contracts\Console\Isolatable; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Testbench\TestCase; +use Hypervel\Tests\Cache\Fixtures\ArrayFilesystem; +use Hypervel\Tests\Console\Fixtures\FakeCacheFactory; use Mockery as m; +use Mockery\MockInterface; use RuntimeException; use Swoole\Coroutine\CanceledException; use Symfony\Component\Console\Input\ArrayInput; @@ -24,7 +30,7 @@ class CommandMutexTest extends TestCase { protected Command $command; - protected CommandMutex&m\MockInterface $commandMutex; + protected CommandMutex $commandMutex; /** * Set up an isolatable command and its mutex. @@ -46,16 +52,15 @@ public function __invoke(): void }; $this->command->setHypervel($this->app); - $this->commandMutex = m::mock(CommandMutex::class); + $this->commandMutex = new CacheCommandMutex(new FakeCacheFactory( + new Repository(new StorageStore(new ArrayFilesystem, 'cache')) + )); $this->app->instance(CommandMutex::class, $this->commandMutex); } public function testCanRunIsolatedCommandIfNotBlocked(): void { - $this->commandMutex->expects('create')->andReturn(true); - $this->commandMutex->expects('forget')->andReturn(true); - $this->runCommand(); $this->assertEquals(1, $this->command->ran); @@ -63,7 +68,7 @@ public function testCanRunIsolatedCommandIfNotBlocked(): void public function testCannotRunIsolatedCommandIfBlocked(): void { - $this->commandMutex->expects('create')->andReturn(false); + $this->commandMutex->create($this->command); $this->runCommand(); @@ -72,9 +77,7 @@ public function testCannotRunIsolatedCommandIfBlocked(): void public function testBlockedIsolatedCommandNormalizesItsConfiguredExitCode(): void { - $this->commandMutex->shouldReceive('create') - ->andReturn(false) - ->once(); + $this->commandMutex->create($this->command); $exitCode = $this->command->run( new ArrayInput(['--isolated' => 300]), @@ -87,13 +90,6 @@ public function testBlockedIsolatedCommandNormalizesItsConfiguredExitCode(): voi public function testCanRunCommandAgainAfterOtherCommandFinished(): void { - $this->commandMutex->expects('create') - ->andReturn(true) - ->times(2); - $this->commandMutex->expects('forget') - ->andReturn(true) - ->times(2); - $this->runCommand(); $this->runCommand(); @@ -102,9 +98,10 @@ public function testCanRunCommandAgainAfterOtherCommandFinished(): void public function testCanRunCommandAgainNonAutomated(): void { + $this->commandMutex->create($this->command); + $this->runCommand(false); - $this->commandMutex->shouldNotHaveReceived('create'); $this->assertEquals(1, $this->command->ran); } @@ -160,8 +157,9 @@ static function () use (&$events, $event): void { ); } - $this->commandMutex->shouldReceive('create')->once()->with($command)->andReturnTrue(); - $this->commandMutex->shouldReceive('forget')->once()->with($command)->andReturnTrue(); + $commandMutex = $this->mockCommandMutex(); + $commandMutex->shouldReceive('create')->once()->with($command)->andReturnTrue(); + $commandMutex->shouldReceive('forget')->once()->with($command)->andReturnTrue(); $command->run( new ArrayInput(['--isolated' => true]), @@ -176,8 +174,9 @@ static function () use (&$events, $event): void { public function testAfterExecuteFailureStillReleasesMutex(): void { - $this->commandMutex->shouldReceive('create')->once()->andReturnTrue(); - $this->commandMutex->shouldReceive('forget')->once()->andReturnTrue(); + $commandMutex = $this->mockCommandMutex(); + $commandMutex->shouldReceive('create')->once()->andReturnTrue(); + $commandMutex->shouldReceive('forget')->once()->andReturnTrue(); $this->app->make(Dispatcher::class)->listen( AfterExecute::class, @@ -200,8 +199,9 @@ public function __invoke(): void }; $command->setHypervel($this->app); - $this->commandMutex->shouldReceive('create')->once()->andReturnTrue(); - $this->commandMutex->shouldReceive('forget')->once()->andThrow(new RuntimeException('mutex release failed')); + $commandMutex = $this->mockCommandMutex(); + $commandMutex->shouldReceive('create')->once()->andReturnTrue(); + $commandMutex->shouldReceive('forget')->once()->andThrow(new RuntimeException('mutex release failed')); $this->app->make(Dispatcher::class)->listen( AfterExecute::class, @@ -227,8 +227,9 @@ public function __invoke(): void $command->setHypervel($this->app); $cancellation = new CanceledException('canceled'); - $this->commandMutex->shouldReceive('create')->once()->andReturnTrue(); - $this->commandMutex->shouldReceive('forget')->once()->andReturnTrue(); + $commandMutex = $this->mockCommandMutex(); + $commandMutex->shouldReceive('create')->once()->andReturnTrue(); + $commandMutex->shouldReceive('forget')->once()->andReturnTrue(); $this->app->make(Dispatcher::class)->listen( AfterExecute::class, fn () => throw $cancellation, @@ -253,8 +254,9 @@ public function __invoke(): void $command->setHypervel($this->app); $cancellation = new CanceledException('canceled'); - $this->commandMutex->shouldReceive('create')->once()->andReturnTrue(); - $this->commandMutex->shouldReceive('forget')->once()->andThrow($cancellation); + $commandMutex = $this->mockCommandMutex(); + $commandMutex->shouldReceive('create')->once()->andReturnTrue(); + $commandMutex->shouldReceive('forget')->once()->andThrow($cancellation); try { $command->run(new ArrayInput(['--isolated' => true]), new NullOutput); @@ -281,8 +283,9 @@ public function __invoke(): void $command->setHypervel($this->app); $observed = null; - $this->commandMutex->shouldReceive('create')->once()->andReturnTrue(); - $this->commandMutex->shouldReceive('forget')->once()->andReturnTrue(); + $commandMutex = $this->mockCommandMutex(); + $commandMutex->shouldReceive('create')->once()->andReturnTrue(); + $commandMutex->shouldReceive('forget')->once()->andReturnTrue(); $this->app->make(Dispatcher::class)->listen( AfterExecute::class, function (AfterExecute $event) use (&$observed): void { @@ -300,6 +303,18 @@ function (AfterExecute $event) use (&$observed): void { $this->assertSame($cancellation, $observed); } + /** + * Replace the bound command mutex with a mock. + */ + protected function mockCommandMutex(): CommandMutex&MockInterface + { + $commandMutex = m::mock(CommandMutex::class); + + $this->app->instance(CommandMutex::class, $commandMutex); + + return $commandMutex; + } + /** * Run the command with optional isolation. */ diff --git a/tests/Console/Concerns/InteractsWithIOTest.php b/tests/Console/Concerns/InteractsWithIOTest.php index bb652e629f..4640ed6ad1 100755 --- a/tests/Console/Concerns/InteractsWithIOTest.php +++ b/tests/Console/Concerns/InteractsWithIOTest.php @@ -23,19 +23,12 @@ public function testWithProgressBarIterable(array $iterable): void { $command = new CommandInteractsWithIO; $bufferedOutput = new BufferedOutput; - $output = m::mock(OutputStyle::class, [new ArgvInput, $bufferedOutput])->makePartial(); - $command->setOutput($output); - - $output->expects('createProgressBar') - ->with(count($iterable)) - ->andReturnUsing(function (int $steps) use ($bufferedOutput): ProgressBar { - // we can't mock ProgressBar because it's final, so return a real one - return new ProgressBar($bufferedOutput, $steps); - }); + $command->setOutput(new OutputStyle(new ArgvInput, $bufferedOutput)); $calledTimes = 0; $result = $command->withProgressBar($iterable, function (string $value, ProgressBar $bar, int|string $key) use (&$calledTimes, $iterable): void { $this->assertInstanceOf(ProgressBar::class, $bar); + $this->assertSame(count($iterable), $bar->getMaxSteps()); $this->assertSame(array_values($iterable)[$calledTimes], $value); $this->assertSame(array_keys($iterable)[$calledTimes], $key); ++$calledTimes; @@ -59,21 +52,14 @@ public function testWithProgressBarInteger(): void { $command = new CommandInteractsWithIO; $bufferedOutput = new BufferedOutput; - $output = m::mock(OutputStyle::class, [new ArgvInput, $bufferedOutput])->makePartial(); - $command->setOutput($output); + $command->setOutput(new OutputStyle(new ArgvInput, $bufferedOutput)); $totalSteps = 5; - $output->expects('createProgressBar') - ->with($totalSteps) - ->andReturnUsing(function (int $steps) use ($bufferedOutput): ProgressBar { - // we can't mock ProgressBar because it's final, so return a real one - return new ProgressBar($bufferedOutput, $steps); - }); - $called = false; - $command->withProgressBar($totalSteps, function (ProgressBar $bar) use (&$called): void { + $command->withProgressBar($totalSteps, function (ProgressBar $bar) use (&$called, $totalSteps): void { $this->assertInstanceOf(ProgressBar::class, $bar); + $this->assertSame($totalSteps, $bar->getMaxSteps()); $called = true; }); diff --git a/tests/Console/Fixtures/FakeCacheFactory.php b/tests/Console/Fixtures/FakeCacheFactory.php new file mode 100644 index 0000000000..e29994079f --- /dev/null +++ b/tests/Console/Fixtures/FakeCacheFactory.php @@ -0,0 +1,34 @@ +name = $name; + + return $this->repository; + } +} diff --git a/tests/Console/Scheduling/CacheEventMutexTest.php b/tests/Console/Scheduling/CacheEventMutexTest.php index 4f4aefb11a..a724af5e49 100644 --- a/tests/Console/Scheduling/CacheEventMutexTest.php +++ b/tests/Console/Scheduling/CacheEventMutexTest.php @@ -5,13 +5,13 @@ namespace Hypervel\Tests\Console\Scheduling; use Hypervel\Cache\ArrayStore; +use Hypervel\Cache\Repository; +use Hypervel\Cache\StorageStore; use Hypervel\Console\Scheduling\CacheEventMutex; use Hypervel\Console\Scheduling\Event; -use Hypervel\Contracts\Cache\Factory as CacheFactory; -use Hypervel\Contracts\Cache\Repository; -use Hypervel\Contracts\Cache\Store; +use Hypervel\Tests\Cache\Fixtures\ArrayFilesystem; +use Hypervel\Tests\Console\Fixtures\FakeCacheFactory; use Hypervel\Tests\TestCase; -use Mockery as m; class CacheEventMutexTest extends TestCase { @@ -19,9 +19,7 @@ class CacheEventMutexTest extends TestCase protected ?Event $event = null; - protected ?CacheFactory $cacheFactory = null; - - protected ?Repository $cacheRepository = null; + protected ?FakeCacheFactory $cacheFactory = null; /** * Set up the test environment. @@ -30,73 +28,80 @@ protected function setUp(): void { parent::setUp(); - $this->cacheFactory = m::mock(CacheFactory::class); - $this->cacheRepository = m::mock(Repository::class); - $this->cacheFactory->shouldReceive('store')->andReturn($this->cacheRepository)->byDefault(); - $this->cacheMutex = new CacheEventMutex($this->cacheFactory); + $this->cacheMutex = new CacheEventMutex($this->cacheFactory = new FakeCacheFactory); $this->event = new Event($this->cacheMutex, 'command'); } + /** + * Create a repository whose store does not provide locks. + */ + protected function storageRepository(): Repository + { + return new Repository(new StorageStore(new ArrayFilesystem, 'cache')); + } + public function testPreventOverlap(): void { - $this->cacheRepository->expects('getStore')->andReturn(m::mock(Store::class)); - $this->cacheRepository->expects('add'); + $this->cacheFactory->repository = $this->storageRepository(); - $this->cacheMutex->create($this->event); + $this->assertTrue($this->cacheMutex->create($this->event)); } public function testCustomConnection(): void { - $this->cacheRepository->expects('getStore')->andReturn(m::mock(Store::class)); - $this->cacheFactory->expects('store')->with('test')->andReturn($this->cacheRepository); - $this->cacheRepository->expects('add'); + $this->cacheFactory->repository = $this->storageRepository(); $this->cacheMutex->useStore('test'); - $this->cacheMutex->create($this->event); + $this->assertTrue($this->cacheMutex->create($this->event)); + $this->assertSame('test', $this->cacheFactory->name); } public function testPreventOverlapFails(): void { - $this->cacheRepository->expects('getStore')->andReturn(m::mock(Store::class)); - $this->cacheRepository->expects('add')->andReturn(false); + $repository = $this->storageRepository(); + $repository->add($this->event->mutexName(), true, 60); + $this->cacheFactory->repository = $repository; $this->assertFalse($this->cacheMutex->create($this->event)); } public function testOverlapsForNonRunningTask(): void { - $this->cacheRepository->expects('getStore')->andReturn(m::mock(Store::class)); - $this->cacheRepository->expects('has')->andReturn(false); + $this->cacheFactory->repository = $this->storageRepository(); $this->assertFalse($this->cacheMutex->exists($this->event)); } public function testOverlapsForRunningTask(): void { - $this->cacheRepository->expects('getStore')->andReturn(m::mock(Store::class)); - $this->cacheRepository->expects('has')->andReturn(true); + $repository = $this->storageRepository(); + $repository->add($this->event->mutexName(), true, 60); + $this->cacheFactory->repository = $repository; $this->assertTrue($this->cacheMutex->exists($this->event)); } public function testResetOverlap(): void { - $this->cacheRepository->expects('getStore')->andReturn(m::mock(Store::class)); - $this->cacheRepository->expects('forget'); + $repository = $this->storageRepository(); + $repository->add($this->event->mutexName(), true, 60); + $this->cacheFactory->repository = $repository; $this->cacheMutex->forget($this->event); + + $this->assertFalse($repository->has($this->event->mutexName())); } public function testPreventOverlapWithLockProvider(): void { - $this->cacheRepository->expects('getStore')->andReturn(new ArrayStore); + $this->cacheFactory->repository = new Repository(new ArrayStore); $this->assertTrue($this->cacheMutex->create($this->event)); } public function testPreventOverlapFailsWithLockProvider(): void { - $this->cacheRepository->expects('getStore')->times(2)->andReturn(new ArrayStore); + $this->cacheFactory->repository = new Repository(new ArrayStore); // first create the lock, so we can test that the next call fails. $this->cacheMutex->create($this->event); @@ -106,14 +111,14 @@ public function testPreventOverlapFailsWithLockProvider(): void public function testOverlapsForNonRunningTaskWithLockProvider(): void { - $this->cacheRepository->expects('getStore')->andReturn(new ArrayStore); + $this->cacheFactory->repository = new Repository(new ArrayStore); $this->assertFalse($this->cacheMutex->exists($this->event)); } public function testOverlapsForRunningTaskWithLockProvider(): void { - $this->cacheRepository->expects('getStore')->times(2)->andReturn(new ArrayStore); + $this->cacheFactory->repository = new Repository(new ArrayStore); $this->cacheMutex->create($this->event); @@ -122,7 +127,7 @@ public function testOverlapsForRunningTaskWithLockProvider(): void public function testResetOverlapWithLockProvider(): void { - $this->cacheRepository->expects('getStore')->times(3)->andReturn(new ArrayStore); + $this->cacheFactory->repository = new Repository(new ArrayStore); $this->cacheMutex->create($this->event); diff --git a/tests/Console/Scheduling/CacheSchedulingMutexTest.php b/tests/Console/Scheduling/CacheSchedulingMutexTest.php index eba6239a26..df468d67e2 100644 --- a/tests/Console/Scheduling/CacheSchedulingMutexTest.php +++ b/tests/Console/Scheduling/CacheSchedulingMutexTest.php @@ -5,15 +5,15 @@ namespace Hypervel\Tests\Console\Scheduling; use Hypervel\Cache\ArrayStore; +use Hypervel\Cache\Repository; +use Hypervel\Cache\StorageStore; use Hypervel\Console\Scheduling\CacheEventMutex; use Hypervel\Console\Scheduling\CacheSchedulingMutex; use Hypervel\Console\Scheduling\Event; -use Hypervel\Contracts\Cache\Factory as CacheFactory; -use Hypervel\Contracts\Cache\Repository; -use Hypervel\Contracts\Cache\Store; use Hypervel\Support\CarbonImmutable; +use Hypervel\Tests\Cache\Fixtures\ArrayFilesystem; +use Hypervel\Tests\Console\Fixtures\FakeCacheFactory; use Hypervel\Tests\TestCase; -use Mockery as m; class CacheSchedulingMutexTest extends TestCase { @@ -23,9 +23,7 @@ class CacheSchedulingMutexTest extends TestCase protected ?CarbonImmutable $time = null; - protected ?CacheFactory $cacheFactory = null; - - protected ?Repository $cacheRepository = null; + protected ?FakeCacheFactory $cacheFactory = null; /** * Set up the test environment. @@ -34,66 +32,87 @@ protected function setUp(): void { parent::setUp(); - $this->cacheFactory = m::mock(CacheFactory::class); - $this->cacheRepository = m::mock(Repository::class); - $this->cacheFactory->shouldReceive('store')->andReturn($this->cacheRepository)->byDefault(); - $this->cacheMutex = new CacheSchedulingMutex($this->cacheFactory); + $this->cacheMutex = new CacheSchedulingMutex($this->cacheFactory = new FakeCacheFactory); $this->event = new Event(new CacheEventMutex($this->cacheFactory), 'command'); $this->time = CarbonImmutable::now(); } + /** + * Create a repository whose store does not provide locks. + */ + protected function storageRepository(): Repository + { + return new Repository(new StorageStore(new ArrayFilesystem, 'cache')); + } + + /** + * Get the scheduling mutex name for the test time. + */ + protected function mutexName(): string + { + return $this->event->mutexName() . $this->time->format('Hi'); + } + public function testMutexReceivesCorrectCreate(): void { - $this->cacheRepository->expects('getStore')->andReturn(m::mock(Store::class)); - $this->cacheRepository->expects('add')->with($this->event->mutexName() . $this->time->format('Hi'), true, 3600)->andReturn(true); + // A whole second keeps availableAt() from rounding the expiry up. + CarbonImmutable::setTestNow($this->time = $this->time->startOfSecond()); + $this->cacheFactory->repository = $this->storageRepository(); $this->assertTrue($this->cacheMutex->create($this->event, $this->time)); + $this->assertTrue($this->cacheFactory->repository->has($this->mutexName())); + + CarbonImmutable::setTestNow($this->time->addSeconds(3599)); + $this->assertTrue($this->cacheFactory->repository->has($this->mutexName())); + + CarbonImmutable::setTestNow($this->time->addSeconds(3600)); + $this->assertFalse($this->cacheFactory->repository->has($this->mutexName())); } public function testCanUseCustomConnection(): void { - $this->cacheRepository->expects('getStore')->andReturn(m::mock(Store::class)); - $this->cacheFactory->expects('store')->with('test')->andReturn($this->cacheRepository); - $this->cacheRepository->expects('add')->with($this->event->mutexName() . $this->time->format('Hi'), true, 3600)->andReturn(true); + $this->cacheFactory->repository = $this->storageRepository(); $this->cacheMutex->useStore('test'); $this->assertTrue($this->cacheMutex->create($this->event, $this->time)); + $this->assertSame('test', $this->cacheFactory->name); } public function testPreventsMultipleRuns(): void { - $this->cacheRepository->expects('getStore')->andReturn(m::mock(Store::class)); - $this->cacheRepository->expects('add')->with($this->event->mutexName() . $this->time->format('Hi'), true, 3600)->andReturn(false); + $repository = $this->storageRepository(); + $repository->add($this->mutexName(), true, 3600); + $this->cacheFactory->repository = $repository; $this->assertFalse($this->cacheMutex->create($this->event, $this->time)); } public function testChecksForNonRunSchedule(): void { - $this->cacheRepository->expects('getStore')->andReturn(m::mock(Store::class)); - $this->cacheRepository->expects('has')->with($this->event->mutexName() . $this->time->format('Hi'))->andReturn(false); + $this->cacheFactory->repository = $this->storageRepository(); $this->assertFalse($this->cacheMutex->exists($this->event, $this->time)); } public function testChecksForAlreadyRunSchedule(): void { - $this->cacheRepository->expects('getStore')->andReturn(m::mock(Store::class)); - $this->cacheRepository->expects('has')->with($this->event->mutexName() . $this->time->format('Hi'))->andReturn(true); + $repository = $this->storageRepository(); + $repository->add($this->mutexName(), true, 3600); + $this->cacheFactory->repository = $repository; $this->assertTrue($this->cacheMutex->exists($this->event, $this->time)); } public function testMutexReceivesCorrectCreateWithLockProvider(): void { - $this->cacheRepository->expects('getStore')->andReturn(new ArrayStore); + $this->cacheFactory->repository = new Repository(new ArrayStore); $this->assertTrue($this->cacheMutex->create($this->event, $this->time)); } public function testPreventsMultipleRunsWithLockProvider(): void { - $this->cacheRepository->expects('getStore')->times(2)->andReturn(new ArrayStore); + $this->cacheFactory->repository = new Repository(new ArrayStore); // first create the lock, so we can test that the next call fails. $this->cacheMutex->create($this->event, $this->time); @@ -103,14 +122,14 @@ public function testPreventsMultipleRunsWithLockProvider(): void public function testChecksForNonRunScheduleWithLockProvider(): void { - $this->cacheRepository->expects('getStore')->andReturn(new ArrayStore); + $this->cacheFactory->repository = new Repository(new ArrayStore); $this->assertFalse($this->cacheMutex->exists($this->event, $this->time)); } public function testChecksForAlreadyRunScheduleWithLockProvider(): void { - $this->cacheRepository->expects('getStore')->times(2)->andReturn(new ArrayStore); + $this->cacheFactory->repository = new Repository(new ArrayStore); $this->cacheMutex->create($this->event, $this->time); diff --git a/tests/Console/Scheduling/EventTest.php b/tests/Console/Scheduling/EventTest.php index 08c5162141..aa9c49305a 100644 --- a/tests/Console/Scheduling/EventTest.php +++ b/tests/Console/Scheduling/EventTest.php @@ -21,6 +21,7 @@ use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Str; use Hypervel\Support\Stringable; +use Hypervel\Tests\Console\Fixtures\FakeEventMutex; use Hypervel\Tests\TestCase; use Mockery as m; use RuntimeException; @@ -140,26 +141,26 @@ public function testSendOutputToWithSystemProcess(): void public function testDaysOfMonthMethod(): void { - $event = new Event(m::mock(EventMutex::class), 'php -i'); + $event = new Event(new FakeEventMutex, 'php -i'); $event->daysOfMonth(1, 15); $this->assertSame('0 0 1,15 * *', $event->getExpression()); - $event = new Event(m::mock(EventMutex::class), 'php -i'); + $event = new Event(new FakeEventMutex, 'php -i'); $event->daysOfMonth([1, 10, 20, 30]); $this->assertSame('0 0 1,10,20,30 * *', $event->getExpression()); } public function testEventDoesNotRunWhenPausedByDefault(): void { - $event = new Event(m::mock(EventMutex::class), 'php -i'); + $event = new Event(new FakeEventMutex, 'php -i'); $this->assertFalse($event->runsWhenPaused()); } public function testEventRunsWhenMarkedAsEvenWhenPaused(): void { - $event = new Event(m::mock(EventMutex::class), 'php -i'); + $event = new Event(new FakeEventMutex, 'php -i'); $event->evenWhenPaused(); $this->assertTrue($event->runsWhenPaused()); @@ -176,8 +177,7 @@ public function testNeverCheckedRepeatableEventIsNotReadyToRepeat(): void public function testRunIndicatesWhenSkippedBecauseOverlapping(): void { $beforeCallbackCalled = false; - $eventMutex = m::mock(EventMutex::class); - $event = new class($eventMutex, 'php -i') extends Event { + $event = new class(new FakeEventMutex, 'php -i') extends Event { public bool $executed = false; /** @@ -191,7 +191,6 @@ protected function execute(ContainerContract $container): int } }; - $eventMutex->expects('create')->with($event)->andReturnFalse(); $event->withoutOverlapping(); $event->before(function () use (&$beforeCallbackCalled): void { $beforeCallbackCalled = true; @@ -568,7 +567,7 @@ public function testProcessIsReleasedAfterAfterCallbackFailure(): void public function testNextRunDate(): void { - $event = new Event(m::mock(EventMutex::class), 'php -i'); + $event = new Event(new FakeEventMutex, 'php -i'); $event->dailyAt('10:15'); $this->assertSame('10:15:00', $event->nextRunDate()->toTimeString()); @@ -576,7 +575,7 @@ public function testNextRunDate(): void public function testCustomMutexName(): void { - $event = new Event(m::mock(EventMutex::class), 'php -i'); + $event = new Event(new FakeEventMutex, 'php -i'); $event->description('Fancy command description'); $this->assertSame( @@ -595,7 +594,7 @@ public function testBeforeAndAfterCallbacksCanReceiveEvent(): void { $beforeEvent = null; $afterEvent = null; - $event = new Event(m::mock(EventMutex::class), 'php -i'); + $event = new Event(new FakeEventMutex, 'php -i'); $event->before(function (Event $event) use (&$beforeEvent): void { $beforeEvent = $event; @@ -615,7 +614,7 @@ public function testFilterCallbacksCanReceiveEvent(): void { $filterEvent = null; $rejectEvent = null; - $event = new Event(m::mock(EventMutex::class), 'php -i'); + $event = new Event(new FakeEventMutex, 'php -i'); $event->when(function (Event $event) use (&$filterEvent): bool { $filterEvent = $event; @@ -637,7 +636,7 @@ public function testEventCallbackResolvesByTypeRegardlessOfParameterName(): void { $beforeEvent = null; $filterEvent = null; - $event = new Event(m::mock(EventMutex::class), 'php -i'); + $event = new Event(new FakeEventMutex, 'php -i'); $event->before(function (Event $scheduledEvent) use (&$beforeEvent): void { $beforeEvent = $scheduledEvent; @@ -659,7 +658,7 @@ public function testEventCallbackDoesNotInjectIntoUnrelatedTypedParameters(): vo { $value = new Stringable('injected-string'); $received = null; - $event = new Event(m::mock(EventMutex::class), 'php -i'); + $event = new Event(new FakeEventMutex, 'php -i'); $this->container->instance(Stringable::class, $value); $event->before(function (Stringable $string) use (&$received): void { @@ -675,7 +674,7 @@ public function testFilterCallbacksMayBeInvokableObjects(): void { $filter = new EventTestInvokableFilter(true); $reject = new EventTestInvokableFilter(false); - $event = new Event(m::mock(EventMutex::class), 'php -i'); + $event = new Event(new FakeEventMutex, 'php -i'); $event->when($filter); $event->skip($reject); @@ -769,7 +768,7 @@ public function testBasicCronCompilation(): void $app->expects('environment')->times(3)->andReturn('production'); $app->shouldReceive('call')->andReturnUsing(fn (callable $callback): bool => $callback()); - $event = new Event(m::mock(EventMutex::class), 'php foo'); + $event = new Event(new FakeEventMutex, 'php foo'); $this->assertSame('* * * * *', $event->getExpression()); $this->assertTrue($event->isDue($app)); $this->assertTrue($event->skip(function (): bool { @@ -779,30 +778,30 @@ public function testBasicCronCompilation(): void return true; })->filtersPass($app)); - $event = new Event(m::mock(EventMutex::class), 'php foo'); + $event = new Event(new FakeEventMutex, 'php foo'); $this->assertSame('* * * * *', $event->getExpression()); $this->assertFalse($event->environments('local')->isDue($app)); - $event = new Event(m::mock(EventMutex::class), 'php foo'); + $event = new Event(new FakeEventMutex, 'php foo'); $this->assertSame('* * * * *', $event->getExpression()); $this->assertFalse($event->when(function (): bool { return false; })->filtersPass($app)); - $event = new Event(m::mock(EventMutex::class), 'php foo'); + $event = new Event(new FakeEventMutex, 'php foo'); $this->assertSame('* * * * *', $event->getExpression()); $this->assertFalse($event->when(false)->filtersPass($app)); // chained rules should be commutative - $eventA = new Event(m::mock(EventMutex::class), 'php foo'); - $eventB = new Event(m::mock(EventMutex::class), 'php foo'); + $eventA = new Event(new FakeEventMutex, 'php foo'); + $eventB = new Event(new FakeEventMutex, 'php foo'); $this->assertEquals( $eventA->daily()->hourly()->getExpression(), $eventB->hourly()->daily()->getExpression() ); - $eventA = new Event(m::mock(EventMutex::class), 'php foo'); - $eventB = new Event(m::mock(EventMutex::class), 'php foo'); + $eventA = new Event(new FakeEventMutex, 'php foo'); + $eventB = new Event(new FakeEventMutex, 'php foo'); $this->assertEquals( $eventA->weekdays()->hourly()->getExpression(), $eventB->hourly()->weekdays()->getExpression() @@ -816,11 +815,11 @@ public function testEventIsDueCheck(): void $app->expects('environment')->times(2)->andReturn('production'); CarbonImmutable::setTestNow(CarbonImmutable::create(2015, 1, 1, 0, 0, 0)); - $event = new Event(m::mock(EventMutex::class), 'php foo'); + $event = new Event(new FakeEventMutex, 'php foo'); $this->assertSame('* * * * 4', $event->thursdays()->getExpression()); $this->assertTrue($event->isDue($app)); - $event = new Event(m::mock(EventMutex::class), 'php foo'); + $event = new Event(new FakeEventMutex, 'php foo'); $this->assertSame('0 19 * * 3', $event->wednesdays()->at('19:00')->timezone('EST')->getExpression()); $this->assertTrue($event->isDue($app)); } @@ -855,27 +854,26 @@ public function testEventIsDueAtUsesEventTimezone(): void public function testTimeBetweenChecks(): void { - $app = m::mock(ApplicationContract::class); - $app->shouldReceive('call')->andReturnUsing(fn (callable $callback): bool => $callback()); + $app = $this->container; CarbonImmutable::setTestNow(CarbonImmutable::today()->addHours(9)); - $event = new Event(m::mock(EventMutex::class), 'php foo', 'UTC'); + $event = new Event(new FakeEventMutex, 'php foo', 'UTC'); $this->assertTrue($event->between('8:00', '10:00')->filtersPass($app)); - $event = new Event(m::mock(EventMutex::class), 'php foo', 'UTC'); + $event = new Event(new FakeEventMutex, 'php foo', 'UTC'); $this->assertTrue($event->between('9:00', '9:00')->filtersPass($app)); - $event = new Event(m::mock(EventMutex::class), 'php foo', 'UTC'); + $event = new Event(new FakeEventMutex, 'php foo', 'UTC'); $this->assertTrue($event->between('23:00', '10:00')->filtersPass($app)); - $event = new Event(m::mock(EventMutex::class), 'php foo', 'UTC'); + $event = new Event(new FakeEventMutex, 'php foo', 'UTC'); $this->assertTrue($event->between('8:00', '6:00')->filtersPass($app)); - $event = new Event(m::mock(EventMutex::class), 'php foo', 'UTC'); + $event = new Event(new FakeEventMutex, 'php foo', 'UTC'); $this->assertFalse($event->between('10:00', '11:00')->filtersPass($app)); - $event = new Event(m::mock(EventMutex::class), 'php foo', 'UTC'); + $event = new Event(new FakeEventMutex, 'php foo', 'UTC'); $this->assertFalse($event->between('10:00', '8:00')->filtersPass($app)); } @@ -898,53 +896,51 @@ public function testTimeBetweenIsEvaluatedUsingTheCurrentTime(): void public function testTimeBetweenChecksTimezoneCallOrder(): void { - $app = m::mock(ApplicationContract::class); - $app->shouldReceive('call')->andReturnUsing(fn (callable $callback): bool => $callback()); + $app = $this->container; CarbonImmutable::setTestNow(CarbonImmutable::parse('2024-07-01 09:00:00', 'UTC')); - $event = new Event(m::mock(EventMutex::class), 'php foo', 'UTC'); + $event = new Event(new FakeEventMutex, 'php foo', 'UTC'); $this->assertTrue($event->timezone('Europe/Rome')->between('10:00', '12:00')->filtersPass($app)); - $event = new Event(m::mock(EventMutex::class), 'php foo', 'UTC'); + $event = new Event(new FakeEventMutex, 'php foo', 'UTC'); $this->assertTrue($event->between('10:00', '12:00')->timezone('Europe/Rome')->filtersPass($app)); - $event = new Event(m::mock(EventMutex::class), 'php foo', 'UTC'); + $event = new Event(new FakeEventMutex, 'php foo', 'UTC'); $this->assertFalse($event->timezone('Europe/Rome')->unlessBetween('10:00', '12:00')->filtersPass($app)); - $event = new Event(m::mock(EventMutex::class), 'php foo', 'UTC'); + $event = new Event(new FakeEventMutex, 'php foo', 'UTC'); $this->assertFalse($event->unlessBetween('10:00', '12:00')->timezone('Europe/Rome')->filtersPass($app)); } public function testTimeUnlessBetweenChecks(): void { - $app = m::mock(ApplicationContract::class); - $app->shouldReceive('call')->andReturnUsing(fn (callable $callback): bool => $callback()); + $app = $this->container; CarbonImmutable::setTestNow(CarbonImmutable::today()->addHours(9)); - $event = new Event(m::mock(EventMutex::class), 'php foo', 'UTC'); + $event = new Event(new FakeEventMutex, 'php foo', 'UTC'); $this->assertFalse($event->unlessBetween('8:00', '10:00')->filtersPass($app)); - $event = new Event(m::mock(EventMutex::class), 'php foo', 'UTC'); + $event = new Event(new FakeEventMutex, 'php foo', 'UTC'); $this->assertFalse($event->unlessBetween('9:00', '9:00')->filtersPass($app)); - $event = new Event(m::mock(EventMutex::class), 'php foo', 'UTC'); + $event = new Event(new FakeEventMutex, 'php foo', 'UTC'); $this->assertFalse($event->unlessBetween('23:00', '10:00')->filtersPass($app)); - $event = new Event(m::mock(EventMutex::class), 'php foo', 'UTC'); + $event = new Event(new FakeEventMutex, 'php foo', 'UTC'); $this->assertFalse($event->unlessBetween('8:00', '6:00')->filtersPass($app)); - $event = new Event(m::mock(EventMutex::class), 'php foo', 'UTC'); + $event = new Event(new FakeEventMutex, 'php foo', 'UTC'); $this->assertTrue($event->unlessBetween('10:00', '11:00')->filtersPass($app)); - $event = new Event(m::mock(EventMutex::class), 'php foo', 'UTC'); + $event = new Event(new FakeEventMutex, 'php foo', 'UTC'); $this->assertTrue($event->unlessBetween('10:00', '8:00')->filtersPass($app)); } public function testEnvironmentsWithEnums(): void { - $event = new Event(m::mock(EventMutex::class), 'php foo'); + $event = new Event(new FakeEventMutex, 'php foo'); $event->environments(ScheduledEventTestEnvironment::Production); $this->assertSame(['production'], $event->environments); diff --git a/tests/Console/Scheduling/ScheduleTest.php b/tests/Console/Scheduling/ScheduleTest.php index 53db453c4d..9099640a0b 100644 --- a/tests/Console/Scheduling/ScheduleTest.php +++ b/tests/Console/Scheduling/ScheduleTest.php @@ -21,6 +21,8 @@ use Hypervel\Filesystem\Filesystem; use Hypervel\Foundation\Application; use Hypervel\Support\CarbonImmutable; +use Hypervel\Tests\Console\Fixtures\FakeCacheFactory; +use Hypervel\Tests\Console\Fixtures\FakeEventMutex; use Hypervel\Tests\Queue\Fixtures\JobToTestWithSchedule; use Hypervel\Tests\TestCase; use Mockery as m; @@ -61,7 +63,7 @@ class ScheduleTest extends TestCase { protected Container $container; - protected EventMutex&MockInterface $eventMutex; + protected EventMutex $eventMutex; protected MockInterface&SchedulingMutex $schedulingMutex; @@ -71,7 +73,7 @@ protected function setUp(): void $this->container = new Application; Container::setInstance($this->container); - $this->eventMutex = m::mock(EventMutex::class); + $this->eventMutex = new FakeEventMutex; $this->container->instance(EventMutex::class, $this->eventMutex); $this->schedulingMutex = m::mock(SchedulingMutex::class); $this->container->instance(SchedulingMutex::class, $this->schedulingMutex); @@ -320,17 +322,15 @@ public function testUseCacheAcceptsIntegerBackedEnum(): void public function testMutexCanReceiveCustomStore(): void { - $eventMutex = m::mock(CacheEventMutex::class); - $eventMutex->shouldReceive('useStore')->once()->with('test'); - - $schedulingMutex = m::mock(CacheSchedulingMutex::class); - $schedulingMutex->shouldReceive('useStore')->once()->with('test'); - - $this->container->instance(EventMutex::class, $eventMutex); - $this->container->instance(SchedulingMutex::class, $schedulingMutex); + $cache = new FakeCacheFactory; + $this->container->instance(EventMutex::class, $eventMutex = new CacheEventMutex($cache)); + $this->container->instance(SchedulingMutex::class, $schedulingMutex = new CacheSchedulingMutex($cache)); $schedule = new Schedule; $schedule->useCache('test'); + + $this->assertSame('test', $eventMutex->store); + $this->assertSame('test', $schedulingMutex->store); } public function testServerShouldRunCachesMutexResultOnlyWithinSameMinute(): void diff --git a/tests/Console/View/ComponentsTest.php b/tests/Console/View/ComponentsTest.php index 26d5674d9a..5f1facffad 100644 --- a/tests/Console/View/ComponentsTest.php +++ b/tests/Console/View/ComponentsTest.php @@ -18,14 +18,16 @@ use Hypervel\Console\View\Components\TwoColumnDetail; use Hypervel\Console\View\Components\Warn; use Hypervel\Console\View\TaskResult; +use Hypervel\Tests\Console\Concerns\CreatesAnsweredOutputStyles; use Hypervel\Tests\TestCase; use Mockery as m; use Symfony\Component\Console\Output\BufferedOutput; -use Symfony\Component\Console\Question\ChoiceQuestion; use Symfony\Component\Console\Question\Question; class ComponentsTest extends TestCase { + use CreatesAnsweredOutputStyles; + public function testAlert(): void { $output = new BufferedOutput; @@ -82,32 +84,16 @@ public function testInfo(): void public function testConfirm(): void { - $output = m::mock(OutputStyle::class); - - $output->expects('confirm') - ->with('Question?', false) - ->andReturnTrue(); + $result = (new Confirm($this->outputStyleWithAnswer('')))->render('Question?'); + $this->assertFalse($result); - $result = (new Confirm($output))->render('Question?'); - $this->assertTrue($result); - - $output->expects('confirm') - ->with('Question?', true) - ->andReturnTrue(); - - $result = (new Confirm($output))->render('Question?', true); + $result = (new Confirm($this->outputStyleWithAnswer('')))->render('Question?', true); $this->assertTrue($result); } public function testChoice(): void { - $output = m::mock(OutputStyle::class); - - $output->expects('askQuestion') - ->with(m::type(ChoiceQuestion::class)) - ->andReturn('a'); - - $result = (new Choice($output))->render('Question?', ['a', 'b']); + $result = (new Choice($this->outputStyleWithAnswer('a')))->render('Question?', ['a', 'b']); $this->assertSame('a', $result); } diff --git a/tests/Container/ContainerCallTest.php b/tests/Container/ContainerCallTest.php index 3282401267..78670ce1fc 100644 --- a/tests/Container/ContainerCallTest.php +++ b/tests/Container/ContainerCallTest.php @@ -134,11 +134,18 @@ public function testBindMethodAcceptsAnArray(): void public function testClosureCallWithInjectedDependency(): void { $container = new Container; - $container->call(function (ContainerCallConcreteStub $stub) { + $resolved = $container->call(function (ContainerCallConcreteStub $stub): ContainerCallConcreteStub { + return $stub; }, ['foo' => 'bar']); - $container->call(function (ContainerCallConcreteStub $stub) { - }, ['foo' => 'bar', 'stub' => new ContainerCallConcreteStub]); + $this->assertInstanceOf(ContainerCallConcreteStub::class, $resolved); + + $given = new ContainerCallConcreteStub; + $resolved = $container->call(function (ContainerCallConcreteStub $stub): ContainerCallConcreteStub { + return $stub; + }, ['foo' => 'bar', 'stub' => $given]); + + $this->assertSame($given, $resolved); } public function testCallWithDependencies(): void diff --git a/tests/Container/ContainerTest.php b/tests/Container/ContainerTest.php index c2fcf178b9..958c59aedb 100755 --- a/tests/Container/ContainerTest.php +++ b/tests/Container/ContainerTest.php @@ -217,7 +217,13 @@ public function testScopedBindingsWithClosureReturnType(): void { $container = new Container; $container->scoped(fn (): stdClass => new stdClass); + + $first = $container->make(stdClass::class); + $this->assertSame($first, $container->make(stdClass::class)); + $container->forgetScopedInstances(); + + $this->assertNotSame($first, $container->make(stdClass::class)); } public function testScopedClosureReturnTypeInstanceIsScopedCorrectly(): void diff --git a/tests/Database/DatabaseEloquentBelongsToManyWithCastedAttributesTest.php b/tests/Database/DatabaseEloquentBelongsToManyWithCastedAttributesTest.php index 05065b9371..93e6862600 100644 --- a/tests/Database/DatabaseEloquentBelongsToManyWithCastedAttributesTest.php +++ b/tests/Database/DatabaseEloquentBelongsToManyWithCastedAttributesTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Database\DatabaseEloquentBelongsToManyWithCastedAttributesTest; +use Hypervel\Database\Connection; use Hypervel\Database\Eloquent\Builder; use Hypervel\Database\Eloquent\Collection; use Hypervel\Database\Eloquent\Model; @@ -99,7 +100,7 @@ protected function getRelation() $related->shouldReceive('qualifyColumn'); $builder->shouldReceive('join', 'where'); $builder->shouldReceive('getQuery')->andReturn( - m::mock(QueryBuilder::class, ['getGrammar' => m::mock(Grammar::class, ['isExpression' => false])]) + m::mock(QueryBuilder::class, ['getGrammar' => new Grammar(m::mock(Connection::class))]) ); return new BelongsToMany( diff --git a/tests/Database/DatabaseEloquentBelongsToManyWithDefaultAttributesTest.php b/tests/Database/DatabaseEloquentBelongsToManyWithDefaultAttributesTest.php index eef7dd435e..03ccb2ad5d 100644 --- a/tests/Database/DatabaseEloquentBelongsToManyWithDefaultAttributesTest.php +++ b/tests/Database/DatabaseEloquentBelongsToManyWithDefaultAttributesTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Database; +use Hypervel\Database\Connection; use Hypervel\Database\ConnectionInterface; use Hypervel\Database\Eloquent\Builder; use Hypervel\Database\Eloquent\Model; @@ -60,7 +61,7 @@ public function getRelationArguments(): array $mockQueryBuilder = m::mock(QueryBuilder::class); $builder->shouldReceive('getQuery')->andReturn($mockQueryBuilder); - $mockQueryBuilder->shouldReceive('getGrammar')->andReturn(m::mock(Grammar::class, ['isExpression' => false])); + $mockQueryBuilder->shouldReceive('getGrammar')->andReturn(new Grammar(m::mock(Connection::class))); $connection = m::mock(ConnectionInterface::class); $connection->shouldReceive('table')->andReturnUsing( fn (string $table): QueryBuilder => $mockQueryBuilder->newQuery()->from($table) diff --git a/tests/Database/DatabaseEloquentMorphToManyTest.php b/tests/Database/DatabaseEloquentMorphToManyTest.php index 2abb0708eb..3b8d6367b3 100644 --- a/tests/Database/DatabaseEloquentMorphToManyTest.php +++ b/tests/Database/DatabaseEloquentMorphToManyTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Database\DatabaseEloquentMorphToManyTest; +use Hypervel\Database\Connection; use Hypervel\Database\ConnectionInterface; use Hypervel\Database\Eloquent\Builder; use Hypervel\Database\Eloquent\Model; @@ -132,9 +133,7 @@ public function getRelationArguments(): array $builder->expects('where')->with('taggables.taggable_id', '=', 1); $builder->expects('where')->with('taggables.taggable_type', get_class($parent)); - $grammar = m::mock(Grammar::class); - $grammar->shouldReceive('isExpression')->with(m::type(Expression::class))->andReturnTrue(); - $grammar->shouldReceive('isExpression')->with(m::type('string'))->andReturnFalse(); + $grammar = new Grammar(m::mock(Connection::class)); $queryBuilder = m::mock(QueryBuilder::class); $queryBuilder->shouldReceive('getGrammar')->andReturn($grammar); $connection = m::mock(ConnectionInterface::class); diff --git a/tests/Database/DatabaseMariaDbSchemaBuilderTest.php b/tests/Database/DatabaseMariaDbSchemaBuilderTest.php index 7ec3c79707..8b6a68022c 100755 --- a/tests/Database/DatabaseMariaDbSchemaBuilderTest.php +++ b/tests/Database/DatabaseMariaDbSchemaBuilderTest.php @@ -4,7 +4,7 @@ namespace Hypervel\Tests\Database; -use Hypervel\Database\Connection; +use Hypervel\Database\MariaDbConnection; use Hypervel\Database\Query\Processors\MariaDbProcessor; use Hypervel\Database\Schema\Grammars\MariaDbGrammar; use Hypervel\Database\Schema\MariaDbBuilder; @@ -15,29 +15,29 @@ class DatabaseMariaDbSchemaBuilderTest extends TestCase { public function testHasTable(): void { - $connection = m::mock(Connection::class); - $grammar = m::mock(MariaDbGrammar::class); + $connection = m::mock(MariaDbConnection::class); + $connection->shouldReceive('usesBackslashEscapes')->andReturnTrue(); + $grammar = new MariaDbGrammar($connection); $connection->expects('getSchemaGrammar')->andReturn($grammar); $builder = new MariaDbBuilder($connection); - $grammar->expects('compileTableExists')->andReturn('sql'); $connection->expects('getTablePrefix')->andReturn('prefix_'); - $connection->expects('selectFromWriteConnection')->with('sql')->andReturn([['exists' => 1]]); + $connection->expects('selectFromWriteConnection')->with($grammar->compileTableExists(null, 'prefix_table'))->andReturn([['exists' => 1]]); $this->assertTrue($builder->hasTable('table')); } public function testGetColumnListing(): void { - $connection = m::mock(Connection::class); - $grammar = m::mock(MariaDbGrammar::class); - $processor = m::mock(MariaDbProcessor::class); + $connection = m::mock(MariaDbConnection::class); + $connection->shouldReceive('usesBackslashEscapes')->andReturnTrue(); + $grammar = new MariaDbGrammar($connection); + $processor = new MariaDbProcessor; $connection->expects('getSchemaGrammar')->andReturn($grammar); $connection->expects('getPostProcessor')->andReturn($processor); - $grammar->expects('compileColumns')->with(null, 'prefix_table')->andReturn('sql'); - $processor->expects('processColumns')->andReturn([['name' => 'column']]); $builder = new MariaDbBuilder($connection); $connection->expects('getTablePrefix')->andReturn('prefix_'); - $connection->expects('selectFromWriteConnection')->with('sql')->andReturn([['name' => 'column']]); + $connection->expects('selectFromWriteConnection')->with($grammar->compileColumns(null, 'prefix_table')) + ->andReturn([(object) ['name' => 'column', 'type_name' => 'int', 'type' => 'int', 'collation' => null, 'nullable' => 'YES', 'default' => null, 'comment' => null, 'expression' => null, 'extra' => '']]); $this->assertSame(['column'], $builder->getColumnListing('table')); } diff --git a/tests/Database/DatabaseMySQLSchemaBuilderTest.php b/tests/Database/DatabaseMySQLSchemaBuilderTest.php index 16c6d29bbf..f8aa01466a 100755 --- a/tests/Database/DatabaseMySQLSchemaBuilderTest.php +++ b/tests/Database/DatabaseMySQLSchemaBuilderTest.php @@ -4,7 +4,7 @@ namespace Hypervel\Tests\Database; -use Hypervel\Database\Connection; +use Hypervel\Database\MySqlConnection; use Hypervel\Database\Query\Processors\MySqlProcessor; use Hypervel\Database\Schema\Grammars\MySqlGrammar; use Hypervel\Database\Schema\MySqlBuilder; @@ -15,29 +15,29 @@ class DatabaseMySQLSchemaBuilderTest extends TestCase { public function testHasTable(): void { - $connection = m::mock(Connection::class); - $grammar = m::mock(MySqlGrammar::class); + $connection = m::mock(MySqlConnection::class); + $connection->shouldReceive('usesBackslashEscapes')->andReturnTrue(); + $grammar = new MySqlGrammar($connection); $connection->expects('getSchemaGrammar')->andReturn($grammar); $builder = new MySqlBuilder($connection); - $grammar->expects('compileTableExists')->andReturn('sql'); $connection->expects('getTablePrefix')->andReturn('prefix_'); - $connection->expects('selectFromWriteConnection')->with('sql')->andReturn([['exists' => 1]]); + $connection->expects('selectFromWriteConnection')->with($grammar->compileTableExists(null, 'prefix_table'))->andReturn([['exists' => 1]]); $this->assertTrue($builder->hasTable('table')); } public function testGetColumnListing(): void { - $connection = m::mock(Connection::class); - $grammar = m::mock(MySqlGrammar::class); - $processor = m::mock(MySqlProcessor::class); + $connection = m::mock(MySqlConnection::class); + $connection->shouldReceive('usesBackslashEscapes')->andReturnTrue(); + $grammar = new MySqlGrammar($connection); + $processor = new MySqlProcessor; $connection->expects('getSchemaGrammar')->andReturn($grammar); $connection->expects('getPostProcessor')->andReturn($processor); - $grammar->expects('compileColumns')->with(null, 'prefix_table')->andReturn('sql'); - $processor->expects('processColumns')->andReturn([['name' => 'column']]); $builder = new MySqlBuilder($connection); $connection->expects('getTablePrefix')->andReturn('prefix_'); - $connection->expects('selectFromWriteConnection')->with('sql')->andReturn([['name' => 'column']]); + $connection->expects('selectFromWriteConnection')->with($grammar->compileColumns(null, 'prefix_table')) + ->andReturn([(object) ['name' => 'column', 'type_name' => 'int', 'type' => 'int', 'collation' => null, 'nullable' => 'YES', 'default' => null, 'comment' => null, 'expression' => null, 'extra' => '']]); $this->assertSame(['column'], $builder->getColumnListing('table')); } diff --git a/tests/Database/DatabasePostgresBuilderTest.php b/tests/Database/DatabasePostgresBuilderTest.php index 12b3f2f0d1..5b38ef3ddf 100644 --- a/tests/Database/DatabasePostgresBuilderTest.php +++ b/tests/Database/DatabasePostgresBuilderTest.php @@ -238,10 +238,9 @@ public function testHasTableWhenSchemaUnqualifiedAndSearchPathIsUserVariable(): public function testHasTableWhenSchemaQualifiedAndSearchPathMismatches(): void { $connection = $this->getConnection(); - $grammar = m::mock(PostgresGrammar::class); + $grammar = new PostgresGrammar($connection); $connection->expects('getSchemaGrammar')->andReturn($grammar); - $grammar->expects('compileTableExists')->andReturn('sql'); - $connection->expects('selectFromWriteConnection')->with('sql')->andReturn([['exists' => 1]]); + $connection->expects('selectFromWriteConnection')->with($grammar->compileTableExists('myapp', 'foo'))->andReturn([['exists' => 1]]); $connection->expects('getTablePrefix'); $builder = $this->getBuilder($connection); @@ -253,7 +252,7 @@ public function testHasTableWhenDatabaseAndSchemaQualifiedAndSearchPathMismatche $this->expectException(InvalidArgumentException::class); $connection = $this->getConnection(); - $grammar = m::mock(PostgresGrammar::class); + $grammar = new PostgresGrammar($connection); $connection->expects('getSchemaGrammar')->andReturn($grammar); $builder = $this->getBuilder($connection); @@ -263,65 +262,65 @@ public function testHasTableWhenDatabaseAndSchemaQualifiedAndSearchPathMismatche public function testGetColumnListingWhenSchemaUnqualifiedAndSearchPathMissing(): void { $connection = $this->getConnection(); - $grammar = m::mock(PostgresGrammar::class); + $grammar = new PostgresGrammar($connection); + $connection->shouldReceive('getServerVersion')->andReturn('12.0.0'); $connection->expects('getSchemaGrammar')->andReturn($grammar); - $grammar->expects('compileColumns')->with(null, 'foo')->andReturn('sql'); - $connection->expects('selectFromWriteConnection')->with('sql')->andReturn([['name' => 'some_column']]); + $connection->expects('selectFromWriteConnection')->with($grammar->compileColumns(null, 'foo'))->andReturn([['name' => 'some_column']]); $connection->expects('getTablePrefix'); $processor = m::mock(PostgresProcessor::class); $connection->expects('getPostProcessor')->andReturn($processor); $processor->expects('processColumns')->andReturn([['name' => 'some_column']]); $builder = $this->getBuilder($connection); - $builder->getColumnListing('foo'); + $this->assertSame(['some_column'], $builder->getColumnListing('foo')); } public function testGetColumnListingWhenSchemaUnqualifiedAndSearchPathFilled(): void { $connection = $this->getConnection(); - $grammar = m::mock(PostgresGrammar::class); + $grammar = new PostgresGrammar($connection); + $connection->shouldReceive('getServerVersion')->andReturn('12.0.0'); $connection->expects('getSchemaGrammar')->andReturn($grammar); - $grammar->expects('compileColumns')->with(null, 'foo')->andReturn('sql'); - $connection->expects('selectFromWriteConnection')->with('sql')->andReturn([['name' => 'some_column']]); + $connection->expects('selectFromWriteConnection')->with($grammar->compileColumns(null, 'foo'))->andReturn([['name' => 'some_column']]); $connection->expects('getTablePrefix'); $processor = m::mock(PostgresProcessor::class); $connection->expects('getPostProcessor')->andReturn($processor); $processor->expects('processColumns')->andReturn([['name' => 'some_column']]); $builder = $this->getBuilder($connection); - $builder->getColumnListing('foo'); + $this->assertSame(['some_column'], $builder->getColumnListing('foo')); } public function testGetColumnListingWhenSchemaUnqualifiedAndSearchPathIsUserVariable(): void { $connection = $this->getConnection(); - $grammar = m::mock(PostgresGrammar::class); + $grammar = new PostgresGrammar($connection); + $connection->shouldReceive('getServerVersion')->andReturn('12.0.0'); $connection->expects('getSchemaGrammar')->andReturn($grammar); - $grammar->expects('compileColumns')->with(null, 'foo')->andReturn('sql'); - $connection->expects('selectFromWriteConnection')->with('sql')->andReturn([['name' => 'some_column']]); + $connection->expects('selectFromWriteConnection')->with($grammar->compileColumns(null, 'foo'))->andReturn([['name' => 'some_column']]); $connection->expects('getTablePrefix'); $processor = m::mock(PostgresProcessor::class); $connection->expects('getPostProcessor')->andReturn($processor); $processor->expects('processColumns')->andReturn([['name' => 'some_column']]); $builder = $this->getBuilder($connection); - $builder->getColumnListing('foo'); + $this->assertSame(['some_column'], $builder->getColumnListing('foo')); } public function testGetColumnListingWhenSchemaQualifiedAndSearchPathMismatches(): void { $connection = $this->getConnection(); - $grammar = m::mock(PostgresGrammar::class); + $grammar = new PostgresGrammar($connection); + $connection->shouldReceive('getServerVersion')->andReturn('12.0.0'); $connection->expects('getSchemaGrammar')->andReturn($grammar); - $grammar->expects('compileColumns')->with('myapp', 'foo')->andReturn('sql'); - $connection->expects('selectFromWriteConnection')->with('sql')->andReturn([['name' => 'some_column']]); + $connection->expects('selectFromWriteConnection')->with($grammar->compileColumns('myapp', 'foo'))->andReturn([['name' => 'some_column']]); $connection->expects('getTablePrefix'); $processor = m::mock(PostgresProcessor::class); $connection->expects('getPostProcessor')->andReturn($processor); $processor->expects('processColumns')->andReturn([['name' => 'some_column']]); $builder = $this->getBuilder($connection); - $builder->getColumnListing('myapp.foo'); + $this->assertSame(['some_column'], $builder->getColumnListing('myapp.foo')); } public function testGetColumnWhenDatabaseAndSchemaQualifiedAndSearchPathMismatches(): void @@ -329,7 +328,7 @@ public function testGetColumnWhenDatabaseAndSchemaQualifiedAndSearchPathMismatch $this->expectException(InvalidArgumentException::class); $connection = $this->getConnection(); - $grammar = m::mock(PostgresGrammar::class); + $grammar = new PostgresGrammar($connection); $connection->expects('getSchemaGrammar')->andReturn($grammar); $builder = $this->getBuilder($connection); @@ -384,19 +383,16 @@ public function testDropAllTablesWhenSearchPathIsString(): void $connection = $this->getConnection(); $connection->expects('getConfig')->with('search_path')->andReturn('public'); $connection->expects('getConfig')->with('dont_drop')->andReturn(['foo']); - $grammar = m::mock(PostgresGrammar::class); - $processor = m::mock(PostgresProcessor::class); + $grammar = new PostgresGrammar($connection); + $processor = new PostgresProcessor; $connection->expects('getSchemaGrammar')->andReturn($grammar); $connection->expects('getPostProcessor')->andReturn($processor); - $grammar->expects('compileTables')->andReturn('sql'); - $processor->expects('processTables')->andReturn([ - ['name' => 'foo', 'schema' => 'public', 'schema_qualified_name' => 'public.foo', 'partition_of' => null], - ['name' => 'foo_1', 'schema' => 'public', 'schema_qualified_name' => 'public.foo_1', 'partition_of' => 'public.foo'], - ['name' => 'users', 'schema' => 'public', 'schema_qualified_name' => 'public.users', 'partition_of' => null], - ['name' => 'users_1', 'schema' => 'public', 'schema_qualified_name' => 'public.users_1', 'partition_of' => 'public.users'], + $connection->expects('selectFromWriteConnection')->with($grammar->compileTables(['public']))->andReturn([ + (object) ['name' => 'foo', 'schema' => 'public', 'partition_of' => null], + (object) ['name' => 'foo_1', 'schema' => 'public', 'partition_of' => 'public.foo'], + (object) ['name' => 'users', 'schema' => 'public', 'partition_of' => null], + (object) ['name' => 'users_1', 'schema' => 'public', 'partition_of' => 'public.users'], ]); - $connection->expects('selectFromWriteConnection')->with('sql')->andReturn([['name' => 'users', 'schema' => 'public', 'schema_qualified_name' => 'public.users']]); - $grammar->expects('compileDropAllTables')->with(['public.users'])->andReturn('drop table "public"."users" cascade'); $connection->expects('statement')->with('drop table "public"."users" cascade')->andReturnTrue(); $builder = $this->getBuilder($connection); @@ -409,14 +405,13 @@ public function testDropAllTablesWhenSearchPathIsStringOfMany(): void $connection->expects('getConfig')->with('username')->andReturn('foouser'); $connection->expects('getConfig')->with('search_path')->andReturn('"$user", public, foo_bar-Baz.Áüõß'); $connection->expects('getConfig')->with('dont_drop')->andReturn(['foo']); - $grammar = m::mock(PostgresGrammar::class); - $processor = m::mock(PostgresProcessor::class); + $grammar = new PostgresGrammar($connection); + $processor = new PostgresProcessor; $connection->expects('getSchemaGrammar')->andReturn($grammar); $connection->expects('getPostProcessor')->andReturn($processor); - $processor->expects('processTables')->andReturn([['name' => 'users', 'schema' => 'foouser', 'schema_qualified_name' => 'foouser.users', 'partition_of' => null]]); - $grammar->expects('compileTables')->andReturn('sql'); - $connection->expects('selectFromWriteConnection')->with('sql')->andReturn([['name' => 'users', 'schema' => 'foouser', 'schema_qualified_name' => 'foouser.users']]); - $grammar->expects('compileDropAllTables')->with(['foouser.users'])->andReturn('drop table "foouser"."users" cascade'); + $connection->expects('selectFromWriteConnection') + ->with($grammar->compileTables(['foouser', 'public', 'foo_bar-Baz.Áüõß'])) + ->andReturn([(object) ['name' => 'users', 'schema' => 'foouser']]); $connection->expects('statement')->with('drop table "foouser"."users" cascade')->andReturnTrue(); $builder = $this->getBuilder($connection); @@ -434,14 +429,13 @@ public function testDropAllTablesWhenSearchPathIsArrayOfMany(): void 'spaced schema', ]); $connection->expects('getConfig')->with('dont_drop')->andReturn(['foo']); - $grammar = m::mock(PostgresGrammar::class); - $processor = m::mock(PostgresProcessor::class); + $grammar = new PostgresGrammar($connection); + $processor = new PostgresProcessor; $connection->expects('getSchemaGrammar')->andReturn($grammar); $connection->expects('getPostProcessor')->andReturn($processor); - $processor->expects('processTables')->andReturn([['name' => 'users', 'schema' => 'foouser', 'schema_qualified_name' => 'foouser.users', 'partition_of' => null]]); - $grammar->expects('compileTables')->andReturn('sql'); - $connection->expects('selectFromWriteConnection')->with('sql')->andReturn([['name' => 'users', 'schema' => 'foouser', 'schema_qualified_name' => 'foouser.users']]); - $grammar->expects('compileDropAllTables')->with(['foouser.users'])->andReturn('drop table "foouser"."users" cascade'); + $connection->expects('selectFromWriteConnection') + ->with($grammar->compileTables(['foouser', 'dev', 'test', 'spaced schema'])) + ->andReturn([(object) ['name' => 'users', 'schema' => 'foouser']]); $connection->expects('statement')->with('drop table "foouser"."users" cascade')->andReturnTrue(); $builder = $this->getBuilder($connection); diff --git a/tests/Database/DatabasePostgresSchemaBuilderTest.php b/tests/Database/DatabasePostgresSchemaBuilderTest.php index b78a649664..51a973c75c 100755 --- a/tests/Database/DatabasePostgresSchemaBuilderTest.php +++ b/tests/Database/DatabasePostgresSchemaBuilderTest.php @@ -16,12 +16,12 @@ class DatabasePostgresSchemaBuilderTest extends TestCase public function testHasTable(): void { $connection = m::mock(Connection::class); - $grammar = m::mock(PostgresGrammar::class); + $grammar = new PostgresGrammar($connection); $connection->expects('getSchemaGrammar')->andReturn($grammar); $builder = new PostgresBuilder($connection); - $grammar->expects('compileTableExists')->times(2)->andReturn('sql'); $connection->expects('getTablePrefix')->times(2)->andReturn('prefix_'); - $connection->expects('selectFromWriteConnection')->times(2)->with('sql')->andReturn([['exists' => 1]]); + $connection->expects('selectFromWriteConnection')->with($grammar->compileTableExists(null, 'prefix_table'))->andReturn([['exists' => 1]]); + $connection->expects('selectFromWriteConnection')->with($grammar->compileTableExists('public', 'prefix_table'))->andReturn([['exists' => 1]]); $this->assertTrue($builder->hasTable('table')); $this->assertTrue($builder->hasTable('public.table')); @@ -30,15 +30,15 @@ public function testHasTable(): void public function testGetColumnListing(): void { $connection = m::mock(Connection::class); - $grammar = m::mock(PostgresGrammar::class); - $processor = m::mock(PostgresProcessor::class); + $grammar = new PostgresGrammar($connection); + $connection->shouldReceive('getServerVersion')->andReturn('12.0.0'); + $processor = new PostgresProcessor; $connection->expects('getSchemaGrammar')->andReturn($grammar); $connection->expects('getPostProcessor')->andReturn($processor); - $grammar->expects('compileColumns')->with(null, 'prefix_table')->andReturn('sql'); - $processor->expects('processColumns')->andReturn([['name' => 'column']]); $builder = new PostgresBuilder($connection); $connection->expects('getTablePrefix')->andReturn('prefix_'); - $connection->expects('selectFromWriteConnection')->with('sql')->andReturn([['name' => 'column']]); + $connection->expects('selectFromWriteConnection')->with($grammar->compileColumns(null, 'prefix_table')) + ->andReturn([(object) ['name' => 'column', 'type_name' => 'int4', 'type' => 'integer', 'collation' => null, 'nullable' => 'YES', 'default' => null, 'comment' => null, 'generated' => null]]); $this->assertSame(['column'], $builder->getColumnListing('table')); } diff --git a/tests/Database/DatabaseSQLiteBuilderTest.php b/tests/Database/DatabaseSQLiteBuilderTest.php index 6fb0b258ed..5118893731 100644 --- a/tests/Database/DatabaseSQLiteBuilderTest.php +++ b/tests/Database/DatabaseSQLiteBuilderTest.php @@ -25,10 +25,9 @@ class DatabaseSQLiteBuilderTest extends TestCase { public function testCreateDatabase(): void { - $connection = m::mock(Connection::class); - $connection->expects('getSchemaGrammar')->andReturn(new SQLiteGrammar($connection)); + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); - $builder = new SQLiteBuilder($connection); + $builder = $connection->getSchemaBuilder(); File::expects('put') ->with('my_temporary_database_a', '') @@ -45,10 +44,9 @@ public function testCreateDatabase(): void public function testDropDatabaseIfExists(): void { - $connection = m::mock(Connection::class); - $connection->expects('getSchemaGrammar')->andReturn(new SQLiteGrammar($connection)); + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); - $builder = new SQLiteBuilder($connection); + $builder = $connection->getSchemaBuilder(); File::expects('exists') ->andReturn(true); diff --git a/tests/Database/DatabaseSQLiteQueryGrammarTest.php b/tests/Database/DatabaseSQLiteQueryGrammarTest.php index 65e49d913d..5994047d23 100755 --- a/tests/Database/DatabaseSQLiteQueryGrammarTest.php +++ b/tests/Database/DatabaseSQLiteQueryGrammarTest.php @@ -5,10 +5,12 @@ namespace Hypervel\Tests\Database; use Hypervel\Database\Connection; +use Hypervel\Database\PdoConnection; use Hypervel\Database\Query\Grammars\SQLiteGrammar; use Hypervel\Tests\TestCase; use JsonException; use Mockery as m; +use PDO; class DatabaseSQLiteQueryGrammarTest extends TestCase { @@ -22,8 +24,7 @@ public function testUpdateBindingsRejectUnencodableArrays(): void public function testToRawSql(): void { - $connection = m::mock(Connection::class); - $connection->expects('escape')->with('foo', false)->andReturn("'foo'"); + $connection = new PdoConnection(new PDO('sqlite::memory:')); $grammar = new SQLiteGrammar($connection); $query = $grammar->substituteBindingsIntoRawSql( diff --git a/tests/Database/DatabaseSchemaBuilderTest.php b/tests/Database/DatabaseSchemaBuilderTest.php index 69351be425..83c0e5f8f6 100644 --- a/tests/Database/DatabaseSchemaBuilderTest.php +++ b/tests/Database/DatabaseSchemaBuilderTest.php @@ -94,10 +94,10 @@ public static function tableExistenceResults(): array public function testCreateDatabase(): void { $connection = m::mock(Connection::class); - $grammar = m::mock(Grammar::class); - $grammar->expects('compileCreateDatabase')->andReturn('sql'); + $grammar = new class($connection) extends Grammar { + }; $connection->expects('getSchemaGrammar')->andReturn($grammar); - $connection->expects('statement')->with('sql')->andReturnTrue(); + $connection->expects('statement')->with($grammar->compileCreateDatabase('foo'))->andReturnTrue(); $builder = new Builder($connection); $this->assertTrue($builder->createDatabase('foo')); @@ -106,10 +106,10 @@ public function testCreateDatabase(): void public function testDropDatabaseIfExists(): void { $connection = m::mock(Connection::class); - $grammar = m::mock(Grammar::class); - $grammar->expects('compileDropDatabaseIfExists')->andReturn('sql'); + $grammar = new class($connection) extends Grammar { + }; $connection->expects('getSchemaGrammar')->andReturn($grammar); - $connection->expects('statement')->with('sql')->andReturnTrue(); + $connection->expects('statement')->with($grammar->compileDropDatabaseIfExists('foo'))->andReturnTrue(); $builder = new Builder($connection); $this->assertTrue($builder->dropDatabaseIfExists('foo')); @@ -351,15 +351,21 @@ public function testTableHasColumns(): void public function testGetColumnTypeAddsPrefix(): void { $connection = m::mock(Connection::class); - $grammar = m::mock(Grammar::class); - $processor = m::mock(Processor::class); + $grammar = new class($connection) extends Grammar { + /** + * Compile the query to determine the columns. + */ + public function compileColumns(?string $schema, string $table): string + { + return "columns for {$table}"; + } + }; + $processor = new Processor; $connection->expects('getSchemaGrammar')->andReturn($grammar); $connection->expects('getPostProcessor')->andReturn($processor); - $processor->expects('processColumns')->andReturn([['name' => 'id', 'type_name' => 'integer']]); $builder = new Builder($connection); $connection->expects('getTablePrefix')->andReturn('prefix_'); - $grammar->expects('compileColumns')->with(null, 'prefix_users')->andReturn('sql'); - $connection->expects('selectFromWriteConnection')->with('sql')->andReturn([['name' => 'id', 'type_name' => 'integer']]); + $connection->expects('selectFromWriteConnection')->with('columns for prefix_users')->andReturn([['name' => 'id', 'type_name' => 'integer']]); $this->assertSame('integer', $builder->getColumnType('users', 'id')); } diff --git a/tests/Events/EventsDispatcherTest.php b/tests/Events/EventsDispatcherTest.php index 01700f32fa..057f1212a4 100755 --- a/tests/Events/EventsDispatcherTest.php +++ b/tests/Events/EventsDispatcherTest.php @@ -23,7 +23,6 @@ use Hypervel\Tests\Events\Fixtures\UnlistenedEvent; use Hypervel\Tests\Events\Fixtures\UnlistenedStringEvent; use Hypervel\Tests\TestCase; -use Mockery as m; use ReflectionClass; use RuntimeException; use Swoole\Coroutine\CanceledException; @@ -1507,23 +1506,23 @@ public function testClassArrayObserverIsAlwaysSynchronous(): void public function testEventDispatchesUsingNamedArguments(): void { - $events = m::mock(Dispatcher::class); + $events = new Dispatcher; Container::getInstance()->instance('events', $events); - $events->expects('dispatch') - ->with(m::on(function (mixed $event): bool { - $this->assertInstanceOf(DispatchableNamedArgumentsEvent::class, $event); - $this->assertSame('first-value', $event->first); - $this->assertSame('second-value', $event->second); + $captured = null; + $events->listen(DispatchableNamedArgumentsEvent::class, function (DispatchableNamedArgumentsEvent $event) use (&$captured): string { + $captured = $event; - return true; - })) - ->andReturn(['dispatched']); + return 'dispatched'; + }); $this->assertSame( ['dispatched'], DispatchableNamedArgumentsEvent::dispatch(second: 'second-value', first: 'first-value') ); + $this->assertInstanceOf(DispatchableNamedArgumentsEvent::class, $captured); + $this->assertSame('first-value', $captured->first); + $this->assertSame('second-value', $captured->second); } public function testClosureListenerRejectsAnUntypedFirstParameterInsteadOfUsingALaterType(): void diff --git a/tests/Foundation/Bootstrap/HandleExceptionsTest.php b/tests/Foundation/Bootstrap/HandleExceptionsTest.php index 7a0a6bd7fe..2770f5a9df 100644 --- a/tests/Foundation/Bootstrap/HandleExceptionsTest.php +++ b/tests/Foundation/Bootstrap/HandleExceptionsTest.php @@ -19,6 +19,8 @@ use InvalidArgumentException; use Mockery as m; use Monolog\Handler\NullHandler; +use Monolog\Handler\TestHandler; +use Monolog\Logger as Monolog; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestWith; use ReflectionClass; @@ -38,7 +40,7 @@ protected function setUp(): void { parent::setUp(); - $this->app = m::mock(Application::setInstance(new Application)); + $this->app = Application::setInstance(new Application); $this->app->instance('config', $this->config = new Config([ 'logging' => [ @@ -54,6 +56,10 @@ protected function setUp(): void ], ], ])); + + (new ReflectionClass(Application::class)) + ->getProperty('hasBeenBootstrapped') + ->setValue($this->app, true); } protected function handleExceptions(): HandleExceptions @@ -63,6 +69,22 @@ protected function handleExceptions(): HandleExceptions }); } + /** + * Route the null channel to a logger that records its messages. + */ + protected function getRealLogger(): TestHandler + { + $handler = new TestHandler; + + $manager = new LogManager($this->app); + $manager->extend('capture', fn (): Monolog => new Monolog('testbench', [$handler])); + + $this->config->set('logging.channels.null', ['driver' => 'capture']); + $this->app->instance(LogManager::class, $manager); + + return $handler; + } + protected function tearDown(): void { Application::setInstance(null); @@ -74,33 +96,27 @@ protected function tearDown(): void public function testPhpDeprecations(): void { - $logger = m::mock(LogManager::class); - $this->app->instance(LogManager::class, $logger); - $this->app->expects('runningUnitTests')->andReturn(false); - $this->app->expects('hasBeenBootstrapped')->andReturn(true); + $handler = $this->getRealLogger(); - $logger->expects('channel')->with('deprecations')->andReturnSelf(); - $logger->expects('warning')->with(sprintf( - '%s in %s on line %s', + $this->handleExceptions()->handleError( + E_DEPRECATED, 'str_contains(): Passing null to parameter #2 ($needle) of type string is deprecated', '/home/user/hypervel/routes/web.php', 17 - )); + ); - $this->handleExceptions()->handleError( - E_DEPRECATED, + $this->assertTrue($handler->hasWarningThatContains(sprintf( + '%s in %s on line %s', 'str_contains(): Passing null to parameter #2 ($needle) of type string is deprecated', '/home/user/hypervel/routes/web.php', 17 - ); + ))); } public function testDeprecationLoggingPreservesCancellationDuringLoggerResolution(): void { $cancellation = new CanceledException('canceled'); $this->app->bind(LogManager::class, fn () => throw $cancellation); - $this->app->expects('runningUnitTests')->andReturn(false); - $this->app->expects('hasBeenBootstrapped')->andReturn(true); try { $this->handleExceptions()->handleDeprecationError( @@ -117,48 +133,40 @@ public function testDeprecationLoggingPreservesCancellationDuringLoggerResolutio public function testPhpDeprecationsWithStackTraces(): void { - $logger = m::mock(LogManager::class); - $this->app->instance(LogManager::class, $logger); - $this->app->expects('runningUnitTests')->andReturn(false); - $this->app->expects('hasBeenBootstrapped')->andReturn(true); + $handler = $this->getRealLogger(); $this->config->set('logging.deprecations', [ 'channel' => 'null', 'trace' => true, ]); - $logger->expects('channel')->with('deprecations')->andReturnSelf(); - $logger->expects('warning')->with( - 'str_contains(): Passing null to parameter #2 ($needle) of type string is deprecated', - m::on(function (array $context): bool { - $exception = $context['exception'] ?? null; - - return $exception instanceof ErrorException - && $exception->getSeverity() === E_DEPRECATED - && $exception->getFile() === '/home/user/hypervel/routes/web.php' - && $exception->getLine() === 17 - && $exception->getTrace() !== []; - }) - ); - $this->handleExceptions()->handleError( E_DEPRECATED, 'str_contains(): Passing null to parameter #2 ($needle) of type string is deprecated', '/home/user/hypervel/routes/web.php', 17 ); + + $this->assertTrue($handler->hasWarningThatContains('str_contains(): Passing null to parameter #2 ($needle) of type string is deprecated')); + + $exception = $handler->getRecords()[0]->context['exception']; + + $this->assertInstanceOf(ErrorException::class, $exception); + $this->assertSame(E_DEPRECATED, $exception->getSeverity()); + $this->assertSame('/home/user/hypervel/routes/web.php', $exception->getFile()); + $this->assertSame(17, $exception->getLine()); + $this->assertNotEmpty($exception->getTrace()); } public function testEnsuresDeprecationsDriver(): void { - $logger = m::mock(LogManager::class); - $this->app->instance(LogManager::class, $logger); - $this->app->expects('runningUnitTests')->andReturn(false); - $this->app->expects('hasBeenBootstrapped')->andReturn(true); + // The stack writes to the capture channel because an unconfigured channel + // falls back to the emergency logger, which writes to stdout. + $handler = $this->getRealLogger(); $this->config->set('logging.channels.stack', [ 'driver' => 'stack', - 'channels' => ['single'], + 'channels' => ['null'], 'ignore_exceptions' => false, ]); $this->config->set('logging.deprecations', [ @@ -166,14 +174,6 @@ public function testEnsuresDeprecationsDriver(): void 'trace' => false, ]); - $logger->expects('channel')->with('deprecations')->andReturnSelf(); - $logger->expects('warning')->with(sprintf( - '%s in %s on line %s', - 'str_contains(): Passing null to parameter #2 ($needle) of type string is deprecated', - '/home/user/hypervel/routes/web.php', - 17 - )); - $this->handleExceptions()->handleError( E_USER_DEPRECATED, 'str_contains(): Passing null to parameter #2 ($needle) of type string is deprecated', @@ -181,10 +181,16 @@ public function testEnsuresDeprecationsDriver(): void 17 ); + $this->assertTrue($handler->hasWarningThatContains(sprintf( + '%s in %s on line %s', + 'str_contains(): Passing null to parameter #2 ($needle) of type string is deprecated', + '/home/user/hypervel/routes/web.php', + 17 + ))); $this->assertSame( [ 'driver' => 'stack', - 'channels' => ['single'], + 'channels' => ['null'], 'ignore_exceptions' => false, ], $this->config->get('logging.channels.deprecations') @@ -193,24 +199,13 @@ public function testEnsuresDeprecationsDriver(): void public function testNullValueAsChannelUsesNullDriver(): void { - $logger = m::mock(LogManager::class); - $this->app->instance(LogManager::class, $logger); - $this->app->expects('runningUnitTests')->andReturn(false); - $this->app->expects('hasBeenBootstrapped')->andReturn(true); + $this->app->instance(LogManager::class, new LogManager($this->app)); $this->config->set('logging.deprecations', [ 'channel' => null, 'trace' => false, ]); - $logger->expects('channel')->with('deprecations')->andReturnSelf(); - $logger->expects('warning')->with(sprintf( - '%s in %s on line %s', - 'str_contains(): Passing null to parameter #2 ($needle) of type string is deprecated', - '/home/user/hypervel/routes/web.php', - 17 - )); - $this->handleExceptions()->handleError( E_DEPRECATED, 'str_contains(): Passing null to parameter #2 ($needle) of type string is deprecated', @@ -231,8 +226,6 @@ public function testMissingDeprecationOptionsUseTheNullChannelWithoutATrace(): v { $logger = m::mock(LogManager::class); $this->app->instance(LogManager::class, $logger); - $this->app->expects('runningUnitTests')->andReturn(false); - $this->app->expects('hasBeenBootstrapped')->andReturn(true); $this->config->set('logging.deprecations', []); $logger->expects('channel')->with('deprecations')->andReturnSelf(); @@ -262,8 +255,6 @@ public function testMissingDeprecationOptionsUseTheNullChannelWithoutATrace(): v public function testInvalidDeprecationConfigurationFailsLoudly(mixed $configuration, string $key): void { $this->app->instance(LogManager::class, m::mock(LogManager::class)); - $this->app->expects('runningUnitTests')->andReturn(false); - $this->app->expects('hasBeenBootstrapped')->andReturn(true); $this->config->set('logging.deprecations', $configuration); $this->expectException(InvalidArgumentException::class); @@ -289,70 +280,53 @@ public static function invalidDeprecationConfigurationProvider(): array public function testUserDeprecations(): void { - $logger = m::mock(LogManager::class); - $this->app->instance(LogManager::class, $logger); - $this->app->expects('runningUnitTests')->andReturn(false); - $this->app->expects('hasBeenBootstrapped')->andReturn(true); + $handler = $this->getRealLogger(); - $logger->expects('channel')->with('deprecations')->andReturnSelf(); - $logger->expects('warning')->with(sprintf( - '%s in %s on line %s', + $this->handleExceptions()->handleError( + E_USER_DEPRECATED, 'str_contains(): Passing null to parameter #2 ($needle) of type string is deprecated', '/home/user/hypervel/routes/web.php', 17 - )); + ); - $this->handleExceptions()->handleError( - E_USER_DEPRECATED, + $this->assertTrue($handler->hasWarningThatContains(sprintf( + '%s in %s on line %s', 'str_contains(): Passing null to parameter #2 ($needle) of type string is deprecated', '/home/user/hypervel/routes/web.php', 17 - ); + ))); } public function testUserDeprecationsWithStackTraces(): void { - $logger = m::mock(LogManager::class); - $this->app->instance(LogManager::class, $logger); - $this->app->expects('runningUnitTests')->andReturn(false); - $this->app->expects('hasBeenBootstrapped')->andReturn(true); + $handler = $this->getRealLogger(); $this->config->set('logging.deprecations', [ 'channel' => 'null', 'trace' => true, ]); - $logger->expects('channel')->with('deprecations')->andReturnSelf(); - $logger->expects('warning')->with( - 'str_contains(): Passing null to parameter #2 ($needle) of type string is deprecated', - m::on(function (array $context): bool { - $exception = $context['exception'] ?? null; - - return $exception instanceof ErrorException - && $exception->getSeverity() === E_USER_DEPRECATED - && $exception->getFile() === '/home/user/hypervel/routes/web.php' - && $exception->getLine() === 17 - && $exception->getTrace() !== []; - }) - ); - $this->handleExceptions()->handleError( E_USER_DEPRECATED, 'str_contains(): Passing null to parameter #2 ($needle) of type string is deprecated', '/home/user/hypervel/routes/web.php', 17 ); + + $this->assertTrue($handler->hasWarningThatContains('str_contains(): Passing null to parameter #2 ($needle) of type string is deprecated')); + + $exception = $handler->getRecords()[0]->context['exception']; + + $this->assertInstanceOf(ErrorException::class, $exception); + $this->assertSame(E_USER_DEPRECATED, $exception->getSeverity()); + $this->assertSame('/home/user/hypervel/routes/web.php', $exception->getFile()); + $this->assertSame(17, $exception->getLine()); + $this->assertNotEmpty($exception->getTrace()); } public function testEnsuresNullDeprecationsDriver(): void { - $logger = m::mock(LogManager::class); - $this->app->instance(LogManager::class, $logger); - $this->app->expects('runningUnitTests')->andReturn(false); - $this->app->expects('hasBeenBootstrapped')->andReturn(true); - - $logger->expects('channel')->with('deprecations')->andReturnSelf(); - $logger->expects('warning'); + $this->app->instance(LogManager::class, new LogManager($this->app)); $this->handleExceptions()->handleError( E_USER_DEPRECATED, @@ -369,13 +343,8 @@ public function testEnsuresNullDeprecationsDriver(): void public function testEnsuresNullLogDriver(): void { - $logger = m::mock(LogManager::class); - $this->app->instance(LogManager::class, $logger); - $this->app->expects('runningUnitTests')->andReturn(false); - $this->app->expects('hasBeenBootstrapped')->andReturn(true); + $this->app->instance(LogManager::class, new LogManager($this->app)); - $logger->expects('channel')->with('deprecations')->andReturnSelf(); - $logger->expects('warning'); $this->config->set('logging.channels.null', null); $this->handleExceptions()->handleError( @@ -393,13 +362,7 @@ public function testEnsuresNullLogDriver(): void public function testDoNotOverrideExistingNullLogDriver(): void { - $logger = m::mock(LogManager::class); - $this->app->instance(LogManager::class, $logger); - $this->app->expects('runningUnitTests')->andReturn(false); - $this->app->expects('hasBeenBootstrapped')->andReturn(true); - - $logger->expects('channel')->with('deprecations')->andReturnSelf(); - $logger->expects('warning'); + $this->app->instance(LogManager::class, new LogManager($this->app)); $this->config->set('logging.channels.null', [ 'driver' => 'monolog', @@ -423,8 +386,6 @@ public function testDeprecationsDriverIsDerivedAgainFromTheRebuiltConfiguration( { $logger = m::mock(LogManager::class); $this->app->instance(LogManager::class, $logger); - $this->app->expects('runningUnitTests')->andReturn(false); - $this->app->expects('hasBeenBootstrapped')->andReturn(true); $logger->expects('channel')->with('deprecations')->andReturnSelf(); $logger->expects('warning'); @@ -506,6 +467,7 @@ public function testNonConsoleExceptionIsReportedWithoutRenderingAResponse(): vo $handler->expects('report')->with($exception); $handler->shouldNotReceive('render'); $handler->shouldNotReceive('renderForConsole'); + $this->app = m::mock($this->app); $this->app->expects('make')->with(ExceptionHandler::class)->andReturn($handler); $this->app->expects('runningInConsole')->andReturnFalse(); @@ -524,6 +486,7 @@ public function testNonConsoleReporterFailureFallsBackToThePhpErrorLog(): void $handler->expects('report')->with($exception)->andThrow(new Error('reporting failed')); $handler->shouldNotReceive('render'); $handler->shouldNotReceive('renderForConsole'); + $this->app = m::mock($this->app); $this->app->expects('make')->with(ExceptionHandler::class)->andReturn($handler); $this->app->expects('runningInConsole')->andReturnFalse(); @@ -563,8 +526,6 @@ public function testIgnoresDeprecationsUntilConfigurationIsBound(): void public function testIgnoreDeprecationIfLoggerUnresolvable(string $exceptionClass): void { $this->app->bind(LogManager::class, static fn (): never => throw new $exceptionClass); - $this->app->expects('runningUnitTests')->andReturn(false); - $this->app->expects('hasBeenBootstrapped')->andReturn(true); $this->handleExceptions()->handleError( E_DEPRECATED, @@ -572,14 +533,16 @@ public function testIgnoreDeprecationIfLoggerUnresolvable(string $exceptionClass '/home/user/hypervel/routes/web.php', 17 ); + + $this->assertTrue(true); } public function testIgnoreDeprecationIfLoggingFails(): void { + // The real LogManager turns channel creation failures into its emergency + // logger, so the mock is what reaches the handler's own failure guard. $logger = m::mock(LogManager::class); $this->app->instance(LogManager::class, $logger); - $this->app->expects('runningUnitTests')->andReturn(false); - $this->app->expects('hasBeenBootstrapped')->andReturn(true); $logger->expects('channel')->with('deprecations')->andThrow(new Error('Class "Monolog\Logger" not found')); @@ -598,8 +561,6 @@ public function testDeprecationLoggingPreservesCancellationDuringChannelCreation $logger->extend('canceling', static fn (): never => throw $cancellation); $this->config->set('logging.channels.deprecations', ['driver' => 'canceling']); $this->app->instance(LogManager::class, $logger); - $this->app->expects('runningUnitTests')->twice()->andReturn(false); - $this->app->expects('hasBeenBootstrapped')->andReturn(true); try { $this->handleExceptions()->handleDeprecationError('Deprecated behavior', __FILE__, __LINE__); @@ -618,8 +579,7 @@ public function testItIgnoreDeprecationLoggingWhenRunningUnitTests(): void throw new RuntimeException; }); - $this->app->expects('runningUnitTests')->andReturn(true); - $this->app->expects('hasBeenBootstrapped')->andReturn(true); + $this->app->instance('env', 'testing'); $this->handleExceptions()->handleError( E_DEPRECATED, @@ -633,12 +593,8 @@ public function testItIgnoreDeprecationLoggingWhenRunningUnitTests(): void public function testItCanForceViaConfigDeprecationLoggingWhenRunningUnitTests(): void { - $logger = m::mock(LogManager::class); - $logger->expects('channel')->with('deprecations')->andReturnSelf(); - $logger->expects('warning'); - $this->app->instance(LogManager::class, $logger); - $this->app->expects('runningUnitTests')->andReturn(true); - $this->app->expects('hasBeenBootstrapped')->andReturn(true); + $handler = $this->getRealLogger(); + $this->app->instance('env', 'testing'); Env::getRepository()->set('LOG_DEPRECATIONS_WHILE_TESTING', 'true'); @@ -648,6 +604,8 @@ public function testItCanForceViaConfigDeprecationLoggingWhenRunningUnitTests(): '/home/user/hypervel/routes/web.php', 17 ); + + $this->assertTrue($handler->hasWarningRecords()); } // REMOVED: forgetApp() is deprecated; flushState() owns application cleanup. @@ -659,9 +617,7 @@ public function testHandlerForgetsPreviousApp(): void $this->assertSame($this->app, $appResolver()); - $instance->bootstrap($newApp = tap(m::mock(Application::class), function ($app) { - $app->expects('environment')->andReturn(true); - })); + $instance->bootstrap($newApp = tap(new Application, fn (Application $app): string => $app->instance('env', 'testing'))); $this->assertNotSame($this->app, $appResolver()); $this->assertSame($newApp, $appResolver()); diff --git a/tests/Foundation/Exceptions/Renderer/FrameTest.php b/tests/Foundation/Exceptions/Renderer/FrameTest.php index 77b47bed87..5ed0e4bbb5 100644 --- a/tests/Foundation/Exceptions/Renderer/FrameTest.php +++ b/tests/Foundation/Exceptions/Renderer/FrameTest.php @@ -45,7 +45,7 @@ protected function tearDown(): void #[DataProvider('unixFileDataProvider')] public function testItNormalizesFilePathOnUnix(array $frameData, string $basePath, string $expected): void { - $exception = m::mock(FlattenException::class); + $exception = new FlattenException; $classMap = []; $frame = new Frame($exception, $classMap, $frameData, $basePath); @@ -82,7 +82,7 @@ public static function unixFileDataProvider(): iterable #[DataProvider('unixIsFromVendorDataProvider')] public function testItDeterminesIfFrameIsFromVendorOnUnix(array $frameData, string $basePath, bool $expected): void { - $exception = m::mock(FlattenException::class); + $exception = new FlattenException; $classMap = []; $frame = new Frame($exception, $classMap, $frameData, $basePath); diff --git a/tests/Foundation/FoundationDevCommandsTest.php b/tests/Foundation/FoundationDevCommandsTest.php index d0ca68bcbd..65152fe286 100644 --- a/tests/Foundation/FoundationDevCommandsTest.php +++ b/tests/Foundation/FoundationDevCommandsTest.php @@ -12,11 +12,14 @@ use Hypervel\Foundation\DevCommandMode; use Hypervel\Foundation\DevCommands; use Hypervel\Support\Facades\File; +use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\TestCase; use ReflectionClass; class FoundationDevCommandsTest extends TestCase { + protected ?string $temporaryBasePath = null; + protected function setUp(): void { parent::setUp(); @@ -30,6 +33,37 @@ protected function setUp(): void File::swap(new Filesystem); } + /** + * Clean up the test environment. + */ + protected function tearDown(): void + { + if ($this->temporaryBasePath !== null) { + (new Filesystem)->deleteDirectory($this->temporaryBasePath); + + $this->temporaryBasePath = null; + } + + parent::tearDown(); + } + + /** + * Point the application at an isolated base path so package.json is never touched in the repository. + */ + protected function useTemporaryBasePath(bool $withPackageJson): void + { + $this->temporaryBasePath = ParallelTesting::tempDir('FoundationDevCommandsTest'); + + (new Filesystem)->deleteDirectory($this->temporaryBasePath); + mkdir($this->temporaryBasePath, 0777, true); + + if ($withPackageJson) { + touch($this->temporaryBasePath . '/package.json'); + } + + Application::getInstance()->setBasePath($this->temporaryBasePath); + } + public function testRegisterAddsCommand(): void { $devCommand = DevCommands::register('echo hello', 'greeter'); @@ -388,7 +422,7 @@ public function testRootPackageFilesRemainUserland(): void public function testRegisterDefaultsRegistersExpectedCommands(): void { - File::shouldReceive('exists')->with(base_path('package.json'))->once()->andReturnTrue(); + $this->useTemporaryBasePath(withPackageJson: true); DevCommands::registerDefaults(); @@ -411,7 +445,7 @@ public function testRegisterDefaultsRegistersExpectedCommands(): void public function testRegisterDefaultsExcludesViteWithoutPackageJson(): void { - File::shouldReceive('exists')->with(base_path('package.json'))->once()->andReturnFalse(); + $this->useTemporaryBasePath(withPackageJson: false); DevCommands::registerDefaults(); diff --git a/tests/Foundation/FoundationExceptionsHandlerTest.php b/tests/Foundation/FoundationExceptionsHandlerTest.php index 7e31e4a0fb..b68621666c 100644 --- a/tests/Foundation/FoundationExceptionsHandlerTest.php +++ b/tests/Foundation/FoundationExceptionsHandlerTest.php @@ -13,8 +13,10 @@ use Hypervel\Contracts\Foundation\ExceptionRenderer; use Hypervel\Contracts\Routing\ResponseFactory as ResponseFactoryContract; use Hypervel\Contracts\Session\Session as SessionContract; +use Hypervel\Contracts\Support\Arrayable; use Hypervel\Contracts\Support\Responsable; use Hypervel\Contracts\View\Factory as ViewFactory; +use Hypervel\Contracts\View\View; use Hypervel\Coroutine\Coroutine; use Hypervel\Database\Eloquent\ModelNotFoundException; use Hypervel\Database\MultipleRecordsFoundException; @@ -44,12 +46,15 @@ use Hypervel\Validation\ValidationException; use Hypervel\Validation\Validator; use InvalidArgumentException; +use LogicException; use Mockery as m; use OutOfRangeException; use PHPUnit\Framework\AssertionFailedError; use Psr\Log\LoggerInterface; +use Psr\Log\LoggerTrait; use Psr\Log\LogLevel; use RuntimeException; +use Stringable; use Swoole\Coroutine\CanceledException; use Symfony\Component\Console\Output\BufferedOutput; use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException; @@ -98,13 +103,26 @@ protected function setUp(): void $this->handler = new Handler($this->container); } + /** + * Bind a logger that records every log call. + */ + protected function getRealLogger(): LoggerSpy + { + $this->container->instance(LoggerInterface::class, $logger = new LoggerSpy); + + return $logger; + } + public function testHandlerReportsExceptionAsContext(): void { - $logger = m::mock(LoggerInterface::class); - $this->container->instance(LoggerInterface::class, $logger); - $logger->expects('error')->withArgs(['Exception message', m::hasKey('exception')]); + $logger = $this->getRealLogger(); $this->handler->report(new RuntimeException('Exception message')); + + $this->assertCount(1, $logger->logs); + $this->assertSame('error', $logger->logs[0]['level']); + $this->assertSame('Exception message', $logger->logs[0]['message']); + $this->assertArrayHasKey('exception', $logger->logs[0]['context']); } public function testHandlerNeverReportsCancellation(): void @@ -241,15 +259,18 @@ function () use ( public function testHandlerCallsContextMethodIfPresent(): void { - $logger = m::mock(LoggerInterface::class); - $this->container->instance(LoggerInterface::class, $logger); - $logger->expects('error')->withArgs(['Exception message', m::subset(['foo' => 'bar'])]); + $logger = $this->getRealLogger(); $exception = new ContextProvidingException('Exception message'); $this->assertSame(['foo' => 'bar', 'exception' => $exception], $this->handler->contextForException($exception)); $this->handler->report($exception); + + $this->assertCount(1, $logger->logs); + $this->assertSame('error', $logger->logs[0]['level']); + $this->assertSame('Exception message', $logger->logs[0]['message']); + $this->assertSame('bar', $logger->logs[0]['context']['foo']); } public function testHandlerMergesInlineContextIntoLogContext(): void @@ -334,21 +355,19 @@ public function testReportingMarkerIsRestoredAfterNestedReportsAndContextFailure public function testHandlerReportsExceptionWhenUnReportable(): void { - $logger = m::mock(LoggerInterface::class); - $this->container->instance(LoggerInterface::class, $logger); - $logger->expects('error')->withArgs(['Exception message', m::hasKey('exception')]); + $logger = $this->getRealLogger(); $this->handler->report(new UnReportableException('Exception message')); + + $this->assertCount(1, $logger->logs); + $this->assertSame('error', $logger->logs[0]['level']); + $this->assertSame('Exception message', $logger->logs[0]['message']); + $this->assertArrayHasKey('exception', $logger->logs[0]['context']); } public function testHandlerReportsExceptionWithCustomLogLevel(): void { - $logger = m::mock(LoggerInterface::class); - $this->container->instance(LoggerInterface::class, $logger); - - $logger->expects('critical')->withArgs(['Critical message', m::hasKey('exception')]); - $logger->expects('error')->withArgs(['Error message', m::hasKey('exception')]); - $logger->expects('log')->withArgs(['custom', 'Custom message', m::hasKey('exception')]); + $logger = $this->getRealLogger(); $this->handler->level(InvalidArgumentException::class, LogLevel::CRITICAL); $this->handler->level(OutOfRangeException::class, 'custom'); @@ -356,6 +375,20 @@ public function testHandlerReportsExceptionWithCustomLogLevel(): void $this->handler->report(new InvalidArgumentException('Critical message')); $this->handler->report(new RuntimeException('Error message')); $this->handler->report(new OutOfRangeException('Custom message')); + + $this->assertCount(3, $logger->logs); + + $this->assertSame('critical', $logger->logs[0]['level']); + $this->assertSame('Critical message', $logger->logs[0]['message']); + $this->assertArrayHasKey('exception', $logger->logs[0]['context']); + + $this->assertSame('error', $logger->logs[1]['level']); + $this->assertSame('Error message', $logger->logs[1]['message']); + $this->assertArrayHasKey('exception', $logger->logs[1]['context']); + + $this->assertSame('custom', $logger->logs[2]['level']); + $this->assertSame('Custom message', $logger->logs[2]['message']); + $this->assertArrayHasKey('exception', $logger->logs[2]['context']); } public function testHandlerReportsExceptionWithInlineLogLevel(): void @@ -520,15 +553,14 @@ public function testHandlerReportsExceptionUsingCallableClass(): void public function testShouldReturnJson(): void { - $this->request->expects('expectsJson')->andReturn(true); $e = new Exception('My custom error message'); - $request = $this->request; + $request = Request::create('/', 'GET', [], [], [], ['HTTP_ACCEPT' => 'application/json']); $shouldReturnJson = (fn (): bool => $this->shouldReturnJson($request, $e))->call($this->handler); $this->assertTrue($shouldReturnJson); - $this->request->expects('expectsJson')->andReturn(false); + $request = Request::create('/'); $shouldReturnJson = (fn (): bool => $this->shouldReturnJson($request, $e))->call($this->handler); $this->assertFalse($shouldReturnJson); @@ -608,9 +640,10 @@ public function testUnauthenticatedRequestWithRedirectReturnsGuestRedirect(): vo public function testReturnsJsonWithStackTraceWhenAjaxRequestAndDebugTrue(): void { $this->config->set('app.debug', true); - $this->request->expects('expectsJson')->andReturn(true); - $response = $this->handler->render($this->request, new Exception('My custom error message'))->getContent(); + $request = Request::create('/', 'GET', [], [], [], ['HTTP_ACCEPT' => 'application/json']); + + $response = $this->handler->render($request, new Exception('My custom error message'))->getContent(); $this->assertStringNotContainsString('', $response); $this->assertStringContainsString('"message": "My custom error message"', $response); @@ -697,9 +730,10 @@ public function testReturnsCustomResponseWhenExceptionImplementsResponsable(): v public function testReturnsJsonWithoutStackTraceWhenAjaxRequestAndDebugFalseAndExceptionMessageIsMasked(): void { $this->config->set('app.debug', false); - $this->request->expects('expectsJson')->andReturn(true); - $response = $this->handler->render($this->request, new Exception('This error message should not be visible'))->getContent(); + $request = Request::create('/', 'GET', [], [], [], ['HTTP_ACCEPT' => 'application/json']); + + $response = $this->handler->render($request, new Exception('This error message should not be visible'))->getContent(); $this->assertStringContainsString('"message": "Server Error"', $response); $this->assertStringNotContainsString('', $response); @@ -712,9 +746,10 @@ public function testReturnsJsonWithoutStackTraceWhenAjaxRequestAndDebugFalseAndE public function testReturnsJsonWithoutStackTraceWhenAjaxRequestAndDebugFalseAndHttpExceptionErrorIsShown(): void { $this->config->set('app.debug', false); - $this->request->expects('expectsJson')->andReturn(true); - $response = $this->handler->render($this->request, new HttpException(403, 'My custom error message'))->getContent(); + $request = Request::create('/', 'GET', [], [], [], ['HTTP_ACCEPT' => 'application/json']); + + $response = $this->handler->render($request, new HttpException(403, 'My custom error message'))->getContent(); $this->assertStringContainsString('"message": "My custom error message"', $response); $this->assertStringNotContainsString('', $response); @@ -727,9 +762,10 @@ public function testReturnsJsonWithoutStackTraceWhenAjaxRequestAndDebugFalseAndH public function testReturnsJsonWithoutStackTraceWhenAjaxRequestAndDebugFalseAndAccessDeniedHttpExceptionErrorIsShown(): void { $this->config->set('app.debug', false); - $this->request->expects('expectsJson')->andReturn(true); - $response = $this->handler->render($this->request, new AccessDeniedHttpException('My custom error message'))->getContent(); + $request = Request::create('/', 'GET', [], [], [], ['HTTP_ACCEPT' => 'application/json']); + + $response = $this->handler->render($request, new AccessDeniedHttpException('My custom error message'))->getContent(); $this->assertStringContainsString('"message": "My custom error message"', $response); $this->assertStringNotContainsString('', $response); @@ -768,7 +804,7 @@ function (array $argument) use (&$argumentActual): bool { return $redirector; }); - $file = m::mock(UploadedFile::class); + $file = new UploadedFile(__FILE__, 'photo.jpg', null, null, true); $request = Request::create('/', 'POST', $argumentExpected, [], ['photo' => $file]); @@ -819,19 +855,19 @@ public function testRecordsNotFoundReturns404WithoutReporting(): void public function testMultipleRecordsFoundIsReported(): void { - $logger = m::mock(LoggerInterface::class); - $this->container->instance(LoggerInterface::class, $logger); - $logger->expects('error')->withArgs(['2 records were found.', m::hasKey('exception')]); + $logger = $this->getRealLogger(); $this->handler->report(new MultipleRecordsFoundException(2)); + + $this->assertCount(1, $logger->logs); + $this->assertSame('error', $logger->logs[0]['level']); + $this->assertSame('2 records were found.', $logger->logs[0]['message']); + $this->assertArrayHasKey('exception', $logger->logs[0]['context']); } public function testItReturnsSpecificErrorViewIfExists(): void { - $viewFactory = m::mock(ViewFactory::class); - $viewFactory->expects('exists')->with('errors::502')->andReturn(true); - - $this->container->instance(ViewFactory::class, $viewFactory); + $this->container->instance(ViewFactory::class, new FakeErrorViewFactory(['errors::502'])); $handler = new class($this->container) extends Handler { /** @@ -848,11 +884,7 @@ public function getErrorView(HttpException $e): ?string public function testItReturnsFallbackErrorViewIfExists(): void { - $viewFactory = m::mock(ViewFactory::class); - $viewFactory->expects('exists')->with('errors::502')->andReturn(false); - $viewFactory->expects('exists')->with('errors::5xx')->andReturn(true); - - $this->container->instance(ViewFactory::class, $viewFactory); + $this->container->instance(ViewFactory::class, new FakeErrorViewFactory(['errors::5xx'])); $handler = new class($this->container) extends Handler { /** @@ -869,11 +901,7 @@ public function getErrorView(HttpException $e): ?string public function testItReturnsNullIfNoErrorViewExists(): void { - $viewFactory = m::mock(ViewFactory::class); - $viewFactory->expects('exists')->with('errors::404')->andReturn(false); - $viewFactory->expects('exists')->with('errors::4xx')->andReturn(false); - - $this->container->instance(ViewFactory::class, $viewFactory); + $this->container->instance(ViewFactory::class, new FakeErrorViewFactory([])); $handler = new class($this->container) extends Handler { /** @@ -893,8 +921,16 @@ public function getErrorView(HttpException $e): ?string */ private function executeScenarioWhereErrorViewThrowsWhileRenderingAndDebugIs(bool $debug): void { - $this->viewFactory->expects('exists')->with('errors::404')->andReturn(true); - $this->viewFactory->expects('make')->withAnyArgs()->andThrow(new Exception('Rendering this view throws an exception')); + $viewFactory = new FakeErrorViewFactory( + ['errors::404'], + new Exception('Rendering this view throws an exception') + ); + + $this->container->instance(ViewFactory::class, $viewFactory); + $this->container->instance(ResponseFactoryContract::class, new ResponseFactory( + $viewFactory, + m::mock(Redirector::class), + )); $this->config->set('app.debug', $debug); @@ -922,12 +958,13 @@ public function testItDoesNotCrashIfErrorViewThrowsWhileRenderingAndDebugFalse() { // When debug is false, the exception thrown while rendering the error view // should not bubble as this may trigger an infinite loop. - - $logger = m::mock(LoggerInterface::class); - $logger->expects('error'); - $this->container->instance(LoggerInterface::class, $logger); + $logger = $this->getRealLogger(); $this->executeScenarioWhereErrorViewThrowsWhileRenderingAndDebugIs(false); + + $this->assertCount(1, $logger->logs); + $this->assertSame('error', $logger->logs[0]['level']); + $this->assertSame('Rendering this view throws an exception', $logger->logs[0]['message']); } public function testItDoesNotCrashIfErrorViewThrowsWhileRenderingAndDebugTrue(): void @@ -1676,6 +1713,114 @@ public function context() } } +class LoggerSpy implements LoggerInterface +{ + use LoggerTrait; + + public array $logs = []; + + /** + * Record the log call. + */ + public function log(mixed $level, Stringable|string $message, array $context = []): void + { + $this->logs[] = [ + 'level' => $level, + 'message' => $message, + 'context' => $context, + ]; + } +} + +class FakeErrorViewFactory implements ViewFactory +{ + /** + * Create a factory that knows the given views. + */ + public function __construct(protected array $views = [], protected ?Exception $makeThrows = null) + { + } + + /** + * Determine if the given view exists. + */ + public function exists(string $view): bool + { + return in_array($view, $this->views, true); + } + + /** + * Fail to render the given view file. + */ + public function file(string $path, Arrayable|array $data = [], array $mergeData = []): View + { + throw new LogicException("View file [{$path}] cannot be rendered."); + } + + /** + * Fail to render the given view. + */ + public function make(string $view, Arrayable|array $data = [], array $mergeData = []): View + { + throw $this->makeThrows ?? new LogicException("View [{$view}] cannot be rendered."); + } + + /** + * Fail to render the first existing view. + */ + public function first(array $views, Arrayable|array $data = [], array $mergeData = []): View + { + throw new LogicException('Views cannot be rendered.'); + } + + /** + * Ignore shared data. + */ + public function share(array|string $key, mixed $value = null): mixed + { + return null; + } + + /** + * Ignore the view composer. + */ + public function composer(array|string $views, Closure|string $callback): array + { + return []; + } + + /** + * Ignore the view creator. + */ + public function creator(array|string $views, Closure|string $callback): array + { + return []; + } + + /** + * Ignore the namespace hints. + */ + public function addNamespace(string $namespace, array|string $hints): static + { + return $this; + } + + /** + * Ignore the replacement namespace hints. + */ + public function replaceNamespace(string $namespace, array|string $hints): static + { + return $this; + } + + /** + * Ignore finder cache flushes. + */ + public function flushFinderCache(): void + { + } +} + class CustomReporter { private $service; diff --git a/tests/Foundation/FoundationFormRequestTest.php b/tests/Foundation/FoundationFormRequestTest.php index 73fc800584..da2a857230 100644 --- a/tests/Foundation/FoundationFormRequestTest.php +++ b/tests/Foundation/FoundationFormRequestTest.php @@ -19,7 +19,10 @@ use Hypervel\Foundation\Http\Attributes\StopOnFirstFailure; use Hypervel\Foundation\Http\FormRequest; use Hypervel\Http\RedirectResponse; +use Hypervel\Http\Request; use Hypervel\Routing\Redirector; +use Hypervel\Routing\Route; +use Hypervel\Routing\RouteCollection; use Hypervel\Routing\UrlGenerator; use Hypervel\Tests\TestCase; use Hypervel\Translation\ArrayLoader; @@ -111,8 +114,7 @@ public function testValidateThrowsWhenValidationFailsWithConfiguredErrorBagAttri public function testAttributesAreInheritedFromParentRequest(): void { $request = $this->createRequest(['unexpected' => 'value'], FoundationTestFormRequestInheritingAttributesStub::class, 'POST'); - - $this->mocks['generator']->shouldReceive('to')->with('/parent')->andReturn('http://localhost/parent'); + $request->setRedirector($this->getRealRedirector()); $exception = $this->catchException(ValidationException::class, function () use ($request): void { $request->validateResolved(); @@ -128,8 +130,7 @@ public function testAttributesAreInheritedFromParentRequest(): void public function testChildAttributesOverrideParentAttributes(): void { $request = $this->createRequest(['unexpected' => 'value'], FoundationTestFormRequestOverridingParentAttributesStub::class, 'POST'); - - $this->mocks['generator']->shouldReceive('route')->with('child.route')->andReturn('http://localhost/child'); + $request->setRedirector($this->getRealRedirector(['child.route' => '/child'])); $exception = $this->catchException(ValidationException::class, function () use ($request): void { $request->validateResolved(); @@ -143,8 +144,7 @@ public function testChildAttributesOverrideParentAttributes(): void public function testChildPropertiesOverrideParentAttributes(): void { $request = $this->createRequest([], FoundationTestFormRequestOverridingParentAttributesWithPropertiesStub::class, 'POST'); - - $this->mocks['generator']->shouldReceive('to')->with('/child')->andReturn('http://localhost/child'); + $request->setRedirector($this->getRealRedirector()); $exception = $this->catchException(ValidationException::class, function () use ($request): void { $request->validateResolved(); @@ -185,12 +185,22 @@ public function testValidateThrowsExceptionFromAuthorizationResponse(): void public function testValidateDoesntThrowExceptionFromResponseAllowed(): void { - $this->createRequest([], FoundationTestFormRequestPassesWithResponseStub::class)->validateResolved(); + $request = $this->createRequest([], FoundationTestFormRequestPassesWithResponseStub::class); + + $request->validateResolved(); + + $this->assertSame([], $request->validated()); } public function testPrepareForValidationRunsBeforeValidation(): void { - $this->createRequest([], FoundationTestFormRequestHooks::class)->validateResolved(); + $request = $this->createRequest([], FoundationTestFormRequestHooks::class); + + $request->validateResolved(); + + // 'name' is required; the only way validation can succeed against an + // empty payload is if prepareForValidation() injected it beforehand. + $this->assertSame(['name' => 'Taylor'], $request->validated()); } public function testAfterValidationRunsAfterValidation(): void @@ -803,6 +813,23 @@ protected function createValidationFactory(Container $container): ValidationFact return new ValidationFactory($translator, $container); } + /** + * Create a real redirector backed by a real URL generator, optionally + * with named routes registered for tests that redirect by route name. + * + * @param array $routes route name => URI + */ + protected function getRealRedirector(array $routes = []): Redirector + { + $collection = new RouteCollection; + + foreach ($routes as $name => $uri) { + $collection->add((new Route('GET', $uri, []))->name($name)); + } + + return new Redirector(new UrlGenerator($collection, Request::create('/'))); + } + /** * Create a mock redirector. */ diff --git a/tests/Foundation/FoundationHelpersTest.php b/tests/Foundation/FoundationHelpersTest.php index 2c129bad17..f165b03c34 100644 --- a/tests/Foundation/FoundationHelpersTest.php +++ b/tests/Foundation/FoundationHelpersTest.php @@ -10,7 +10,6 @@ use Hypervel\Cache\CacheManager; use Hypervel\Container\Container; use Hypervel\Context\RequestContext; -use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Support\Responsable; use Hypervel\Http\Exceptions\HttpResponseException; use Hypervel\Http\Request; @@ -26,6 +25,7 @@ use Psr\Log\LoggerInterface; use stdClass; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; +use Symfony\Component\HttpKernel\Exception\HttpException; enum StringEnum: string { @@ -201,27 +201,27 @@ public function testTodayWithNull(): void public function testCache(): void { - $cache = m::mock(CacheManager::class); - $this->app->instance('cache', $cache); + $this->freezeTime(); // cache() returns the CacheManager $this->assertInstanceOf(CacheManager::class, cache()); // cache(['foo' => 'bar'], 1) puts - $cache->expects('put')->withArgs(['foo', 'bar', 'ttl' => 1]); cache(['foo' => 'bar'], 1); + $this->assertSame('bar', $this->app->make('cache')->get('foo')); // cache('foo') gets - $cache->expects('get')->with('foo', null)->andReturn('bar'); $this->assertSame('bar', cache('foo')); // cache('foo', null) gets with null default - $cache->expects('get')->with('foo', null)->andReturn('bar'); $this->assertSame('bar', cache('foo', null)); // cache('baz', 'default') gets with default - $cache->expects('get')->with('baz', 'default')->andReturn('default'); $this->assertSame('default', cache('baz', 'default')); + + // cache(['foo' => 'bar'], 1) expires after one second + $this->travel(1)->second(); + $this->assertNull(cache('foo')); } public function testSessionAcceptsEnumKeys(): void @@ -284,11 +284,9 @@ public function testLogsResolvesAChannelNamedZero(): void public function testEvents(): void { - $dispatcher = m::mock(Dispatcher::class); - $this->app->instance('events', $dispatcher); + $this->app->make('events')->listen('a', fn (string $payload): ?string => $payload === 'b' ? 'foo' : null); - $dispatcher->expects('dispatch')->with('test.event', ['payload'], false)->andReturn('foo'); - $this->assertSame('foo', event('test.event', ['payload'], false)); + $this->assertSame('foo', event('a', 'b', true)); } public function testEventHelperReturnsArrayForNormalDispatch(): void @@ -373,12 +371,11 @@ public function testAbortReceivesCodeAsInteger(): void { try { abort(400, 'Bad request', ['X-FOO' => 'BAR']); - - $this->fail('abort function must throw HttpException when receiving code as integer.'); - } catch (\Symfony\Component\HttpKernel\Exception\HttpException $exception) { - $this->assertSame(400, $exception->getStatusCode()); - $this->assertSame('Bad request', $exception->getMessage()); - $this->assertSame('BAR', $exception->getHeaders()['X-FOO']); + $this->fail('abort() should throw an HttpException.'); + } catch (HttpException $e) { + $this->assertSame(400, $e->getStatusCode()); + $this->assertSame('Bad request', $e->getMessage()); + $this->assertSame(['X-FOO' => 'BAR'], $e->getHeaders()); } } diff --git a/tests/Foundation/FoundationInteractsWithDatabaseTest.php b/tests/Foundation/FoundationInteractsWithDatabaseTest.php index 3c5a664479..782b2f5c0d 100644 --- a/tests/Foundation/FoundationInteractsWithDatabaseTest.php +++ b/tests/Foundation/FoundationInteractsWithDatabaseTest.php @@ -4,10 +4,12 @@ namespace Hypervel\Tests\Foundation\FoundationInteractsWithDatabaseTest; +use Hypervel\Database\Capsule\Manager; use Hypervel\Database\Connection; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\SoftDeletes; use Hypervel\Database\Query\Builder; +use Hypervel\Database\Schema\Blueprint; use Hypervel\Foundation\Testing\Concerns\InteractsWithDatabase; use Hypervel\Foundation\Testing\TestCase as TestingTestCase; use Hypervel\Support\Arr; @@ -29,7 +31,7 @@ class FoundationInteractsWithDatabaseTest extends TestCase 'name' => 'Hypervel', ]; - protected Connection&MockInterface $connection; + protected Connection $connection; protected function setUp(): void { @@ -85,35 +87,29 @@ public function testSeeInDatabaseDoesNotFindResults(): void { $this->expectExceptionObject(new ExpectationFailedException('The table is empty.')); - $builder = $this->mockCountBuilder(false); - - $builder->shouldReceive('get')->andReturn(collect()); + $this->useInMemoryConnection(); $this->assertDatabaseHas($this->table, $this->data); } public function testSeeInDatabaseFindsNotMatchingResults(): void { - $this->expectExceptionObject(new ExpectationFailedException('Found similar results: ' . json_encode([['title' => 'Forge']], JSON_PRETTY_PRINT))); + $row = ['title' => 'Spark', 'name' => 'Forge']; - $builder = $this->mockCountBuilder(false); + $this->useInMemoryConnection()->table($this->table)->insert($row); - $builder->shouldReceive('limit')->andReturnSelf(); - $builder->shouldReceive('get')->andReturn(collect([['title' => 'Forge']])); + $this->expectExceptionObject(new ExpectationFailedException('Found similar results: ' . json_encode([$row], JSON_PRETTY_PRINT))); $this->assertDatabaseHas($this->table, $this->data); } public function testSeeInDatabaseFindsManyNotMatchingResults(): void { - $this->expectExceptionObject(new ExpectationFailedException('Found similar results: ' . json_encode(['data', 'data', 'data'], JSON_PRETTY_PRINT) . ' and 2 others.')); + $row = ['title' => 'Spark', 'name' => 'Forge']; - $builder = $this->mockCountBuilder(false, countResult: [5, 5]); + $this->useInMemoryConnection()->table($this->table)->insert(array_fill(0, 5, $row)); - $builder->shouldReceive('limit')->andReturnSelf(); - $builder->shouldReceive('get')->andReturn( - collect(array_fill(0, 3, 'data')) - ); + $this->expectExceptionObject(new ExpectationFailedException('Found similar results: ' . json_encode(array_fill(0, 3, $row), JSON_PRETTY_PRINT) . ' and 2 others.')); $this->assertDatabaseHas($this->table, $this->data); } @@ -165,10 +161,7 @@ public function testDontSeeInDatabaseFindsResults(): void { $this->expectException(ExpectationFailedException::class); - $builder = $this->mockCountBuilder(true); - - $builder->shouldReceive('limit')->andReturnSelf(); - $builder->shouldReceive('get')->andReturn(collect([$this->data])); + $this->useInMemoryConnection()->table($this->table)->insert($this->data); $this->assertDatabaseMissing($this->table, $this->data); } @@ -190,14 +183,12 @@ public function testAssertDatabaseCountSupportModels(): void public function testAssertDatabaseCountSupportsArraysOfTablesAndCounts(): void { - $products = m::mock(Builder::class); - $products->expects('count')->andReturn(2); - - $orders = m::mock(Builder::class); - $orders->expects('count')->andReturn(5); - - $this->connection->shouldReceive('table')->with($this->table)->andReturn($products); - $this->connection->shouldReceive('table')->with('orders')->andReturn($orders); + $connection = $this->useInMemoryConnection(); + $connection->table('products')->insert([ + ['title' => 'Spark', 'name' => 'Hypervel'], + ['title' => 'Forge', 'name' => 'Hypervel'], + ]); + $connection->table('orders')->insert(array_fill(0, 5, ['title' => 'Order'])); $this->assertDatabaseCount([ ProductStub::class => 2, @@ -333,48 +324,34 @@ public function testAssertDatabaseMissingFailsWhenFindsResults(): void { $this->expectException(ExpectationFailedException::class); - $builder = $this->mockCountBuilder(true); - - $builder->shouldReceive('get')->andReturn(collect([$this->data])); + $this->useInMemoryConnection()->table($this->table)->insert($this->data); $this->assertDatabaseMissing($this->table, $this->data); } public function testAssertModelMissingPassesWhenDoesNotFindModelResults(): void { - $this->data = ['id' => 1]; - - $builder = $this->mockCountBuilder(false); - - $builder->shouldReceive('get')->andReturn(collect()); + $this->useInMemoryConnection(); - $this->assertModelMissing(new ProductStub($this->data)); + $this->assertModelMissing(new ProductStub(['id' => 1])); } public function testAssertModelMissingFailsWhenFindsModelResults(): void { $this->expectException(ExpectationFailedException::class); - $this->data = ['id' => 1]; - - $builder = $this->mockCountBuilder(true); - - $builder->shouldReceive('get')->andReturn(collect([$this->data])); + $this->useInMemoryConnection()->table($this->table)->insert(['id' => 1, 'title' => 'Spark', 'name' => 'Hypervel']); - $this->assertModelMissing(new ProductStub($this->data)); + $this->assertModelMissing(new ProductStub(['id' => 1])); } public function testAssertModelExistsFailsWhenDoesNotFindModelResults(): void { $this->expectException(ExpectationFailedException::class); - $this->data = ['id' => 1]; + $this->useInMemoryConnection(); - $builder = $this->mockCountBuilder(false); - - $builder->shouldReceive('get')->andReturn(collect()); - - $this->assertModelExists(new ProductStub($this->data)); + $this->assertModelExists(new ProductStub(['id' => 1])); } public function testAssertSoftDeletedInDatabaseFindsResults(): void @@ -396,9 +373,7 @@ public function testAssertSoftDeletedInDatabaseDoesNotFindResults(): void { $this->expectExceptionObject(new ExpectationFailedException('The table is empty.')); - $builder = $this->mockCountBuilder(false); - - $builder->shouldReceive('get')->andReturn(collect()); + $this->useInMemoryConnection(); $this->assertSoftDeleted($this->table, $this->data); } @@ -407,13 +382,9 @@ public function testAssertSoftDeletedInDatabaseDoesNotFindModelResults(): void { $this->expectExceptionObject(new ExpectationFailedException('The table is empty.')); - $this->data = ['id' => 1]; - - $builder = $this->mockCountBuilder(false); - - $builder->shouldReceive('get')->andReturn(collect()); + $this->useInMemoryConnection(); - $this->assertSoftDeleted(new ProductStub($this->data)); + $this->assertSoftDeleted(new ProductStub(['id' => 1])); } public function testAssertSoftDeletedInDatabaseDoesNotFindModelWithCustomColumnResults(): void @@ -421,11 +392,8 @@ public function testAssertSoftDeletedInDatabaseDoesNotFindModelWithCustomColumnR $this->expectExceptionObject(new ExpectationFailedException('The table is empty.')); $model = new CustomProductStub(['id' => 1, 'name' => 'Hypervel']); - $this->data = ['id' => 1, 'name' => 'Tailwind']; - $builder = $this->mockCountBuilder(false, 'trashed_at'); - - $builder->shouldReceive('get')->andReturn(collect()); + $this->useInMemoryConnection(); $this->assertSoftDeleted($model, ['name' => 'Tailwind']); } @@ -435,11 +403,8 @@ public function testAssertSoftDeletedInDatabaseDoesNotFindModePassedViaFcnWithCu $this->expectExceptionObject(new ExpectationFailedException('The table is empty.')); $model = new CustomProductStub(['id' => 1, 'name' => 'Hypervel']); - $this->data = ['id' => 1]; - - $builder = $this->mockCountBuilder(false, 'trashed_at'); - $builder->shouldReceive('get')->andReturn(collect()); + $this->useInMemoryConnection(); $this->assertSoftDeleted(CustomProductStub::class, ['id' => $model->id]); } @@ -463,9 +428,7 @@ public function testAssertNotSoftDeletedOnlyFindsMatchingModels(): void { $this->expectExceptionObject(new ExpectationFailedException('Failed asserting that any existing row')); - $builder = $this->mockCountBuilder(false); - - $builder->shouldReceive('get')->andReturn(collect(), collect(1)); + $this->useInMemoryConnection(); $this->assertNotSoftDeleted(ProductStub::class, $this->data); } @@ -474,9 +437,7 @@ public function testAssertNotSoftDeletedInDatabaseDoesNotFindResults(): void { $this->expectExceptionObject(new ExpectationFailedException('The table is empty.')); - $builder = $this->mockCountBuilder(false); - - $builder->shouldReceive('get')->andReturn(collect()); + $this->useInMemoryConnection(); $this->assertNotSoftDeleted($this->table, $this->data); } @@ -485,13 +446,9 @@ public function testAssertNotSoftDeletedInDatabaseDoesNotFindModelResults(): voi { $this->expectExceptionObject(new ExpectationFailedException('The table is empty.')); - $this->data = ['id' => 1]; - - $builder = $this->mockCountBuilder(false); + $this->useInMemoryConnection(); - $builder->shouldReceive('get')->andReturn(collect()); - - $this->assertNotSoftDeleted(new ProductStub($this->data)); + $this->assertNotSoftDeleted(new ProductStub(['id' => 1])); } public function testAssertNotSoftDeletedInDatabaseDoesNotFindModelWithCustomColumnResults(): void @@ -499,11 +456,8 @@ public function testAssertNotSoftDeletedInDatabaseDoesNotFindModelWithCustomColu $this->expectExceptionObject(new ExpectationFailedException('The table is empty.')); $model = new CustomProductStub(['id' => 1, 'name' => 'Hypervel']); - $this->data = ['id' => 1, 'name' => 'Tailwind']; - - $builder = $this->mockCountBuilder(false, 'trashed_at'); - $builder->shouldReceive('get')->andReturn(collect()); + $this->useInMemoryConnection(); $this->assertNotSoftDeleted($model, ['name' => 'Tailwind']); } @@ -513,24 +467,17 @@ public function testAssertNotSoftDeletedInDatabaseDoesNotFindModelPassedViaFcnWi $this->expectExceptionObject(new ExpectationFailedException('The table is empty.')); $model = new CustomProductStub(['id' => 1, 'name' => 'Hypervel']); - $this->data = ['id' => 1]; - - $builder = $this->mockCountBuilder(false, 'trashed_at'); - $builder->shouldReceive('get')->andReturn(collect()); + $this->useInMemoryConnection(); $this->assertNotSoftDeleted(CustomProductStub::class, ['id' => $model->id]); } public function testAssertExistsPassesWhenFindsResults(): void { - $this->data = ['id' => 1]; - - $builder = $this->mockCountBuilder(true); - - $builder->shouldReceive('get')->andReturn(collect($this->data)); + $this->useInMemoryConnection()->table($this->table)->insert(['id' => 1, 'title' => 'Spark', 'name' => 'Hypervel']); - $this->assertModelExists(new ProductStub($this->data)); + $this->assertModelExists(new ProductStub(['id' => 1])); } public function testGetTableNameFromModel(): void @@ -680,10 +627,38 @@ protected function mockCountBuilder(bool $existsResult, string $deletedAtColumn /** * Get the mocked database connection. */ - protected function getConnection(): Connection&MockInterface + protected function getConnection(): Connection { return $this->connection; } + + /** + * Use an in-memory SQLite connection for the database assertions. + */ + protected function useInMemoryConnection(): Connection + { + $capsule = new Manager; + $capsule->addConnection(['driver' => 'sqlite', 'database' => ':memory:']); + + $this->connection = $capsule->getConnection(); + + $this->connection->getSchemaBuilder()->create('products', function (Blueprint $table): void { + $table->increments('id'); + $table->string('title')->nullable(); + $table->string('name')->nullable(); + $table->timestamp('deleted_at')->nullable(); + $table->timestamp('trashed_at')->nullable(); + }); + + $this->connection->getSchemaBuilder()->create('orders', function (Blueprint $table): void { + $table->increments('id'); + $table->string('title')->nullable(); + $table->string('name')->nullable(); + $table->timestamp('deleted_at')->nullable(); + }); + + return $this->connection; + } } class ProductStub extends Model diff --git a/tests/Foundation/Testing/Concerns/InteractsWithContainerTest.php b/tests/Foundation/Testing/Concerns/InteractsWithContainerTest.php index b358f3a7d5..fea3ed62ff 100644 --- a/tests/Foundation/Testing/Concerns/InteractsWithContainerTest.php +++ b/tests/Foundation/Testing/Concerns/InteractsWithContainerTest.php @@ -122,9 +122,15 @@ public function testWithoutDefer(): void public function testForgetMock(): void { - $this->mock(InstanceStub::class) - ->expects('execute') - ->andReturn('bar'); + $this->app->instance(InstanceStub::class, new class extends InstanceStub { + /** + * Execute the stub. + */ + public function execute(): string + { + return 'bar'; + } + }); $this->assertSame('bar', $this->app->make(InstanceStub::class)->execute()); diff --git a/tests/Foundation/Testing/DatabaseMigrationsTest.php b/tests/Foundation/Testing/DatabaseMigrationsTest.php index b83d590fd0..f6edf9fc91 100644 --- a/tests/Foundation/Testing/DatabaseMigrationsTest.php +++ b/tests/Foundation/Testing/DatabaseMigrationsTest.php @@ -10,7 +10,7 @@ use Hypervel\Foundation\Testing\Concerns\InteractsWithConsole; use Hypervel\Foundation\Testing\DatabaseMigrations; use Hypervel\Testbench\TestCase; -use Mockery as m; +use Hypervel\Tests\Foundation\Testing\Fixtures\ConsoleKernelSpy; class DatabaseMigrationsTest extends TestCase { @@ -48,109 +48,123 @@ protected function setUpTraits(): array public function testRefreshTestDatabaseDefault(): void { - $kernel = m::mock(KernelContract::class); - $kernel->expects('call') - ->with('migrate:fresh', [ - '--drop-views' => false, - '--drop-types' => false, - '--seed' => false, - ])->andReturn(0); - $kernel->expects('setArtisan')->with(null); - $kernel->expects('call') - ->with('migrate:rollback', []) - ->andReturn(0); + $kernel = new ConsoleKernelSpy; $this->app = new Application; $this->app->singleton('config', fn () => new Repository(['database' => ['default' => 'default']])); $this->app->singleton(KernelContract::class, fn () => $kernel); + // beforeApplicationDestroyed() callbacks run most-recently-registered + // first, so registering ours before runDatabaseMigrations() ensures + // its own migrate:rollback callback has already fired by the time + // this one checks the full call list. + $this->beforeApplicationDestroyed(function () use ($kernel): void { + $this->assertSame([ + ['migrate:fresh', ['--drop-views' => false, '--drop-types' => false, '--seed' => false]], + ['migrate:rollback', []], + ], $kernel->calls); + }); + $this->runDatabaseMigrations(); + + $this->assertSame([ + ['migrate:fresh', ['--drop-views' => false, '--drop-types' => false, '--seed' => false]], + ], $kernel->calls); + $this->assertSame([null], $kernel->setArtisanCalls); } public function testRefreshTestDatabaseWithDropViewsOption(): void { $this->dropViews = true; - $kernel = m::mock(KernelContract::class); - $kernel->expects('call') - ->with('migrate:fresh', [ - '--drop-views' => true, - '--drop-types' => false, - '--seed' => false, - ])->andReturn(0); - $kernel->expects('setArtisan')->with(null); - $kernel->expects('call') - ->with('migrate:rollback', []) - ->andReturn(0); + $kernel = new ConsoleKernelSpy; $this->app = new Application; $this->app->singleton('config', fn () => new Repository(['database' => ['default' => 'default']])); $this->app->singleton(KernelContract::class, fn () => $kernel); + $this->beforeApplicationDestroyed(function () use ($kernel): void { + $this->assertSame([ + ['migrate:fresh', ['--drop-views' => true, '--drop-types' => false, '--seed' => false]], + ['migrate:rollback', []], + ], $kernel->calls); + }); + $this->runDatabaseMigrations(); + + $this->assertSame([ + ['migrate:fresh', ['--drop-views' => true, '--drop-types' => false, '--seed' => false]], + ], $kernel->calls); + $this->assertSame([null], $kernel->setArtisanCalls); } public function testRefreshTestDatabaseWithDropTypesOption(): void { $this->dropTypes = true; - $kernel = m::mock(KernelContract::class); - $kernel->expects('call') - ->with('migrate:fresh', [ - '--drop-views' => false, - '--drop-types' => true, - '--seed' => false, - ])->andReturn(0); - $kernel->expects('setArtisan')->with(null); - $kernel->expects('call') - ->with('migrate:rollback', []) - ->andReturn(0); + $kernel = new ConsoleKernelSpy; $this->app = new Application; $this->app->singleton('config', fn () => new Repository(['database' => ['default' => 'default']])); $this->app->singleton(KernelContract::class, fn () => $kernel); + $this->beforeApplicationDestroyed(function () use ($kernel): void { + $this->assertSame([ + ['migrate:fresh', ['--drop-views' => false, '--drop-types' => true, '--seed' => false]], + ['migrate:rollback', []], + ], $kernel->calls); + }); + $this->runDatabaseMigrations(); + + $this->assertSame([ + ['migrate:fresh', ['--drop-views' => false, '--drop-types' => true, '--seed' => false]], + ], $kernel->calls); + $this->assertSame([null], $kernel->setArtisanCalls); } public function testRefreshTestDatabaseWithSeedOption(): void { $this->seed = true; - $kernel = m::mock(KernelContract::class); - $kernel->expects('call') - ->with('migrate:fresh', [ - '--drop-views' => false, - '--drop-types' => false, - '--seed' => true, - ])->andReturn(0); - $kernel->expects('setArtisan')->with(null); - $kernel->expects('call') - ->with('migrate:rollback', []) - ->andReturn(0); + $kernel = new ConsoleKernelSpy; $this->app = new Application; $this->app->singleton('config', fn () => new Repository(['database' => ['default' => 'default']])); $this->app->singleton(KernelContract::class, fn () => $kernel); + $this->beforeApplicationDestroyed(function () use ($kernel): void { + $this->assertSame([ + ['migrate:fresh', ['--drop-views' => false, '--drop-types' => false, '--seed' => true]], + ['migrate:rollback', []], + ], $kernel->calls); + }); + $this->runDatabaseMigrations(); + + $this->assertSame([ + ['migrate:fresh', ['--drop-views' => false, '--drop-types' => false, '--seed' => true]], + ], $kernel->calls); + $this->assertSame([null], $kernel->setArtisanCalls); } public function testRefreshTestDatabaseWithSeederOption(): void { $this->seeder = 'seeder'; - $kernel = m::mock(KernelContract::class); - $kernel->expects('call') - ->with('migrate:fresh', [ - '--drop-views' => false, - '--drop-types' => false, - '--seeder' => 'seeder', - ])->andReturn(0); - $kernel->expects('setArtisan')->with(null); - $kernel->expects('call') - ->with('migrate:rollback', []) - ->andReturn(0); + $kernel = new ConsoleKernelSpy; $this->app = new Application; $this->app->singleton('config', fn () => new Repository(['database' => ['default' => 'default']])); $this->app->singleton(KernelContract::class, fn () => $kernel); + $this->beforeApplicationDestroyed(function () use ($kernel): void { + $this->assertSame([ + ['migrate:fresh', ['--drop-views' => false, '--drop-types' => false, '--seeder' => 'seeder']], + ['migrate:rollback', []], + ], $kernel->calls); + }); + $this->runDatabaseMigrations(); + + $this->assertSame([ + ['migrate:fresh', ['--drop-views' => false, '--drop-types' => false, '--seeder' => 'seeder']], + ], $kernel->calls); + $this->assertSame([null], $kernel->setArtisanCalls); } } diff --git a/tests/Foundation/Testing/Fixtures/ConsoleKernelSpy.php b/tests/Foundation/Testing/Fixtures/ConsoleKernelSpy.php new file mode 100644 index 0000000000..dfd33c4be5 --- /dev/null +++ b/tests/Foundation/Testing/Fixtures/ConsoleKernelSpy.php @@ -0,0 +1,148 @@ + + */ + public array $calls = []; + + /** + * @var list + */ + public array $setArtisanCalls = []; + + /** + * Bootstrap the application for artisan commands. + */ + public function bootstrap(): void + { + } + + /** + * Handle an incoming console command. + */ + public function handle(InputInterface $input, ?OutputInterface $output = null): int + { + return 0; + } + + /** + * Resolve a console schedule instance. + */ + public function resolveConsoleSchedule(): Schedule + { + throw new LogicException('The console schedule cannot be resolved.'); + } + + /** + * Register a Closure based command with the application. + */ + public function command(string $signature, Closure $callback): ClosureCommand + { + throw new LogicException('Closure commands cannot be registered.'); + } + + /** + * Set the Artisan commands provided by the application. + */ + public function addCommands(array $commands): static + { + return $this; + } + + /** + * Set the paths that should have their Artisan commands automatically discovered. + */ + public function addCommandPaths(array $paths): static + { + return $this; + } + + /** + * Set the paths that should have their Artisan "routes" automatically discovered. + */ + public function addCommandRoutePaths(array $paths): static + { + return $this; + } + + /** + * Register the given command with the console application. + */ + public function registerCommand(SymfonyCommand $command): void + { + } + + /** + * Run an Artisan console command by name. + */ + public function call(string $command, array $parameters = [], ?OutputInterface $outputBuffer = null): int + { + $this->calls[] = [$command, $parameters]; + + return 0; + } + + /** + * Queue the given console command. + */ + public function queue(string $command, array $parameters = []): PendingDispatch + { + throw new LogicException('Console commands cannot be queued.'); + } + + /** + * Get all of the commands registered with the console. + */ + public function all(): array + { + return []; + } + + /** + * Get the output for the last run command. + */ + public function output(): string + { + return ''; + } + + /** + * Terminate the application. + */ + public function terminate(InputInterface $input, int $status): void + { + } + + /** + * Set the Artisan application instance. + */ + public function setArtisan(?Application $artisan): void + { + $this->setArtisanCalls[] = $artisan; + } + + /** + * Get the Artisan application instance. + */ + public function getArtisan(): Application + { + throw new LogicException('The Artisan application cannot be resolved.'); + } +} diff --git a/tests/Foundation/Testing/LazilyRefreshDatabaseTest.php b/tests/Foundation/Testing/LazilyRefreshDatabaseTest.php index f54faa513b..0a2ad80cab 100644 --- a/tests/Foundation/Testing/LazilyRefreshDatabaseTest.php +++ b/tests/Foundation/Testing/LazilyRefreshDatabaseTest.php @@ -11,6 +11,7 @@ use Hypervel\Testbench\Attributes\ResetRefreshDatabaseState; use Hypervel\Testbench\Attributes\WithConfig; use Hypervel\Testbench\TestCase; +use Hypervel\Tests\Foundation\Testing\Fixtures\ConsoleKernelSpy; use Mockery as m; use RuntimeException; use Throwable; @@ -44,20 +45,18 @@ protected function defineEnvironment(ApplicationContract $app): void public function testDatabaseIsRefreshedOnInteraction(): void { - $kernel = m::mock(KernelContract::class); + $kernel = new ConsoleKernelSpy; $this->app->instance(KernelContract::class, $kernel); - $kernel->expects('call')->with('migrate:fresh', [ - '--drop-views' => false, - '--drop-types' => false, - '--seed' => false, - ])->andReturn(0); - $kernel->expects('setArtisan')->with(null); $database = $this->app->make('db'); $database->select('select 1'); $database->select('select 1'); + $this->assertSame([ + ['migrate:fresh', ['--drop-views' => false, '--drop-types' => false, '--seed' => false]], + ], $kernel->calls); + $this->assertSame([null], $kernel->setArtisanCalls); $this->assertSame(1, $this->refreshCount); $this->assertSame(1, $this->transactionCount); $this->assertSame(1, $this->afterRefreshCount); @@ -80,17 +79,15 @@ public function testDatabaseIsNotRefreshedWithoutInteraction(): void public function testNonDefaultConnectionTriggersRefresh(): void { - $kernel = m::mock(KernelContract::class); + $kernel = new ConsoleKernelSpy; $this->app->instance(KernelContract::class, $kernel); - $kernel->expects('call')->with('migrate:fresh', [ - '--drop-views' => false, - '--drop-types' => false, - '--seed' => false, - ])->andReturn(0); - $kernel->expects('setArtisan')->with(null); $this->app->make('db')->connection('testing2')->select('select 1'); + $this->assertSame([ + ['migrate:fresh', ['--drop-views' => false, '--drop-types' => false, '--seed' => false]], + ], $kernel->calls); + $this->assertSame([null], $kernel->setArtisanCalls); $this->assertSame(1, $this->refreshCount); $this->assertSame(1, $this->transactionCount); $this->assertSame(1, $this->afterRefreshCount); @@ -98,14 +95,8 @@ public function testNonDefaultConnectionTriggersRefresh(): void public function testRuntimeCoroutineOptOutRegistersLazyHooksImmediately(): void { - $kernel = m::mock(KernelContract::class); + $kernel = new ConsoleKernelSpy; $this->app->instance(KernelContract::class, $kernel); - $kernel->expects('call')->with('migrate:fresh', [ - '--drop-views' => false, - '--drop-types' => false, - '--seed' => false, - ])->andReturn(0); - $kernel->expects('setArtisan')->with(null); $this->app->make('config')->set('database.connections.optout', [ 'driver' => 'sqlite', @@ -117,6 +108,10 @@ public function testRuntimeCoroutineOptOutRegistersLazyHooksImmediately(): void $this->refreshDatabase(); $this->app->make('db')->connection('optout')->select('select 1'); + $this->assertSame([ + ['migrate:fresh', ['--drop-views' => false, '--drop-types' => false, '--seed' => false]], + ], $kernel->calls); + $this->assertSame([null], $kernel->setArtisanCalls); $this->assertSame(1, $this->refreshCount); $this->assertSame(1, $this->afterRefreshCount); $this->assertTrue(RefreshDatabaseState::$lazilyRefreshed); @@ -141,14 +136,8 @@ public function testRefreshFailureRestoresStateAndMockConsoleOutput(): void public function testLazyTeardownRunsOnlyAfterSuccessfulRefresh(): void { - $kernel = m::mock(KernelContract::class); + $kernel = new ConsoleKernelSpy; $this->app->instance(KernelContract::class, $kernel); - $kernel->expects('call')->with('migrate:fresh', [ - '--drop-views' => false, - '--drop-types' => false, - '--seed' => false, - ])->andReturn(0); - $kernel->expects('setArtisan')->with(null); $this->tearDownLazilyRefreshDatabaseInCoroutine(); @@ -157,6 +146,10 @@ public function testLazyTeardownRunsOnlyAfterSuccessfulRefresh(): void $this->app->make('db')->select('select 1'); $this->tearDownLazilyRefreshDatabaseInCoroutine(); + $this->assertSame([ + ['migrate:fresh', ['--drop-views' => false, '--drop-types' => false, '--seed' => false]], + ], $kernel->calls); + $this->assertSame([null], $kernel->setArtisanCalls); $this->assertSame(1, $this->rollbackCount); RefreshDatabaseState::$lazilyRefreshed = false; diff --git a/tests/Foundation/Testing/RefreshDatabaseTest.php b/tests/Foundation/Testing/RefreshDatabaseTest.php index 571cb06bed..08c668ed61 100644 --- a/tests/Foundation/Testing/RefreshDatabaseTest.php +++ b/tests/Foundation/Testing/RefreshDatabaseTest.php @@ -17,6 +17,7 @@ use Hypervel\Testbench\Attributes\ResetRefreshDatabaseState; use Hypervel\Testbench\TestCase; use Hypervel\Testing\ParallelTesting; +use Hypervel\Tests\Foundation\Testing\Fixtures\ConsoleKernelSpy; use LogicException; use Mockery as m; use PDO; @@ -74,14 +75,7 @@ protected function afterRefreshingDatabase(): void public function testRefreshTestDatabaseDefault(): void { - $kernel = m::mock(KernelContract::class); - $kernel->expects('call') - ->with('migrate:fresh', [ - '--drop-views' => false, - '--drop-types' => false, - '--seed' => false, - ])->andReturn(0); - $kernel->expects('setArtisan')->with(null); + $kernel = new ConsoleKernelSpy; $this->app = new Application; $this->app->singleton('config', fn () => new Repository([ @@ -96,20 +90,18 @@ public function testRefreshTestDatabaseDefault(): void $this->app->singleton('db', fn () => $this->getMockedDatabase()); $this->refreshTestDatabase(); + + $this->assertSame([ + ['migrate:fresh', ['--drop-views' => false, '--drop-types' => false, '--seed' => false]], + ], $kernel->calls); + $this->assertSame([null], $kernel->setArtisanCalls); } public function testRefreshTestDatabaseWithDropViewsOption(): void { $this->dropViews = true; - $kernel = m::mock(KernelContract::class); - $kernel->expects('call') - ->with('migrate:fresh', [ - '--drop-views' => true, - '--drop-types' => false, - '--seed' => false, - ])->andReturn(0); - $kernel->expects('setArtisan')->with(null); + $kernel = new ConsoleKernelSpy; $this->app = new Application; $this->app->singleton('config', fn () => new Repository([ 'database' => [ @@ -123,20 +115,18 @@ public function testRefreshTestDatabaseWithDropViewsOption(): void $this->app->singleton('db', fn () => $this->getMockedDatabase()); $this->refreshTestDatabase(); + + $this->assertSame([ + ['migrate:fresh', ['--drop-views' => true, '--drop-types' => false, '--seed' => false]], + ], $kernel->calls); + $this->assertSame([null], $kernel->setArtisanCalls); } public function testRefreshTestDatabaseWithDropTypesOption(): void { $this->dropTypes = true; - $kernel = m::mock(KernelContract::class); - $kernel->expects('call') - ->with('migrate:fresh', [ - '--drop-views' => false, - '--drop-types' => true, - '--seed' => false, - ])->andReturn(0); - $kernel->expects('setArtisan')->with(null); + $kernel = new ConsoleKernelSpy; $this->app = new Application; $this->app->singleton('config', fn () => new Repository([ 'database' => [ @@ -150,20 +140,18 @@ public function testRefreshTestDatabaseWithDropTypesOption(): void $this->app->singleton('db', fn () => $this->getMockedDatabase()); $this->refreshTestDatabase(); + + $this->assertSame([ + ['migrate:fresh', ['--drop-views' => false, '--drop-types' => true, '--seed' => false]], + ], $kernel->calls); + $this->assertSame([null], $kernel->setArtisanCalls); } public function testRefreshTestDatabaseWithSeedOption(): void { $this->seed = true; - $kernel = m::mock(KernelContract::class); - $kernel->expects('call') - ->with('migrate:fresh', [ - '--drop-views' => false, - '--drop-types' => false, - '--seed' => true, - ])->andReturn(0); - $kernel->expects('setArtisan')->with(null); + $kernel = new ConsoleKernelSpy; $this->app = new Application; $this->app->singleton('config', fn () => new Repository([ 'database' => [ @@ -177,20 +165,18 @@ public function testRefreshTestDatabaseWithSeedOption(): void $this->app->singleton('db', fn () => $this->getMockedDatabase()); $this->refreshTestDatabase(); + + $this->assertSame([ + ['migrate:fresh', ['--drop-views' => false, '--drop-types' => false, '--seed' => true]], + ], $kernel->calls); + $this->assertSame([null], $kernel->setArtisanCalls); } public function testRefreshTestDatabaseWithSeederOption(): void { $this->seeder = 'seeder'; - $kernel = m::mock(KernelContract::class); - $kernel->expects('call') - ->with('migrate:fresh', [ - '--drop-views' => false, - '--drop-types' => false, - '--seeder' => 'seeder', - ])->andReturn(0); - $kernel->expects('setArtisan')->with(null); + $kernel = new ConsoleKernelSpy; $this->app = new Application; $this->app->singleton('config', fn () => new Repository([ 'database' => [ @@ -204,6 +190,11 @@ public function testRefreshTestDatabaseWithSeederOption(): void $this->app->singleton('db', fn () => $this->getMockedDatabase()); $this->refreshTestDatabase(); + + $this->assertSame([ + ['migrate:fresh', ['--drop-views' => false, '--drop-types' => false, '--seeder' => 'seeder']], + ], $kernel->calls); + $this->assertSame([null], $kernel->setArtisanCalls); } public function testRefreshTestDatabaseRestoresMockConsoleOutputAfterMigrationFailure(): void diff --git a/tests/Integration/Console/EnvironmentDecryptCommandTest.php b/tests/Integration/Console/EnvironmentDecryptCommandTest.php index 257ac063fe..2d0a677b58 100644 --- a/tests/Integration/Console/EnvironmentDecryptCommandTest.php +++ b/tests/Integration/Console/EnvironmentDecryptCommandTest.php @@ -9,13 +9,14 @@ use Hypervel\Support\Facades\File; use Hypervel\Testbench\TestCase; use Hypervel\Testing\ParallelTesting; +use Hypervel\Tests\Integration\Console\Fixtures\ArrayFilesystem; use Mockery as m; use RuntimeException; use Swoole\Coroutine\CanceledException; class EnvironmentDecryptCommandTest extends TestCase { - protected Filesystem $filesystem; + protected ArrayFilesystem $filesystem; /** * Set up the test environment. @@ -24,38 +25,47 @@ protected function setUp(): void { parent::setUp(); - $this->filesystem = File::spy(); - $this->filesystem->shouldReceive('replace'); - $this->filesystem->shouldReceive('chmod')->andReturn('0640'); + $this->filesystem = new ArrayFilesystem; + + File::swap($this->filesystem); } public function testItFailsWithInvalidCipherFails(): void { - $this->filesystem->expects('exists')->andReturn(true); - $this->filesystem->expects('exists')->andReturn(false); + $this->filesystem->files[base_path('.env.encrypted')] = 'encrypted'; $this->artisan('env:decrypt', ['--cipher' => 'invalid', '--key' => 'abcdefghijklmnop']) ->expectsOutputToContain('Unsupported cipher') ->assertExitCode(1); + + $this->assertSame([], $this->filesystem->writes); } public function testItFailsUsingCipherWithInvalidKey(): void { - $this->filesystem->expects('exists')->andReturn(true); - $this->filesystem->expects('exists')->andReturn(false); + $this->filesystem->files[base_path('.env.encrypted')] = 'encrypted'; $this->artisan('env:decrypt', ['--cipher' => 'aes-128-cbc', '--key' => 'invalid']) ->expectsOutputToContain('incorrect key length') ->assertExitCode(1); + + $this->assertSame([], $this->filesystem->writes); } public function testItPreservesCancellationWhileReadingTheEncryptedEnvironment(): void { $cancellation = new CanceledException('canceled'); - - $this->filesystem->expects('exists')->andReturn(true); - $this->filesystem->expects('exists')->andReturn(false); - $this->filesystem->expects('get')->andThrow($cancellation); + $filesystem = m::mock(Filesystem::class); + $filesystem->expects('exists') + ->with(base_path('.env.encrypted')) + ->andReturn(true); + $filesystem->expects('exists') + ->with(base_path('.env')) + ->andReturn(false); + $filesystem->expects('get') + ->with(base_path('.env.encrypted')) + ->andThrow($cancellation); + File::swap($filesystem); try { $this->artisan('env:decrypt', ['--key' => 'abcdefghijklmnopabcdefghijklmnop'])->execute(); @@ -67,54 +77,50 @@ public function testItPreservesCancellationWhileReadingTheEncryptedEnvironment() public function testItFailsWhenEnvironmentFileExists(): void { - $this->filesystem->expects('exists')->times(2)->andReturn(true); + $this->filesystem->files[base_path('.env.encrypted')] = 'encrypted'; + $this->filesystem->files[base_path('.env')] = 'existing'; $this->artisan('env:decrypt', ['--key' => 'secret-key']) ->expectsOutputToContain('Environment file already exists.') ->assertExitCode(1); + + $this->assertSame([], $this->filesystem->writes); } public function testItFailsWhenEncryptionFileCannotBeFound(): void { - $this->filesystem->expects('exists')->andReturn(false); - $this->artisan('env:decrypt', ['--key' => 'secret-key']) ->expectsOutputToContain('Encrypted environment file not found.') ->assertExitCode(1); + + $this->assertSame([], $this->filesystem->writes); } public function testItGeneratesTheEnvironmentFileWithGeneratedKey(): void { - $this->filesystem->expects('exists')->andReturn(true); - $this->filesystem->expects('exists')->andReturn(true); - $this->filesystem->expects('get')->andReturn( - (new Encrypter($key = Encrypter::generateKey('AES-256-CBC'), 'AES-256-CBC')) - ->encrypt('APP_NAME=Hypervel') - ); + $this->filesystem->files[base_path('.env.encrypted')] = (new Encrypter($key = Encrypter::generateKey('AES-256-CBC'), 'AES-256-CBC')) + ->encrypt('APP_NAME=Hypervel'); + $this->filesystem->files[base_path('.env')] = 'existing'; $this->artisan('env:decrypt', ['--force' => true, '--key' => 'base64:' . base64_encode($key)]) ->expectsOutputToContain('Environment successfully decrypted.') ->assertExitCode(0); - $this->filesystem->shouldHaveReceived('replace') - ->with(base_path('.env'), 'APP_NAME=Hypervel', 0640); + $this->assertSame('APP_NAME=Hypervel', $this->filesystem->files[base_path('.env')]); + $this->assertSame(0644, $this->filesystem->modes[base_path('.env')]); } public function testItGeneratesTheEnvironmentFileWithUserProvidedKey(): void { - $this->filesystem->expects('exists')->andReturn(true); - $this->filesystem->expects('exists')->andReturn(false); - $this->filesystem->expects('get')->andReturn( - (new Encrypter('abcdefghijklmnop', 'aes-128-gcm')) - ->encrypt('APP_NAME="Hypervel Two"') - ); + $this->filesystem->files[base_path('.env.encrypted')] = (new Encrypter('abcdefghijklmnop', 'aes-128-gcm')) + ->encrypt('APP_NAME="Hypervel Two"'); $this->artisan('env:decrypt', ['--cipher' => 'aes-128-gcm', '--key' => 'abcdefghijklmnop']) ->expectsOutputToContain('Environment successfully decrypted.') ->assertExitCode(0); - $this->filesystem->shouldHaveReceived('replace') - ->with(base_path('.env'), 'APP_NAME="Hypervel Two"', 0600); + $this->assertSame('APP_NAME="Hypervel Two"', $this->filesystem->files[base_path('.env')]); + $this->assertSame(0600, $this->filesystem->modes[base_path('.env')]); } public function testItGeneratesTheEnvironmentFileWithKeyFromEnvironment(): void @@ -124,19 +130,15 @@ public function testItGeneratesTheEnvironmentFileWithKeyFromEnvironment(): void $_SERVER['HYPERVEL_ENV_ENCRYPTION_KEY'] = 'ponmlkjihgfedcbaponmlkjihgfedcba'; try { - $this->filesystem->expects('exists')->andReturn(true); - $this->filesystem->expects('exists')->andReturn(false); - $this->filesystem->expects('get')->andReturn( - (new Encrypter('ponmlkjihgfedcbaponmlkjihgfedcba', 'AES-256-CBC')) - ->encrypt('APP_NAME="Hypervel Three"') - ); + $this->filesystem->files[base_path('.env.encrypted')] = (new Encrypter('ponmlkjihgfedcbaponmlkjihgfedcba', 'AES-256-CBC')) + ->encrypt('APP_NAME="Hypervel Three"'); $this->artisan('env:decrypt') ->expectsOutputToContain('Environment successfully decrypted.') ->assertExitCode(0); - $this->filesystem->shouldHaveReceived('replace') - ->with(base_path('.env'), 'APP_NAME="Hypervel Three"', 0600); + $this->assertSame('APP_NAME="Hypervel Three"', $this->filesystem->files[base_path('.env')]); + $this->assertSame(0600, $this->filesystem->modes[base_path('.env')]); } finally { if ($hadEncryptionKey) { $_SERVER['HYPERVEL_ENV_ENCRYPTION_KEY'] = $previousEncryptionKey; @@ -148,19 +150,16 @@ public function testItGeneratesTheEnvironmentFileWithKeyFromEnvironment(): void public function testItGeneratesTheEnvironmentFileWhenForcing(): void { - $this->filesystem->expects('exists')->andReturn(true); - $this->filesystem->expects('exists')->andReturn(true); - $this->filesystem->expects('get')->andReturn( - (new Encrypter('abcdefghijklmnop', 'aes-128-gcm')) - ->encrypt('APP_NAME="Hypervel Two"') - ); + $this->filesystem->files[base_path('.env.encrypted')] = (new Encrypter('abcdefghijklmnop', 'aes-128-gcm')) + ->encrypt('APP_NAME="Hypervel Two"'); + $this->filesystem->files[base_path('.env')] = 'existing'; $this->artisan('env:decrypt', ['--force' => true, '--key' => 'abcdefghijklmnop', '--cipher' => 'aes-128-gcm']) ->expectsOutputToContain('Environment successfully decrypted.') ->assertExitCode(0); - $this->filesystem->shouldHaveReceived('replace') - ->with(base_path('.env'), 'APP_NAME="Hypervel Two"', 0640); + $this->assertSame('APP_NAME="Hypervel Two"', $this->filesystem->files[base_path('.env')]); + $this->assertSame(0644, $this->filesystem->modes[base_path('.env')]); } public function testItDecryptsMultiLineEnvironmentCorrectly(): void @@ -183,88 +182,69 @@ public function testItDecryptsMultiLineEnvironmentCorrectly(): void DB_PASSWORD= Text; - $this->filesystem->expects('exists')->andReturn(true); - $this->filesystem->expects('exists')->andReturn(true); - $this->filesystem->expects('get')->andReturn( - (new Encrypter('abcdefghijklmnop', 'aes-128-gcm')) - ->encrypt($contents) - ); + $this->filesystem->files[base_path('.env.encrypted')] = (new Encrypter('abcdefghijklmnop', 'aes-128-gcm')) + ->encrypt($contents); + $this->filesystem->files[base_path('.env')] = 'existing'; $this->artisan('env:decrypt', ['--force' => true, '--key' => 'abcdefghijklmnop', '--cipher' => 'aes-128-gcm']) ->expectsOutputToContain('Environment successfully decrypted.') ->assertExitCode(0); - $this->filesystem->shouldHaveReceived('replace') - ->with(base_path('.env'), $contents, 0640); + $this->assertSame($contents, $this->filesystem->files[base_path('.env')]); + $this->assertSame(0644, $this->filesystem->modes[base_path('.env')]); } public function testItWritesTheEnvironmentFileCustomFilename(): void { - $this->filesystem->expects('exists')->andReturn(true); - $this->filesystem->expects('exists')->andReturn(false); - $this->filesystem->expects('get')->andReturn( - (new Encrypter('abcdefghijklmnopabcdefghijklmnop', 'AES-256-CBC')) - ->encrypt('APP_NAME="Hypervel Two"') - ); + $this->filesystem->files[base_path('.env.production.encrypted')] = (new Encrypter('abcdefghijklmnopabcdefghijklmnop', 'AES-256-CBC')) + ->encrypt('APP_NAME="Hypervel Two"'); $this->artisan('env:decrypt', ['--env' => 'production', '--key' => 'abcdefghijklmnopabcdefghijklmnop', '--filename' => '.env']) ->expectsOutputToContain('Environment successfully decrypted.') ->assertExitCode(0); - $this->filesystem->shouldHaveReceived('replace') - ->with(base_path('.env'), 'APP_NAME="Hypervel Two"', 0600); + $this->assertSame('APP_NAME="Hypervel Two"', $this->filesystem->files[base_path('.env')]); + $this->assertSame(0600, $this->filesystem->modes[base_path('.env')]); } public function testItWritesTheEnvironmentFileCustomPath(): void { - $this->filesystem->expects('exists')->andReturn(true); - $this->filesystem->expects('exists')->andReturn(false); - $this->filesystem->expects('get')->andReturn( - (new Encrypter('abcdefghijklmnopabcdefghijklmnop', 'AES-256-CBC')) - ->encrypt('APP_NAME="Hypervel Two"') - ); + $this->filesystem->files[base_path('.env.production.encrypted')] = (new Encrypter('abcdefghijklmnopabcdefghijklmnop', 'AES-256-CBC')) + ->encrypt('APP_NAME="Hypervel Two"'); $this->artisan('env:decrypt', ['--env' => 'production', '--key' => 'abcdefghijklmnopabcdefghijklmnop', '--path' => '/tmp']) ->expectsOutputToContain('Environment successfully decrypted.') ->assertExitCode(0); - $this->filesystem->shouldHaveReceived('replace') - ->with('/tmp' . DIRECTORY_SEPARATOR . '.env.production', 'APP_NAME="Hypervel Two"', 0600); + $this->assertSame('APP_NAME="Hypervel Two"', $this->filesystem->files['/tmp' . DIRECTORY_SEPARATOR . '.env.production']); + $this->assertSame(0600, $this->filesystem->modes['/tmp' . DIRECTORY_SEPARATOR . '.env.production']); } public function testItWritesTheEnvironmentFileCustomPathAndFilename(): void { - $this->filesystem->expects('exists')->andReturn(true); - $this->filesystem->expects('exists')->andReturn(false); - $this->filesystem->expects('get')->andReturn( - (new Encrypter('abcdefghijklmnopabcdefghijklmnop', 'AES-256-CBC')) - ->encrypt('APP_NAME="Hypervel Two"') - ); + $this->filesystem->files[base_path('.env.production.encrypted')] = (new Encrypter('abcdefghijklmnopabcdefghijklmnop', 'AES-256-CBC')) + ->encrypt('APP_NAME="Hypervel Two"'); $this->artisan('env:decrypt', ['--env' => 'production', '--key' => 'abcdefghijklmnopabcdefghijklmnop', '--filename' => '.env', '--path' => '/tmp']) ->expectsOutputToContain('Environment successfully decrypted.') ->assertExitCode(0); - $this->filesystem->shouldHaveReceived('replace') - ->with('/tmp' . DIRECTORY_SEPARATOR . '.env', 'APP_NAME="Hypervel Two"', 0600); + $this->assertSame('APP_NAME="Hypervel Two"', $this->filesystem->files['/tmp' . DIRECTORY_SEPARATOR . '.env']); + $this->assertSame(0600, $this->filesystem->modes['/tmp' . DIRECTORY_SEPARATOR . '.env']); } public function testItDecryptsNamedEnvironmentFilesBesideAnEnvironmentFileInASubdirectory(): void { $this->app->loadEnvironmentFrom('secrets/.env'); - $this->filesystem->expects('exists')->with(base_path('secrets/.env.production.encrypted'))->andReturn(true); - $this->filesystem->expects('exists')->with(base_path('secrets/.env.production'))->andReturn(false); - $this->filesystem->expects('get')->with(base_path('secrets/.env.production.encrypted'))->andReturn( - (new Encrypter('abcdefghijklmnopabcdefghijklmnop', 'AES-256-CBC')) - ->encrypt('APP_NAME=Hypervel') - ); + $this->filesystem->files[base_path('secrets/.env.production.encrypted')] = (new Encrypter('abcdefghijklmnopabcdefghijklmnop', 'AES-256-CBC')) + ->encrypt('APP_NAME=Hypervel'); $this->artisan('env:decrypt', ['--env' => 'production', '--key' => 'abcdefghijklmnopabcdefghijklmnop']) ->expectsOutputToContain('Environment successfully decrypted.') ->assertExitCode(0); - $this->filesystem->shouldHaveReceived('replace') - ->with(base_path('secrets/.env.production'), 'APP_NAME=Hypervel', 0600); + $this->assertSame('APP_NAME=Hypervel', $this->filesystem->files[base_path('secrets/.env.production')]); + $this->assertSame(0600, $this->filesystem->modes[base_path('secrets/.env.production')]); } public function testItCannotOverwriteEncryptedFiles(): void @@ -273,27 +253,27 @@ public function testItCannotOverwriteEncryptedFiles(): void ->expectsOutputToContain('Invalid filename.') ->assertExitCode(1); + $this->assertSame([], $this->filesystem->writes); + $this->artisan('env:decrypt', ['--env' => 'production', '--key' => 'abcdefghijklmnop', '--filename' => '.env.staging.encrypted']) ->expectsOutputToContain('Invalid filename.') ->assertExitCode(1); + + $this->assertSame([], $this->filesystem->writes); } public function testItGeneratesTheEnvironmentFileWithInteractivelyUserProvidedKey(): void { - $this->filesystem->expects('exists')->andReturn(true); - $this->filesystem->expects('exists')->andReturn(false); - $this->filesystem->expects('get')->andReturn( - (new Encrypter($key = 'abcdefghijklmnop', 'aes-128-gcm')) - ->encrypt('APP_NAME="Hypervel Two"') - ); + $this->filesystem->files[base_path('.env.encrypted')] = (new Encrypter($key = 'abcdefghijklmnop', 'aes-128-gcm')) + ->encrypt('APP_NAME="Hypervel Two"'); $this->artisan('env:decrypt', ['--cipher' => 'aes-128-gcm']) ->expectsQuestion('What is the decryption key?', $key) ->expectsOutputToContain('Environment successfully decrypted.') ->assertExitCode(0); - $this->filesystem->shouldHaveReceived('replace') - ->with(base_path('.env'), 'APP_NAME="Hypervel Two"', 0600); + $this->assertSame('APP_NAME="Hypervel Two"', $this->filesystem->files[base_path('.env')]); + $this->assertSame(0600, $this->filesystem->modes[base_path('.env')]); } public function testItAutoDetectsAndDecryptsReadableFormat(): void @@ -305,16 +285,14 @@ public function testItAutoDetectsAndDecryptsReadableFormat(): void $encryptedContent = 'APP_NAME=' . $encrypter->encryptString('Hypervel') . "\n" . 'APP_ENV=' . $encrypter->encryptString('local'); - $this->filesystem->expects('exists')->andReturn(true); - $this->filesystem->expects('exists')->andReturn(false); - $this->filesystem->expects('get')->andReturn($encryptedContent); + $this->filesystem->files[base_path('.env.encrypted')] = $encryptedContent; $this->artisan('env:decrypt', ['--key' => $key]) ->expectsOutputToContain('Environment successfully decrypted.') ->assertExitCode(0); - $this->filesystem->shouldHaveReceived('replace') - ->with(base_path('.env'), "APP_NAME=Hypervel\nAPP_ENV=local\n", 0600); + $this->assertSame("APP_NAME=Hypervel\nAPP_ENV=local\n", $this->filesystem->files[base_path('.env')]); + $this->assertSame(0600, $this->filesystem->modes[base_path('.env')]); } public function testItStillDecryptsBlobFormat(): void @@ -326,16 +304,14 @@ public function testItStillDecryptsBlobFormat(): void $originalContent = "APP_NAME=Hypervel\nAPP_ENV=local"; $encryptedContent = $encrypter->encrypt($originalContent); - $this->filesystem->expects('exists')->andReturn(true); - $this->filesystem->expects('exists')->andReturn(false); - $this->filesystem->expects('get')->andReturn($encryptedContent); + $this->filesystem->files[base_path('.env.encrypted')] = $encryptedContent; $this->artisan('env:decrypt', ['--key' => $key]) ->expectsOutputToContain('Environment successfully decrypted.') ->assertExitCode(0); - $this->filesystem->shouldHaveReceived('replace') - ->with(base_path('.env'), $originalContent, 0600); + $this->assertSame($originalContent, $this->filesystem->files[base_path('.env')]); + $this->assertSame(0600, $this->filesystem->modes[base_path('.env')]); } public function testItDecryptsBlobFormatWithNewlineInContent(): void @@ -351,16 +327,14 @@ public function testItDecryptsBlobFormatWithNewlineInContent(): void $midpoint = (int) (strlen($encryptedContent) / 2); $encryptedContentWithNewline = substr($encryptedContent, 0, $midpoint) . "\n" . substr($encryptedContent, $midpoint); - $this->filesystem->expects('exists')->andReturn(true); - $this->filesystem->expects('exists')->andReturn(false); - $this->filesystem->expects('get')->andReturn($encryptedContentWithNewline); + $this->filesystem->files[base_path('.env.encrypted')] = $encryptedContentWithNewline; $this->artisan('env:decrypt', ['--key' => $key]) ->expectsOutputToContain('Environment successfully decrypted.') ->assertExitCode(0); - $this->filesystem->shouldHaveReceived('replace') - ->with(base_path('.env'), $originalContent, 0600); + $this->assertSame($originalContent, $this->filesystem->files[base_path('.env')]); + $this->assertSame(0600, $this->filesystem->modes[base_path('.env')]); } public function testItDecryptsReadableFormatWithBase64Values(): void @@ -372,16 +346,14 @@ public function testItDecryptsReadableFormatWithBase64Values(): void $encryptedContent = 'APP_KEY=' . $encrypter->encryptString('base64:Ge+W23u+VZI2tbrp5QCGWrsUuxgcD65i7jtTRR2ZqfY=') . "\n" . 'APP_ENV=' . $encrypter->encryptString('local'); - $this->filesystem->expects('exists')->andReturn(true); - $this->filesystem->expects('exists')->andReturn(false); - $this->filesystem->expects('get')->andReturn($encryptedContent); + $this->filesystem->files[base_path('.env.encrypted')] = $encryptedContent; $this->artisan('env:decrypt', ['--key' => $key]) ->expectsOutputToContain('Environment successfully decrypted.') ->assertExitCode(0); - $this->filesystem->shouldHaveReceived('replace') - ->with(base_path('.env'), "APP_KEY=base64:Ge+W23u+VZI2tbrp5QCGWrsUuxgcD65i7jtTRR2ZqfY=\nAPP_ENV=local\n", 0600); + $this->assertSame("APP_KEY=base64:Ge+W23u+VZI2tbrp5QCGWrsUuxgcD65i7jtTRR2ZqfY=\nAPP_ENV=local\n", $this->filesystem->files[base_path('.env')]); + $this->assertSame(0600, $this->filesystem->modes[base_path('.env')]); } public function testNewEnvironmentFileUsesPrivatePermissions(): void diff --git a/tests/Integration/Console/EnvironmentEncryptCommandTest.php b/tests/Integration/Console/EnvironmentEncryptCommandTest.php index 8e6274d563..43eee42c46 100644 --- a/tests/Integration/Console/EnvironmentEncryptCommandTest.php +++ b/tests/Integration/Console/EnvironmentEncryptCommandTest.php @@ -9,13 +9,14 @@ use Hypervel\Support\Facades\File; use Hypervel\Testbench\TestCase; use Hypervel\Testing\ParallelTesting; +use Hypervel\Tests\Integration\Console\Fixtures\ArrayFilesystem; use Mockery as m; use RuntimeException; use Swoole\Coroutine\CanceledException; class EnvironmentEncryptCommandTest extends TestCase { - protected Filesystem $filesystem; + protected ArrayFilesystem $filesystem; /** * Set up the test environment. @@ -24,31 +25,29 @@ protected function setUp(): void { parent::setUp(); - $this->filesystem = File::spy(); - $this->filesystem->shouldReceive('get')->andReturn('APP_NAME=Hypervel'); - $this->filesystem->shouldReceive('replace'); - $this->filesystem->shouldReceive('chmod')->andReturn('0640'); + $this->filesystem = new ArrayFilesystem; + $this->filesystem->files[base_path('.env')] = 'APP_NAME=Hypervel'; + + File::swap($this->filesystem); } public function testItFailsWithInvalidCipherFails(): void { - $this->filesystem->expects('exists')->andReturn(true); - $this->filesystem->expects('exists')->andReturn(false); - $this->artisan('env:encrypt', ['--cipher' => 'invalid']) ->expectsQuestion('What encryption key would you like to use?', 'generate') ->expectsOutputToContain('Unsupported cipher') ->assertExitCode(1); + + $this->assertSame([], $this->filesystem->writes); } public function testItFailsUsingCipherWithInvalidKey(): void { - $this->filesystem->expects('exists')->andReturn(true); - $this->filesystem->expects('exists')->andReturn(false); - $this->artisan('env:encrypt', ['--cipher' => 'aes-128-cbc', '--key' => 'invalid']) ->expectsOutputToContain('incorrect key length') ->assertExitCode(1); + + $this->assertSame([], $this->filesystem->writes); } public function testItPreservesCancellationWhileReadingTheEnvironment(): void @@ -77,128 +76,112 @@ public function testItPreservesCancellationWhileReadingTheEnvironment(): void public function testItGeneratesTheCorrectFileWhenUsingEnvironment(): void { - $this->filesystem->expects('exists')->andReturn(true); - $this->filesystem->expects('exists')->andReturn(false); + $this->filesystem->files[base_path('.env.production')] = 'APP_NAME=Hypervel'; $this->artisan('env:encrypt', ['--env' => 'production']) ->expectsQuestion('What encryption key would you like to use?', 'generate') ->expectsOutputToContain('.env.production.encrypted') ->assertExitCode(0); - $this->filesystem->shouldHaveReceived('replace') - ->with(base_path('.env.production.encrypted'), m::any(), null); + $this->assertSame([base_path('.env.production.encrypted')], $this->filesystem->writes); + $this->assertNull($this->filesystem->modes[base_path('.env.production.encrypted')]); } public function testItGeneratesTheCorrectFileWhenNotUsingEnvironment(): void { - $this->filesystem->expects('exists')->andReturn(true); - $this->filesystem->expects('exists')->andReturn(false); - $this->filesystem->shouldReceive('get'); - $this->artisan('env:encrypt') ->expectsQuestion('What encryption key would you like to use?', 'generate') ->expectsOutputToContain('.env.encrypted') ->assertExitCode(0); - $this->filesystem->shouldHaveReceived('replace') - ->with(base_path('.env.encrypted'), m::any(), null); + $this->assertSame([base_path('.env.encrypted')], $this->filesystem->writes); + $this->assertNull($this->filesystem->modes[base_path('.env.encrypted')]); + $this->assertSame([], $this->filesystem->deletes); } public function testItEncryptsNamedEnvironmentFilesBesideAnEnvironmentFileInASubdirectory(): void { $this->app->loadEnvironmentFrom('secrets/.env'); - $this->filesystem->expects('exists')->with(base_path('secrets/.env.production'))->andReturn(true); - $this->filesystem->expects('exists')->with(base_path('secrets/.env.production.encrypted'))->andReturn(false); + $this->filesystem->files[base_path('secrets/.env.production')] = 'APP_NAME=Hypervel'; $this->artisan('env:encrypt', ['--env' => 'production']) ->expectsQuestion('What encryption key would you like to use?', 'generate') ->assertExitCode(0); - $this->filesystem->shouldHaveReceived('replace') - ->with(base_path('secrets/.env.production.encrypted'), m::any(), null); + $this->assertSame([base_path('secrets/.env.production.encrypted')], $this->filesystem->writes); + $this->assertNull($this->filesystem->modes[base_path('secrets/.env.production.encrypted')]); } public function testItFailsWhenEnvironmentFileCannotBeFound(): void { - $this->filesystem->expects('exists')->andReturn(false); + unset($this->filesystem->files[base_path('.env')]); $this->artisan('env:encrypt') ->expectsOutputToContain('Environment file not found.') ->assertExitCode(1); + + $this->assertSame([], $this->filesystem->writes); } public function testItFailsWhenEncryptionFileExists(): void { - $this->filesystem->expects('exists')->times(2)->andReturn(true); + $this->filesystem->files[base_path('.env.encrypted')] = 'existing'; $this->artisan('env:encrypt') ->expectsQuestion('What encryption key would you like to use?', 'generate') ->expectsOutputToContain('Encrypted environment file already exists.') ->assertExitCode(1); + + $this->assertSame([], $this->filesystem->writes); + $this->assertSame('existing', $this->filesystem->files[base_path('.env.encrypted')]); } public function testItGeneratesTheEncryptionFileWhenForcing(): void { - $this->filesystem->expects('exists')->andReturn(true); - $this->filesystem->expects('exists')->andReturn(true); + $this->filesystem->files[base_path('.env.encrypted')] = 'existing'; $this->artisan('env:encrypt', ['--force' => true]) ->expectsQuestion('What encryption key would you like to use?', 'generate') ->expectsOutputToContain('.env.encrypted') ->assertExitCode(0); - $this->filesystem->shouldHaveReceived('replace') - ->with(base_path('.env.encrypted'), m::any(), 0640); + $this->assertSame([base_path('.env.encrypted')], $this->filesystem->writes); + $this->assertNotSame('existing', $this->filesystem->files[base_path('.env.encrypted')]); + $this->assertSame(0644, $this->filesystem->modes[base_path('.env.encrypted')]); } public function testItReencryptsReadableValuesWithANewKeyWhenForcing(): void { $key = 'ANvVbPbE0tWMHpUySh6liY4WaCmAYKXP'; $oldEncrypter = new Encrypter(str_repeat('x', 32), 'AES-256-CBC'); - $encryptedOutput = null; - File::swap(m::mock(Filesystem::class)); - - File::expects('exists') - ->with(base_path('.env')) - ->andReturn(true); - File::expects('exists') - ->with(base_path('.env.encrypted')) - ->andReturn(true); - File::expects('get') - ->with(base_path('.env')) - ->andReturn('DB_PASSWORD=1'); - File::shouldReceive('get') - ->with(base_path('.env.encrypted')) - ->andReturn('DB_PASSWORD=' . $oldEncrypter->encryptString('1') . "\n"); - File::expects('chmod')->with(base_path('.env.encrypted'))->andReturn('0640'); - File::expects('replace') - ->with(base_path('.env.encrypted'), m::capture($encryptedOutput), 0640); + $this->filesystem->files[base_path('.env')] = 'DB_PASSWORD=1'; + $this->filesystem->files[base_path('.env.encrypted')] = 'DB_PASSWORD=' . $oldEncrypter->encryptString('1') . "\n"; $this->artisan('env:encrypt', ['--readable' => true, '--force' => true, '--key' => $key]) ->assertExitCode(0); + $encryptedOutput = $this->filesystem->files[base_path('.env.encrypted')]; + $encrypter = new Encrypter($key, 'AES-256-CBC'); $this->assertSame('1', $encrypter->decryptString(substr(rtrim($encryptedOutput), strlen('DB_PASSWORD=')))); + $this->assertSame(0644, $this->filesystem->modes[base_path('.env.encrypted')]); } public function testItEncryptsWithGivenKeyAndDisplaysIt(): void { - $this->filesystem->expects('exists')->andReturn(true); - $this->filesystem->expects('exists')->andReturn(false); - $this->artisan('env:encrypt', ['--key' => $key = 'ANvVbPbE0tWMHpUySh6liY4WaCmAYKXP']) ->expectsOutputToContain('Environment successfully encrypted') ->expectsOutputToContain($key) ->expectsOutputToContain('.env.encrypted') ->assertExitCode(0); + + $encrypter = new Encrypter($key, 'AES-256-CBC'); + $this->assertSame('APP_NAME=Hypervel', $encrypter->decrypt($this->filesystem->files[base_path('.env.encrypted')])); } public function testItEncryptsWithGivenGeneratedBase64KeyAndDisplaysIt(): void { - $this->filesystem->expects('exists')->andReturn(true); - $this->filesystem->expects('exists')->andReturn(false); - $key = Encrypter::generateKey('AES-256-CBC'); $this->artisan('env:encrypt', ['--key' => 'base64:' . base64_encode($key)]) @@ -206,61 +189,40 @@ public function testItEncryptsWithGivenGeneratedBase64KeyAndDisplaysIt(): void ->expectsOutputToContain('base64:' . base64_encode($key)) ->expectsOutputToContain('.env.encrypted') ->assertExitCode(0); + + $encrypter = new Encrypter($key, 'AES-256-CBC'); + $this->assertSame('APP_NAME=Hypervel', $encrypter->decrypt($this->filesystem->files[base_path('.env.encrypted')])); } public function testItEncryptsInReadableFormat(): void { - File::swap(m::mock(Filesystem::class)); - - File::expects('exists') - ->with(base_path('.env')) - ->andReturn(true); - File::expects('exists') - ->with(base_path('.env.encrypted')) - ->andReturn(false); - File::expects('get') - ->with(base_path('.env')) - ->andReturn("APP_NAME=Hypervel\nAPP_ENV=local"); - File::expects('replace') - ->with(base_path('.env.encrypted'), m::on(function (string $content): bool { - $lines = explode("\n", rtrim($content)); - - return count($lines) === 2 - && str_starts_with($lines[0], 'APP_NAME=') - && str_starts_with($lines[1], 'APP_ENV='); - }), null); + $this->filesystem->files[base_path('.env')] = "APP_NAME=Hypervel\nAPP_ENV=local"; $this->artisan('env:encrypt', ['--readable' => true, '--key' => 'ANvVbPbE0tWMHpUySh6liY4WaCmAYKXP']) ->expectsOutputToContain('Environment successfully encrypted') ->assertExitCode(0); + + $lines = explode("\n", rtrim($this->filesystem->files[base_path('.env.encrypted')])); + + $this->assertCount(2, $lines); + $this->assertTrue(str_starts_with($lines[0], 'APP_NAME=')); + $this->assertTrue(str_starts_with($lines[1], 'APP_ENV=')); } public function testItSkipsCommentsAndBlankLinesInReadableFormat(): void { - File::swap(m::mock(Filesystem::class)); - - File::expects('exists') - ->with(base_path('.env')) - ->andReturn(true); - File::expects('exists') - ->with(base_path('.env.encrypted')) - ->andReturn(false); - File::expects('get') - ->with(base_path('.env')) - ->andReturn("# Comment\nAPP_NAME=Hypervel\n\nAPP_ENV=local"); - File::expects('replace') - ->with(base_path('.env.encrypted'), m::on(function (string $content): bool { - $lines = explode("\n", rtrim($content)); - - // Comments and blank lines are skipped - return count($lines) === 2 - && str_starts_with($lines[0], 'APP_NAME=') - && str_starts_with($lines[1], 'APP_ENV='); - }), null); + $this->filesystem->files[base_path('.env')] = "# Comment\nAPP_NAME=Hypervel\n\nAPP_ENV=local"; $this->artisan('env:encrypt', ['--readable' => true, '--key' => 'ANvVbPbE0tWMHpUySh6liY4WaCmAYKXP']) ->expectsOutputToContain('Environment successfully encrypted') ->assertExitCode(0); + + $lines = explode("\n", rtrim($this->filesystem->files[base_path('.env.encrypted')])); + + // Comments and blank lines are skipped + $this->assertCount(2, $lines); + $this->assertTrue(str_starts_with($lines[0], 'APP_NAME=')); + $this->assertTrue(str_starts_with($lines[1], 'APP_ENV=')); } public function testItEncryptsMultiLineValuesInReadableFormat(): void @@ -277,30 +239,14 @@ public function testItEncryptsMultiLineValuesInReadableFormat(): void lïne3" ENV; - $encryptedOutput = null; - - File::swap(m::mock(Filesystem::class)); - - File::expects('exists') - ->with(base_path('.env')) - ->andReturn(true); - File::expects('exists') - ->with(base_path('.env.encrypted')) - ->andReturn(false); - File::expects('get') - ->with(base_path('.env')) - ->andReturn($originalContent); - File::expects('replace') - ->with(base_path('.env.encrypted'), m::on(function (string $content) use (&$encryptedOutput): bool { - $encryptedOutput = $content; - - return true; - }), null); + $this->filesystem->files[base_path('.env')] = $originalContent; $this->artisan('env:encrypt', ['--readable' => true, '--key' => $key]) ->expectsOutputToContain('Environment successfully encrypted') ->assertExitCode(0); + $encryptedOutput = $this->filesystem->files[base_path('.env.encrypted')]; + // Verify structure $lines = explode("\n", rtrim($encryptedOutput)); $this->assertCount(2, $lines); @@ -326,30 +272,14 @@ public function testItEncryptsVariableReferencesInReadableFormat(): void APP_TEST_2="${APP_TEST}" ENV; - $encryptedOutput = null; - - File::swap(m::mock(Filesystem::class)); - - File::expects('exists') - ->with(base_path('.env')) - ->andReturn(true); - File::expects('exists') - ->with(base_path('.env.encrypted')) - ->andReturn(false); - File::expects('get') - ->with(base_path('.env')) - ->andReturn($originalContent); - File::expects('replace') - ->with(base_path('.env.encrypted'), m::on(function (string $content) use (&$encryptedOutput): bool { - $encryptedOutput = $content; - - return true; - }), null); + $this->filesystem->files[base_path('.env')] = $originalContent; $this->artisan('env:encrypt', ['--readable' => true, '--key' => $key]) ->expectsOutputToContain('Environment successfully encrypted') ->assertExitCode(0); + $encryptedOutput = $this->filesystem->files[base_path('.env.encrypted')]; + // Verify structure $lines = explode("\n", rtrim($encryptedOutput)); $this->assertCount(2, $lines); @@ -377,30 +307,14 @@ public function testItSkipsInvalidEnvLinesInReadableFormat(): void APP_TEST_3=also_valid ENV; - $encryptedOutput = null; - - File::swap(m::mock(Filesystem::class)); - - File::expects('exists') - ->with(base_path('.env')) - ->andReturn(true); - File::expects('exists') - ->with(base_path('.env.encrypted')) - ->andReturn(false); - File::expects('get') - ->with(base_path('.env')) - ->andReturn($originalContent); - File::expects('replace') - ->with(base_path('.env.encrypted'), m::on(function (string $content) use (&$encryptedOutput): bool { - $encryptedOutput = $content; - - return true; - }), null); + $this->filesystem->files[base_path('.env')] = $originalContent; $this->artisan('env:encrypt', ['--readable' => true, '--key' => $key]) ->expectsOutputToContain('Environment successfully encrypted') ->assertExitCode(0); + $encryptedOutput = $this->filesystem->files[base_path('.env.encrypted')]; + // Skip entries without an equals sign or a variable name. $lines = explode("\n", rtrim($encryptedOutput)); $this->assertCount(2, $lines); @@ -426,30 +340,14 @@ public function testItEncryptsSpecialCharactersInReadableFormat(): void NAME_3="M'aximus Decimus Meridius" ENV; - $encryptedOutput = null; - - File::swap(m::mock(Filesystem::class)); - - File::expects('exists') - ->with(base_path('.env')) - ->andReturn(true); - File::expects('exists') - ->with(base_path('.env.encrypted')) - ->andReturn(false); - File::expects('get') - ->with(base_path('.env')) - ->andReturn($originalContent); - File::expects('replace') - ->with(base_path('.env.encrypted'), m::on(function (string $content) use (&$encryptedOutput): bool { - $encryptedOutput = $content; - - return true; - }), null); + $this->filesystem->files[base_path('.env')] = $originalContent; $this->artisan('env:encrypt', ['--readable' => true, '--key' => $key]) ->expectsOutputToContain('Environment successfully encrypted') ->assertExitCode(0); + $encryptedOutput = $this->filesystem->files[base_path('.env.encrypted')]; + // Verify structure $lines = explode("\n", rtrim($encryptedOutput)); $this->assertCount(3, $lines); @@ -470,22 +368,14 @@ public function testItEncryptsSpecialCharactersInReadableFormat(): void public function testItCanRemoveTheOriginalFile(): void { - $this->filesystem->expects('exists')->andReturn(true); - $this->filesystem->expects('exists')->andReturn(false); - $this->filesystem->expects('delete') - ->with(base_path('.env')) - ->andReturnTrue(); - $this->artisan('env:encrypt', ['--prune' => true]) ->expectsQuestion('What encryption key would you like to use?', 'generate') ->expectsOutputToContain('.env.encrypted') ->assertExitCode(0); - $this->filesystem->shouldHaveReceived('replace') - ->with(base_path('.env.encrypted'), m::any(), null); - - $this->filesystem->shouldHaveReceived('delete') - ->with(base_path('.env')); + $this->assertSame([base_path('.env.encrypted')], $this->filesystem->writes); + $this->assertSame([base_path('.env')], $this->filesystem->deletes); + $this->assertArrayNotHasKey(base_path('.env'), $this->filesystem->files); } public function testItFailsWhenPrunedEnvironmentFileRemains(): void @@ -618,9 +508,6 @@ public function testOverwrittenEncryptedFilePreservesExistingPermissions(): void public function testItEncryptsWithInteractivelyGivenKeyAndDisplaysIt(): void { - $this->filesystem->expects('exists')->andReturn(true); - $this->filesystem->expects('exists')->andReturn(false); - $this->artisan('env:encrypt') ->expectsQuestion('What encryption key would you like to use?', 'ask') ->expectsQuestion('What is the encryption key?', $key = 'ANvVbPbE0tWMHpUySh6liY4WaCmAYKXP') @@ -628,5 +515,8 @@ public function testItEncryptsWithInteractivelyGivenKeyAndDisplaysIt(): void ->expectsOutputToContain($key) ->expectsOutputToContain('.env.encrypted') ->assertExitCode(0); + + $encrypter = new Encrypter($key, 'AES-256-CBC'); + $this->assertSame('APP_NAME=Hypervel', $encrypter->decrypt($this->filesystem->files[base_path('.env.encrypted')])); } } diff --git a/tests/Integration/Console/EnvironmentEncryptIncrementalCommandTest.php b/tests/Integration/Console/EnvironmentEncryptIncrementalCommandTest.php index 76e3c479a7..f7d088e707 100644 --- a/tests/Integration/Console/EnvironmentEncryptIncrementalCommandTest.php +++ b/tests/Integration/Console/EnvironmentEncryptIncrementalCommandTest.php @@ -5,39 +5,33 @@ namespace Hypervel\Tests\Integration\Console; use Hypervel\Encryption\Encrypter; -use Hypervel\Filesystem\Filesystem; use Hypervel\Support\Facades\File; use Hypervel\Testbench\TestCase; -use Mockery as m; +use Hypervel\Tests\Integration\Console\Fixtures\ArrayFilesystem; use PHPUnit\Framework\Attributes\DataProvider; -use RuntimeException; class EnvironmentEncryptIncrementalCommandTest extends TestCase { protected string $key = 'ANvVbPbE0tWMHpUySh6liY4WaCmAYKXP'; + protected ArrayFilesystem $filesystem; + /** - * Mock the source and encrypted environment files. + * Seed the source and encrypted environment files. */ - protected function mockFiles(?string $originalContent, ?string $encryptedContent, string $env = '.env'): void + protected function seedFiles(?string $originalContent, ?string $encryptedContent, string $env = '.env'): void { - File::swap(m::mock(Filesystem::class)); - - File::shouldReceive('exists') - ->with(base_path($env)) - ->andReturn($originalContent !== null); - File::shouldReceive('exists') - ->with(base_path($env . '.encrypted')) - ->andReturn($encryptedContent !== null); - File::shouldReceive('get') - ->with(base_path($env)) - ->andReturn($originalContent); - File::shouldReceive('get') - ->with(base_path($env . '.encrypted')) - ->andReturn($encryptedContent); - File::shouldReceive('chmod') - ->with(base_path($env . '.encrypted')) - ->andReturn('0640'); + $this->filesystem = new ArrayFilesystem; + + if ($originalContent !== null) { + $this->filesystem->files[base_path($env)] = $originalContent; + } + + if ($encryptedContent !== null) { + $this->filesystem->files[base_path($env . '.encrypted')] = $encryptedContent; + } + + File::swap($this->filesystem); } public function testItChangesOnlyTheEditedValue(): void @@ -60,16 +54,13 @@ public function testItChangesOnlyTheEditedValue(): void ENV; - $this->mockFiles($originalContent, $encryptedContent); - - $encryptedOutput = null; - - File::expects('replace') - ->with(base_path('.env.encrypted'), m::capture($encryptedOutput), 0640); + $this->seedFiles($originalContent, $encryptedContent); $this->artisan('env:encrypt', ['--readable' => true, '--key' => $this->key]) ->assertExitCode(0); + $encryptedOutput = $this->filesystem->files[base_path('.env.encrypted')]; + $lines = explode("\n", $encryptedOutput); $this->assertSame("DB_HOST={$host}", $lines[0]); $this->assertSame("APP_ENV={$app}", $lines[2]); @@ -77,6 +68,7 @@ public function testItChangesOnlyTheEditedValue(): void $payload = substr($lines[1], strlen('DB_PASSWORD=')); $this->assertNotSame($password, $payload); $this->assertSame('2', $encrypter->decryptString($payload)); + $this->assertSame([], $this->filesystem->deletes); } public function testItDoesNotWriteWhenRawValuesAreUnchanged(): void @@ -112,12 +104,13 @@ public function testItDoesNotWriteWhenRawValuesAreUnchanged(): void ENV; - $this->mockFiles($originalContent, $encryptedContent); - File::shouldReceive('replace')->never(); - File::expects('delete')->with(base_path('.env'))->andReturnTrue(); + $this->seedFiles($originalContent, $encryptedContent); $this->artisan('env:encrypt', ['--readable' => true, '--key' => $this->key, '--prune' => true]) ->assertExitCode(0); + + $this->assertSame([], $this->filesystem->writes); + $this->assertSame([base_path('.env')], $this->filesystem->deletes); } public function testItPreservesOccurrencesWhileAddingDeletingAndReordering(): void @@ -140,15 +133,12 @@ public function testItPreservesOccurrencesWhileAddingDeletingAndReordering(): vo ENV; - $this->mockFiles($originalContent, $encryptedContent); - $encryptedOutput = null; - - File::expects('replace') - ->with(base_path('.env.encrypted'), m::capture($encryptedOutput), 0640); - + $this->seedFiles($originalContent, $encryptedContent); $this->artisan('env:encrypt', ['--readable' => true, '--key' => $this->key]) ->assertExitCode(0); + $encryptedOutput = $this->filesystem->files[base_path('.env.encrypted')]; + $lines = explode("\n", $encryptedOutput); $this->assertCount(4, $lines); $this->assertSame('3', $encrypter->decryptString(substr($lines[0], strlen('NEW=')))); @@ -163,30 +153,24 @@ public function testItPreservesALaterDuplicateWhenAnEarlierDuplicateIsRemoved(): $first = $encrypter->encryptString('1'); $second = $encrypter->encryptString('2'); - $this->mockFiles("DUP=2\n", "DUP={$first}\nDUP={$second}\n"); - - $encryptedOutput = null; - - File::expects('replace') - ->with(base_path('.env.encrypted'), m::capture($encryptedOutput), 0640); + $this->seedFiles("DUP=2\n", "DUP={$first}\nDUP={$second}\n"); $this->artisan('env:encrypt', ['--readable' => true, '--key' => $this->key]) ->assertExitCode(0); + $encryptedOutput = $this->filesystem->files[base_path('.env.encrypted')]; + $this->assertSame("DUP={$second}\n", $encryptedOutput); } public function testItCreatesAMissingTargetForTheSelectedEnvironment(): void { - $this->mockFiles("DB_PASSWORD=1\n", null, '.env.production'); - $encryptedOutput = null; - - File::expects('replace') - ->with(base_path('.env.production.encrypted'), m::capture($encryptedOutput), null); - + $this->seedFiles("DB_PASSWORD=1\n", null, '.env.production'); $this->artisan('env:encrypt', ['--readable' => true, '--env' => 'production', '--key' => 'base64:' . base64_encode($this->key)]) ->assertExitCode(0); + $encryptedOutput = $this->filesystem->files[base_path('.env.production.encrypted')]; + $encrypter = new Encrypter($this->key, 'AES-256-CBC'); $this->assertSame('1', $encrypter->decryptString(substr(rtrim($encryptedOutput), strlen('DB_PASSWORD=')))); } @@ -195,27 +179,23 @@ public function testItTreatsQuoteChangesAsRawValueChanges(): void { $encrypter = new Encrypter($this->key, 'AES-256-CBC'); $old = $encrypter->encryptString('1'); - $this->mockFiles('DB_PASSWORD="1"', 'DB_PASSWORD=' . $old . "\n"); - $encryptedOutput = null; - - File::expects('replace') - ->with(base_path('.env.encrypted'), m::capture($encryptedOutput), 0640); - + $this->seedFiles('DB_PASSWORD="1"', 'DB_PASSWORD=' . $old . "\n"); $this->artisan('env:encrypt', ['--readable' => true, '--key' => $this->key]) ->assertExitCode(0); + $encryptedOutput = $this->filesystem->files[base_path('.env.encrypted')]; + $this->assertSame('"1"', $encrypter->decryptString(substr(rtrim($encryptedOutput), strlen('DB_PASSWORD=')))); } public function testItCanDeleteAllEntries(): void { $encrypter = new Encrypter($this->key, 'AES-256-CBC'); - $this->mockFiles('', 'DB_PASSWORD=' . $encrypter->encryptString('1') . "\n"); - File::expects('replace') - ->with(base_path('.env.encrypted'), '', 0640); - + $this->seedFiles('', 'DB_PASSWORD=' . $encrypter->encryptString('1') . "\n"); $this->artisan('env:encrypt', ['--readable' => true, '--key' => $this->key]) ->assertExitCode(0); + + $this->assertSame('', $this->filesystem->files[base_path('.env.encrypted')]); } #[DataProvider('invalidBaselines')] @@ -229,12 +209,13 @@ public function testItRejectsInvalidBaselinesWithoutWritingOrPruning(string $kin 'malformed' => '<<<<<<< HEAD', 'corrupt-deleted-entry' => 'REMOVED=invalid', }; - $this->mockFiles("DB_PASSWORD=1\n", $encryptedContent); - File::shouldReceive('replace')->never(); - File::shouldReceive('delete')->never(); + $this->seedFiles("DB_PASSWORD=1\n", $encryptedContent); $this->artisan('env:encrypt', ['--readable' => true, '--key' => $this->key, '--prune' => true]) ->assertExitCode(1); + + $this->assertSame([], $this->filesystem->writes); + $this->assertSame([], $this->filesystem->deletes); } /** @@ -269,17 +250,13 @@ public function testItRebuildsWithoutReadingTheBaselineWhenForced(string $cipher ENV; - $this->mockFiles($originalContent, $encryptedContent); - - $encryptedOutput = null; - - File::expects('replace') - ->with(base_path('.env.encrypted'), m::capture($encryptedOutput), 0640); + $this->seedFiles($originalContent, $encryptedContent); $this->artisan('env:encrypt', ['--readable' => true, '--force' => true, '--cipher' => $cipher, '--key' => $key]) ->assertExitCode(0); - File::shouldNotHaveReceived('get', [base_path('.env.encrypted')]); + $encryptedOutput = $this->filesystem->files[base_path('.env.encrypted')]; + $this->assertNotContains(base_path('.env.encrypted'), $this->filesystem->reads); $lines = explode("\n", $encryptedOutput); $encrypter = new Encrypter($key, $cipher); @@ -312,58 +289,60 @@ public function testItOverwritesInvalidBaselinesWhenForced(string $kind): void 'malformed' => '<<<<<<< HEAD', 'corrupt-deleted-entry' => 'REMOVED=invalid', }; - $this->mockFiles('DB_PASSWORD=1', $baseline); - $encryptedOutput = null; - File::expects('replace') - ->with(base_path('.env.encrypted'), m::capture($encryptedOutput), 0640); - + $this->seedFiles('DB_PASSWORD=1', $baseline); $this->artisan('env:encrypt', ['--readable' => true, '--force' => true, '--key' => $this->key]) ->assertExitCode(0); + $encryptedOutput = $this->filesystem->files[base_path('.env.encrypted')]; + $this->assertSame('1', $encrypter->decryptString(substr(rtrim($encryptedOutput), strlen('DB_PASSWORD=')))); } public function testItRejectsAMissingSource(): void { - $this->mockFiles(null, 'existing'); - File::shouldReceive('replace')->never(); + $this->seedFiles(null, 'existing'); $this->artisan('env:encrypt', ['--readable' => true, '--key' => $this->key]) ->expectsOutputToContain('Environment file not found.') ->assertExitCode(1); + + $this->assertSame([], $this->filesystem->writes); } public function testItRequiresTheExistingKeyInsteadOfGeneratingOne(): void { - $this->mockFiles('DB_PASSWORD=1', 'existing'); - File::shouldReceive('replace')->never(); + $this->seedFiles('DB_PASSWORD=1', 'existing'); $this->artisan('env:encrypt', ['--readable' => true, '--no-interaction' => true]) ->expectsOutputToContain('The existing encryption key is required') ->assertExitCode(1); + + $this->assertSame([], $this->filesystem->writes); } public function testItAsksForTheExistingKeyWhenUpdatingInteractively(): void { $encrypter = new Encrypter($this->key, 'AES-256-CBC'); - $this->mockFiles('DB_PASSWORD=1', 'DB_PASSWORD=' . $encrypter->encryptString('1') . "\n"); - File::shouldReceive('replace')->never(); + $this->seedFiles('DB_PASSWORD=1', 'DB_PASSWORD=' . $encrypter->encryptString('1') . "\n"); $this->artisan('env:encrypt', ['--readable' => true]) ->expectsQuestion('What is the encryption key?', $this->key) ->assertExitCode(0); + + $this->assertSame([], $this->filesystem->writes); } #[DataProvider('writeModes')] public function testItDoesNotPruneAfterAFailedWrite(bool $readable, bool $force): void { - $this->mockFiles('DB_PASSWORD=1', null); - File::expects('replace')->andThrow(new RuntimeException('publication failed')); - File::shouldReceive('delete')->never(); + $this->seedFiles('DB_PASSWORD=1', null); + $this->filesystem->failWrites = true; $this->artisan('env:encrypt', ['--readable' => $readable, '--force' => $force, '--key' => $this->key, '--prune' => true]) - ->expectsOutputToContain('publication failed') + ->expectsOutputToContain('Unable to write the complete replacement contents for') ->assertExitCode(1); + + $this->assertSame([], $this->filesystem->deletes); } /** @@ -392,11 +371,12 @@ public function testItPreservesBaselineBytesWhenEntriesAreUnchanged(string $sepa ENV; - $this->mockFiles($originalContent, $encryptedContent); - File::shouldReceive('replace')->never(); + $this->seedFiles($originalContent, $encryptedContent); $this->artisan('env:encrypt', ['--readable' => true, '--key' => $this->key]) ->assertExitCode(0); + + $this->assertSame([], $this->filesystem->writes); } /** @@ -431,15 +411,12 @@ public function testItUpdatesEntriesInABaselineContainingComments(): void ENV; - $this->mockFiles($originalContent, str_replace("\n", "\r\n", $encryptedContent)); - $encryptedOutput = null; - - File::expects('replace') - ->with(base_path('.env.encrypted'), m::capture($encryptedOutput), 0640); - + $this->seedFiles($originalContent, str_replace("\n", "\r\n", $encryptedContent)); $this->artisan('env:encrypt', ['--readable' => true, '--key' => $this->key]) ->assertExitCode(0); + $encryptedOutput = $this->filesystem->files[base_path('.env.encrypted')]; + $lines = explode("\n", $encryptedOutput); $this->assertCount(3, $lines); $this->assertSame("FIRST={$first}", $lines[0]); @@ -455,13 +432,14 @@ public function testItRejectsCorruptionAfterIgnorableBaselineLines(): void ENV; - $this->mockFiles('FIRST=1', str_replace("\n", "\r\n", $encryptedContent)); - File::shouldReceive('replace')->never(); - File::shouldReceive('delete')->never(); + $this->seedFiles('FIRST=1', str_replace("\n", "\r\n", $encryptedContent)); $this->artisan('env:encrypt', ['--readable' => true, '--key' => $this->key, '--prune' => true]) ->expectsOutputToContain('Unable to decrypt the encrypted environment entry on line 3.') ->assertExitCode(1); + + $this->assertSame([], $this->filesystem->writes); + $this->assertSame([], $this->filesystem->deletes); } public function testItWritesAReorderedBaselineEvenWhenEveryValueIsUnchanged(): void @@ -481,15 +459,12 @@ public function testItWritesAReorderedBaselineEvenWhenEveryValueIsUnchanged(): v ENV; - $this->mockFiles($originalContent, str_replace("\n", "\r\n", $encryptedContent)); - $encryptedOutput = null; - - File::expects('replace') - ->with(base_path('.env.encrypted'), m::capture($encryptedOutput), 0640); - + $this->seedFiles($originalContent, str_replace("\n", "\r\n", $encryptedContent)); $this->artisan('env:encrypt', ['--readable' => true, '--key' => $this->key]) ->assertExitCode(0); + $encryptedOutput = $this->filesystem->files[base_path('.env.encrypted')]; + $expected = <<mockFiles('FIRST=1', 'existing'); - File::shouldReceive('replace')->never(); + $this->seedFiles('FIRST=1', 'existing'); $this->artisan('env:encrypt', ['--readable' => true]) ->expectsQuestion('What is the encryption key?', '') ->expectsOutputToContain('The existing encryption key is required to update the encrypted environment file.') ->assertExitCode(1); + + $this->assertSame([], $this->filesystem->writes); } public function testItRejectsAnExplicitEmptyKeyNoninteractively(): void { - $this->mockFiles('FIRST=1', 'existing'); - File::shouldReceive('replace')->never(); + $this->seedFiles('FIRST=1', 'existing'); $this->artisan('env:encrypt', ['--readable' => true, '--key' => '', '--no-interaction' => true]) ->expectsOutputToContain('The existing encryption key is required to update the encrypted environment file.') ->assertExitCode(1); + + $this->assertSame([], $this->filesystem->writes); } public function testItRejectsAMissingSourceBeforeAskingForAKey(): void { - $this->mockFiles(null, 'existing'); - File::shouldReceive('replace')->never(); + $this->seedFiles(null, 'existing'); $this->artisan('env:encrypt', ['--readable' => true]) ->expectsOutputToContain('Environment file not found.') ->assertExitCode(1); + + $this->assertSame([], $this->filesystem->writes); } } diff --git a/tests/Integration/Console/Fixtures/ArrayFilesystem.php b/tests/Integration/Console/Fixtures/ArrayFilesystem.php new file mode 100644 index 0000000000..c93e75d6c7 --- /dev/null +++ b/tests/Integration/Console/Fixtures/ArrayFilesystem.php @@ -0,0 +1,133 @@ + + */ + public array $files = []; + + /** + * The file permission modes, keyed by path. Null means the default mode. + * + * @var array + */ + public array $modes = []; + + /** + * The paths written through put() or replace(), in order. + * + * @var array + */ + public array $writes = []; + + /** + * The paths read through get(), in order. + * + * @var array + */ + public array $reads = []; + + /** + * Indicates if put() and replace() should report a failed write. + */ + public bool $failWrites = false; + + /** + * The paths deleted through delete(), in order. + * + * @var array + */ + public array $deletes = []; + + /** + * Determine if a file or directory exists. + */ + public function exists(string $path): bool + { + return array_key_exists($path, $this->files); + } + + /** + * Get the contents of a file. + * + * @throws FileNotFoundException + */ + public function get(string $path, bool $lock = false): string + { + if (! $this->exists($path)) { + throw new FileNotFoundException("File does not exist at path {$path}."); + } + + $this->reads[] = $path; + + return $this->files[$path]; + } + + /** + * Write the contents of a file. + * + * @param resource|string $contents + */ + public function put(string $path, $contents, bool $lock = false): int|false + { + if ($this->failWrites) { + return false; + } + + $this->writes[] = $path; + $this->files[$path] = $contents; + + return strlen($contents); + } + + /** + * Write the contents of a file, replacing it atomically if it already exists. + */ + public function replace(string $path, #[SensitiveParameter] string $content, ?int $mode = null): void + { + if ($this->failWrites) { + throw new RuntimeException("Unable to write the complete replacement contents for [{$path}]."); + } + + $this->writes[] = $path; + $this->files[$path] = $content; + $this->modes[$path] = $mode; + } + + /** + * Get or set UNIX mode of a file or directory. + */ + public function chmod(string $path, ?int $mode = null): string|bool + { + if (! $this->exists($path)) { + return false; + } + + return sprintf('%04o', $this->modes[$path] ?? 0644); + } + + /** + * Delete the file at a given path. + */ + public function delete(array|string $paths): bool + { + foreach ((array) $paths as $path) { + $this->deletes[] = $path; + unset($this->files[$path]); + } + + return true; + } +} diff --git a/tests/Integration/Console/Scheduling/EventPingTest.php b/tests/Integration/Console/Scheduling/EventPingTest.php index 249bb826a6..cc3eda11e3 100644 --- a/tests/Integration/Console/Scheduling/EventPingTest.php +++ b/tests/Integration/Console/Scheduling/EventPingTest.php @@ -11,18 +11,15 @@ use GuzzleHttp\Psr7\Response as Psr7Response; use Hypervel\Console\Scheduling\Event; use Hypervel\Contracts\Container\Container; -use Hypervel\Contracts\Debug\ExceptionHandler; +use Hypervel\Support\Facades\Exceptions; use Hypervel\Testbench\TestCase; use Hypervel\Tests\Console\Fixtures\FakeEventMutex; -use Mockery as m; class EventPingTest extends TestCase { public function testPingRescuesTransferExceptions(): void { - $this->spy(ExceptionHandler::class) - ->expects('report') - ->with(m::type(ServerException::class)); + $exceptions = Exceptions::fake(); $httpMock = new HttpClient([ 'handler' => HandlerStack::create( @@ -45,5 +42,7 @@ public function testPingRescuesTransferExceptions(): void $event->callAfterCallbacks($this->app->make(Container::class)); $this->assertTrue($thenCalled); + $this->assertCount(1, $exceptions->reported()); + $this->assertInstanceOf(ServerException::class, $exceptions->reported()[0]); } } diff --git a/tests/Integration/Database/DatabaseEloquentBroadcastingTest.php b/tests/Integration/Database/DatabaseEloquentBroadcastingTest.php index 53b2322fe1..3e1d023cde 100644 --- a/tests/Integration/Database/DatabaseEloquentBroadcastingTest.php +++ b/tests/Integration/Database/DatabaseEloquentBroadcastingTest.php @@ -6,6 +6,7 @@ use Closure; use Hypervel\Broadcasting\BroadcastEvent; +use Hypervel\Broadcasting\PendingBroadcast; use Hypervel\Contracts\Broadcasting\Broadcaster; use Hypervel\Contracts\Broadcasting\Factory as BroadcastingFactory; use Hypervel\Database\Eloquent\BroadcastableModelEventOccurred; @@ -13,10 +14,13 @@ use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\SoftDeletes; use Hypervel\Database\Schema\Blueprint; +use Hypervel\Http\Request; use Hypervel\Support\Arr; use Hypervel\Support\Facades\Event; use Hypervel\Support\Facades\Schema; -use Mockery as m; +use LogicException; +use PHPUnit\Framework\ExpectationFailedException; +use UnitEnum; class DatabaseEloquentBroadcastingTest extends DatabaseTestCase { @@ -202,17 +206,87 @@ public function testBroadcastPayloadCanBeDefined(): void */ private function assertHandldedBroadcastableEvent(BroadcastableModelEventOccurred $event, Closure $closure): bool { - $broadcaster = m::mock(Broadcaster::class); - $broadcaster->expects('broadcast') - ->withArgs(function (array $channels, string $eventName, array $payload) use ($closure): bool { - return $closure($channels, $eventName, $payload); - }); - - $manager = m::mock(BroadcastingFactory::class); - $manager->expects('connection')->with(null)->andReturn($broadcaster); + $broadcaster = new class($closure) implements Broadcaster { + public int $broadcasts = 0; + + /** + * Create a broadcaster that checks each broadcast with the given callback. + */ + public function __construct(protected Closure $closure) + { + } + + /** + * Authenticate the incoming request for a given channel. + */ + public function auth(Request $request): mixed + { + return null; + } + + /** + * Return the valid authentication response. + */ + public function validAuthenticationResponse(Request $request, mixed $result): mixed + { + return null; + } + + /** + * Broadcast the given event. + */ + public function broadcast(array $channels, string $event, array $payload = []): void + { + ++$this->broadcasts; + + if (! ($this->closure)($channels, $event, $payload)) { + throw new ExpectationFailedException('Broadcast arguments did not match.'); + } + } + }; + + $manager = new class($broadcaster) implements BroadcastingFactory { + public array $requested = []; + + /** + * Create a broadcasting factory that returns the given broadcaster. + */ + public function __construct(protected Broadcaster $broadcaster) + { + } + + /** + * Get a broadcaster implementation by name. + */ + public function connection(UnitEnum|string|null $name = null): Broadcaster + { + $this->requested[] = $name; + + return $this->broadcaster; + } + + /** + * Begin broadcasting an event. + */ + public function event(mixed $event = null): PendingBroadcast + { + throw new LogicException('Events cannot be broadcast.'); + } + + /** + * Queue the given event for broadcast. + */ + public function queue(mixed $event): void + { + throw new LogicException('Events cannot be queued.'); + } + }; (new BroadcastEvent($event))->handle($manager); + $this->assertSame(1, $broadcaster->broadcasts); + $this->assertSame([null], $manager->requested); + return true; } } diff --git a/tests/Integration/Events/ListenerTest.php b/tests/Integration/Events/ListenerTest.php index e459e2f5a4..0adb18f487 100644 --- a/tests/Integration/Events/ListenerTest.php +++ b/tests/Integration/Events/ListenerTest.php @@ -7,7 +7,6 @@ use Hypervel\Database\DatabaseTransactionsManager; use Hypervel\Support\Facades\Event; use Hypervel\Testbench\TestCase; -use Mockery as m; use RuntimeException; class ListenerTest extends TestCase @@ -23,17 +22,15 @@ protected function tearDown(): void parent::tearDown(); } - public function testClassListenerRunsNormallyIfNoTransactions(): void + public function testClassListenerRunsImmediatelyInsideTransaction(): void { - $this->app->singleton('db.transactions', function (): DatabaseTransactionsManager { - $transactionManager = m::mock(DatabaseTransactionsManager::class); - $transactionManager->shouldNotReceive('addCallback'); - - return $transactionManager; - }); + $manager = new DatabaseTransactionsManager; + $this->app->instance('db.transactions', $manager); Event::listen(ListenerTestEvent::class, ListenerTestListener::class); + $manager->begin('default', 1); + Event::dispatch(new ListenerTestEvent); $this->assertTrue(ListenerTestListener::$ran); @@ -41,18 +38,20 @@ public function testClassListenerRunsNormallyIfNoTransactions(): void public function testClassListenerDoesntRunInsideTransaction(): void { - $this->app->singleton('db.transactions', function (): DatabaseTransactionsManager { - $transactionManager = m::mock(DatabaseTransactionsManager::class); - $transactionManager->expects('addCallback')->andReturn(null); - - return $transactionManager; - }); + $manager = new DatabaseTransactionsManager; + $this->app->instance('db.transactions', $manager); Event::listen(ListenerTestEvent::class, ListenerTestListenerAfterCommit::class); + $manager->begin('default', 1); + Event::dispatch(new ListenerTestEvent); $this->assertFalse(ListenerTestListenerAfterCommit::$ran); + + $manager->commit('default', 1, 0); + + $this->assertTrue(ListenerTestListenerAfterCommit::$ran); } public function testAfterCommitListenersContinueAfterSiblingFailure(): void diff --git a/tests/Integration/Notifications/Fixtures/mail/my-custom-theme.blade.php b/tests/Integration/Notifications/Fixtures/mail/my-custom-theme.blade.php new file mode 100644 index 0000000000..98ee577388 --- /dev/null +++ b/tests/Integration/Notifications/Fixtures/mail/my-custom-theme.blade.php @@ -0,0 +1 @@ +.wrapper { background-color: #123456; } diff --git a/tests/Integration/Notifications/SendingMailNotificationsTest.php b/tests/Integration/Notifications/SendingMailNotificationsTest.php index 4586cc5a9a..8ac051ba36 100644 --- a/tests/Integration/Notifications/SendingMailNotificationsTest.php +++ b/tests/Integration/Notifications/SendingMailNotificationsTest.php @@ -4,55 +4,37 @@ namespace Hypervel\Tests\Integration\Notifications; -use Closure; use Hypervel\Contracts\Foundation\Application as ApplicationContract; -use Hypervel\Contracts\Mail\Factory as MailFactory; -use Hypervel\Contracts\Mail\Mailable; -use Hypervel\Contracts\Mail\Mailer; use Hypervel\Database\Schema\Blueprint; -use Hypervel\Mail\Markdown; -use Hypervel\Mail\Message; +use Hypervel\Mail\Events\MessageSending; +use Hypervel\Mail\Mailable; +use Hypervel\Mail\Mailables\Content; +use Hypervel\Mail\Mailables\Envelope; use Hypervel\Notifications\Channels\MailChannel; use Hypervel\Notifications\Messages\MailMessage; use Hypervel\Notifications\Notification; +use Hypervel\Support\Arr; +use Hypervel\Support\Facades\Event; use Hypervel\Support\Facades\Schema; -use Hypervel\Support\HtmlString; use Hypervel\Support\Str; use Hypervel\Testbench\Attributes\WithConfig; use Hypervel\Testbench\TestCase; use Hypervel\Tests\Notifications\Fixtures\Models\NotifiableUser; -use Mockery as m; +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Email; #[WithConfig('database.default', 'testing')] class SendingMailNotificationsTest extends TestCase { - public MailFactory $mailFactory; - - public Mailer $mailer; - - public Markdown $markdown; - /** * Define the test environment. */ protected function defineEnvironment(ApplicationContract $app): void { - $this->mailFactory = m::mock(MailFactory::class); - $this->mailer = m::mock(Mailer::class); - $this->mailFactory->shouldReceive('mailer')->andReturn($this->mailer); - $this->markdown = m::mock(Markdown::class); - - $app->extend(Markdown::class, function (): Markdown { - return $this->markdown; - }); - - $app->extend(Mailer::class, function (): Mailer { - return $this->mailer; - }); - - $app->extend(MailFactory::class, function (): MailFactory { - return $this->mailFactory; - }); + $config = $app->make('config'); + $config->set('mail.default', 'array'); + $config->set('mail.mailers.array', ['transport' => 'array']); + $config->set('mail.mailers.foo', ['transport' => 'array']); $app->make('view')->addLocation(__DIR__ . '/Fixtures'); } @@ -80,34 +62,32 @@ public function testMailIsSent(): void 'email' => 'taylor@hypervel.com', ]); - $this->markdown->expects('render') - ->withArgs(fn (mixed ...$args): bool => ($args['theme'] ?? $args[3] ?? null) === 'default') - ->andReturn(new HtmlString('htmlContent')); - $this->markdown->expects('renderText')->andReturn(new HtmlString('textContent')); - - $this->setMailerSendAssertions($notification, $user, function (Closure $closure): bool { - $message = m::mock(Message::class); - - $message->expects('to')->with(['taylor@hypervel.com']); - - $message->expects('cc')->with('cc@deepblue.com', 'cc'); - - $message->expects('bcc')->with('bcc@deepblue.com', 'bcc'); - - $message->expects('from')->with('jack@deepblue.com', 'Jacques Mayol'); - - $message->expects('replyTo')->with('jack@deepblue.com', 'Jacques Mayol'); - - $message->expects('subject')->with('Test Mail Notification'); - - $message->expects('priority')->with(1); - - $closure($message); - - return true; - }); + $sending = $this->captureSendingData(); $user->notify($notification); + + $message = $this->sentMessage('foo'); + + $this->assertSame([['taylor@hypervel.com', '']], $this->addresses($message->getTo())); + $this->assertSame([['cc@deepblue.com', 'cc']], $this->addresses($message->getCc())); + $this->assertSame([['bcc@deepblue.com', 'bcc']], $this->addresses($message->getBcc())); + $this->assertSame([['jack@deepblue.com', 'Jacques Mayol']], $this->addresses($message->getFrom())); + $this->assertSame([['jack@deepblue.com', 'Jacques Mayol']], $this->addresses($message->getReplyTo())); + $this->assertSame('Test Mail Notification', $message->getSubject()); + $this->assertSame(1, $message->getPriority()); + $this->assertStringContainsString('The introduction to the notification.', $message->getHtmlBody()); + $this->assertStringContainsString('The introduction to the notification.', $message->getTextBody()); + + $this->assertSame([ + '__hypervel_notification_id' => $notification->id, + '__hypervel_notification' => TestMailNotification::class, + '__hypervel_notification_queued' => false, + ], Arr::only($sending->data, [ + '__hypervel_notification_id', '__hypervel_notification', '__hypervel_notification_queued', + ])); + $this->assertSame($notification->toMail($user)->toArray(), Arr::except($sending->data, [ + 'message', 'mailer', '__hypervel_notification_id', '__hypervel_notification', '__hypervel_notification_queued', + ])); } public function testMailIsSentWithCustomTheme(): void @@ -119,112 +99,41 @@ public function testMailIsSentWithCustomTheme(): void 'email' => 'taylor@hypervel.com', ]); - $this->markdown->expects('render') - ->withArgs(fn (mixed ...$args): bool => ($args['theme'] ?? $args[3] ?? null) === 'my-custom-theme') - ->andReturn(new HtmlString('htmlContent')); - $this->markdown->expects('renderText')->andReturn(new HtmlString('textContent')); - - $this->setMailerSendAssertions($notification, $user, function (Closure $closure): bool { - $message = m::mock(Message::class); - - $message->expects('to')->with(['taylor@hypervel.com']); - - $message->expects('cc')->with('cc@deepblue.com', 'cc'); - - $message->expects('bcc')->with('bcc@deepblue.com', 'bcc'); - - $message->expects('from')->with('jack@deepblue.com', 'Jacques Mayol'); - - $message->expects('replyTo')->with('jack@deepblue.com', 'Jacques Mayol'); - - $message->expects('subject')->with('Test Mail Notification With Custom Theme'); + $user->notify($notification); - $message->expects('priority')->with(1); + $message = $this->sentMessage('foo'); - $closure($message); + $this->assertSame('Test Mail Notification With Custom Theme', $message->getSubject()); + $this->assertStringContainsString('#123456', $message->getHtmlBody()); + } - return true; - }); + public function testMailUsesDefaultThemeWhenNoneIsGiven(): void + { + $user = NotifiableUser::forceCreate([ + 'email' => 'taylor@hypervel.com', + ]); - $user->notify($notification); - } + $user->notify(new TestMailNotificationWithSubject); - /** - * Expect the notification view, data, and message callback. - */ - private function setMailerSendAssertions( - Notification $notification, - NotifiableUser $user, - callable $callbackExpectationClosure - ): void { - $this->mailer->expects('send')->withArgs(function (mixed ...$args) use ($notification, $user, $callbackExpectationClosure): bool { - $viewArray = $args[0]; - - if (! m::on(fn (Closure $closure): bool => (string) $closure([]) === 'htmlContent')->match($viewArray['html'])) { - return false; - } - - if (! m::on(fn (Closure $closure): bool => (string) $closure([]) === 'textContent')->match($viewArray['text'])) { - return false; - } - - $data = $args[1]; - - $expected = array_merge($notification->toMail($user)->toArray(), [ - '__hypervel_notification_id' => $notification->id, - '__hypervel_notification' => get_class($notification), - '__hypervel_notification_queued' => false, - ]); - - if (array_keys($data) !== array_keys($expected)) { - return false; - } - if (array_values($data) !== array_values($expected)) { - return false; - } - - return m::on($callbackExpectationClosure)->match($args[2]); - }); + $this->assertStringNotContainsString('#123456', $this->sentMessage()->getHtmlBody()); } public function testMailIsSentToNamedAddress(): void { - $notification = new TestMailNotification; - $notification->id = Str::uuid()->toString(); - $user = NotifiableUserWithNamedAddress::forceCreate([ 'email' => 'taylor@hypervel.com', 'name' => 'Taylor Otwell', ]); - $this->markdown->expects('render') - ->withArgs(fn (mixed ...$args): bool => ($args['theme'] ?? $args[3] ?? null) === 'default') - ->andReturn(new HtmlString('htmlContent')); - $this->markdown->expects('renderText')->andReturn(new HtmlString('textContent')); - - $this->setMailerSendAssertions($notification, $user, function (Closure $closure): bool { - $message = m::mock(Message::class); - - $message->expects('to')->with(['taylor@hypervel.com' => 'Taylor Otwell', 'foo_taylor@hypervel.com']); - - $message->expects('cc')->with('cc@deepblue.com', 'cc'); - - $message->expects('bcc')->with('bcc@deepblue.com', 'bcc'); - - $message->expects('from')->with('jack@deepblue.com', 'Jacques Mayol'); - - $message->expects('replyTo')->with('jack@deepblue.com', 'Jacques Mayol'); + $user->notify(new TestMailNotificationWithSubject); - $message->expects('subject')->with('Test Mail Notification'); + $message = $this->sentMessage(); - $message->expects('priority')->with(1); - - $closure($message); - - return true; - }); - - $user->notify($notification); + $this->assertSame([ + ['taylor@hypervel.com', 'Taylor Otwell'], + ['foo_taylor@hypervel.com', ''], + ], $this->addresses($message->getTo())); + $this->assertSame('mail custom subject', $message->getSubject()); } public function testMailIsSentWithSubject(): void @@ -236,24 +145,12 @@ public function testMailIsSentWithSubject(): void 'email' => 'taylor@hypervel.com', ]); - $this->markdown->expects('render') - ->withArgs(fn (mixed ...$args): bool => ($args['theme'] ?? $args[3] ?? null) === 'default') - ->andReturn(new HtmlString('htmlContent')); - $this->markdown->expects('renderText')->andReturn(new HtmlString('textContent')); - - $this->setMailerSendAssertions($notification, $user, function (Closure $closure): bool { - $message = m::mock(Message::class); - - $message->expects('to')->with(['taylor@hypervel.com']); - - $message->expects('subject')->with('mail custom subject'); + $user->notify($notification); - $closure($message); + $message = $this->sentMessage(); - return true; - }); - - $user->notify($notification); + $this->assertSame([['taylor@hypervel.com', '']], $this->addresses($message->getTo())); + $this->assertSame('mail custom subject', $message->getSubject()); } public function testMailIsSentToMultipleAddresses(): void @@ -265,24 +162,15 @@ public function testMailIsSentToMultipleAddresses(): void 'email' => 'taylor@hypervel.com', ]); - $this->markdown->expects('render') - ->withArgs(fn (mixed ...$args): bool => ($args['theme'] ?? $args[3] ?? null) === 'default') - ->andReturn(new HtmlString('htmlContent')); - $this->markdown->expects('renderText')->andReturn(new HtmlString('textContent')); - - $this->setMailerSendAssertions($notification, $user, function (Closure $closure): bool { - $message = m::mock(Message::class); - - $message->expects('to')->with(['foo_taylor@hypervel.com', 'bar_taylor@hypervel.com']); - - $message->expects('subject')->with('mail custom subject'); + $user->notify($notification); - $closure($message); + $message = $this->sentMessage(); - return true; - }); - - $user->notify($notification); + $this->assertSame([ + ['foo_taylor@hypervel.com', ''], + ['bar_taylor@hypervel.com', ''], + ], $this->addresses($message->getTo())); + $this->assertSame('mail custom subject', $message->getSubject()); } public function testMailIsSentUsingMailable(): void @@ -294,6 +182,12 @@ public function testMailIsSentUsingMailable(): void ]); $user->notify($notification); + + $message = $this->sentMessage(); + + $this->assertSame([['taylor@hypervel.com', '']], $this->addresses($message->getTo())); + $this->assertSame('Mailable Subject', $message->getSubject()); + $this->assertStringContainsString('mailable body', $message->getHtmlBody()); } public function testMailIsSentUsingMailMessageWithHtmlAndPlain(): void @@ -305,27 +199,14 @@ public function testMailIsSentUsingMailMessageWithHtmlAndPlain(): void 'email' => 'taylor@hypervel.com', ]); - $this->mailer->expects('send')->with( - ['html', 'plain'], - array_merge($notification->toMail($user)->toArray(), [ - '__hypervel_notification_id' => $notification->id, - '__hypervel_notification' => get_class($notification), - '__hypervel_notification_queued' => false, - ]), - m::on(function (Closure $closure): bool { - $message = m::mock(Message::class); - - $message->expects('to')->with(['taylor@hypervel.com']); - - $message->expects('subject')->with('Test Mail Notification With Html And Plain'); - - $closure($message); + $user->notify($notification); - return true; - }) - ); + $message = $this->sentMessage(); - $user->notify($notification); + $this->assertSame([['taylor@hypervel.com', '']], $this->addresses($message->getTo())); + $this->assertSame('Test Mail Notification With Html And Plain', $message->getSubject()); + $this->assertSame('htmlContent', trim($message->getHtmlBody())); + $this->assertSame('plainContent', trim($message->getTextBody())); } public function testMailIsSentUsingMailMessageWithHtmlOnly(): void @@ -337,27 +218,14 @@ public function testMailIsSentUsingMailMessageWithHtmlOnly(): void 'email' => 'taylor@hypervel.com', ]); - $this->mailer->expects('send')->with( - 'html', - array_merge($notification->toMail($user)->toArray(), [ - '__hypervel_notification_id' => $notification->id, - '__hypervel_notification' => get_class($notification), - '__hypervel_notification_queued' => false, - ]), - m::on(function (Closure $closure): bool { - $message = m::mock(Message::class); - - $message->expects('to')->with(['taylor@hypervel.com']); - - $message->expects('subject')->with('Test Mail Notification With Html Only'); - - $closure($message); + $user->notify($notification); - return true; - }) - ); + $message = $this->sentMessage(); - $user->notify($notification); + $this->assertSame([['taylor@hypervel.com', '']], $this->addresses($message->getTo())); + $this->assertSame('Test Mail Notification With Html Only', $message->getSubject()); + $this->assertSame('htmlContent', trim($message->getHtmlBody())); + $this->assertNull($message->getTextBody()); } public function testMailIsSentUsingMailMessageWithPlainOnly(): void @@ -369,27 +237,52 @@ public function testMailIsSentUsingMailMessageWithPlainOnly(): void 'email' => 'taylor@hypervel.com', ]); - $this->mailer->expects('send')->with( - [null, 'plain'], - array_merge($notification->toMail($user)->toArray(), [ - '__hypervel_notification_id' => $notification->id, - '__hypervel_notification' => get_class($notification), - '__hypervel_notification_queued' => false, - ]), - m::on(function (Closure $closure): bool { - $message = m::mock(Message::class); + $user->notify($notification); + + $message = $this->sentMessage(); - $message->expects('to')->with(['taylor@hypervel.com']); + $this->assertSame([['taylor@hypervel.com', '']], $this->addresses($message->getTo())); + $this->assertSame('Test Mail Notification With Plain Only', $message->getSubject()); + $this->assertSame('plainContent', trim($message->getTextBody())); + $this->assertNull($message->getHtmlBody()); + } - $message->expects('subject')->with('Test Mail Notification With Plain Only'); + /** + * Capture the data of the next message being sent. + */ + private function captureSendingData(): object + { + $captured = new class { + public array $data = []; + }; - $closure($message); + Event::listen(MessageSending::class, function (MessageSending $event) use ($captured): void { + $captured->data = $event->data; + }); - return true; - }) - ); + return $captured; + } - $user->notify($notification); + /** + * Get the only message sent through the given array mailer. + */ + private function sentMessage(string $mailer = 'array'): Email + { + $messages = $this->app->make('mail.manager')->mailer($mailer)->getSymfonyTransport()->messages(); + + $this->assertCount(1, $messages); + + return $messages->first()->getOriginalMessage(); + } + + /** + * Get the email and name of each address. + * + * @param Address[] $addresses + */ + private function addresses(array $addresses): array + { + return array_map(fn (Address $address): array => [$address->getAddress(), $address->getName()], $addresses); } } @@ -483,11 +376,26 @@ public function via(NotifiableUser $notifiable): array */ public function toMail(NotifiableUser $notifiable): Mailable { - $mailable = m::mock(Mailable::class); + return new TestNotificationMailable; + } +} - $mailable->expects('send'); +class TestNotificationMailable extends Mailable +{ + /** + * Get the message envelope. + */ + public function envelope(): Envelope + { + return new Envelope(to: ['taylor@hypervel.com'], subject: 'Mailable Subject'); + } - return $mailable; + /** + * Get the message content definition. + */ + public function content(): Content + { + return new Content(htmlString: 'mailable body'); } } diff --git a/tests/Queue/QueueBeanstalkdJobTest.php b/tests/Queue/QueueBeanstalkdJobTest.php index 9cb55f5752..aa40ea0031 100644 --- a/tests/Queue/QueueBeanstalkdJobTest.php +++ b/tests/Queue/QueueBeanstalkdJobTest.php @@ -9,6 +9,7 @@ use Hypervel\Config\Repository as ConfigRepository; use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Events\Dispatcher; +use Hypervel\Events\Dispatcher as EventsDispatcher; use Hypervel\ObjectPool\CallbackObjectPool; use Hypervel\ObjectPool\Lease; use Hypervel\ObjectPool\PoolOptions; @@ -61,12 +62,18 @@ public function testFailProperlyCallsTheJobHandler(): void $job->getContainer()->expects('make')->with('foo')->andReturn($handler); $job->getPheanstalk()->expects('delete')->with($job->getPheanstalkJob())->andReturnSelf(); $handler->expects('failed')->with(['data'], m::type(Exception::class), 'test-uuid', m::type(BeanstalkdJob::class)); - $events = m::mock(Dispatcher::class); + $events = new EventsDispatcher; + $failed = []; + $events->listen(JobFailed::class, function (JobFailed $event) use (&$failed): void { + $failed[] = $event; + }); $job->getContainer()->expects('make')->with(Dispatcher::class)->andReturn($events); - $events->expects('hasListeners')->with(JobFailed::class)->andReturnTrue(); - $events->expects('dispatch')->with(m::type(JobFailed::class))->andReturnNull(); - $job->fail(new Exception); + $job->fail($exception = new Exception); + + $this->assertCount(1, $failed); + $this->assertSame($job, $failed[0]->job); + $this->assertSame($exception, $failed[0]->exception); } public function testFailWithNullFailedDriverDoesNotRollBackDatabaseTransaction(): void diff --git a/tests/Queue/QueueClearCommandTest.php b/tests/Queue/QueueClearCommandTest.php index 10a3cfbf9d..6c9a391d71 100644 --- a/tests/Queue/QueueClearCommandTest.php +++ b/tests/Queue/QueueClearCommandTest.php @@ -9,79 +9,74 @@ use Hypervel\Contracts\Queue\Queue; use Hypervel\Foundation\Application; use Hypervel\Queue\Console\ClearCommand; +use Hypervel\Queue\NullQueue; use Hypervel\Queue\QueueManager; use Hypervel\Tests\TestCase; use Mockery as m; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\BufferedOutput; +use UnitEnum; class QueueClearCommandTest extends TestCase { public function testClearingDefaultQueue(): void { - $queue = m::mock(Queue::class, ClearableQueue::class); - $queue->expects('clear')->with('default')->andReturn(2); + $queue = new FakeClearableQueue(['default' => 2]); $output = $this->runClearCommand($queue); $this->assertStringContainsString('Cleared 2 jobs from the [default] queue', $output); + $this->assertSame(['default'], $queue->cleared); } public function testClearingMultipleQueues(): void { - $queue = m::mock(Queue::class, ClearableQueue::class); - $queue->expects('clear')->with('high')->andReturn(3); - $queue->expects('clear')->with('low')->andReturn(0); - $queue->expects('clear')->with('emails')->andReturn(1); + $queue = new FakeClearableQueue(['high' => 3, 'low' => 0, 'emails' => 1]); $output = $this->runClearCommand($queue, ['--queue' => 'high,low,emails']); $this->assertStringContainsString('Cleared 4 jobs from the [high, low, emails] queues', $output); + $this->assertSame(['high', 'low', 'emails'], $queue->cleared); } public function testClearingMultipleQueuesWithWhitespace(): void { - $queue = m::mock(Queue::class, ClearableQueue::class); - $queue->expects('clear')->with('high')->andReturn(3); - $queue->expects('clear')->with('low')->andReturn(0); + $queue = new FakeClearableQueue(['high' => 3, 'low' => 0]); $output = $this->runClearCommand($queue, ['--queue' => 'high, low']); $this->assertStringContainsString('Cleared 3 jobs from the [high, low] queues', $output); + $this->assertSame(['high', 'low'], $queue->cleared); } public function testClearingMultipleQueuesWithEmptyValues(): void { - $queue = m::mock(Queue::class, ClearableQueue::class); - $queue->expects('clear')->with('high')->andReturn(3); - $queue->expects('clear')->with('low')->andReturn(0); + $queue = new FakeClearableQueue(['high' => 3, 'low' => 0]); $output = $this->runClearCommand($queue, ['--queue' => 'high,,low']); $this->assertStringContainsString('Cleared 3 jobs from the [high, low] queues', $output); + $this->assertSame(['high', 'low'], $queue->cleared); } public function testClearingMultipleQueuesWithDuplicates(): void { - $queue = m::mock(Queue::class, ClearableQueue::class); - $queue->expects('clear')->with('high')->andReturn(3); - $queue->expects('clear')->with('low')->andReturn(0); + $queue = new FakeClearableQueue(['high' => 3, 'low' => 0]); $output = $this->runClearCommand($queue, ['--queue' => 'high,low,high']); $this->assertStringContainsString('Cleared 3 jobs from the [high, low] queues', $output); + $this->assertSame(['high', 'low'], $queue->cleared); } public function testClearingDistinctNumericQueueNames(): void { - $queue = m::mock(Queue::class, ClearableQueue::class); - $queue->expects('clear')->with('0')->andReturn(1); - $queue->expects('clear')->with('01')->andReturn(2); - $queue->expects('clear')->with('1')->andReturn(3); + $queue = new FakeClearableQueue(['0' => 1, '01' => 2, '1' => 3]); $output = $this->runClearCommand($queue, ['--queue' => '0,01,1,0,01,1']); $this->assertStringContainsString('Cleared 6 jobs from the [0, 01, 1] queues', $output); + $this->assertSame(['0', '01', '1'], $queue->cleared); } public function testProhibitedCommandCannotBeForced(): void @@ -129,3 +124,27 @@ protected function runClearCommand(Queue&ClearableQueue $queue, array $arguments return $output->fetch(); } } + +class FakeClearableQueue extends NullQueue implements ClearableQueue +{ + public array $cleared = []; + + /** + * Create a new fake clearable queue. + * + * @param array $counts + */ + public function __construct(protected array $counts) + { + } + + /** + * Delete all of the jobs from the queue. + */ + public function clear(UnitEnum|string|null $queue): int + { + $this->cleared[] = $queue; + + return $this->counts[$queue] ?? 0; + } +} diff --git a/tests/Queue/QueueSqsJobTest.php b/tests/Queue/QueueSqsJobTest.php index 956ea9d2ea..957691c160 100644 --- a/tests/Queue/QueueSqsJobTest.php +++ b/tests/Queue/QueueSqsJobTest.php @@ -16,7 +16,6 @@ use Hypervel\ObjectPool\Lease; use Hypervel\ObjectPool\PoolOptions; use Hypervel\Queue\Jobs\SqsJob; -use Hypervel\Queue\SqsQueue; use Hypervel\Tests\TestCase; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; @@ -126,8 +125,6 @@ public function testFireProperlyCallsTheJobHandler(): void public function testDeleteRemovesTheJobFromSqs(): void { $this->mockedSqsClient = m::mock(SqsClient::class)->makePartial(); - $queue = m::mock(SqsQueue::class, [$this->mockedSqsClient, $this->queueName, $this->account])->makePartial(); - $queue->setContainer($this->mockedContainer); $job = $this->getJob(); $job->getSqs()->expects('deleteMessage')->with(['QueueUrl' => $this->queueUrl, 'ReceiptHandle' => $this->mockedReceiptHandle]); $job->delete(); @@ -136,8 +133,6 @@ public function testDeleteRemovesTheJobFromSqs(): void public function testReleaseProperlyReleasesTheJobOntoSqs(): void { $this->mockedSqsClient = m::mock(SqsClient::class)->makePartial(); - $queue = m::mock(SqsQueue::class, [$this->mockedSqsClient, $this->queueName, $this->account])->makePartial(); - $queue->setContainer($this->mockedContainer); $job = $this->getJob(); $job->getSqs()->expects('changeMessageVisibility')->with(['QueueUrl' => $this->queueUrl, 'ReceiptHandle' => $this->mockedReceiptHandle, 'VisibilityTimeout' => $this->releaseDelay]); $job->release($this->releaseDelay); diff --git a/tests/Queue/QueueSyncQueueTest.php b/tests/Queue/QueueSyncQueueTest.php index 80ecd3533b..05fdefb2be 100644 --- a/tests/Queue/QueueSyncQueueTest.php +++ b/tests/Queue/QueueSyncQueueTest.php @@ -152,12 +152,11 @@ public function testFailedJobGetsHandledWhenAnExceptionIsThrown(): void $sync = new SyncQueue; $sync->setConnectionName('sync'); $container = $this->getContainer(); - $events = m::mock(EventDispatcher::class); - $events->expects('hasListeners')->with(JobProcessing::class)->andReturnTrue(); - $events->expects('hasListeners')->with(JobExceptionOccurred::class)->andReturnTrue(); - $events->expects('hasListeners')->with(JobFailed::class)->andReturnTrue(); - $events->expects('hasListeners')->with(JobAttempted::class)->andReturnTrue(); - $events->expects('dispatch')->times(4); + $events = new EventsDispatcher($container); + $dispatched = []; + $events->listen('Hypervel\Queue\Events\*', function (string $event) use (&$dispatched): void { + $dispatched[] = $event; + }); $container->instance('events', $events); $container->instance(EventDispatcher::class, $events); $sync->setContainer($container); @@ -167,6 +166,13 @@ public function testFailedJobGetsHandledWhenAnExceptionIsThrown(): void } catch (Exception) { $this->assertTrue($_SERVER['__sync.failed']); } + + $this->assertSame([ + JobProcessing::class, + JobExceptionOccurred::class, + JobFailed::class, + JobAttempted::class, + ], $dispatched); } public function testDurationIsPassedToJobEvents(): void diff --git a/tests/Routing/RouteRegistrarTest.php b/tests/Routing/RouteRegistrarTest.php index aab6e20f10..883972bc48 100644 --- a/tests/Routing/RouteRegistrarTest.php +++ b/tests/Routing/RouteRegistrarTest.php @@ -6,7 +6,7 @@ use BadMethodCallException; use Hypervel\Container\Container; -use Hypervel\Contracts\Events\Dispatcher; +use Hypervel\Events\Dispatcher; use Hypervel\Http\Request; use Hypervel\Routing\CallableDispatcher; use Hypervel\Routing\Contracts\CallableDispatcher as CallableDispatcherContract; @@ -19,7 +19,6 @@ use Hypervel\Tests\Routing\Fixtures\RouteNameEnum; use Hypervel\Tests\Routing\RoutingTestCase; use InvalidArgumentException; -use Mockery as m; use Stringable; class RouteRegistrarTest extends RoutingTestCase @@ -33,7 +32,7 @@ protected function setUp(): void $container = new Container; $container->bind(CallableDispatcherContract::class, fn (Container $app): CallableDispatcher => new CallableDispatcher($app)); - $this->router = new Router(m::mock(Dispatcher::class), $container); + $this->router = new Router(new Dispatcher($container), $container); } public function testMiddlewareFluentRegistration(): void diff --git a/tests/Support/SupportFacadeTest.php b/tests/Support/SupportFacadeTest.php index 39035834cf..4697b88614 100755 --- a/tests/Support/SupportFacadeTest.php +++ b/tests/Support/SupportFacadeTest.php @@ -25,8 +25,15 @@ protected function setUp(): void public function testFacadeCallsUnderlyingApplication(): void { $app = new ApplicationStub; - $app->setInstances(['foo' => $mock = m::mock(stdClass::class)]); - $mock->expects('bar')->andReturn('baz'); + $app->setInstances(['foo' => new class { + /** + * Return the result for the facade call. + */ + public function bar(): string + { + return 'baz'; + } + }]); FacadeStub::setFacadeApplication($app); $this->assertSame('baz', FacadeStub::bar()); } diff --git a/tests/Support/SupportFluentTest.php b/tests/Support/SupportFluentTest.php index 32016c9ab1..4a5ff036a1 100644 --- a/tests/Support/SupportFluentTest.php +++ b/tests/Support/SupportFluentTest.php @@ -143,8 +143,15 @@ public function testToArrayReturnsAttribute(): void public function testToJsonEncodesTheToArrayResult(): void { - $fluent = $this->getMockBuilder(Fluent::class)->onlyMethods(['toArray'])->getMock(); - $fluent->expects($this->once())->method('toArray')->willReturn(['foo']); + $fluent = new class extends Fluent { + /** + * Convert the fluent instance to an array. + */ + public function toArray(): array + { + return ['foo']; + } + }; $results = $fluent->toJson(); $this->assertJsonStringEqualsJsonString(json_encode(['foo']), $results); @@ -152,8 +159,7 @@ public function testToJsonEncodesTheToArrayResult(): void public function testToPrettyJson(): void { - $fluent = $this->getMockBuilder(Fluent::class)->onlyMethods(['toArray'])->getMock(); - $fluent->expects($this->exactly(2))->method('toArray')->willReturn(['foo' => 'bar', 'bar' => 'foo']); + $fluent = new Fluent(['foo' => 'bar', 'bar' => 'foo']); $results = $fluent->toPrettyJson(); $expected = $fluent->toJson(JSON_PRETTY_PRINT); diff --git a/tests/View/ViewTest.php b/tests/View/ViewTest.php index fa8cc83773..deb728ff22 100755 --- a/tests/View/ViewTest.php +++ b/tests/View/ViewTest.php @@ -147,8 +147,15 @@ public function testViewNestBindsASubView(): void public function testViewAcceptsArrayableImplementations(): void { - $arrayable = m::mock(Arrayable::class); - $arrayable->expects('toArray')->andReturn(['foo' => 'bar', 'baz' => ['qux', 'corge']]); + $arrayable = new class implements Arrayable { + /** + * Get the instance as an array. + */ + public function toArray(): array + { + return ['foo' => 'bar', 'baz' => ['qux', 'corge']]; + } + }; $view = $this->getView($arrayable); From b018cfe6078c3e8fb0e48e97a9142da2cfd27d24 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:45:40 +0000 Subject: [PATCH 4/8] Stop compiling dynamic component slot attributes as Blade DynamicComponent::compileSlots() inlined each slot's rendered attribute string into the Blade it compiles, so an attribute value containing Blade syntax, such as {{ 7191 * 2 }}, was compiled and run. The generated slot now binds :attributes="$name->attributes", so the values pass through as data, matching a static component. Ported from laravel/framework@772a27d8cc at the 13.x revision 5d7797528723107cd4f495326838a7d7d67fbb20, with its BladeTest regression test. Verified with php-cs-fixer, PHPStan and the view test files. --- src/view/src/DynamicComponent.php | 2 +- tests/Integration/View/BladeTest.php | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/view/src/DynamicComponent.php b/src/view/src/DynamicComponent.php index 14deba6491..feeb2438ee 100644 --- a/src/view/src/DynamicComponent.php +++ b/src/view/src/DynamicComponent.php @@ -108,7 +108,7 @@ protected function compileSlots(array $slots): string { return (new Collection($slots)) ->reject(fn ($slot, $name) => $name === '__default') - ->map(fn ($slot, $name) => 'attributes) . '>{{ $' . $name . ' }}') + ->map(fn ($slot, $name) => '{{ $' . $name . ' }}') ->implode(PHP_EOL); } diff --git a/tests/Integration/View/BladeTest.php b/tests/Integration/View/BladeTest.php index 6598aceab6..6a8867fb3d 100644 --- a/tests/Integration/View/BladeTest.php +++ b/tests/Integration/View/BladeTest.php @@ -237,6 +237,23 @@ public function testBoundNameAttributeCanBeUsedIfUsingShortSlotNamesAndNotFirstA ', trim($content)); } + public function testDynamicComponentSlotAttributesAreNotCompiledAsBlade(): void + { + $payload = '{{ 7191 * 2 }}'; + + $static = Blade::render(' + Test +', ['payload' => $payload]); + + $dynamic = Blade::render(' + Test +', ['payload' => $payload]); + + $this->assertStringContainsString('data-x="{{ 7191 * 2 }}"', $static); + $this->assertStringNotContainsString('14382', $dynamic); + $this->assertSame(trim($static), trim($dynamic)); + } + public function testNoNamePassedToSlotUsesDefaultName(): void { $content = Blade::render('default slot'); From 65c89f0f8eeaf921f610f172e5dd90f4bd7b3d50 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:45:57 +0000 Subject: [PATCH 5/8] Use one keyed exclusion set in the validator Laravel now keys validator exclusions by attribute and finds parent rule keys with a precomputed set instead of a regular expression per rule (laravel/framework#61698). Hypervel had two exclusion stores: a private set for the base validator and Laravel's list for subclasses, which needed a separate lookup loop and array_unique() on every exclusion. Both paths now use the protected keyed $excludeAttributes, so subclasses that reset it in passes() keep working. validated() uses upstream's parentRuleKeys() in place of preg_grep(), casting integer rule keys to strings. shouldBeExcluded() keeps its exact lookup and dotted-ancestor walk instead of upstream's explode() loop. It checks the same boundaries, skips the walk when nothing is excluded and does not split every attribute into segments. Ported at the 13.x revision 5d7797528723107cd4f495326838a7d7d67fbb20, with upstream's test that excluding an attribute does not exclude another attribute sharing its name as a prefix. Verified with php-cs-fixer, PHPStan and the validation and data test suites. --- src/validation/src/Validator.php | 72 +++++++++----------- tests/Validation/ValidationValidatorTest.php | 12 ++++ 2 files changed, 46 insertions(+), 38 deletions(-) diff --git a/src/validation/src/Validator.php b/src/validation/src/Validator.php index 4ef860d6ae..6306271aa1 100644 --- a/src/validation/src/Validator.php +++ b/src/validation/src/Validator.php @@ -52,18 +52,10 @@ class Validator implements ValidatorContract /** * Attributes that should be excluded from the validated data. - */ - protected array $excludeAttributes = []; - - /** - * Active exclusions for exact-base compiled execution. - * - * Subclasses keep Laravel's protected list because their passes() methods - * may reset that list directly. * - * @var array + * @var array */ - private array $activeExclusions = []; + protected array $excludeAttributes = []; /** * The message bag instance. @@ -434,7 +426,6 @@ public function passes(): bool $this->messages = new MessageBag; [$this->distinctValues, $this->failedRules, $this->excludeAttributes] = [[], [], []]; $this->distinctValueCounts = []; - $this->activeExclusions = []; $this->originalPresenceVerifier = null; $this->parsedTables = []; @@ -982,28 +973,17 @@ public function whenFails(callable $callback, ?callable $default = null): mixed */ protected function shouldBeExcluded(string $attribute): bool { - if (static::class === self::class) { - return isset($this->activeExclusions[$attribute]) - || ($this->activeExclusions !== [] - && $this->hasAttributeAncestorInSet($attribute, $this->activeExclusions)); - } - - // Keep the subclass fallback as a loop to avoid an extra callback per exclusion. - foreach ($this->excludeAttributes as $excludeAttribute) { - if ($attribute === $excludeAttribute - || Str::startsWith($attribute, $excludeAttribute . '.') - ) { - return true; - } - } - - return false; + // Unlike Laravel's explode() loop, this skips the ancestor walk when nothing + // is excluded and avoids splitting every attribute into a segment array. + return isset($this->excludeAttributes[$attribute]) + || ($this->excludeAttributes !== [] + && $this->hasAttributeAncestorInSet($attribute, $this->excludeAttributes)); } /** * Determine if any strict ancestor of an attribute belongs to a set. * - * @param array $attributes + * @param array $attributes */ private function hasAttributeAncestorInSet(string $attribute, array $attributes): bool { @@ -1092,6 +1072,8 @@ public function validated(): array $missingValue = new stdClass; + $parentKeys = $this->excludeUnvalidatedArrayKeys ? $this->parentRuleKeys() : []; + foreach ($this->getRules() as $key => $rules) { $key = (string) $key; @@ -1101,7 +1083,7 @@ public function validated(): array $this->excludeUnvalidatedArrayKeys && (in_array('array', $rules) || in_array('list', $rules)) && $value !== null - && ! empty(preg_grep('/^' . preg_quote($key, '/') . '\.+/', array_keys($this->getRules()))) + && isset($parentKeys[$key]) ) { continue; } @@ -1114,6 +1096,28 @@ public function validated(): array return $this->replacePlaceholders($results); } + /** + * Get the rule keys that have nested rules beneath them. + * + * @return array + */ + protected function parentRuleKeys(): array + { + $parentKeys = []; + + foreach (array_keys($this->getRules()) as $key) { + $key = (string) $key; + + while (str_contains($key, '.')) { + $key = Str::beforeLast($key, '.'); + + $parentKeys[$key] = true; + } + } + + return $parentKeys; + } + /** * Validate a given attribute against a rule. */ @@ -1464,15 +1468,7 @@ public function addFailure(string $attribute, string $rule, array $parameters = */ protected function excludeAttribute(string $attribute): void { - if (static::class === self::class) { - $this->activeExclusions[$attribute] = true; - - return; - } - - $this->excludeAttributes[] = $attribute; - - $this->excludeAttributes = array_unique($this->excludeAttributes); + $this->excludeAttributes[$attribute] = $attribute; } /** diff --git a/tests/Validation/ValidationValidatorTest.php b/tests/Validation/ValidationValidatorTest.php index cf853e7ba6..6758e24955 100755 --- a/tests/Validation/ValidationValidatorTest.php +++ b/tests/Validation/ValidationValidatorTest.php @@ -10671,6 +10671,18 @@ public function testExcludeValuesAreReallyRemoved(): void $this->assertSame(['mouse' => null], $validator->invalid()); } + public function testExcludedAttributesDoNotExcludeAttributesSharingTheirName(): void + { + $validator = new Validator( + $this->getArrayTranslator(), + ['price' => ['amount' => 10], 'price_note' => 'On sale'], + ['price' => 'exclude|array', 'price.amount' => 'integer', 'price_note' => 'string'] + ); + + $this->assertTrue($validator->passes()); + $this->assertSame(['price_note' => 'On sale'], $validator->validated()); + } + public function testExcludeWithValuesAreReallyRemoved(): void { $validator = new Validator( From 54a8aea9ed3b751129c788394859bc051df01806 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:45:57 +0000 Subject: [PATCH 6/8] Port Laravel's elsePushIf tests for commas in conditions Laravel fixed @elsePushIf conditions that contain commas by splitting at the last comma (laravel/framework#61741). Hypervel already splits at the first top-level comma with splitTopLevel(), which also handles commas inside the stack expression, where a last-comma split fails, so no source change is needed. This ports upstream's tests for a condition with several commas and a condition with a comma inside a string, at the 13.x revision 5d7797528723107cd4f495326838a7d7d67fbb20. Upstream's several-comma test replaces Hypervel's test of the same name, whose parenthesized condition the string-comma test also covers. The expected output keeps Hypervel's trimmed stack name, and the existing test for commas in the stack expression stays. Verified with php-cs-fixer and the Blade test files. --- tests/View/Blade/BladePushTest.php | 39 +++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/tests/View/Blade/BladePushTest.php b/tests/View/Blade/BladePushTest.php index 613a69c6a2..402f0731e4 100644 --- a/tests/View/Blade/BladePushTest.php +++ b/tests/View/Blade/BladePushTest.php @@ -118,38 +118,53 @@ public function testPushIfWithCommaInStringIsCompiled(): void $this->assertEquals($expected, $this->compiler->compileString($string)); } - public function testElsePushIfWithMoreThanOneCommaIsCompiled(): void + public function testPushIfElseIsCompiled(): void { - $string = '@pushIf(true, \'body-end\') + $string = '@pushIf(true, \'stack\') if -@elsePushIf(Str::startsWith(\'abc\', \'a\'), \'body-end\') +@elsePushIf(false, \'stack\') elseif +@elsePush(\'stack\') +else @endPushIf'; + $expected = 'startPush(\'stack\'); ?> +if +stopPush(); elseif(false): $__env->startPush(\'stack\'); ?> +elseif +stopPush(); else: $__env->startPush(\'stack\'); ?> +else +stopPush(); endif; ?>'; + + $this->assertEquals($expected, $this->compiler->compileString($string)); + } - $expected = 'startPush(\'body-end\'); ?> + public function testElsePushIfWithMoreThanOneCommaIsCompiled(): void + { + $string = '@pushIf(true, \'stack\') +if +@elsePushIf(in_array($x, [3, 4]), \'scripts\') +elseif +@endPushIf'; + $expected = 'startPush(\'stack\'); ?> if -stopPush(); elseif(Str::startsWith(\'abc\', \'a\')): $__env->startPush(\'body-end\'); ?> +stopPush(); elseif(in_array($x, [3, 4])): $__env->startPush(\'scripts\'); ?> elseif stopPush(); endif; ?>'; $this->assertEquals($expected, $this->compiler->compileString($string)); } - public function testPushIfElseIsCompiled(): void + public function testElsePushIfWithCommaInStringIsCompiled(): void { $string = '@pushIf(true, \'stack\') if -@elsePushIf(false, \'stack\') +@elsePushIf(Str::startsWith(\'abc,,,\', \'a,,,\'), \'body-end\') elseif -@elsePush(\'stack\') -else @endPushIf'; $expected = 'startPush(\'stack\'); ?> if -stopPush(); elseif(false): $__env->startPush(\'stack\'); ?> +stopPush(); elseif(Str::startsWith(\'abc,,,\', \'a,,,\')): $__env->startPush(\'body-end\'); ?> elseif -stopPush(); else: $__env->startPush(\'stack\'); ?> -else stopPush(); endif; ?>'; $this->assertEquals($expected, $this->compiler->compileString($string)); From 6d393e0a1343896115c4f1ab23b4996c53669394 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:48:24 +0000 Subject: [PATCH 7/8] Add native return types to the test case lifecycle methods setUpTraits(), afterApplicationCreated(), beforeApplicationDestroyed() and callBeforeApplicationDestroyedCallbacks() kept Laravel's untyped signatures when the lifecycle trait was extracted from the Foundation test case. They now declare their array and void returns, like the rest of the trait. Every existing override already declares the same type. Verified with php-cs-fixer, PHPStan and the Foundation and Testbench test suites. --- .../Concerns/InteractsWithTestCaseLifecycle.php | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/foundation/src/Testing/Concerns/InteractsWithTestCaseLifecycle.php b/src/foundation/src/Testing/Concerns/InteractsWithTestCaseLifecycle.php index 657fb59267..69885faea4 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithTestCaseLifecycle.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithTestCaseLifecycle.php @@ -192,10 +192,8 @@ protected function flushState(): void /** * Boot the testing helper traits. - * - * @return array */ - protected function setUpTraits() + protected function setUpTraits(): array { $uses = $this->traitsUsedByTest ??= class_uses_recursive(static::class); @@ -321,7 +319,7 @@ public function afterApplicationRefreshed(callable $callback): void /** * Register a callback to be run after the application is created. */ - public function afterApplicationCreated(callable $callback) + public function afterApplicationCreated(callable $callback): void { $this->afterApplicationCreatedCallbacks[] = $callback; @@ -333,7 +331,7 @@ public function afterApplicationCreated(callable $callback) /** * Register a callback to be run before the application is destroyed. */ - protected function beforeApplicationDestroyed(callable $callback) + protected function beforeApplicationDestroyed(callable $callback): void { $this->beforeApplicationDestroyedCallbacks[] = $callback; } @@ -341,7 +339,7 @@ protected function beforeApplicationDestroyed(callable $callback) /** * Execute the application's pre-destruction callbacks. */ - protected function callBeforeApplicationDestroyedCallbacks() + protected function callBeforeApplicationDestroyedCallbacks(): void { foreach ($this->beforeApplicationDestroyedCallbacks as $callback) { try { From 29188b9ecba6f852e789e9dee158f696ecef9dd7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:30:06 +0000 Subject: [PATCH 8/8] Use the minimum bcrypt cost in password test hashers The token repository tests and the two Eloquent provider credential tests built BcryptHasher with its default cost of 12. Each token insert and check paid that cost, which made these files several times slower than the behavior they exercise needs. Use cost 4, bcrypt's minimum, for those hashers. The tests still hash and check real bcrypt values. The rehash tests keep their explicit cost 4 and cost 5 hashers, because the difference between those costs is what they assert. Validation: both test files pass and formatting reports no changes. --- tests/Auth/AuthDatabaseTokenRepositoryTest.php | 2 +- tests/Auth/AuthEloquentUserProviderTest.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Auth/AuthDatabaseTokenRepositoryTest.php b/tests/Auth/AuthDatabaseTokenRepositoryTest.php index f209fe43d3..694bfe409f 100755 --- a/tests/Auth/AuthDatabaseTokenRepositoryTest.php +++ b/tests/Auth/AuthDatabaseTokenRepositoryTest.php @@ -194,7 +194,7 @@ protected function getRepo(): DatabaseTokenRepository $table->timestamp('created_at')->nullable(); }); - return new DatabaseTokenRepository($connection, new BcryptHasher, 'table', 'key'); + return new DatabaseTokenRepository($connection, new BcryptHasher(['rounds' => 4]), 'table', 'key'); } /** diff --git a/tests/Auth/AuthEloquentUserProviderTest.php b/tests/Auth/AuthEloquentUserProviderTest.php index 63615f751f..8fcc045024 100755 --- a/tests/Auth/AuthEloquentUserProviderTest.php +++ b/tests/Auth/AuthEloquentUserProviderTest.php @@ -131,7 +131,7 @@ public function testRetrieveByCredentialsWithMultiplyPasswordsReturnsNull(): voi public function testCredentialValidation(): void { - $hasher = new BcryptHasher; + $hasher = new BcryptHasher(['rounds' => 4]); $provider = new EloquentUserProvider($hasher, 'foo'); $user = new GenericUser(['password' => $hasher->make('plain')]); $result = $provider->validateCredentials($user, ['password' => 'plain']); @@ -141,7 +141,7 @@ public function testCredentialValidation(): void public function testCredentialValidationFailed(): void { - $hasher = new BcryptHasher; + $hasher = new BcryptHasher(['rounds' => 4]); $provider = new EloquentUserProvider($hasher, 'foo'); $user = new GenericUser(['password' => $hasher->make('plain')]); $result = $provider->validateCredentials($user, ['password' => 'wrong']);