diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 050ac21..4ed3a5c 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -12,18 +12,13 @@ jobs: strategy: fail-fast: true matrix: - php: [8.2, 8.1, 8.0] - laravel: [10.*, 9.*] + php: [8.2, 8.1] + laravel: [10.*] os: [ubuntu-latest, windows-latest] stability: [prefer-lowest, prefer-stable] include: - laravel: 10.* testbench: 8.* - - laravel: 9.* - testbench: 7.* - exclude: - - laravel: 10.* - php: 8.0 name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} diff --git a/README.md b/README.md index f135820..ea019df 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,55 @@ You can install the package via composer: composer require protonemedia/laravel-dusk-fakes --dev ``` +### Persist Bus (queued jobs) + +Make sure you've set the `DUSK_FAKE_BUS` environment variable to `true` in the [Dusk environment](https://laravel.com/docs/9.x/dusk#environment-handling). + +Finally, add the `PersistentBus` trait to your test. You don't have to manually call the `fake()` method on the `Bus` facade. + +```php +browse(function (Browser $browser) { + $order = Order::factory()->create(); + + $browser->visit('/order/'.$order->id) + ->press('Confirm') + ->waitForText('We will generate an invoice!'); + + Bus::assertDispatched(SendOrderInvoice::class); + }); + } +} +``` + +If you only need to fake specific jobs while allowing your other jobs to execute normally, you may pass the class names of the jobs that should be faked to the `jobsToFake()` method: + +```php +Bus::jobsToFake(ShipOrder::class); + +$browser->visit(...); + +Bus::assertDispatched(SendOrderInvoice::class); +``` + ### Persist Mails Make sure you've set the `DUSK_FAKE_MAILS` environment variable to `true` in the [Dusk environment](https://laravel.com/docs/9.x/dusk#environment-handling). @@ -104,6 +153,55 @@ class PasswordResetTest extends DuskTestCase } ``` +### Persist Queue + +Make sure you've set the `DUSK_FAKE_QUEUE` environment variable to `true` in the [Dusk environment](https://laravel.com/docs/9.x/dusk#environment-handling). + +Finally, add the `PersistentQueue` trait to your test. You don't have to manually call the `fake()` method on the `Queue` facade. + +```php +browse(function (Browser $browser) { + $order = Order::factory()->create(); + + $browser->visit('/order/'.$order->id) + ->press('Confirm') + ->waitForText('We will generate an invoice!'); + + Queue::assertDispatched(SendOrderInvoice::class); + }); + } +} +``` + +If you only need to fake specific jobs while allowing your other jobs to execute normally, you may pass the class names of the jobs that should be faked to the `jobsToFake()` method: + +```php +Queue::jobsToFake(ShipOrder::class); + +$browser->visit(...); + +Queue::assertDispatched(SendOrderInvoice::class); +``` + ## Changelog Please see [CHANGELOG](CHANGELOG.md) for more information what has changed recently. diff --git a/composer.json b/composer.json index 56e52d8..74dc192 100644 --- a/composer.json +++ b/composer.json @@ -16,18 +16,16 @@ } ], "require": { - "php": "^8.0|^8.1|^8.2", - "illuminate/contracts": "^9.0|^10.0", - "laravel/dusk": "^7.0" - }, - "conflict": { - "laravel/framework": "<9.15.0" + "php": "^8.1|^8.2", + "illuminate/contracts": "^10.0", + "laravel/dusk": "^7.0", + "spatie/invade": "^1.1" }, "require-dev": { "laravel/pint": "^1.0", "nesbot/carbon": "^2.66", "nunomaduro/collision": "^6.0", - "orchestra/testbench": "^7.0|^8.0", + "orchestra/testbench": "^8.0", "pestphp/pest": "^1.21", "pestphp/pest-plugin-laravel": "^1.1", "phpunit/phpunit": "^9.5|^10.0" @@ -63,4 +61,4 @@ }, "minimum-stability": "dev", "prefer-stable": true -} +} \ No newline at end of file diff --git a/config/dusk-fakes.php b/config/dusk-fakes.php index e0f0138..e2766f7 100644 --- a/config/dusk-fakes.php +++ b/config/dusk-fakes.php @@ -1,6 +1,11 @@ [ + 'enabled' => env('DUSK_FAKE_BUS', false), + 'storage_root' => storage_path('framework/testing/bus'), + ], + 'mails' => [ 'enabled' => env('DUSK_FAKE_MAILS', false), 'storage_root' => storage_path('framework/testing/mails'), @@ -10,4 +15,9 @@ 'enabled' => env('DUSK_FAKE_NOTIFICATIONS', false), 'storage_root' => storage_path('framework/testing/notifications'), ], + + 'queue' => [ + 'enabled' => env('DUSK_FAKE_QUEUE', false), + 'storage_root' => storage_path('framework/testing/queue'), + ], ]; diff --git a/src/Bus/PersistentBus.php b/src/Bus/PersistentBus.php new file mode 100644 index 0000000..7bed2a6 --- /dev/null +++ b/src/Bus/PersistentBus.php @@ -0,0 +1,20 @@ +directory = rtrim(config('dusk-fakes.bus.storage_root'), '/'); + + $this->storage = $this->directory.'/serialized'; + + (new Filesystem)->ensureDirectoryExists($this->directory); + + $this->loadBus(); + } + + public function jobsToFake($jobsToFake = []) + { + $this->jobsToFake = Arr::wrap($jobsToFake); + + $this->storeBus(); + } + + public function cleanStorage() + { + (new Filesystem)->cleanDirectory($this->directory); + } + + public function loadBus(): self + { + $unserialized = file_exists($this->storage) + ? rescue(fn () => unserialize(file_get_contents($this->storage)), [], false) + : []; + + $this->jobsToFake = $unserialized['jobsToFake'] ?? []; + $this->commands = $unserialized['commands'] ?? []; + $this->commandsSync = $unserialized['commandsSync'] ?? []; + $this->commandsAfterResponse = $unserialized['commandsAfterResponse'] ?? []; + $this->batches = $unserialized['batches'] ?? []; + + return $this; + } + + public function dispatch($command) + { + return tap(parent::dispatch($command), fn () => $this->storeBus()); + } + + public function dispatchSync($command, $handler = null) + { + return tap(parent::dispatchSync($command, $handler), fn () => $this->storeBus()); + } + + public function dispatchNow($command, $handler = null) + { + return tap(parent::dispatchNow($command, $handler), fn () => $this->storeBus()); + } + + public function dispatchToQueue($command) + { + return tap(parent::dispatchToQueue($command), fn () => $this->storeBus()); + } + + public function dispatchAfterResponse($command) + { + return tap(parent::dispatchAfterResponse($command), fn () => $this->storeBus()); + } + + public function recordPendingBatch(PendingBatch $pendingBatch) + { + return tap(parent::recordPendingBatch($pendingBatch), fn () => $this->storeBus()); + } + + public function cleanupCommand(array $jobs): array + { + return collect($jobs)->map(function ($job) { + tap(invade($job), function ($job) { + if (! $job->job) { + return; + } + + $job = invade($job->job); + $job->container = null; + + if (! $job->instance) { + return; + } + + invade($job->instance)->container = null; + invade($job->instance)->dispatcher = null; + }); + + return $job; + })->all(); + } + + private function storeBus() + { + (new Filesystem)->ensureDirectoryExists($this->directory); + + file_put_contents($this->storage, serialize([ + 'jobsToFake' => $this->jobsToFake, + 'commands' => collect($this->commands)->map([$this, 'cleanupCommand'])->all(), + 'commandsSync' => collect($this->commandsSync)->map([$this, 'cleanupCommand'])->all(), + 'commandsAfterResponse' => collect($this->commandsAfterResponse)->map([$this, 'cleanupCommand'])->all(), + 'batches' => collect($this->batches)->each(function (PendingBatchFake $batch) { + tap(invade($batch), function ($batch) { + $batch->bus = null; + }); + + return $batch; + })->all(), + ])); + } +} diff --git a/src/Bus/UncachedPersistentBusFake.php b/src/Bus/UncachedPersistentBusFake.php new file mode 100644 index 0000000..3e7eb88 --- /dev/null +++ b/src/Bus/UncachedPersistentBusFake.php @@ -0,0 +1,41 @@ +forwardCallTo($this->fake->loadBus(), $method, $parameters); + } + + /** + * Handle dynamic static method calls into the fake. + * + * @param string $method + * @param array $parameters + * @return mixed + */ + public static function __callStatic($method, $parameters) + { + return app(static::class)->$method(...$parameters); + } +} diff --git a/src/LaravelDuskFakesServiceProvider.php b/src/LaravelDuskFakesServiceProvider.php index 38aab95..cb64b1c 100644 --- a/src/LaravelDuskFakesServiceProvider.php +++ b/src/LaravelDuskFakesServiceProvider.php @@ -2,11 +2,17 @@ namespace ProtoneMedia\LaravelDuskFakes; +use Illuminate\Contracts\Bus\QueueingDispatcher; +use Illuminate\Contracts\Queue\Queue as QueueContract; +use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Notification; +use Illuminate\Support\Facades\Queue; use Illuminate\Support\ServiceProvider; +use ProtoneMedia\LaravelDuskFakes\Bus\PersistentBusFake; use ProtoneMedia\LaravelDuskFakes\Mails\PersistentMailFake; use ProtoneMedia\LaravelDuskFakes\Notifications\PersistentNotificationFake; +use ProtoneMedia\LaravelDuskFakes\Queue\PersistentQueueFake; class LaravelDuskFakesServiceProvider extends ServiceProvider { @@ -26,8 +32,26 @@ public function boot() ], 'config'); } + $this->bootFakeBus(); $this->bootFakeMails(); $this->bootFakeNotifications(); + $this->bootFakeQueue(); + } + + private function bootFakeBus() + { + if (! config('dusk-fakes.bus.enabled')) { + return; + } + + $fake = new PersistentBusFake(app(QueueingDispatcher::class)); + + $this->app->singleton( + PersistentBusFake::class, + fn () => $fake + ); + + Bus::swap($fake); } private function bootFakeMails() @@ -61,4 +85,20 @@ private function bootFakeNotifications() Notification::swap($fake); } + + private function bootFakeQueue() + { + if (! config('dusk-fakes.queue.enabled')) { + return; + } + + $fake = new PersistentQueueFake(app(), [], app(QueueContract::class)); + + $this->app->singleton( + PersistentQueueFake::class, + fn () => $fake + ); + + Queue::swap($fake); + } } diff --git a/src/Mails/PersistentMailFake.php b/src/Mails/PersistentMailFake.php index 400b5f8..95ec819 100644 --- a/src/Mails/PersistentMailFake.php +++ b/src/Mails/PersistentMailFake.php @@ -55,6 +55,8 @@ public function queue($view, $queue = null) private function storeMails() { + (new Filesystem)->ensureDirectoryExists($this->directory); + file_put_contents($this->storage, serialize([ 'mailables' => $this->mailables, 'queuedMailables' => $this->queuedMailables, diff --git a/src/Notifications/PersistentNotificationFake.php b/src/Notifications/PersistentNotificationFake.php index da26c6f..ab73490 100644 --- a/src/Notifications/PersistentNotificationFake.php +++ b/src/Notifications/PersistentNotificationFake.php @@ -45,6 +45,8 @@ public function sendNow($notifiables, $notification, array $channels = null) private function storeNotifications() { + (new Filesystem)->ensureDirectoryExists($this->directory); + file_put_contents($this->storage, serialize($this->notifications)); } } diff --git a/src/Queue/PersistentQueue.php b/src/Queue/PersistentQueue.php new file mode 100644 index 0000000..fcd9f70 --- /dev/null +++ b/src/Queue/PersistentQueue.php @@ -0,0 +1,20 @@ +directory = rtrim(config('dusk-fakes.queue.storage_root'), '/'); + + $this->storage = $this->directory.'/serialized'; + + (new Filesystem)->ensureDirectoryExists($this->directory); + + $this->loadQueue(); + } + + public function jobsToFake($jobsToFake = []) + { + $this->jobsToFake = Collection::wrap($jobsToFake); + + $this->storeQueue(); + } + + public function cleanStorage() + { + (new Filesystem)->cleanDirectory($this->directory); + } + + public function loadQueue(): self + { + $unserialized = file_exists($this->storage) + ? rescue(fn () => unserialize(file_get_contents($this->storage)), [], false) + : []; + + $this->jobsToFake = Collection::make($unserialized['jobsToFake'] ?? []); + $this->jobsToBeQueued = Collection::make($unserialized['jobsToBeQueued'] ?? []); + $this->jobs = $unserialized['jobs'] ?? []; + + return $this; + } + + public function push($job, $data = '', $queue = null) + { + parent::push($job, $data, $queue); + + $this->storeQueue(); + } + + private function storeQueue() + { + (new Filesystem)->ensureDirectoryExists($this->directory); + + file_put_contents($this->storage, serialize([ + 'jobsToFake' => $this->jobsToFake->all(), + 'jobsToBeQueued' => $this->jobsToBeQueued->all(), + 'jobs' => $this->jobs, + ])); + } +} diff --git a/src/Queue/UncachedPersistentQueueFake.php b/src/Queue/UncachedPersistentQueueFake.php new file mode 100644 index 0000000..68082b3 --- /dev/null +++ b/src/Queue/UncachedPersistentQueueFake.php @@ -0,0 +1,41 @@ +forwardCallTo($this->fake->loadQueue(), $method, $parameters); + } + + /** + * Handle dynamic static method calls into the fake. + * + * @param string $method + * @param array $parameters + * @return mixed + */ + public static function __callStatic($method, $parameters) + { + return app(static::class)->$method(...$parameters); + } +} diff --git a/tests/AnotherDummyJob.php b/tests/AnotherDummyJob.php new file mode 100644 index 0000000..8091da0 --- /dev/null +++ b/tests/AnotherDummyJob.php @@ -0,0 +1,18 @@ +handled = true; + } +} diff --git a/tests/BusTest.php b/tests/BusTest.php new file mode 100644 index 0000000..6b92a05 --- /dev/null +++ b/tests/BusTest.php @@ -0,0 +1,149 @@ + (new Filesystem)->cleanDirectory(storage_path('framework/testing'))); + +it('can persist a queued job', function () use ($dummyTest) { + expect(Bus::getFacadeRoot())->toBeInstanceOf(PersistentBusFake::class); + + Bus::dispatch(new DummyJob); + + expect(storage_path('framework/testing/bus/serialized'))->toBeFile(); + + $dummyTest->setUpPersistentBus(); + + expect(Bus::getFacadeRoot())->toBeInstanceOf(UncachedPersistentBusFake::class); + + Bus::assertDispatched(DummyJob::class); + + unlink(storage_path('framework/testing/bus/serialized')); + + Bus::assertNotDispatched(DummyJob::class); +}); + +it('can persist a specific queued job', function () use ($dummyTest) { + expect(Bus::getFacadeRoot())->toBeInstanceOf(PersistentBusFake::class); + + Bus::jobsToFake(DummyJob::class); + + Bus::dispatch(new AnotherDummyJob); + Bus::dispatch(new DummyJob); + + expect(storage_path('framework/testing/bus/serialized'))->toBeFile(); + + $dummyTest->setUpPersistentBus(); + + expect(Bus::getFacadeRoot())->toBeInstanceOf(UncachedPersistentBusFake::class); + + Bus::assertDispatched(DummyJob::class); + Bus::assertNotDispatched(AnotherDummyJob::class); +}); + +it('can persist a queued job using the sync method', function () use ($dummyTest) { + expect(Bus::getFacadeRoot())->toBeInstanceOf(PersistentBusFake::class); + + Bus::dispatchSync(new DummyJob); + + expect(storage_path('framework/testing/bus/serialized'))->toBeFile(); + + $dummyTest->setUpPersistentBus(); + + expect(Bus::getFacadeRoot())->toBeInstanceOf(UncachedPersistentBusFake::class); + + Bus::assertDispatchedSync(DummyJob::class); + + unlink(storage_path('framework/testing/bus/serialized')); + + Bus::assertNotDispatchedSync(DummyJob::class); +}); + +it('can persist a queued job using the now method', function () use ($dummyTest) { + expect(Bus::getFacadeRoot())->toBeInstanceOf(PersistentBusFake::class); + + Bus::dispatchNow(new DummyJob); + + expect(storage_path('framework/testing/bus/serialized'))->toBeFile(); + + $dummyTest->setUpPersistentBus(); + + expect(Bus::getFacadeRoot())->toBeInstanceOf(UncachedPersistentBusFake::class); + + Bus::assertDispatched(DummyJob::class); + + unlink(storage_path('framework/testing/bus/serialized')); + + Bus::assertNotDispatched(DummyJob::class); +}); + +it('can persist a queued job using the toQueue method', function () use ($dummyTest) { + expect(Bus::getFacadeRoot())->toBeInstanceOf(PersistentBusFake::class); + + Bus::dispatchToQueue(new DummyJob); + + expect(storage_path('framework/testing/bus/serialized'))->toBeFile(); + + $dummyTest->setUpPersistentBus(); + + expect(Bus::getFacadeRoot())->toBeInstanceOf(UncachedPersistentBusFake::class); + + Bus::assertDispatched(DummyJob::class); + + unlink(storage_path('framework/testing/bus/serialized')); + + Bus::assertNotDispatched(DummyJob::class); +}); + +it('can persist a queued job using the afterResponse method', function () use ($dummyTest) { + expect(Bus::getFacadeRoot())->toBeInstanceOf(PersistentBusFake::class); + + dispatch(new DummyJob)->afterResponse(); + + expect(storage_path('framework/testing/bus/serialized'))->toBeFile(); + + $dummyTest->setUpPersistentBus(); + + expect(Bus::getFacadeRoot())->toBeInstanceOf(UncachedPersistentBusFake::class); + + Bus::assertDispatchedAfterResponse(DummyJob::class); + + unlink(storage_path('framework/testing/bus/serialized')); + + Bus::assertNotDispatchedAfterResponse(DummyJob::class); +}); + +it('can persist a queued batch', function () use ($dummyTest) { + expect(Bus::getFacadeRoot())->toBeInstanceOf(PersistentBusFake::class); + + Bus::batch([ + new DummyJob, + new DummyJob, + ])->dispatch(); + + expect(storage_path('framework/testing/bus/serialized'))->toBeFile(); + + $dummyTest->setUpPersistentBus(); + + expect(Bus::getFacadeRoot())->toBeInstanceOf(UncachedPersistentBusFake::class); + + Bus::assertBatchCount(1); + Bus::assertBatched(function (PendingBatchFake $batch) { + return $batch->jobs->count() === 2; + }); + + unlink(storage_path('framework/testing/bus/serialized')); + + Bus::assertBatchCount(0); +}); diff --git a/tests/DummyJob.php b/tests/DummyJob.php new file mode 100644 index 0000000..a581d74 --- /dev/null +++ b/tests/DummyJob.php @@ -0,0 +1,13 @@ +not->toBeFile(); +afterEach(fn () => (new Filesystem)->cleanDirectory(storage_path('framework/testing'))); +it('can persist sent mails', function () use ($dummyTest) { expect(Mail::getFacadeRoot())->toBeInstanceOf(PersistentMailFake::class); Mail::to('test@example.com')->send(new DummyMail); @@ -33,11 +34,7 @@ Mail::assertNothingSent(); }); -afterEach(fn () => $dummyTest->tearDownPersistentMails()); - it('can persist queued mails', function () use ($dummyTest) { - expect(storage_path('framework/testing/mails/0'))->not->toBeFile(); - expect(Mail::getFacadeRoot())->toBeInstanceOf(PersistentMailFake::class); Mail::to('test@example.com')->queue(new DummyMail); diff --git a/tests/NotificationTest.php b/tests/NotificationTest.php index be2b39a..a887501 100644 --- a/tests/NotificationTest.php +++ b/tests/NotificationTest.php @@ -1,5 +1,6 @@ not->toBeFile(); +afterEach(fn () => (new Filesystem)->cleanDirectory(storage_path('framework/testing'))); +it('can persist sent notifications', function () use ($dummyTest) { expect(Notification::getFacadeRoot())->toBeInstanceOf(PersistentNotificationFake::class); $user = (new DummyUser)->forceFill(['id' => 1]); @@ -33,5 +34,3 @@ Notification::assertNothingSent(); }); - -afterEach(fn () => $dummyTest->tearDownPersistentNotifications()); diff --git a/tests/QueueTest.php b/tests/QueueTest.php new file mode 100644 index 0000000..b74a41d --- /dev/null +++ b/tests/QueueTest.php @@ -0,0 +1,54 @@ + (new Filesystem)->cleanDirectory(storage_path('framework/testing'))); + +it('can persist a queued job', function () use ($dummyTest) { + expect(Queue::getFacadeRoot())->toBeInstanceOf(PersistentQueueFake::class); + + Queue::push(new DummyJob); + + expect(storage_path('framework/testing/queue/serialized'))->toBeFile(); + + $dummyTest->setUpPersistentQueue(); + + expect(Queue::getFacadeRoot())->toBeInstanceOf(UncachedPersistentQueueFake::class); + + Queue::assertPushed(DummyJob::class); + + unlink(storage_path('framework/testing/queue/serialized')); + + Queue::assertNotPushed(DummyJob::class); +}); + +it('can persist a specific queued job', function () use ($dummyTest) { + expect(Queue::getFacadeRoot())->toBeInstanceOf(PersistentQueueFake::class); + + Queue::jobsToFake(DummyJob::class); + + Queue::push(new AnotherDummyJob); + Queue::push(new DummyJob); + + expect(storage_path('framework/testing/queue/serialized'))->toBeFile(); + + $dummyTest->setUpPersistentQueue(); + + expect(Queue::getFacadeRoot())->toBeInstanceOf(UncachedPersistentQueueFake::class); + + Queue::assertPushed(DummyJob::class); + Queue::assertNotPushed(AnotherDummyJob::class); + + unlink(storage_path('framework/testing/queue/serialized')); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index b42bbec..46e8b1a 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -16,7 +16,9 @@ protected function getPackageProviders($app) public function getEnvironmentSetUp($app) { + config()->set('dusk-fakes.bus.enabled', true); config()->set('dusk-fakes.mails.enabled', true); config()->set('dusk-fakes.notifications.enabled', true); + config()->set('dusk-fakes.queue.enabled', true); } }