diff --git a/assets/config.php b/assets/config.php index 2a3a07e2c..b20010919 100644 --- a/assets/config.php +++ b/assets/config.php @@ -185,6 +185,7 @@ // Bootstrappers\RootUrlBootstrapper::class, // Bootstrappers\UrlGeneratorBootstrapper::class, // Bootstrappers\MailConfigBootstrapper::class, + // Bootstrappers\LogChannelBootstrapper::class, // Bootstrappers\BroadcastingConfigBootstrapper::class, // Bootstrappers\BroadcastChannelPrefixBootstrapper::class, diff --git a/src/Bootstrappers/FilesystemTenancyBootstrapper.php b/src/Bootstrappers/FilesystemTenancyBootstrapper.php index 560916006..0a864fbd5 100644 --- a/src/Bootstrappers/FilesystemTenancyBootstrapper.php +++ b/src/Bootstrappers/FilesystemTenancyBootstrapper.php @@ -255,4 +255,10 @@ public function scopeSessions(string|false $suffix): void $sessionManager->getDrivers()['file']->setHandler($handler); } } + + /** Get the central storage path from the bound singleton instance of this class. */ + public static function getBoundCentralStoragePath(): string + { + return app(static::class)->originalStoragePath; + } } diff --git a/src/Bootstrappers/LogChannelBootstrapper.php b/src/Bootstrappers/LogChannelBootstrapper.php new file mode 100644 index 000000000..293028e30 --- /dev/null +++ b/src/Bootstrappers/LogChannelBootstrapper.php @@ -0,0 +1,215 @@ + ['url' => 'webhookUrl']] + * - this maps $tenant->webhookUrl to slack.url (if $tenant->webhookUrl is null, the override is ignored) + * - Closure: ['slack' => fn (Tenant $tenant, array $channel) => array_merge($channel, ['url' => $tenant->slackUrl])] + * - this manually merges ['url' => $tenant->slackUrl] into the channel's config + * - null is not ignored, the closure controls the override fully + * + * So the channel overrides can be arrays and closures that return arrays. + */ + public static array $channelOverrides = []; + + public function __construct( + protected Repository $config, + protected LogManager $logManager, + ) {} + + public function bootstrap(Tenant $tenant): void + { + $this->defaultConfig = $this->config->get('logging.channels'); + $this->configuredChannels = $this->getChannels(); + + try { + $this->configureChannels($this->configuredChannels, $tenant); + $this->forgetChannels($this->configuredChannels); + } catch (\Throwable $exception) { + // If an exception is thrown while updating the logging config, the logging config + // could be left in a corrupt state, so we revert to the original config to + // to avoid logging the exception in a tenant channel or a broken channel. + $this->revert(); + + // We re-throw the exception after having reverted the logging config to central. + throw $exception; + } + } + + public function revert(): void + { + $this->config->set('logging.channels', $this->defaultConfig); + + $this->forgetChannels($this->configuredChannels); + } + + /** + * Channels to configure and forget from the log manager so they can be + * re-resolved with the new, tenant-specific config on the next use. + * + * Includes: + * - all channels in the $storagePathChannels array + * - all channels that have custom overrides in the $channelOverrides property + * - any 'stack' channel that includes one of the above as a member + * + * Stack channels are included because once a stack has been used, it keeps logging + * to wherever its members pointed to at that moment. So a stack used in the central + * context would keep writing to the central logs, even after tenancy is initialized + * and its member channels are configured for the tenant. + * Forgetting the stack forces it to be re-resolved with its members' updated (tenant) + * config. + * + * Importantly, stacks are only inspected one level deep - they are not traversed recursively. + */ + protected function getChannels(): array + { + $configuredChannels = array_unique([ + ...static::$storagePathChannels, + ...array_keys(static::$channelOverrides), + ]); + + $stackChannels = []; + + foreach ($this->config->get('logging.channels') as $channel => $config) { + // Include stack channels that have at least one configured channel as a member + if (($config['driver'] ?? null) === 'stack' && array_intersect($config['channels'] ?? [], $configuredChannels)) { + $stackChannels[] = $channel; + } + } + + return array_filter( + array_unique([...$configuredChannels, ...$stackChannels]), + fn (string $channel): bool => $this->config->has("logging.channels.{$channel}") + ); + } + + /** + * Configure channels for the tenant context. + * + * This handles both $storagePathChannels and $channelOverrides. + */ + protected function configureChannels(array $channels, Tenant $tenant): void + { + foreach ($channels as $channel) { + if (isset(static::$channelOverrides[$channel])) { + $this->overrideChannelConfig($channel, static::$channelOverrides[$channel], $tenant); + } elseif (in_array($channel, static::$storagePathChannels)) { + // Set storage path channels to use a tenant-specific directory. + // The tenant log will be located at e.g. "storage/tenant{$tenantKey}/logs/laravel.log". + $originalChannelPath = $this->config->get("logging.channels.{$channel}.path"); + $centralStoragePath = FilesystemTenancyBootstrapper::getBoundCentralStoragePath(); + + // The tenant log will inherit the segment that follows the storage path from the central channel path config. + // For example, if a channel's path is configured to storage_path('logs/foo.log') (storage/logs/foo.log), + // the 'logs/foo.log' segment will be passed to storage_path() in the tenant context (storage/tenant123/logs/foo.log). + $this->config->set("logging.channels.{$channel}.path", storage_path(Str::after($originalChannelPath, $centralStoragePath))); + } + } + } + + /** + * Update channel configurations per $channelOverrides. + * + * For overrides set in array format, update individual keys of the channel. + * - This ignores cases where the value of the respective tenant attribute is null. + * For overrides set as closures, replace the entire channel with the returned config override. + * - This does not ignore cases where parts of the config may be null - the closure fully controls the override. + */ + protected function overrideChannelConfig(string $channel, array|Closure $override, Tenant $tenant): void + { + if (is_array($override)) { + // Map tenant attributes to channel config keys. + foreach ($override as $configKey => $tenantAttributeName) { + /** @var Tenant&Model $tenant */ + $tenantAttribute = data_get($tenant, $tenantAttributeName); + + // If the tenant attribute is null, the override is ignored + // and the channel config key's value remains unchanged. + if ($tenantAttribute !== null) { + $this->config->set("logging.channels.{$channel}.{$configKey}", $tenantAttribute); + } + } + } elseif ($override instanceof Closure) { + $channelConfigKey = "logging.channels.{$channel}"; + + $result = $override($tenant, $this->config->get($channelConfigKey)); + + if (! is_array($result)) { + throw new InvalidArgumentException("Channel override closure for '{$channel}' must return an array."); + } + + $this->config->set($channelConfigKey, $result); + } + } + + /** + * Forget all passed channels from the log manager so that they can be + * re-resolved with the updated config on the next logging attempt. + */ + protected function forgetChannels(array $channels): void + { + foreach ($channels as $channel) { + $this->logManager->forgetChannel($channel); + } + } +} diff --git a/tests/Bootstrappers/LogChannelBootstrapperTest.php b/tests/Bootstrappers/LogChannelBootstrapperTest.php new file mode 100644 index 000000000..c37e7b54c --- /dev/null +++ b/tests/Bootstrappers/LogChannelBootstrapperTest.php @@ -0,0 +1,522 @@ + [ + LogChannelBootstrapper::class, + ], + ]); + + $cleanup(); + + Event::listen(TenancyInitialized::class, BootstrapTenancy::class); + Event::listen(TenancyEnded::class, RevertToCentralContext::class); +}); + +test('storage path channels get tenant-specific paths by default', function () { + // Note that for LogChannelBootstrapper to change the paths correctly by default, + // the bootstrapper MUST run after FilesystemTenancyBootstrapper. + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + LogChannelBootstrapper::class, + ], + ]); + + $centralStoragePath = storage_path(); + $tenant = Tenant::create(); + + // Storage path channels are 'single' and 'daily' by default. + // This can be customized via LogChannelBootstrapper::$storagePathChannels. + foreach (LogChannelBootstrapper::$storagePathChannels as $channel) { + $originalPath = config("logging.channels.{$channel}.path"); + + tenancy()->initialize($tenant); + + // Path should now point to the log in the tenant's storage directory + $tenantLogPath = "{$centralStoragePath}/tenant{$tenant->id}/logs/laravel.log"; + expect(config("logging.channels.{$channel}.path")) + ->not()->toBe($originalPath) + ->toBe($tenantLogPath); + + tenancy()->end(); + + // Path should be reverted + expect(config("logging.channels.{$channel}.path"))->toBe($originalPath); + } +}); + +test('all channels included in a stack get processed correctly', function () { + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + LogChannelBootstrapper::class, + ], + 'logging.channels.stack' => [ + 'driver' => 'stack', + 'channels' => ['single', 'daily'], + ], + ]); + + $centralStoragePath = storage_path(); + $centralLogPath = $centralStoragePath . '/logs/laravel.log'; + $originalSinglePath = config('logging.channels.single.path'); + $originalDailyPath = config('logging.channels.daily.path'); + + // By default, both paths are the same in the config. + // Note that in actual usage, the daily log file name is parsed differently from the path in the config, + // e.g. if daily channel has 'path' => storage_path('logs/laravel.log') in config, the log will be + // located at storage_path('logs/laravel-2026-01-01.log'). But the paths *in the config* are the same. + expect($centralLogPath) + ->toBe($centralStoragePath . '/logs/laravel.log') + ->toBe($originalSinglePath) + ->toBe($originalDailyPath); + + $tenant = Tenant::create(); + + tenancy()->initialize($tenant); + + // Both channels in the stack are updated correctly + expect("{$centralStoragePath}/tenant{$tenant->id}/logs/laravel.log") + ->not()->toBe($originalSinglePath) + ->not()->toBe($originalDailyPath) + ->toBe(config('logging.channels.single.path')) + ->toBe(config('logging.channels.daily.path')); + + tenancy()->end(); + + expect(config('logging.channels.single.path'))->toBe($originalSinglePath); + expect(config('logging.channels.daily.path'))->toBe($originalDailyPath); +}); + +test('channel overrides work correctly with both arrays and closures', function () { + config([ + 'logging.channels.stack.channels' => ['slack', 'single'], + 'logging.channels.slack' => [ + 'url' => $originalSlackUrl = 'default-webhook', + 'username' => 'Default', + ], + ]); + + $centralStoragePath = storage_path(); + $originalSinglePath = config('logging.channels.single.path'); + + $tenant = Tenant::create(['webhookUrl' => 'tenant-webhook']); + + // Channel override closures must return an array, otherwise an exception is thrown + LogChannelBootstrapper::$channelOverrides['slack'] = fn (Tenant $tenant, array $channel) => 'invalid override'; + + expect(fn() => tenancy()->initialize($tenant))->toThrow(InvalidArgumentException::class); + + // Test both array mapping and closure-based overrides + LogChannelBootstrapper::$channelOverrides = [ + 'slack' => ['url' => 'webhookUrl'], // slack.url will be mapped to $tenant->webhookUrl + 'single' => function (Tenant $tenant, array $channel) use ($centralStoragePath) { + return array_merge($channel, ['path' => $centralStoragePath . "/logs/override-{$tenant->id}.log"]); + }, + ]; + + // Reinitialize tenancy to apply the new overrides + tenancy()->reinitialize(); + + // Array mapping overrides work + expect(config('logging.channels.slack.url'))->toBe($tenant->webhookUrl); + expect(config('logging.channels.slack.username'))->toBe('Default'); // Default username, remains default unless overridden + + // Closure overrides work + expect(config('logging.channels.single.path'))->toBe("{$centralStoragePath}/logs/override-{$tenant->id}.log"); + + tenancy()->end(); + + // After tenancy ends, the original config should be restored + expect(config('logging.channels.slack.url'))->toBe($originalSlackUrl); + expect(config('logging.channels.single.path'))->toBe($originalSinglePath); + expect(config('logging.channels.slack.username'))->toBe('Default'); // Unchanged +}); + +test('channel config keys remain unchanged if the specified tenant override attribute is null', function() { + config(['logging.channels.slack.username' => 'Default username']); + + LogChannelBootstrapper::$channelOverrides = [ + 'slack' => ['username' => 'nonExistentAttribute'], // $tenant->nonExistentAttribute + ]; + + tenancy()->initialize(Tenant::create()); + + // The username should remain unchanged since the tenant attribute is null + expect(config('logging.channels.slack.username'))->toBe('Default username'); +}); + +test('channel overrides take precedence over the default storage path channel updating logic', function () { + $tenant = Tenant::create(['id' => 'tenant1']); + + LogChannelBootstrapper::$storagePathChannels = ['single']; + + LogChannelBootstrapper::$channelOverrides = [ + 'single' => function (Tenant $tenant, array $channel) { + return array_merge($channel, ['path' => storage_path("logs/override-{$tenant->id}.log")]); + }, + ]; + + tenancy()->initialize($tenant); + + // Should use channel override, not the storage path updating behavior + expect(config('logging.channels.single.path'))->toEndWith('storage/logs/override-tenant1.log'); +}); + +test('channels are forgotten and re-resolved during bootstrap and revert', function () { + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + LogChannelBootstrapper::class, + ], + ]); + + $logManager = app('log'); + $originalChannel = $logManager->channel('single'); + $originalSinglePath = config('logging.channels.single.path'); + + $tenant = Tenant::create(); + + tenancy()->initialize($tenant); + + // After bootstrap, the channel should be a new instance with the updated config + $tenantChannel = $logManager->channel('single'); + $tenantSingleChannelPath = $tenantChannel->getLogger()->getHandlers()[0]->getUrl(); + + expect($tenantChannel)->not()->toBe($originalChannel); + expect($tenantSingleChannelPath) + ->not()->toBe($originalSinglePath) + ->toEndWith("storage/tenant{$tenant->id}/logs/laravel.log"); + + tenancy()->end(); + + // After revert, the channel should get re-resolved with the original config + $currentChannel = $logManager->channel('single'); + $currentChannelPath = $currentChannel->getLogger()->getHandlers()[0]->getUrl(); + + expect($currentChannel)->not()->toBe($tenantChannel); + expect($currentChannelPath)->toBe($originalSinglePath); +}); + +// Test real usage +test('logs are written to tenant-specific files and do not leak between contexts', function () { + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + LogChannelBootstrapper::class, + ], + ]); + + $centralLogPath = storage_path('logs/laravel.log'); + + Log::channel('single')->info('central'); + + expect(file_get_contents($centralLogPath))->toContain('central'); + + [$tenant1, $tenant2] = [Tenant::create(['id' => 'tenant1']), Tenant::create(['id' => 'tenant2'])]; + + tenancy()->runForMultiple([$tenant1, $tenant2], function (Tenant $tenant) use ($centralLogPath) { + Log::channel('single')->info($tenant->id); + + $tenantLogPath = storage_path('logs/laravel.log'); + + // The log gets saved to the tenant's storage directory (default behavior) + expect($tenantLogPath) + ->not()->toBe($centralLogPath) + ->toEndWith("storage/tenant{$tenant->id}/logs/laravel.log"); + + expect(file_get_contents($tenantLogPath)) + ->toContain($tenant->id) + ->not()->toContain('central'); + }); + + // Tenant log messages didn't leak into central log + expect(file_get_contents($centralLogPath)) + ->toContain('central') + ->not()->toContain('tenant1') + ->not()->toContain('tenant2'); + + // Tenant log messages didn't leak to logs of other tenants + tenancy()->initialize($tenant1); + + expect(file_get_contents(storage_path('logs/laravel.log'))) + ->toContain('tenant1') + ->not()->toContain('central') + ->not()->toContain('tenant2'); + + tenancy()->initialize($tenant2); + + expect(file_get_contents(storage_path('logs/laravel.log'))) + ->toContain('tenant2') + ->not()->toContain('central') + ->not()->toContain('tenant1'); + + // Overriding the channels also works + // Channel overrides also override the default behavior for the storage path-based channels + $tenant = Tenant::create(['id' => 'override-tenant']); + + LogChannelBootstrapper::$channelOverrides = [ + 'single' => function (Tenant $tenant, array $channel) { + // The tenant log path will be set to storage/tenantoverride-tenant/logs/custom-override-tenant.log + return array_merge($channel, ['path' => storage_path("logs/custom-{$tenant->id}.log")]); + }, + ]; + + // Tenant context log (should use custom path due to override) + tenancy()->initialize($tenant); + + Log::channel('single')->info('tenant-override'); + + expect(file_get_contents(storage_path('logs/custom-override-tenant.log')))->toContain('tenant-override'); +}); + +test('stack logs are written to all configured channels with tenant-specific paths', function () { + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + LogChannelBootstrapper::class, + ], + 'logging.channels.stack' => [ + 'driver' => 'stack', + 'channels' => ['single', 'daily'], + ], + ]); + + $tenant = Tenant::create(['id' => 'stack-tenant']); + $today = now()->format('Y-m-d'); + + // Central context stack log + Log::channel('stack')->info('central'); + $centralSingleLogPath = storage_path('logs/laravel.log'); + + // The single and daily channels have the same path in the config, but the daily driver parses the file name so that the date is included in the file name + $centralDailyLogPath = storage_path("logs/laravel-{$today}.log"); + + expect(file_get_contents($centralSingleLogPath))->toContain('central'); + expect(file_get_contents($centralDailyLogPath))->toContain('central'); + + // Tenant context stack log + tenancy()->initialize($tenant); + Log::channel('stack')->info('tenant'); + $tenantSingleLogPath = storage_path('logs/laravel.log'); + $tenantDailyLogPath = storage_path("logs/laravel-{$today}.log"); + + expect(file_get_contents($tenantSingleLogPath))->toContain('tenant'); + expect(file_get_contents($tenantDailyLogPath))->toContain('tenant'); + + // Verify tenant logs don't contain central messages + expect(file_get_contents($tenantSingleLogPath))->not()->toContain('central'); + expect(file_get_contents($tenantDailyLogPath))->not()->toContain('central'); + + tenancy()->end(); + + // Verify central logs still only contain the central messages + expect(file_get_contents($centralSingleLogPath)) + ->toContain('central') + ->not()->toContain('tenant'); + + expect(file_get_contents($centralDailyLogPath)) + ->toContain('central') + ->not()->toContain('tenant'); +}); + +test('stack channels that include any configured channel are re-resolved', function () { + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + LogChannelBootstrapper::class, + ], + 'logging.channels.custom_stack' => [ + 'driver' => 'stack', + 'channels' => ['single'], + ], + ]); + + $tenant = Tenant::create(['id' => 'stack-tenant']); + $centralLogPath = storage_path('logs/laravel.log'); + + $logManager = app('log'); + + // Resolve the stack channel in the central context first + // (this caches the stack with its members still pointing at the central logs). + $originalStackChannel = $logManager->channel('custom_stack'); + $originalStackChannel->info('central log message'); + expect(file_get_contents($centralLogPath))->toContain('central log message'); + + tenancy()->initialize($tenant); + + // The stack channel should have been re-resolved with the + // updated (tenant) config for its member channels + $tenantStackChannel = $logManager->channel('custom_stack'); + expect($tenantStackChannel)->not()->toBe($originalStackChannel); + + $tenantStackChannel->info('tenant log message'); + + // so 'tenant log message' should be logged to the tenant log, + // not the central log. + expect(file_get_contents($centralLogPath)) + ->toContain('central log message') + ->not()->toContain('tenant log message'); + + $tenantLogPath = storage_path('logs/laravel.log'); + expect(file_exists($tenantLogPath))->toBeTrue(); + expect(file_get_contents($tenantLogPath)) + ->toContain('tenant log message'); + + tenancy()->end(); + + // After revert, the stack channel should get re-resolved again with the original config + $currentStackChannel = $logManager->channel('custom_stack'); + expect($currentStackChannel)->not()->toBe($tenantStackChannel); + + $currentStackChannel->info('central after revert'); + + expect(file_get_contents($centralLogPath)) + ->toContain('central log message') + ->toContain('central after revert') + ->not()->toContain('tenant log message'); + + expect(file_get_contents($tenantLogPath)) + ->toContain('tenant log message') + ->not()->toContain('central after revert'); +}); + +test('slack channel uses correct webhook urls', function () { + config([ + 'logging.channels.slack.url' => 'central-webhook', + 'logging.channels.slack.level' => 'debug', // Set level to debug to keep the tests simple, since the default level here is 'critical' + ]); + + $assertWebhook = function (string $expectedWebhook, string $message): void { + $thrown = false; + + // Because the Slack channel uses cURL to send messages, we cannot use Http::fake() here. + // Instead, we catch the exception and check the error message which contains the actual webhook URL + // (logging always throws "Curl error (code 6): Could not resolve host: {webhook_url}"). + try { + Log::channel('slack')->info($message); + } catch (Exception $e) { + $thrown = true; + expect($e->getMessage())->toContain($expectedWebhook); + } + + expect($thrown)->toBeTrue(); + }; + + $tenant1 = Tenant::create(['id' => 'tenant1', 'logging' => ['slackUrl' => 'tenant1-webhook']]); + $tenant2 = Tenant::create(['id' => 'tenant2', 'logging' => ['slackUrl' => 'tenant2-webhook']]); + + // Attribute mapping using nested attributes (dot notation) works + LogChannelBootstrapper::$channelOverrides = [ + 'slack' => ['url' => 'logging.slackUrl'], + ]; + + // Test central context - should attempt to use central webhook + $assertWebhook('central-webhook', 'central'); + + // Slack channel should attempt to use the tenant-specific webhooks + tenancy()->runForMultiple([$tenant1, $tenant2], function (Tenant $tenant) use ($assertWebhook) { + $assertWebhook($tenant->logging['slackUrl'], $tenant->id); + }); + + // Central context, central webhook should be used again + $assertWebhook('central-webhook', 'central'); +}); + +test('tenant logs inherit the path from the central log path config', function () { + config([ + 'tenancy.bootstrappers' => [ + FilesystemTenancyBootstrapper::class, + LogChannelBootstrapper::class, + ], + 'logging.channels.stack' => [ + 'driver' => 'stack', + 'channels' => ['single', 'daily'], + ], + 'logging.channels.single.path' => storage_path('logs/single/custom-name.log'), + 'logging.channels.daily.path' => storage_path('logs/daily/custom-name.log'), + ]); + + $tenant = Tenant::create(); + $today = now()->format('Y-m-d'); + + Log::channel('stack')->info('central'); + + expect(file_get_contents(storage_path('logs/single/custom-name.log')))->toContain('central'); + expect(file_get_contents(storage_path("logs/daily/custom-name-{$today}.log")))->toContain('central'); + + tenancy()->initialize($tenant); + + // Tenant log is located at storage/tenantX/logs/custom-name.log + Log::channel('stack')->info($tenant->id); + + // The filename from the central config is preserved in tenant context + expect(config('logging.channels.single.path'))->toEndWith('logs/single/custom-name.log'); + expect(config('logging.channels.daily.path'))->toEndWith('logs/daily/custom-name.log'); + + expect(file_get_contents(storage_path('logs/single/custom-name.log'))) + ->toContain($tenant->id) + ->not()->toContain('central'); + + expect(file_get_contents(storage_path("logs/daily/custom-name-{$today}.log"))) + ->toContain($tenant->id) + ->not()->toContain('central'); +}); + +test('logging config is reverted to the original state if configuration fails', function() { + config([ + 'logging.channels.slack.url' => $originalSlackUrl = 'default-webhook', + 'logging.channels.single.path' => $originalSinglePath = storage_path('logs/default-single-path.log'), + ]); + + $tenant = Tenant::create(['loggingPath' => storage_path('logs/tenant-single-path.log')]); + + // Valid override first, the config will be updated properly, + // then an invalid override that will cause the configuration to fail and throw an exception. + LogChannelBootstrapper::$channelOverrides = [ + 'single' => ['path' => 'loggingPath'], // Valid override + 'slack' => fn () => 'invalid override', + ]; + + expect(fn() => tenancy()->initialize($tenant))->toThrow(InvalidArgumentException::class); + + // Single channel config reverted to original state after the exception was thrown + expect(config('logging.channels.single.path'))->toBe($originalSinglePath); + + // Exception thrown before slack config got changed + expect(config('logging.channels.slack.url'))->toBe($originalSlackUrl); + + // The single channel uses the original path for logging + Log::channel('single')->info('bootstrap failed'); + expect(file_exists($originalSinglePath))->toBeTrue(); + expect(file_get_contents($originalSinglePath))->toContain('bootstrap failed'); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index eb58a0d9c..e59d491a0 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -23,10 +23,12 @@ use Stancl\Tenancy\Bootstrappers\BroadcastingConfigBootstrapper; use Stancl\Tenancy\Bootstrappers\BroadcastChannelPrefixBootstrapper; use Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper; -use function Stancl\Tenancy\Tests\pest; +use Stancl\Tenancy\Bootstrappers\LogChannelBootstrapper; use Stancl\Tenancy\Bootstrappers\DatabaseCacheBootstrapper; use Stancl\Tenancy\Bootstrappers\TenantConfigBootstrapper; +use function Stancl\Tenancy\Tests\pest; + abstract class TestCase extends \Orchestra\Testbench\TestCase { /** @@ -191,6 +193,7 @@ protected function getEnvironmentSetUp($app) $app->singleton(RootUrlBootstrapper::class); $app->singleton(UrlGeneratorBootstrapper::class); $app->singleton(FilesystemTenancyBootstrapper::class); + $app->singleton(LogChannelBootstrapper::class); $app->singleton(TenantConfigBootstrapper::class); }