diff --git a/CHANGELOG.md b/CHANGELOG.md index bc797c4715..a2f72fdac2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,30 @@ All notable changes to this project will be documented in this file. -## [2.12.0](https://github.com/tempestphp/tempest-framework/compare/v2.11.0..2.12.0) — 2025-11-28 +## [2.13.0](https://github.com/tempestphp/tempest-framework/compare/v2.12.0..2.13.0) — 2025-12-04 + +### 🚀 Features + +- **auth**: add OAuth installer (#1674) ([9c82b71](https://github.com/tempestphp/tempest-framework/commit/9c82b715b448633a704591e9b78823da28debc98)) +- **cache**: make `assertLocked` ensure that the checked lock has an expiration (#1758) ([1a2e8fb](https://github.com/tempestphp/tempest-framework/commit/1a2e8fbe90259d2bd5a8a0876d4b8fed35c5dcd7)) +- **container**: make all container properties publicly readable (#1785) ([be93ec1](https://github.com/tempestphp/tempest-framework/commit/be93ec1388ec7d253637705d4335d13a78a39f00)) +- **database**: add support for self-referencing relations (#1745) ([df2dcdc](https://github.com/tempestphp/tempest-framework/commit/df2dcdc231384d2dd359f8b621f0ae1f31a3e703)) +- **http**: add support to mark Request properties as #[SensitiveField] (#1746) ([0000c99](https://github.com/tempestphp/tempest-framework/commit/0000c99251b31d0bfa84389fb101be1560d916c3)) + +### 🐛 Bug fixes + +- **auth**: correctly map user in GitHub OAuth provider (#1751) ([ad2182a](https://github.com/tempestphp/tempest-framework/commit/ad2182ac40684b78752e7f7511228688f5093c1a)) +- **auth**: pass scopes/options to auth URL builder (#1750) ([cbe54d7](https://github.com/tempestphp/tempest-framework/commit/cbe54d7f3f7e137fe43e9ad7f8837bd2f7103e9a)) +- **auth**: update outdated authenticatable import (#1752) ([5c68b96](https://github.com/tempestphp/tempest-framework/commit/5c68b968763229dfb5a78c01a80df3b1b134e6c0)) +- **cache**: support enum tags (#1756) ([678b695](https://github.com/tempestphp/tempest-framework/commit/678b69582e526e25ff545c346179bda9636f1415)) +- **cache**: add descriptions to `cache:clear` arguments (#1755) ([e324f6e](https://github.com/tempestphp/tempest-framework/commit/e324f6e767b50acd6e76e8310be12422b85e782b)) +- **command-bus**: extract uuid from pending commands when not provided (#1761) ([b787c16](https://github.com/tempestphp/tempest-framework/commit/b787c16e57f60de3bd7883944561f02fce3a661a)) +- **console**: properly normalize boolean flag names (#1762) ([c6e6867](https://github.com/tempestphp/tempest-framework/commit/c6e6867ede678b9798386bab12e1e2afaef91bc8)) +- **core**: gracefully handle missing seeders when using `db:seed` (#1759) ([450ca75](https://github.com/tempestphp/tempest-framework/commit/450ca7576c6e5a8f4f5719dd27e7d4d4a29954c9)) +- **process**: properly return exit code if missing (#1776) ([9ad1587](https://github.com/tempestphp/tempest-framework/commit/9ad158747a810db490aef43a7a6c1bcfe062d900)) + + +## [2.12.0](https://github.com/tempestphp/tempest-framework/compare/v2.11.0..v2.12.0) — 2025-11-28 ### 🚀 Features @@ -798,7 +821,7 @@ All notable changes to this project will be documented in this file. - rector (#680) ([7fdff1d](https://github.com/tempestphp/tempest-framework/commit/7fdff1d7be48ab91fb35e1a07434ae54ef47781c)) -## [1.0.0-alpha.3](https://github.com/tempestphp/tempest-framework/compare/v1.0.0-alpha.2..v1.0.0-alpha.3) — 2024-10-30 +## [1.0.0-alpha.3](https://github.com/tempestphp/tempest-framework/compare/v1.0.0-alpha.2..v1.0.0-alpha.3) — 2024-10-31 ### 🚨 Breaking changes diff --git a/composer.json b/composer.json index fef33c3c10..411d6500d0 100644 --- a/composer.json +++ b/composer.json @@ -49,6 +49,7 @@ "adam-paterson/oauth2-slack": "^1.1", "aws/aws-sdk-php": "^3.338.0", "azure-oss/storage-blob-flysystem": "^1.2", + "brianium/paratest": "^7.14", "carthage-software/mago": "1.0.0-beta.28", "depotwarehouse/oauth2-twitch": "^1.3", "guzzlehttp/psr7": "^2.6.1", @@ -87,8 +88,7 @@ "tempest/blade": "dev-main", "thenetworg/oauth2-azure": "^2.2", "twig/twig": "^3.16", - "wohali/oauth2-discord-new": "^1.2", - "brianium/paratest": "^7.14" + "wohali/oauth2-discord-new": "^1.2" }, "replace": { "tempest/auth": "self.version", diff --git a/docs/2-features/09-logging.md b/docs/2-features/09-logging.md index 0b98496ca3..26be2f9d6e 100644 --- a/docs/2-features/09-logging.md +++ b/docs/2-features/09-logging.md @@ -1,96 +1,196 @@ --- title: Logging +description: "Learn how to use Tempest's logging features to monitor and debug your application." --- -Logging is an essential part of any developer's job. Whether it's for debugging or for production monitoring. Tempest has a powerful set of tools to help you access the relevant information you need. +## Overview -## Debug log +Tempest provides a logging implementation built on top of [Monolog](https://github.com/Seldaek/monolog) that follows PSR-3 and the [RFC 5424 specification](https://datatracker.ietf.org/doc/html/rfc5424). This gives you access to eight standard log levels and the ability to send log messages to multiple destinations simultaneously. -First up are Tempest's debug functions: `ld()` (log, die), `lw()` (log, write), and `ll()` (log, log). These three functions are similar to Symfony's var dumper and Laravel's `dd()`, although there's an important difference. +The system supports file logging, Slack integration, system logs, and custom channels. You can configure different loggers for different parts of your application using Tempest's [tagged singletons](../1-essentials/05-container.md#tagged-singletons) feature. -You can think of `ld()` or `lw()` as Laravel's `dd()` and `dump()` variants. In fact, Tempest uses Symfony's var-dumper under the hood, just like Laravel. Furthermore, if you haven't installed Tempest in a project that already includes Laravel, Tempest will also provide `dd()` and `dump()` as aliases to `ld()` and `lw()`. +## Writing logs -The main difference is that Tempest's debug functions will **also write to the debug log**, which can be tailed with tempest's built-in `tail` command. This is its default output: +To start logging messsages, you may inject the {b`Tempest\Log\Logger`} interface in any class. By default, log messages will be written to a daily rotating log file stored in `.tempest/logs`. This may be customized by providing a different [logging configuration](#configuration). -```console -./tempest tail +```php app/Services/UserService.php +use Tempest\Log\Logger; -

Project

Listening at /Users/brent/Dev/tempest-docs/log/tempest.log -

Server

No server log configured in LogConfig -

Debug

Listening at /Users/brent/Dev/tempest-docs/log/debug.log +final readonly class UserService +{ + public function __construct( + private Logger $logger, + ) {} +} ``` -Wherever you call `ld()` or `lw()` from, the output will also be written to the debug log, and tailed automatically with the `./tempest tail` command. On top of that, `tail` also monitors two other logs: +Tempest supports all eight levels described in the [RFC 5424](https://tools.ietf.org/html/rfc5424) specification. It is possible to configure channels to only log messages at or above a certain level. -- The **project log**, which contains everything the default logger writes to -- The **server log**, which should be manually configured in `LogConfig`: +```php +$logger->emergency('System is unusable'); +$logger->alert('Action required immediately'); +$logger->critical('Important, unexpected error'); +$logger->error('Runtime error that should be monitored'); +$logger->warning('Exceptional occurrence that is not an error'); +$logger->notice('Uncommon event'); +$logger->info('Miscellaneous event'); +$logger->debug('Detailed debug information'); +``` + +### Providing context + +All log methods accept an optional context array for additional information. This data is formatted as JSON and included with your log message: ```php -// app/Config/log.config.php +$logger->error('Order processing failed', context: [ + 'user_id' => $order->userId, + 'order_id' => $order->id, + 'total_amount' => $order->total, + 'payment_method' => $order->paymentMethod, + 'error_code' => $exception->getCode(), + 'error_message' => $exception->getMessage(), +]); +``` -use Tempest\Log\LogConfig; +## Configuration -return new LogConfig( - serverLogPath: '/path/to/nginx.log' +By default, Tempest uses a daily rotating log configuration that creates a new log file each day and retains up to 31 files: - // … +```php config/logging.config.php +use Tempest\Log\Config\DailyLogConfig; +use Tempest; + +return new DailyLogConfig( + path: Tempest\internal_storage_path('logs', 'tempest.log'), + maxFiles: Tempest\env('LOG_MAX_FILES', default: 31) ); ``` -If you're only interested in tailing one or more specific logs, you can filter the `tail` output like so: +To configure a different logging channel, you may create a `logging.config.php` file anywhere and return one of the [available configuration classes](#available-configurations-and-channels). + +### Specifying a minimum log level + +Every configuration class and log channel accept a `minimumLogLevel` property, which defines the lowest severity level that will be logged. Messages below this level will be ignored. -```console -./tempest tail --debug +```php config/logging.config.php +use Tempest\Log\Config\MultipleChannelsLogConfig; +use Tempest\Log\Channels\DailyLogChannel; +use Tempest\Log\Channels\SlackLogChannel; +use Tempest; -

Debug

Listening at /Users/brent/Dev/tempest-docs/log/debug.log +return new MultipleChannelsLogConfig( + channels: [ + new DailyLogChannel( + path: Tempest\internal_storage_path('logs', 'tempest.log'), + maxFiles: Tempest\env('LOG_MAX_FILES', default: 31), + minimumLogLevel: LogLevel::DEBUG, + ), + new SlackLogChannel( + webhookUrl: Tempest\env('SLACK_LOGGING_WEBHOOK_URL'), + channelId: '#alerts', + minimumLogLevel: LogLevel::CRITICAL, + ), + ], +); ``` -Finally, the `ll()` function will do exactly the same as `lw()`, but **only write to the debug log, and not output anything in the browser or terminal**. +### Using multiple loggers -## Logging channels +In situations where you would like to log different types of information to different places, you may create multiple tagged configurations to create separate loggers for different purposes. -On top of debug logging, Tempest includes a monolog implementation which allows you to log to one or more channels. Writing to the logger is as simple as injecting `\Tempest\Log\Logger` wherever you'd like: +For instance, you could have a logger dedicated to critical alerts, while each of your application's module have its own logger: -```php -// app/Rss.php +```php src/Monitoring/logging.config.php +use Tempest\Log\Config\DailyLogConfig; +use Modules\Monitoring\Logging; +use Tempest; + +return new SlackLogConfig( + webhookUrl: Tempest\env('SLACK_LOGGING_WEBHOOK_URL'), + channelId: '#alerts', + minimumLogLevel: LogLevel::CRITICAL, + tag: Logging::SLACK, +); +``` + +```php src/Orders/logging.config.php +use Tempest\Log\Config\DailyLogConfig; +use Modules\Monitoring\Logging; +use Tempest; -use Tempest\Console\Console; -use Tempest\Console\ConsoleCommand; +return new DailyLogConfig( + path: Tempest\internal_storage_path('logs', 'orders.log'), + tag: Logging::ORDERS, +); +``` + +Using this approach, you can inject the appropriate logger using [tagged singletons](../1-essentials/05-container.md#tagged-singletons). This gives you the flexibility to customize logging behavior in different parts of your application. + +```php src/Orders/ProcessOrder.php use Tempest\Log\Logger; -final readonly class Rss +final readonly class ProcessOrder { public function __construct( - private Console $console, + #[Tag(Logging::ORDERS)] private Logger $logger, ) {} - #[ConsoleCommand] - public function sync() + public function __invoke(Order $order): void { - $this->logger->info('Starting RSS sync'); - - // … + $this->logger->info('Processing new order', ['order' => $order]); + + // ... } } ``` -If you're familiar with [monolog](https://seldaek.github.io/monolog/), you know how it supports multiple handlers to handle a log message. Tempest adds a small layer on top of these handlers called channels, they can be configured within `LogConfig`: +### Available configurations and channels -```php -// app/Config/log.config.php +Tempest provides a few log channels that correspond to common logging needs: -use Tempest\Log\LogConfig; -use Tempest\Log\Channels\AppendLogChannel; +- {b`Tempest\Log\Channel\AppendLogChannel`} — append all messages to a single file without rotation, +- {b`Tempest\Log\Channel\DailyLogChannel`} — create a new file each day and remove old files automatically, +- {b`Tempest\Log\Channel\WeeklyLogChannel`} — create a new file each week and remove old files automatically, +- {b`Tempest\Log\Channel\SlackLogChannel`} — send messages to a Slack channel via webhook, +- {b`Tempest\Log\Channel\SysLogChannel`} — write messages to the system log. -return new LogConfig( - channels: [ - new AppendLogChannel(path: __DIR__ . '/../log/project.log'), - ] -); -``` +As a convenient abstraction, a configuration class for each channel is provided: + +- {b`Tempest\Log\Config\SimpleLogConfig`} +- {b`Tempest\Log\Config\DailyLogConfig`} +- {b`Tempest\Log\Config\WeeklyLogConfig`} +- {b`Tempest\Log\Config\SlackLogConfig`} +- {b`Tempest\Log\Config\SysLogConfig`} + +These configuration classes also accept a `channels` property, which allows for providing multiple channels for a single logger. Alternatively, you may use the {b`Tempest\Log\Config\MultipleChannelsLogConfig`} configuration class to achieve the same result more explicitly. -**Please note:** +## Debugging -- Currently, Tempest only supports the `AppendLogChannel` and `DailyLogChannel`, but we're adding more channels in the future. You can always add your own channels by implementing `\Tempest\Log\LogChannel`. -- Also, it's currently not possible to configure environment-specific logging channels, this we'll also support in the future. Again, you're free to make your own channels that take the current environment into account. +Tempest includes several global functions for debugging. Typically, these functions are for quick debugging and should not be committed to production. + +- `ll()` — writes values to the debug log without displaying them, +- `lw()` (also `dump()`) — logs values and displays them, +- `ld()` (also `dd()`) — logs values, displays them, and stops execution, +- `le()` — logs values and emits an {b`Tempest\Debug\ItemsDebugged`} event. + +### Tailing debug logs + +Debug logs are written with console formatting, so they can be tailed with syntax highlighting. You may use `./tempest tail:debug` to monitor the debug log in real time. + +:::warning +By default, debug logs are cleared every time the `tail:debug` command is run. If you want to keep previous log entries, you may pass the `--no-clear` flag. +::: + +### Configuring the debug log + +By default, the debug log is written to `.tempest/debug.log`. This is configurable by creating a `debug.config.php` file that returns a {b`Tempest\Debug\DebugConfig`} with a different `path`: + +```php config/debug.config.php +use Tempest\Debug\DebugConfig; +use Tempest; + +return new DebugConfig( + logPath: Tempest\internal_storage_path('logs', 'debug.log') +); +``` diff --git a/packages/auth/src/Exceptions/AuthenticatableModelWasInvalid.php b/packages/auth/src/Exceptions/AuthenticatableModelWasInvalid.php index dcabe5ac07..fc8387ba95 100644 --- a/packages/auth/src/Exceptions/AuthenticatableModelWasInvalid.php +++ b/packages/auth/src/Exceptions/AuthenticatableModelWasInvalid.php @@ -3,7 +3,7 @@ namespace Tempest\Auth\Exceptions; use Exception; -use Tempest\Auth\Authenticatable; +use Tempest\Auth\Authentication\Authenticatable; final class AuthenticatableModelWasInvalid extends Exception implements AuthenticationException { diff --git a/packages/auth/src/OAuth/GenericOAuthClient.php b/packages/auth/src/OAuth/GenericOAuthClient.php index e3eba0b2ee..9759711227 100644 --- a/packages/auth/src/OAuth/GenericOAuthClient.php +++ b/packages/auth/src/OAuth/GenericOAuthClient.php @@ -62,7 +62,7 @@ public function buildAuthorizationUrl(array $scopes = [], array $options = []): public function createRedirect(array $scopes = [], array $options = []): Redirect { - $to = $this->buildAuthorizationUrl(); + $to = $this->buildAuthorizationUrl($scopes, $options); $this->session->set($this->sessionKey, $this->provider->getState()); @@ -100,7 +100,12 @@ public function fetchUser(AccessToken $token): OAuthUser public function authenticate(Request $request, Closure $map): Authenticatable { - if ($this->session->get($this->sessionKey) !== $request->get('state')) { + $expectedState = $this->session->get($this->sessionKey); + $actualState = $request->get('state'); + + $this->session->remove($this->sessionKey); + + if ($expectedState !== $actualState) { throw new OAuthStateWasInvalid(); } diff --git a/packages/auth/src/OAuth/Testing/TestingOAuthClient.php b/packages/auth/src/OAuth/Testing/TestingOAuthClient.php index 5e00ec4b4c..470cf0ccd7 100644 --- a/packages/auth/src/OAuth/Testing/TestingOAuthClient.php +++ b/packages/auth/src/OAuth/Testing/TestingOAuthClient.php @@ -9,6 +9,7 @@ use PHPUnit\Framework\Assert; use Tempest\Auth\Authentication\Authenticatable; use Tempest\Auth\Authentication\Authenticator; +use Tempest\Auth\Exceptions\OAuthStateWasInvalid; use Tempest\Auth\OAuth\OAuthClient; use Tempest\Auth\OAuth\OAuthConfig; use Tempest\Auth\OAuth\OAuthUser; @@ -64,11 +65,11 @@ public function buildAuthorizationUrl(array $scopes = [], array $options = []): $this->state = Random\secure_string(16); $provider = $this->config->createProvider(); - $provider->getBaseAuthorizationUrl(); + $baseAuthorizationUrl = $provider->getBaseAuthorizationUrl(); $url = sprintf( '%s/oauth/authorize?redirect_uri=%s&client_id=%s&state=%s', - $this->baseUrl ?? $provider->getBaseAuthorizationUrl(), + $this->baseUrl ?? $baseAuthorizationUrl, $this->redirectUri, $this->clientId, $this->state, @@ -124,6 +125,15 @@ public function createRedirect(array $scopes = [], array $options = []): Redirec public function authenticate(Request $request, Closure $map): Authenticatable { + $expectedState = $this->state; + $actualState = $request->get('state'); + + $this->state = null; + + if ($expectedState !== $actualState) { + throw new OAuthStateWasInvalid(); + } + $user = $this->fetchUser($this->requestAccessToken($request->get('code'))); $authenticatable = $map($user); diff --git a/packages/cache/src/Commands/CacheClearCommand.php b/packages/cache/src/Commands/CacheClearCommand.php index bcf2434a57..e4022e28ab 100644 --- a/packages/cache/src/Commands/CacheClearCommand.php +++ b/packages/cache/src/Commands/CacheClearCommand.php @@ -38,9 +38,9 @@ public function __construct( public function __invoke( #[ConsoleArgument(description: 'Name of the tagged cache to clear')] ?string $tag = null, - #[ConsoleCommand(description: 'Whether to clear all caches')] + #[ConsoleArgument(description: 'Whether to clear all caches')] bool $all = false, - #[ConsoleCommand(description: 'Whether to clear internal caches')] + #[ConsoleArgument(description: 'Whether to clear internal caches')] bool $internal = false, ): void { if (! $this->container instanceof GenericContainer) { diff --git a/packages/cache/src/Commands/CacheStatusCommand.php b/packages/cache/src/Commands/CacheStatusCommand.php index c7b93c0aca..61e45da515 100644 --- a/packages/cache/src/Commands/CacheStatusCommand.php +++ b/packages/cache/src/Commands/CacheStatusCommand.php @@ -38,7 +38,7 @@ public function __construct( public function __invoke(bool $internal = true): void { if (! $this->container instanceof GenericContainer) { - $this->console->error('Clearing caches is only available when using the default container.'); + $this->console->error('Checking cache status is only available when using the default container.'); return; } diff --git a/packages/cache/src/GenericCache.php b/packages/cache/src/GenericCache.php index df378a6e6e..1d59a34f00 100644 --- a/packages/cache/src/GenericCache.php +++ b/packages/cache/src/GenericCache.php @@ -130,6 +130,13 @@ public function get(Stringable|string $key): mixed public function getMany(iterable $key): array { + if (! $this->enabled) { + return Arr\map_with_keys( + array: $key, + map: fn (string|Stringable $key) => yield (string) $key => null, + ); + } + return Arr\map_with_keys( array: $key, map: fn (string|Stringable $key) => yield (string) $key => $this->adapter->getItem((string) $key)->get(), diff --git a/packages/cache/src/InternalCacheInsightsProvider.php b/packages/cache/src/InternalCacheInsightsProvider.php index f6a3fe812f..5e8cd9b788 100644 --- a/packages/cache/src/InternalCacheInsightsProvider.php +++ b/packages/cache/src/InternalCacheInsightsProvider.php @@ -4,6 +4,7 @@ use Tempest\Core\ConfigCache; use Tempest\Core\DiscoveryCache; +use Tempest\Core\DiscoveryCacheStrategy; use Tempest\Core\Insight; use Tempest\Core\InsightsProvider; use Tempest\Icon\IconCache; @@ -26,7 +27,11 @@ public function getInsights(): array 'Discovery' => match ($this->discoveryCache->valid) { false => new Insight('Invalid', Insight::ERROR), true => match ($this->discoveryCache->enabled) { - true => new Insight('Enabled', Insight::ERROR), + true => match ($this->discoveryCache->strategy) { + DiscoveryCacheStrategy::FULL => new Insight('Enabled', Insight::SUCCESS), + DiscoveryCacheStrategy::PARTIAL => new Insight('Enabled (partial)', Insight::SUCCESS), + default => null, // INVALID and NONE are handled + }, false => new Insight('Disabled', Insight::WARNING), }, }, diff --git a/packages/cache/src/Testing/RestrictedCache.php b/packages/cache/src/Testing/RestrictedCache.php index 71ca12fc5f..eb7e5d4655 100644 --- a/packages/cache/src/Testing/RestrictedCache.php +++ b/packages/cache/src/Testing/RestrictedCache.php @@ -10,67 +10,73 @@ use Tempest\Cache\Lock; use Tempest\DateTime\DateTimeInterface; use Tempest\DateTime\Duration; +use UnitEnum; final class RestrictedCache implements Cache { public bool $enabled; public function __construct( - private ?string $tag = null, + private null|string|UnitEnum $tag = null, ) {} + private function resolveTag(): ?string + { + return $this->tag instanceof UnitEnum ? $this->tag->name : $this->tag; + } + public function lock(Stringable|string $key, null|Duration|DateTimeInterface $expiration = null, null|Stringable|string $owner = null): Lock { - throw new CacheUsageWasForbidden($this->tag); + throw new CacheUsageWasForbidden($this->resolveTag()); } public function has(Stringable|string $key): bool { - throw new CacheUsageWasForbidden($this->tag); + throw new CacheUsageWasForbidden($this->resolveTag()); } public function put(Stringable|string $key, mixed $value, null|Duration|DateTimeInterface $expiration = null): CacheItemInterface { - throw new CacheUsageWasForbidden($this->tag); + throw new CacheUsageWasForbidden($this->resolveTag()); } public function putMany(iterable $values, null|Duration|DateTimeInterface $expiration = null): array { - throw new CacheUsageWasForbidden($this->tag); + throw new CacheUsageWasForbidden($this->resolveTag()); } public function increment(Stringable|string $key, int $by = 1): int { - throw new CacheUsageWasForbidden($this->tag); + throw new CacheUsageWasForbidden($this->resolveTag()); } public function decrement(Stringable|string $key, int $by = 1): int { - throw new CacheUsageWasForbidden($this->tag); + throw new CacheUsageWasForbidden($this->resolveTag()); } public function get(Stringable|string $key): mixed { - throw new CacheUsageWasForbidden($this->tag); + throw new CacheUsageWasForbidden($this->resolveTag()); } public function getMany(iterable $key): array { - throw new CacheUsageWasForbidden($this->tag); + throw new CacheUsageWasForbidden($this->resolveTag()); } public function resolve(Stringable|string $key, Closure $callback, null|Duration|DateTimeInterface $expiration = null, ?Duration $stale = null): mixed { - throw new CacheUsageWasForbidden($this->tag); + throw new CacheUsageWasForbidden($this->resolveTag()); } public function remove(Stringable|string $key): void { - throw new CacheUsageWasForbidden($this->tag); + throw new CacheUsageWasForbidden($this->resolveTag()); } public function clear(): void { - throw new CacheUsageWasForbidden($this->tag); + throw new CacheUsageWasForbidden($this->resolveTag()); } } diff --git a/packages/cache/src/Testing/TestingLock.php b/packages/cache/src/Testing/TestingLock.php index 9dcaf26cee..e1c0a873e1 100644 --- a/packages/cache/src/Testing/TestingLock.php +++ b/packages/cache/src/Testing/TestingLock.php @@ -66,6 +66,11 @@ public function assertLocked(null|Stringable|string $by = null, null|DateTimeInt $until = DateTime::now()->plus($until); } + Assert::assertNotNull( + actual: $this->expiration, + message: "Expected lock `{$this->key}` to have an expiration, but it has none.", + ); + Assert::assertTrue( condition: $this->expiration->afterOrAtTheSameTime($until), message: "Expected lock `{$this->key}` to expire at or after `{$until}`, but it expires at `{$this->expiration}`.", diff --git a/packages/console/src/Commands/TailCommand.php b/packages/console/src/Commands/TailCommand.php deleted file mode 100644 index be8edd600c..0000000000 --- a/packages/console/src/Commands/TailCommand.php +++ /dev/null @@ -1,61 +0,0 @@ - $loggers */ - $loggers = array_filter([ - $shouldFilter === false || $project ? $this->tailProjectLogCommand : null, - $shouldFilter === false || $server ? $this->tailServerLogCommand : null, - $shouldFilter === false || $debug ? $this->tailDebugLogCommand : null, - ]); - - /** @var Fiber[] $fibers */ - $fibers = []; - - foreach ($loggers as $key => $logger) { - $fiber = new Fiber(fn () => $logger()); - $fibers[$key] = $fiber; - $fiber->start(); - } - - while ($fibers !== []) { - foreach ($fibers as $key => $fiber) { - if ($fiber->isSuspended()) { - $fiber->resume(); - } - - if ($fiber->isTerminated()) { - unset($fibers[$key]); - } - } - } - } -} diff --git a/packages/console/src/Commands/TailDebugLogCommand.php b/packages/console/src/Commands/TailDebugLogCommand.php deleted file mode 100644 index 4dd60e9e50..0000000000 --- a/packages/console/src/Commands/TailDebugLogCommand.php +++ /dev/null @@ -1,55 +0,0 @@ -logConfig->debugLogPath; - - if (! $debugLogPath) { - $this->console->error('No debug log configured in LogConfig.'); - - return; - } - - $dir = pathinfo($debugLogPath, PATHINFO_DIRNAME); - - if (! is_dir($dir)) { - mkdir($dir); - } - - if (! file_exists($debugLogPath)) { - touch($debugLogPath); - } - - $this->console->header('Tailing debug logs', "Reading …"); - - new TailReader()->tail( - path: $debugLogPath, - format: fn (string $text) => $this->highlighter->parse( - $text, - new VarExportLanguage(), - ), - ); - } -} diff --git a/packages/console/src/Commands/TailServerLogCommand.php b/packages/console/src/Commands/TailServerLogCommand.php deleted file mode 100644 index 42d5cebe0c..0000000000 --- a/packages/console/src/Commands/TailServerLogCommand.php +++ /dev/null @@ -1,51 +0,0 @@ -logConfig->serverLogPath; - - if (! $serverLogPath) { - $this->console->error('No server log configured in LogConfig.'); - - return; - } - - if (! file_exists($serverLogPath)) { - $this->console->error("No valid server log at "); - - return; - } - - $this->console->header('Tailing server logs', "Reading …"); - - new TailReader()->tail( - path: $serverLogPath, - format: fn (string $text) => $this->highlighter->parse( - $text, - new LogLanguage(), - ), - ); - } -} diff --git a/packages/container/src/GenericContainer.php b/packages/container/src/GenericContainer.php index 5be785208b..bc1342c741 100644 --- a/packages/container/src/GenericContainer.php +++ b/packages/container/src/GenericContainer.php @@ -26,20 +26,20 @@ final class GenericContainer implements Container public function __construct( /** @var ArrayIterator $definitions */ - private ArrayIterator $definitions = new ArrayIterator(), + private(set) ArrayIterator $definitions = new ArrayIterator(), /** @var ArrayIterator $singletons */ - private ArrayIterator $singletons = new ArrayIterator(), + private(set) ArrayIterator $singletons = new ArrayIterator(), /** @var ArrayIterator $initializers */ - public ArrayIterator $initializers = new ArrayIterator(), + private(set) ArrayIterator $initializers = new ArrayIterator(), /** @var ArrayIterator $dynamicInitializers */ - private ArrayIterator $dynamicInitializers = new ArrayIterator(), + private(set) ArrayIterator $dynamicInitializers = new ArrayIterator(), /** @var ArrayIterator $decorators */ - private ArrayIterator $decorators = new ArrayIterator(), - private ?DependencyChain $chain = null, + private(set) ArrayIterator $decorators = new ArrayIterator(), + private(set) ?DependencyChain $chain = null, ) {} public function setDefinitions(array $definitions): self diff --git a/packages/core/src/Kernel.php b/packages/core/src/Kernel.php index 878dc281ce..1ce183e2f5 100644 --- a/packages/core/src/Kernel.php +++ b/packages/core/src/Kernel.php @@ -8,7 +8,7 @@ interface Kernel { - public const string VERSION = '2.12.0'; + public const string VERSION = '2.13.0'; public string $root { get; diff --git a/packages/core/src/LogExceptionProcessor.php b/packages/core/src/LogExceptionProcessor.php index f74569f675..f851866de3 100644 --- a/packages/core/src/LogExceptionProcessor.php +++ b/packages/core/src/LogExceptionProcessor.php @@ -3,6 +3,7 @@ namespace Tempest\Core; use Tempest\Debug\Debug; +use Tempest\Log\Logger; use Throwable; /** @@ -10,6 +11,10 @@ */ final class LogExceptionProcessor implements ExceptionProcessor { + public function __construct( + private readonly Logger $logger, + ) {} + public function process(Throwable $throwable): void { $items = [ @@ -21,6 +26,8 @@ public function process(Throwable $throwable): void : [], ]; + $this->logger->error($throwable->getMessage(), $items); + Debug::resolve()->log($items, writeToOut: false); } } diff --git a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php index bbb0917a1e..0cf62c3fbf 100644 --- a/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php +++ b/packages/database/src/Builder/QueryBuilders/InsertQueryBuilder.php @@ -145,7 +145,7 @@ private function convertObjectToArray(object $object, array $excludeProperties = $data = []; foreach ($reflection->getPublicProperties() as $property) { - if (! $property->isInitialized($object)) { + if ($property->isUninitialized($object)) { continue; } @@ -153,10 +153,6 @@ private function convertObjectToArray(object $object, array $excludeProperties = continue; } - if ($property->isUninitialized($object)) { - continue; - } - $propertyName = $property->getName(); if (! in_array($propertyName, $excludeProperties, strict: true)) { diff --git a/packages/database/src/Migrations/MigrationManager.php b/packages/database/src/Migrations/MigrationManager.php index fed474be4c..1e1b43b626 100644 --- a/packages/database/src/Migrations/MigrationManager.php +++ b/packages/database/src/Migrations/MigrationManager.php @@ -247,10 +247,6 @@ public function executeDown(MigratesDown $migration): void $statement = $migration->down(); - if ($statement === null) { - return; - } - $query = new Query($statement->compile($this->dialect)); try { @@ -261,10 +257,10 @@ public function executeDown(MigratesDown $migration): void $this->database->execute($query); - // Disable foreign key checks + // Enable foreign key checks new SetForeignKeyChecksStatement(enable: true)->execute($this->dialect, $this->onDatabase); } catch (QueryWasInvalid $queryWasInvalid) { - // Disable foreign key checks + // Enable foreign key checks new SetForeignKeyChecksStatement(enable: true)->execute($this->dialect, $this->onDatabase); event(new MigrationFailed($migration->name, $queryWasInvalid)); diff --git a/packages/database/src/Transactions/GenericTransactionManager.php b/packages/database/src/Transactions/GenericTransactionManager.php index c43ab8daa5..d9e73d2c60 100644 --- a/packages/database/src/Transactions/GenericTransactionManager.php +++ b/packages/database/src/Transactions/GenericTransactionManager.php @@ -35,7 +35,7 @@ public function commit(): void public function rollback(): void { - $transactionRolledBack = $this->connection->rollBack(); + $transactionRolledBack = $this->connection->rollback(); if (! $transactionRolledBack) { throw new CouldNotRollbackTransaction(); diff --git a/packages/datetime/src/DateTimeInterface.php b/packages/datetime/src/DateTimeInterface.php index bf010a1131..069b9edce3 100644 --- a/packages/datetime/src/DateTimeInterface.php +++ b/packages/datetime/src/DateTimeInterface.php @@ -228,7 +228,7 @@ public function getMonthEnum(): Month; /** * Returns the day. * - * @return int<0, 31> + * @return int<1, 31> */ public function getDay(): int; diff --git a/packages/datetime/src/Exception/UnexpectedValueException.php b/packages/datetime/src/Exception/UnexpectedValueException.php index 4f4f7ab01d..69ebcda060 100644 --- a/packages/datetime/src/Exception/UnexpectedValueException.php +++ b/packages/datetime/src/Exception/UnexpectedValueException.php @@ -117,8 +117,8 @@ public static function forSeconds(int $provided_seconds, int $calendar_seconds): { return new self(sprintf( 'Unexpected seconds value encountered. Provided "%d", but the calendar expects "%d". Ensure the seconds are correct and within the 0-59 range.', - $calendar_seconds, $provided_seconds, + $calendar_seconds, )); } } diff --git a/packages/datetime/tests/DateTimeTest.php b/packages/datetime/tests/DateTimeTest.php index b6e5912df7..056b6f18bc 100644 --- a/packages/datetime/tests/DateTimeTest.php +++ b/packages/datetime/tests/DateTimeTest.php @@ -164,7 +164,7 @@ public static function provide_invalid_component_parts(): array 0, ], [ - 'Unexpected seconds value encountered. Provided "59", but the calendar expects "-1". Ensure the seconds are correct and within the 0-59 range.', + 'Unexpected seconds value encountered. Provided "-1", but the calendar expects "59". Ensure the seconds are correct and within the 0-59 range.', 2024, 1, 1, diff --git a/packages/debug/composer.json b/packages/debug/composer.json index d5eae92891..bdf25a63d0 100644 --- a/packages/debug/composer.json +++ b/packages/debug/composer.json @@ -5,6 +5,7 @@ "minimum-stability": "dev", "require": { "php": "^8.4", + "tempest/console": "dev-main", "tempest/highlight": "^2.11.4", "symfony/var-dumper": "^7.1" }, diff --git a/packages/debug/src/Debug.php b/packages/debug/src/Debug.php index 3022dabf80..5a6f957c07 100644 --- a/packages/debug/src/Debug.php +++ b/packages/debug/src/Debug.php @@ -11,29 +11,33 @@ use Tempest\Container\GenericContainer; use Tempest\EventBus\EventBus; use Tempest\Highlight\Themes\TerminalStyle; -use Tempest\Log\LogConfig; +use Tempest\Support\Filesystem; final readonly class Debug { private function __construct( - private ?LogConfig $logConfig = null, + private ?DebugConfig $config = null, private ?EventBus $eventBus = null, ) {} public static function resolve(): self { try { - $container = GenericContainer::instance(); - return new self( - logConfig: $container?->get(LogConfig::class), - eventBus: $container?->get(EventBus::class), + config: GenericContainer::instance()->get(DebugConfig::class), + eventBus: GenericContainer::instance()->get(EventBus::class), ); } catch (Exception) { return new self(); } } + /** + * Logs and/or dumps the given items. + * + * @param bool $writeToLog Whether to write the items to the log file. + * @param bool $writeToOut Whether to dump the items to the standard output. + */ public function log(array $items, bool $writeToLog = true, bool $writeToOut = true): void { $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS); @@ -52,30 +56,19 @@ public function log(array $items, bool $writeToLog = true, bool $writeToOut = tr private function writeToLog(array $items, string $callPath): void { - if ($this->logConfig === null) { - return; - } - - if (! $this->logConfig->debugLogPath) { + if (! $this->config?->logPath) { return; } - $directory = dirname($this->logConfig->debugLogPath); - - if (! is_dir($directory)) { - mkdir(directory: $directory, recursive: true); - } - - $handle = @fopen($this->logConfig->debugLogPath, 'a'); + Filesystem\create_directory_for_file($this->config->logPath); - if (! $handle) { + if (! ($handle = @fopen($this->config->logPath, 'a'))) { return; } foreach ($items as $key => $item) { - $output = $this->createDump($item) . $callPath; - - fwrite($handle, "{$key} " . $output . PHP_EOL); + fwrite($handle, TerminalStyle::BG_BLUE(" {$key} ") . TerminalStyle::FG_GRAY(' → ' . TerminalStyle::ITALIC($callPath))); + fwrite($handle, $this->createCliDump($item) . PHP_EOL); } fclose($handle); @@ -86,27 +79,27 @@ private function writeToOut(array $items, string $callPath): void foreach ($items as $key => $item) { if (defined('STDOUT')) { fwrite(STDOUT, TerminalStyle::BG_BLUE(" {$key} ") . ' '); - - $output = $this->createDump($item); - - fwrite(STDOUT, $output); - - fwrite(STDOUT, $callPath . PHP_EOL); + fwrite(STDOUT, $this->createCliDump($item)); + fwrite(STDOUT, TerminalStyle::DIM('→ ' . TerminalStyle::ITALIC($callPath)) . PHP_EOL . PHP_EOL); } else { echo - sprintf( - '%s (%s)', - 'Source Code Pro, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace', - $key, - $callPath, + vsprintf( + <<%s (%s) + HTML, + [ + 'Source Code Pro, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace', + $key, + $callPath, + ], ) ; @@ -115,10 +108,9 @@ private function writeToOut(array $items, string $callPath): void } } - private function createDump(mixed $input): string + private function createCliDump(mixed $input): string { $cloner = new VarCloner(); - $output = ''; $dumper = new CliDumper(function ($line, $depth) use (&$output): void { @@ -130,7 +122,6 @@ private function createDump(mixed $input): string }); $dumper->setColors(true); - $dumper->dump($cloner->cloneVar($input)); return preg_replace( diff --git a/packages/debug/src/DebugConfig.php b/packages/debug/src/DebugConfig.php new file mode 100644 index 0000000000..11cc9fad50 --- /dev/null +++ b/packages/debug/src/DebugConfig.php @@ -0,0 +1,13 @@ +debugConfig->logPath; + + if (! $debugLogPath) { + $this->console->error('No debug log configured in DebugConfig.'); + + return; + } + + if ($clear && Filesystem\is_file($debugLogPath)) { + Filesystem\delete_file($debugLogPath); + } + + Filesystem\create_file($debugLogPath); + + $this->console->header('Tailing debug logs', "Reading …"); + + new TailReader()->tail($debugLogPath); + } +} diff --git a/packages/debug/src/debug.config.php b/packages/debug/src/debug.config.php new file mode 100644 index 0000000000..30e24ba3f1 --- /dev/null +++ b/packages/debug/src/debug.config.php @@ -0,0 +1,7 @@ +parseIconIdentifier($icon); + [$collection, $iconName] = $this->parseIconIdentifier($icon) ?? [null, null]; if ($this->iconCache->get("icon-failure-{$collection}-{$iconName}")) { return null; diff --git a/packages/icon/src/IconCache.php b/packages/icon/src/IconCache.php index 565ad5e802..d630707998 100644 --- a/packages/icon/src/IconCache.php +++ b/packages/icon/src/IconCache.php @@ -43,6 +43,10 @@ public function get(string $key): mixed public function clear(): void { + if (! $this->enabled) { + return; + } + $this->pool->clear(); } diff --git a/packages/icon/src/IconInitializer.php b/packages/icon/src/IconInitializer.php index 2bcf2d9a74..629d41cc7c 100644 --- a/packages/icon/src/IconInitializer.php +++ b/packages/icon/src/IconInitializer.php @@ -7,7 +7,6 @@ use Tempest\Container\Singleton; use Tempest\EventBus\EventBus; use Tempest\HttpClient\HttpClient; -use Tempest\Icon\IconCache; final class IconInitializer implements Initializer { diff --git a/packages/kv-store/src/Redis/Config/RedisConfig.php b/packages/kv-store/src/Redis/Config/RedisConfig.php index 3b84127777..ec63e7759c 100644 --- a/packages/kv-store/src/Redis/Config/RedisConfig.php +++ b/packages/kv-store/src/Redis/Config/RedisConfig.php @@ -38,7 +38,7 @@ public function __construct( /** * Path of the UNIX domain socket file used when connecting to Redis using UNIX domain sockets. */ - public ?int $unixSocketPath = null, + public ?string $unixSocketPath = null, /** * Specifies the protocol used to communicate with the Redis instance. This is specific to predis. diff --git a/packages/kv-store/src/Redis/PhpRedisClient.php b/packages/kv-store/src/Redis/PhpRedisClient.php index 89f5882298..8002e1486b 100644 --- a/packages/kv-store/src/Redis/PhpRedisClient.php +++ b/packages/kv-store/src/Redis/PhpRedisClient.php @@ -99,7 +99,7 @@ public function command(Stringable|string $command, Stringable|string ...$argume public function set(Stringable|string $key, mixed $value, null|Duration|DateTimeInterface $expiration = null): void { if ($expiration instanceof DateTimeInterface) { - $expiration = DateTime::now()->since($expiration); + $expiration = $expiration->since(DateTime::now()); } if ($expiration?->isNegative()) { diff --git a/packages/kv-store/src/Redis/PredisClient.php b/packages/kv-store/src/Redis/PredisClient.php index 5bc5068157..002a8bd3a4 100644 --- a/packages/kv-store/src/Redis/PredisClient.php +++ b/packages/kv-store/src/Redis/PredisClient.php @@ -56,7 +56,7 @@ public function command(Stringable|string $command, Stringable|string ...$argume public function set(Stringable|string $key, mixed $value, null|Duration|DateTimeInterface $expiration = null): void { if ($expiration instanceof DateTimeInterface) { - $expiration = DateTime::now()->since($expiration); + $expiration = $expiration->since(DateTime::now()); } if ($expiration?->isNegative()) { diff --git a/packages/kv-store/src/Redis/ReddisExtensionWasMissing.php b/packages/kv-store/src/Redis/RedisExtensionWasMissing.php similarity index 83% rename from packages/kv-store/src/Redis/ReddisExtensionWasMissing.php rename to packages/kv-store/src/Redis/RedisExtensionWasMissing.php index 8d331a2dd3..0e595127bb 100644 --- a/packages/kv-store/src/Redis/ReddisExtensionWasMissing.php +++ b/packages/kv-store/src/Redis/RedisExtensionWasMissing.php @@ -5,7 +5,7 @@ use Exception; use Predis; -final class ReddisExtensionWasMissing extends Exception implements RedisException +final class RedisExtensionWasMissing extends Exception implements RedisException { public function __construct(string $fqcn) { diff --git a/packages/kv-store/src/Redis/RedisInitializer.php b/packages/kv-store/src/Redis/RedisInitializer.php index c291ec7432..9eeccfe570 100644 --- a/packages/kv-store/src/Redis/RedisInitializer.php +++ b/packages/kv-store/src/Redis/RedisInitializer.php @@ -19,7 +19,7 @@ public function initialize(Container $container): Redis try { return new PhpRedisClient($this->buildPhpRedisClient(), $config, $bus); - } catch (ReddisExtensionWasMissing) { + } catch (RedisExtensionWasMissing) { return new PredisClient($this->buildPredisClient($config), $bus); } } @@ -27,7 +27,7 @@ public function initialize(Container $container): Redis private function buildPhpRedisClient(): \Redis { if (! extension_loaded('redis') || ! class_exists(\Redis::class)) { - throw new ReddisExtensionWasMissing(\Redis::class); + throw new RedisExtensionWasMissing(\Redis::class); } return new \Redis(); @@ -36,7 +36,7 @@ private function buildPhpRedisClient(): \Redis private function buildPredisClient(RedisConfig $config): Predis\Client { if (! class_exists(Predis\Client::class)) { - throw new ReddisExtensionWasMissing(Predis\Client::class); + throw new RedisExtensionWasMissing(Predis\Client::class); } return new Predis\Client( diff --git a/packages/log/src/Channels/AppendLogChannel.php b/packages/log/src/Channels/AppendLogChannel.php index 0fc52f6a84..35de7b4e37 100644 --- a/packages/log/src/Channels/AppendLogChannel.php +++ b/packages/log/src/Channels/AppendLogChannel.php @@ -8,18 +8,31 @@ use Monolog\Level; use Monolog\Processor\PsrLogMessageProcessor; use Tempest\Log\LogChannel; +use Tempest\Log\LogLevel; final readonly class AppendLogChannel implements LogChannel { + /** + * @param string $path The log file path. + * @param bool $useLocking Whether to try to lock log file before doing any writes. + * @param LogLevel $minimumLogLevel The minimum log level to record. + * @param bool $bubble Whether the messages that are handled can bubble up the stack or not + * @param null|int $filePermission Optional file permissions (default (0644) are only for owner read/write). + */ public function __construct( - private string $path, - private bool $bubble = true, - private ?int $filePermission = null, - private bool $useLocking = false, + private(set) string $path, + private(set) bool $useLocking = false, + private(set) LogLevel $minimumLogLevel = LogLevel::DEBUG, + private(set) bool $bubble = true, + private(set) ?int $filePermission = null, ) {} public function getHandlers(Level $level): array { + if (! $this->minimumLogLevel->includes(LogLevel::fromMonolog($level))) { + return []; + } + return [ new StreamHandler( stream: $this->path, @@ -37,9 +50,4 @@ public function getProcessors(): array new PsrLogMessageProcessor(), ]; } - - public function getPath(): string - { - return $this->path; - } } diff --git a/packages/log/src/Channels/DailyLogChannel.php b/packages/log/src/Channels/DailyLogChannel.php index 18534524d0..938c165760 100644 --- a/packages/log/src/Channels/DailyLogChannel.php +++ b/packages/log/src/Channels/DailyLogChannel.php @@ -8,27 +8,42 @@ use Monolog\Processor\PsrLogMessageProcessor; use Tempest\Log\FileHandlers\RotatingFileHandler; use Tempest\Log\LogChannel; +use Tempest\Log\LogLevel; final readonly class DailyLogChannel implements LogChannel { + /** + * This channel writes logs to a file that is rotated daily. + * + * @param string $path The base log file name. + * @param int $maxFiles The maximal amount of files to keep (0 means unlimited) + * @param bool $lockFilesDuringWrites Whether to try to lock log file before doing any writes. + * @param bool $bubble Whether the messages that are handled can bubble up the stack or not + * @param null|int $filePermission Optional file permissions (default (0644) are only for owner read/write) + */ public function __construct( private string $path, private int $maxFiles = 31, + private LogLevel $minimumLogLevel = LogLevel::DEBUG, + private bool $lockFilesDuringWrites = false, private bool $bubble = true, private ?int $filePermission = null, - private bool $useLocking = false, ) {} public function getHandlers(Level $level): array { + if (! $this->minimumLogLevel->includes(LogLevel::fromMonolog($level))) { + return []; + } + return [ new RotatingFileHandler( filename: $this->path, - maxFiles: $this->maxFiles, + maxFiles: $this->maxFiles ?? 0, level: $level, bubble: $this->bubble, filePermission: $this->filePermission, - useLocking: $this->useLocking, + useLocking: $this->lockFilesDuringWrites, dateFormat: RotatingFileHandler::FILE_PER_DAY, ), ]; diff --git a/packages/log/src/Channels/Slack/PresentationMode.php b/packages/log/src/Channels/Slack/PresentationMode.php new file mode 100644 index 0000000000..76b25ce962 --- /dev/null +++ b/packages/log/src/Channels/Slack/PresentationMode.php @@ -0,0 +1,21 @@ +minimumLogLevel->includes(LogLevel::fromMonolog($level))) { + return []; + } + + return [ + new SlackWebhookHandler( + webhookUrl: $this->webhookUrl, + channel: $this->channelId, + username: $this->username, + level: $level, + useAttachment: $this->mode === PresentationMode::BLOCKS || $this->mode === PresentationMode::BLOCKS_WITH_CONTEXT, + includeContextAndExtra: $this->mode === PresentationMode::BLOCKS_WITH_CONTEXT, + ), + ]; + } + + public function getProcessors(): array + { + return [ + new PsrLogMessageProcessor(), + ]; + } +} diff --git a/packages/log/src/Channels/SysLogChannel.php b/packages/log/src/Channels/SysLogChannel.php new file mode 100644 index 0000000000..5e5caa78da --- /dev/null +++ b/packages/log/src/Channels/SysLogChannel.php @@ -0,0 +1,53 @@ +minimumLogLevel->includes(LogLevel::fromMonolog($level))) { + return []; + } + + return [ + new SyslogHandler( + ident: $this->identity, + facility: $this->facility, + level: $level, + bubble: $this->bubble, + logopts: $this->flags, + ), + ]; + } + + public function getProcessors(): array + { + return [ + new PsrLogMessageProcessor(), + ]; + } +} diff --git a/packages/log/src/Channels/WeeklyLogChannel.php b/packages/log/src/Channels/WeeklyLogChannel.php index af52ab6898..007d582ffe 100644 --- a/packages/log/src/Channels/WeeklyLogChannel.php +++ b/packages/log/src/Channels/WeeklyLogChannel.php @@ -8,19 +8,33 @@ use Monolog\Processor\PsrLogMessageProcessor; use Tempest\Log\FileHandlers\RotatingFileHandler; use Tempest\Log\LogChannel; +use Tempest\Log\LogLevel; final readonly class WeeklyLogChannel implements LogChannel { + /** + * @param string $path The base log file name. + * @param int $maxFiles The maximal amount of files to keep. + * @param bool $lockFilesDuringWrites Whether to try to lock log file before doing any writes. + * @param LogLevel $minimumLogLevel The minimum log level to record. + * @param bool $bubble Whether the messages that are handled can bubble up the stack or not. + * @param null|int $filePermission Optional file permissions (default (0644) are only for owner read/write). + */ public function __construct( private string $path, private int $maxFiles = 5, + private bool $lockFilesDuringWrites = false, + private LogLevel $minimumLogLevel = LogLevel::DEBUG, private bool $bubble = true, private ?int $filePermission = null, - private bool $useLocking = false, ) {} public function getHandlers(Level $level): array { + if (! $this->minimumLogLevel->includes(LogLevel::fromMonolog($level))) { + return []; + } + return [ new RotatingFileHandler( filename: $this->path, @@ -28,7 +42,7 @@ public function getHandlers(Level $level): array level: $level, bubble: $this->bubble, filePermission: $this->filePermission, - useLocking: $this->useLocking, + useLocking: $this->lockFilesDuringWrites, dateFormat: RotatingFileHandler::FILE_PER_WEEK, ), ]; diff --git a/packages/log/src/Config/DailyLogConfig.php b/packages/log/src/Config/DailyLogConfig.php new file mode 100644 index 0000000000..281c7ef383 --- /dev/null +++ b/packages/log/src/Config/DailyLogConfig.php @@ -0,0 +1,47 @@ + [ + new DailyLogChannel( + path: $this->path, + maxFiles: $this->maxFiles, + minimumLogLevel: $this->minimumLogLevel, + lockFilesDuringWrites: $this->lockFilesDuringWrites, + filePermission: $this->filePermission, + ), + ...$this->channels, + ]; + } + + /** + * A logging configuration that creates a new log file each day and retains a maximum number of files. + * + * @param string $path The base log file name. + * @param int $maxFiles The maximal amount of files to keep (0 means unlimited) + * @param LogLevel $minimumLogLevel The minimum log level to record. + * @param array $channels Additional channels to include in the configuration. + * @param bool $lockFilesDuringWrites Whether to try to lock log file before doing any writes. + * @param null|int $filePermission Optional file permissions (default (0644) are only for owner read/write) + * @param null|string $prefix An optional prefix displayed in all log messages. By default, the current environment is used. + * @param null|UnitEnum|string $tag An optional tag to identify the logger instance associated to this configuration. + */ + public function __construct( + private(set) string $path, + private(set) int $maxFiles = 31, + private(set) LogLevel $minimumLogLevel = LogLevel::DEBUG, + private(set) array $channels = [], + private(set) bool $lockFilesDuringWrites = false, + private(set) ?int $filePermission = null, + private(set) ?string $prefix = null, + private(set) null|UnitEnum|string $tag = null, + ) {} +} diff --git a/packages/log/src/Config/MultipleChannelsLogConfig.php b/packages/log/src/Config/MultipleChannelsLogConfig.php new file mode 100644 index 0000000000..7ffef9125a --- /dev/null +++ b/packages/log/src/Config/MultipleChannelsLogConfig.php @@ -0,0 +1,27 @@ + $this->channels; + } + + /** + * A logging configuration that uses multiple log channels. + * + * @param LogChannel[] $channels The log channels to which log messages will be sent. + * @param null|string $prefix An optional prefix displayed in all log messages. By default, the current environment is used. + * @param null|UnitEnum|string $tag An optional tag to identify the logger instance associated to this configuration. + */ + public function __construct( + private(set) array $channels, + private(set) ?string $prefix, + private(set) null|UnitEnum|string $tag = null, + ) {} +} diff --git a/packages/log/src/Config/NullLogConfig.php b/packages/log/src/Config/NullLogConfig.php new file mode 100644 index 0000000000..d72a4b711e --- /dev/null +++ b/packages/log/src/Config/NullLogConfig.php @@ -0,0 +1,21 @@ + []; + } + + /** + * A logging configuration that does not log anything. + */ + public function __construct( + private(set) ?string $prefix = null, + private(set) null|UnitEnum|string $tag = null, + ) {} +} diff --git a/packages/log/src/Config/SimpleLogConfig.php b/packages/log/src/Config/SimpleLogConfig.php new file mode 100644 index 0000000000..3d12dfcbad --- /dev/null +++ b/packages/log/src/Config/SimpleLogConfig.php @@ -0,0 +1,44 @@ + [ + new AppendLogChannel( + path: $this->path, + useLocking: $this->useLocking, + minimumLogLevel: $this->minimumLogLevel, + filePermission: $this->filePermission, + ), + ...$this->channels, + ]; + } + + /** + * A basic logging configuration that appends all logs to a single file. + * + * @param string $path The log file path. + * @param LogLevel $minimumLogLevel The minimum log level to record. + * @param array $channels Additional channels to include in the configuration. + * @param bool $useLocking Whether to try to lock log file before doing any writes. + * @param null|int $filePermission Optional file permissions (default (0644) are only for owner read/write). + * @param null|string $prefix An optional prefix displayed in all log messages. By default, the current environment is used. + * @param null|UnitEnum|string $tag An optional tag to identify the logger instance associated to this configuration. + */ + public function __construct( + private(set) string $path, + private(set) LogLevel $minimumLogLevel = LogLevel::DEBUG, + private(set) array $channels = [], + private(set) bool $useLocking = false, + private(set) ?int $filePermission = null, + private(set) ?string $prefix = null, + private(set) null|UnitEnum|string $tag = null, + ) {} +} diff --git a/packages/log/src/Config/SlackLogConfig.php b/packages/log/src/Config/SlackLogConfig.php new file mode 100644 index 0000000000..b4f471d6fc --- /dev/null +++ b/packages/log/src/Config/SlackLogConfig.php @@ -0,0 +1,46 @@ + [ + new SlackLogChannel( + webhookUrl: $this->webhookUrl, + channelId: $this->channelId, + username: $this->username, + mode: $this->mode, + minimumLogLevel: $this->minimumLogLevel, + ), + ...$this->channels, + ]; + } + + /** + * A logging configuration for sending log messages to a Slack channel using an Incoming Webhook. + * + * @param string $webhookUrl The Slack Incoming Webhook URL. + * @param string|null $channelId The Slack channel ID to send messages to. If null, the default channel configured in the webhook will be used. + * @param string|null $username The username to display as the sender of the message. + * @param PresentationMode $mode The display mode for the Slack messages. + * @param LogLevel $minimumLogLevel The minimum log level to record. + * @param null|string $prefix An optional prefix displayed in all log messages. By default, the current environment is used. + * @param null|UnitEnum|string $tag An optional tag to identify the logger instance associated to this configuration. + */ + public function __construct( + private(set) string $webhookUrl, + private(set) ?string $channelId = null, + private(set) ?string $username = null, + private(set) PresentationMode $mode = PresentationMode::INLINE, + private LogLevel $minimumLogLevel = LogLevel::DEBUG, + private(set) ?string $prefix = null, + private(set) null|UnitEnum|string $tag = null, + ) {} +} diff --git a/packages/log/src/Config/WeeklyLogConfig.php b/packages/log/src/Config/WeeklyLogConfig.php new file mode 100644 index 0000000000..3b57e3e077 --- /dev/null +++ b/packages/log/src/Config/WeeklyLogConfig.php @@ -0,0 +1,47 @@ + [ + new WeeklyLogChannel( + path: $this->path, + maxFiles: $this->maxFiles, + lockFilesDuringWrites: $this->lockFilesDuringWrites, + minimumLogLevel: $this->minimumLogLevel, + filePermission: $this->filePermission, + ), + ...$this->channels, + ]; + } + + /** + * A logging configuration that creates a new log file each week and retains a maximum number of files. + * + * @param string $path The base log file name. + * @param int $maxFiles The maximal amount of files to keep. + * @param LogLevel $minimumLogLevel The minimum log level to record. + * @param null|string $prefix An optional prefix displayed in all log messages. By default, the current environment is used. + * @param bool $lockFilesDuringWrites Whether to try to lock log file before doing any writes. + * @param null|int $filePermission Optional file permissions (default (0644) are only for owner read/write). + * @param array $channels Additional channels to include in the configuration. + * @param null|UnitEnum|string $tag An optional tag to identify the logger instance associated to this configuration. + */ + public function __construct( + private(set) string $path, + private(set) int $maxFiles = 5, + private(set) LogLevel $minimumLogLevel = LogLevel::DEBUG, + private(set) array $channels = [], + private(set) ?string $prefix = null, + private(set) bool $lockFilesDuringWrites = false, + private(set) ?int $filePermission = null, + private(set) null|UnitEnum|string $tag = null, + ) {} +} diff --git a/packages/log/src/Config/logs.config.php b/packages/log/src/Config/logs.config.php deleted file mode 100644 index 1b16a3c766..0000000000 --- a/packages/log/src/Config/logs.config.php +++ /dev/null @@ -1,14 +0,0 @@ - */ + /** @var array */ private array $drivers = []; public function __construct( private readonly LogConfig $logConfig, + private readonly AppConfig $appConfig, private readonly EventBus $eventBus, ) {} @@ -82,9 +84,9 @@ public function log(mixed $level, Stringable|string $message, array $context = [ $this->eventBus->dispatch(new MessageLogged(LogLevel::fromMonolog($level), $message, $context)); } - private function writeLog(MonologLogLevel $level, string $message, array $context): void + private function writeLog(MonologLogLevel $level, Stringable|string $message, array $context): void { - foreach ($this->logConfig->channels as $channel) { + foreach ($this->logConfig->logChannels as $channel) { $this->resolveDriver($channel, $level)->log($level, $message, $context); } } @@ -95,7 +97,7 @@ private function resolveDriver(LogChannel $channel, MonologLogLevel $level): Mon if (! isset($this->drivers[$key])) { $this->drivers[$key] = new Monolog( - name: $this->logConfig->prefix, + name: $this->logConfig->prefix ?? $this->appConfig->environment->value, handlers: $channel->getHandlers($level), processors: $channel->getProcessors(), ); diff --git a/packages/log/src/LogConfig.php b/packages/log/src/LogConfig.php index e9293d98e8..4e3502073f 100644 --- a/packages/log/src/LogConfig.php +++ b/packages/log/src/LogConfig.php @@ -4,23 +4,23 @@ namespace Tempest\Log; -use Tempest\Log\Channels\AppendLogChannel; +use Tempest\Container\HasTag; -use function Tempest\root_path; - -final class LogConfig +interface LogConfig extends HasTag { - public function __construct( - /** @var LogChannel[] */ - public array $channels = [], - public string $prefix = 'tempest', - public ?string $debugLogPath = null, - public ?string $serverLogPath = null, - ) { - $this->debugLogPath ??= root_path('/log/debug.log'); + /** + * An optional prefix displayed in all log messages. By default, the current environment is used. + */ + public ?string $prefix { + get; + } - if ($this->channels === []) { - $this->channels[] = new AppendLogChannel(root_path('/log/tempest.log')); - } + /** + * The log channels to which log messages will be sent. + * + * @var LogChannel[] + */ + public array $logChannels { + get; } } diff --git a/packages/log/src/LogLevel.php b/packages/log/src/LogLevel.php index 002ccb8a37..f969504646 100644 --- a/packages/log/src/LogLevel.php +++ b/packages/log/src/LogLevel.php @@ -61,4 +61,26 @@ public static function fromMonolog(Level $level): self Level::Debug => self::DEBUG, }; } + + public function toMonolog(): Level + { + return match ($this) { + self::EMERGENCY => Level::Emergency, + self::ALERT => Level::Alert, + self::CRITICAL => Level::Critical, + self::ERROR => Level::Error, + self::WARNING => Level::Warning, + self::NOTICE => Level::Notice, + self::INFO => Level::Info, + self::DEBUG => Level::Debug, + }; + } + + /** + * Determines if this log level is higher than or equal to the given level. + */ + public function includes(self $level): bool + { + return $this->toMonolog()->includes($level->toMonolog()); + } } diff --git a/packages/log/src/LoggerInitializer.php b/packages/log/src/LoggerInitializer.php index b2bd6d19f7..b3fc0bd5b1 100644 --- a/packages/log/src/LoggerInitializer.php +++ b/packages/log/src/LoggerInitializer.php @@ -6,18 +6,27 @@ use Psr\Log\LoggerInterface; use Tempest\Container\Container; -use Tempest\Container\Initializer; +use Tempest\Container\DynamicInitializer; use Tempest\Container\Singleton; +use Tempest\Core\AppConfig; use Tempest\EventBus\EventBus; +use Tempest\Reflection\ClassReflector; +use UnitEnum; -final readonly class LoggerInitializer implements Initializer +final readonly class LoggerInitializer implements DynamicInitializer { + public function canInitialize(ClassReflector $class, null|string|UnitEnum $tag): bool + { + return $class->getType()->matches(Logger::class) || $class->getType()->matches(LoggerInterface::class); + } + #[Singleton] - public function initialize(Container $container): LoggerInterface|Logger + public function initialize(ClassReflector $class, null|string|UnitEnum $tag, Container $container): LoggerInterface|Logger { return new GenericLogger( - $container->get(LogConfig::class), - $container->get(EventBus::class), + logConfig: $container->get(LogConfig::class, $tag), + appConfig: $container->get(AppConfig::class), + eventBus: $container->get(EventBus::class), ); } } diff --git a/packages/console/src/Commands/TailProjectLogCommand.php b/packages/log/src/TailLogsCommand.php similarity index 55% rename from packages/console/src/Commands/TailProjectLogCommand.php rename to packages/log/src/TailLogsCommand.php index 0777335f7a..b4cd35543c 100644 --- a/packages/console/src/Commands/TailProjectLogCommand.php +++ b/packages/log/src/TailLogsCommand.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tempest\Console\Commands; +namespace Tempest\Log; use Tempest\Console\Console; use Tempest\Console\ConsoleCommand; @@ -12,53 +12,41 @@ use Tempest\Highlight\Highlighter; use Tempest\Log\Channels\AppendLogChannel; use Tempest\Log\LogConfig; +use Tempest\Support\Filesystem; -final readonly class TailProjectLogCommand +final readonly class TailLogsCommand { public function __construct( private Console $console, - private LogConfig $logConfig, + private LogConfig $config, #[Tag('console')] private Highlighter $highlighter, ) {} - #[ConsoleCommand('tail:project', description: 'Tails the project log')] + #[ConsoleCommand('tail:logs', description: 'Tails the project logs', aliases: ['log:tail', 'logs:tail'])] public function __invoke(): void { $appendLogChannel = null; - foreach ($this->logConfig->channels as $channel) { + foreach ($this->config->logChannels as $channel) { if ($channel instanceof AppendLogChannel) { $appendLogChannel = $channel; - break; } } if ($appendLogChannel === null) { - $this->console->error('No AppendLogChannel registered'); - + $this->console->error('Tailing logs is only supported when a AppendLogChannel is configured.'); return; } - $dir = pathinfo($appendLogChannel->getPath(), PATHINFO_DIRNAME); - - if (! is_dir($dir)) { - mkdir($dir); - } - - if (! file_exists($appendLogChannel->getPath())) { - touch($appendLogChannel->getPath()); - } + Filesystem\create_file($appendLogChannel->path); - $this->console->header('Tailing project logs', "Reading getPath()}'/>…"); + $this->console->header('Tailing project logs', "Reading path}'/>…"); new TailReader()->tail( - path: $appendLogChannel->getPath(), - format: fn (string $text) => $this->highlighter->parse( - $text, - new LogLanguage(), - ), + path: $appendLogChannel->path, + format: fn (string $text) => $this->highlighter->parse($text, new LogLanguage()), ); } } diff --git a/packages/log/src/logging.config.php b/packages/log/src/logging.config.php new file mode 100644 index 0000000000..227806df96 --- /dev/null +++ b/packages/log/src/logging.config.php @@ -0,0 +1,14 @@ +assertSame($expected, LogLevel::fromMonolog($level)); } diff --git a/packages/mail/src/Attachment.php b/packages/mail/src/Attachment.php index 7ad4b61b8a..b0a88f9805 100644 --- a/packages/mail/src/Attachment.php +++ b/packages/mail/src/Attachment.php @@ -61,7 +61,7 @@ public static function fromFilesystem(string $path, ?string $name = null, ?strin $path = Path\normalize($path); if (! Filesystem\is_file($path)) { - throw new FileAttachmentWasNotFound($path); + throw FileAttachmentWasNotFound::forFilesystemFile($path); } return new self( diff --git a/packages/mail/src/Transports/Postmark/PostmarkConfig.php b/packages/mail/src/Transports/Postmark/PostmarkConfig.php index f04cd863b7..2e8a7741cc 100644 --- a/packages/mail/src/Transports/Postmark/PostmarkConfig.php +++ b/packages/mail/src/Transports/Postmark/PostmarkConfig.php @@ -53,7 +53,7 @@ public function __construct( public function createTransport(): TransportInterface { return new PostmarkTransportFactory()->create(new Dsn( - scheme: PostmarkConnectionScheme::API->value, + scheme: $this->scheme->value, host: $this->host ?? 'default', user: $this->key, port: $this->port, diff --git a/packages/mail/src/mail.config.php b/packages/mail/src/mail.config.php index b91c2affb5..2865a4b73a 100644 --- a/packages/mail/src/mail.config.php +++ b/packages/mail/src/mail.config.php @@ -20,7 +20,7 @@ 'smtps' => SmtpScheme::SMTPS, 'smtp' => SmtpScheme::SMTP, }, - host: env('MAIL_SMTP_HOST', default: '127.0.0.0'), + host: env('MAIL_SMTP_HOST', default: '127.0.0.1'), port: env('MAIL_SMTP_PORT', default: 2525), username: env('MAIL_SMTP_USERNAME', default: ''), password: env('MAIL_SMTP_PASSWORD', default: ''), diff --git a/packages/mapper/src/Casters/EnumCaster.php b/packages/mapper/src/Casters/EnumCaster.php index c4e6489e3d..3520662d88 100644 --- a/packages/mapper/src/Casters/EnumCaster.php +++ b/packages/mapper/src/Casters/EnumCaster.php @@ -30,6 +30,10 @@ public function cast(mixed $input): ?object return constant("{$this->enum}::{$input}"); } + if (! is_a($this->enum, \BackedEnum::class, allow_string: true)) { + return null; + } + return forward_static_call("{$this->enum}::from", $input); } } diff --git a/packages/mapper/src/Mappers/ObjectToArrayMapper.php b/packages/mapper/src/Mappers/ObjectToArrayMapper.php index beaa946807..2012b2e99b 100644 --- a/packages/mapper/src/Mappers/ObjectToArrayMapper.php +++ b/packages/mapper/src/Mappers/ObjectToArrayMapper.php @@ -49,6 +49,10 @@ public function map(mixed $from, mixed $to): mixed private function resolvePropertyValue(PropertyReflector $property, object $object): mixed { + if (! $property->isInitialized($object)) { + return null; + } + $propertyValue = $property->getValue($object); if ($property->getIterableType()?->isClass()) { diff --git a/packages/process/src/InvokedProcessPool.php b/packages/process/src/InvokedProcessPool.php index 1fa2b51867..1e8598bb12 100644 --- a/packages/process/src/InvokedProcessPool.php +++ b/packages/process/src/InvokedProcessPool.php @@ -29,7 +29,7 @@ final class InvokedProcessPool implements Countable } public function __construct( - /** @var MutableArray */ + /** @var MutableArray */ private MutableArray $processes, ) {} diff --git a/packages/process/src/ProcessResult.php b/packages/process/src/ProcessResult.php index e602e2c3fe..ce1cc47504 100644 --- a/packages/process/src/ProcessResult.php +++ b/packages/process/src/ProcessResult.php @@ -34,7 +34,7 @@ public function failed(): bool public static function fromSymfonyProcess(SymfonyProcess $process): self { return new self( - exitCode: $process->getExitCode(), + exitCode: $process->getExitCode() ?? -1, output: $process->getOutput(), errorOutput: $process->getErrorOutput(), ); diff --git a/packages/process/src/Testing/InvokedTestingProcess.php b/packages/process/src/Testing/InvokedTestingProcess.php index d9d5271257..723015b692 100644 --- a/packages/process/src/Testing/InvokedTestingProcess.php +++ b/packages/process/src/Testing/InvokedTestingProcess.php @@ -115,11 +115,7 @@ public function signal(int $signal): self public function stop(float|int|Duration $timeout = 10, ?int $signal = null): self { - if ($timeout instanceof Duration) { - $timeout = $timeout->getTotalSeconds(); - } - - $this->process->stop((float) $timeout, $signal); + $this->remainingRunIterations = 0; return $this; } diff --git a/packages/process/src/Testing/TestingProcessExecutor.php b/packages/process/src/Testing/TestingProcessExecutor.php index f67de4192b..9f0308d958 100644 --- a/packages/process/src/Testing/TestingProcessExecutor.php +++ b/packages/process/src/Testing/TestingProcessExecutor.php @@ -2,7 +2,6 @@ namespace Tempest\Process\Testing; -use RuntimeException; use Tempest\Process\GenericProcessExecutor; use Tempest\Process\InvokedProcess; use Tempest\Process\InvokedSystemProcess; diff --git a/packages/router/src/HttpApplication.php b/packages/router/src/HttpApplication.php index 78b6e6961d..faee9bc69a 100644 --- a/packages/router/src/HttpApplication.php +++ b/packages/router/src/HttpApplication.php @@ -11,11 +11,6 @@ use Tempest\Core\Tempest; use Tempest\Http\RequestFactory; use Tempest\Http\Session\SessionManager; -use Tempest\Log\Channels\AppendLogChannel; -use Tempest\Log\LogConfig; - -use function Tempest\env; -use function Tempest\Support\path; #[Singleton] final readonly class HttpApplication implements Application @@ -25,24 +20,9 @@ public function __construct( ) {} /** @param \Tempest\Discovery\DiscoveryLocation[] $discoveryLocations */ - public static function boot( - string $root, - array $discoveryLocations = [], - ): self { - $container = Tempest::boot($root, $discoveryLocations); - - $application = $container->get(HttpApplication::class); - - // Application-specific setup - $logConfig = $container->get(LogConfig::class); - - if ($logConfig->debugLogPath === null && $logConfig->serverLogPath === null && $logConfig->channels === []) { - $logConfig->debugLogPath = path($container->get(Kernel::class)->root, '/log/debug.log')->toString(); - $logConfig->serverLogPath = env('SERVER_LOG'); - $logConfig->channels[] = new AppendLogChannel(path($root, '/log/tempest.log')->toString()); - } - - return $application; + public static function boot(string $root, array $discoveryLocations = []): self + { + return Tempest::boot($root, $discoveryLocations)->get(HttpApplication::class); } public function run(): void diff --git a/packages/support/src/Arr/IsIterable.php b/packages/support/src/Arr/IsIterable.php index ca97e14f75..e293451e42 100644 --- a/packages/support/src/Arr/IsIterable.php +++ b/packages/support/src/Arr/IsIterable.php @@ -24,7 +24,7 @@ public function key(): string|int|null public function valid(): bool { - return $this->current() !== false; + return $this->key() !== null; } public function rewind(): void diff --git a/packages/support/src/Arr/functions.php b/packages/support/src/Arr/functions.php index c618e40257..b1a5695d0f 100644 --- a/packages/support/src/Arr/functions.php +++ b/packages/support/src/Arr/functions.php @@ -516,6 +516,12 @@ function combine(iterable $array, iterable $values): array $array = to_array($array); $values = to_array($values); + if (count($array) !== count($values)) { + throw new InvalidArgumentException( + sprintf('Cannot combine arrays of different lengths (%d keys vs %d values)', count($array), count($values)), + ); + } + return array_combine($array, $values); } @@ -792,6 +798,10 @@ function get_by_key(iterable $array, int|string $key, mixed $default = null): mi : explode('.', $key); foreach ($keys as $key) { + if (! is_array($value) && ! $value instanceof \ArrayAccess) { + return $default; + } + if (! isset($value[$key])) { return $default; } @@ -843,11 +853,12 @@ function has_key(iterable $array, int|string $key): bool */ function contains(iterable $array, mixed $search): bool { + $array = to_array($array); $search = $search instanceof Closure ? $search : static fn (mixed $value) => $value === $search; - return namespace\first(to_array($array), $search) !== null; + return array_any($array, static fn ($value, $key) => $search($value, $key)); } /** diff --git a/packages/validation/src/Rules/DoesNotEndWith.php b/packages/validation/src/Rules/DoesNotEndWith.php index c57623d99b..532f8829a7 100644 --- a/packages/validation/src/Rules/DoesNotEndWith.php +++ b/packages/validation/src/Rules/DoesNotEndWith.php @@ -20,6 +20,10 @@ public function __construct( public function isValid(mixed $value): bool { + if (! is_string($value)) { + return false; + } + return ! str_ends_with($value, $this->needle); } diff --git a/packages/validation/src/Rules/DoesNotStartWith.php b/packages/validation/src/Rules/DoesNotStartWith.php index 8128592019..0e17fae120 100644 --- a/packages/validation/src/Rules/DoesNotStartWith.php +++ b/packages/validation/src/Rules/DoesNotStartWith.php @@ -20,6 +20,10 @@ public function __construct( public function isValid(mixed $value): bool { + if (! is_string($value)) { + return false; + } + return ! str_starts_with($value, $this->needle); } diff --git a/packages/validation/src/Rules/EndsWith.php b/packages/validation/src/Rules/EndsWith.php index 43f639eaec..5e42c038fd 100644 --- a/packages/validation/src/Rules/EndsWith.php +++ b/packages/validation/src/Rules/EndsWith.php @@ -20,6 +20,10 @@ public function __construct( public function isValid(mixed $value): bool { + if (! is_string($value)) { + return false; + } + return str_ends_with($value, $this->needle); } diff --git a/packages/validation/src/Rules/HasCount.php b/packages/validation/src/Rules/HasCount.php index 7d2b91df35..7f8d62bf0b 100644 --- a/packages/validation/src/Rules/HasCount.php +++ b/packages/validation/src/Rules/HasCount.php @@ -26,6 +26,10 @@ public function __construct( public function isValid(mixed $value): bool { + if (! is_array($value) && ! $value instanceof \Countable) { + return false; + } + $length = count($value); $min = $this->min ?? $length; diff --git a/packages/validation/src/Rules/HasLength.php b/packages/validation/src/Rules/HasLength.php index 78641575f0..1e1756f1ea 100644 --- a/packages/validation/src/Rules/HasLength.php +++ b/packages/validation/src/Rules/HasLength.php @@ -30,7 +30,7 @@ public function isValid(mixed $value): bool return false; } - $length = strlen($value); + $length = mb_strlen($value); $min = $this->min ?? $length; $max = $this->max ?? $length; diff --git a/packages/validation/src/Rules/IsBetween.php b/packages/validation/src/Rules/IsBetween.php index 0c514c3362..c5e5dfec14 100644 --- a/packages/validation/src/Rules/IsBetween.php +++ b/packages/validation/src/Rules/IsBetween.php @@ -21,6 +21,10 @@ public function __construct( public function isValid(mixed $value): bool { + if (! is_numeric($value)) { + return false; + } + return $value >= $this->min && $value <= $this->max; } diff --git a/packages/validation/src/Rules/IsDivisibleBy.php b/packages/validation/src/Rules/IsDivisibleBy.php index 6ae654dee4..eaa7311d32 100644 --- a/packages/validation/src/Rules/IsDivisibleBy.php +++ b/packages/validation/src/Rules/IsDivisibleBy.php @@ -20,11 +20,13 @@ public function __construct( public function isValid(mixed $value): bool { - if (! is_numeric($value) || $value === 0) { + if (! is_numeric($value)) { return false; } - return new IsMultipleOf($this->divisor)->isValid($value); + $intValue = (int) $value; + + return ($intValue % $this->divisor) === 0; } public function getTranslationVariables(): array diff --git a/packages/validation/src/Rules/IsEnum.php b/packages/validation/src/Rules/IsEnum.php index 4a53164dcc..ff598942cd 100644 --- a/packages/validation/src/Rules/IsEnum.php +++ b/packages/validation/src/Rules/IsEnum.php @@ -62,6 +62,8 @@ enum: $this->enum, ...$this->only, ...(is_array($values) ? $values : func_get_args()), ], + except: $this->except, + orNull: $this->orNull, ); } diff --git a/packages/validation/src/Rules/IsJsonString.php b/packages/validation/src/Rules/IsJsonString.php index 20ce516ccd..246460543e 100644 --- a/packages/validation/src/Rules/IsJsonString.php +++ b/packages/validation/src/Rules/IsJsonString.php @@ -20,6 +20,10 @@ public function __construct( public function isValid(mixed $value): bool { + if (! is_string($value)) { + return false; + } + $arguments = ['json' => $value]; if ($this->depth !== null) { diff --git a/packages/validation/src/Rules/IsLowercase.php b/packages/validation/src/Rules/IsLowercase.php index dc539597d7..e6e3f3d67d 100644 --- a/packages/validation/src/Rules/IsLowercase.php +++ b/packages/validation/src/Rules/IsLowercase.php @@ -15,6 +15,10 @@ { public function isValid(mixed $value): bool { + if (! is_string($value)) { + return false; + } + return $value === mb_strtolower($value); } } diff --git a/packages/validation/src/Rules/IsPassword.php b/packages/validation/src/Rules/IsPassword.php index 1b723db05f..04447f0815 100644 --- a/packages/validation/src/Rules/IsPassword.php +++ b/packages/validation/src/Rules/IsPassword.php @@ -32,7 +32,7 @@ public function isValid(mixed $value): bool return false; } - if (strlen($value) < $this->min) { + if (mb_strlen($value) < $this->min) { return false; } diff --git a/packages/validation/src/Rules/IsUnixTimestamp.php b/packages/validation/src/Rules/IsUnixTimestamp.php index 1bbfd5acb9..b4bb55517c 100644 --- a/packages/validation/src/Rules/IsUnixTimestamp.php +++ b/packages/validation/src/Rules/IsUnixTimestamp.php @@ -15,7 +15,7 @@ { public function isValid(mixed $value): bool { - if (! filter_var($value, FILTER_VALIDATE_INT)) { + if (filter_var($value, FILTER_VALIDATE_INT) === false) { return false; } diff --git a/packages/validation/src/Rules/IsUppercase.php b/packages/validation/src/Rules/IsUppercase.php index 3bc8883d91..f59af2508c 100644 --- a/packages/validation/src/Rules/IsUppercase.php +++ b/packages/validation/src/Rules/IsUppercase.php @@ -15,6 +15,10 @@ { public function isValid(mixed $value): bool { + if (! is_string($value)) { + return false; + } + return $value === mb_strtoupper($value); } } diff --git a/packages/validation/src/Rules/StartsWith.php b/packages/validation/src/Rules/StartsWith.php index 88621a7986..f97028f088 100644 --- a/packages/validation/src/Rules/StartsWith.php +++ b/packages/validation/src/Rules/StartsWith.php @@ -20,6 +20,10 @@ public function __construct( public function isValid(mixed $value): bool { + if (! is_string($value)) { + return false; + } + return str_starts_with($value, $this->needle); } diff --git a/packages/validation/tests/Rules/DivisibleByTest.php b/packages/validation/tests/Rules/DivisibleByTest.php index b57abe09d9..ba9665d92b 100644 --- a/packages/validation/tests/Rules/DivisibleByTest.php +++ b/packages/validation/tests/Rules/DivisibleByTest.php @@ -18,7 +18,7 @@ public function test_it_works(): void $this->assertTrue($rule->isValid(10)); $this->assertTrue($rule->isValid(5)); - $this->assertFalse($rule->isValid(0)); + $this->assertTrue($rule->isValid(0)); $this->assertFalse($rule->isValid(3)); $this->assertFalse($rule->isValid(4)); diff --git a/packages/vite-plugin-tempest/package.json b/packages/vite-plugin-tempest/package.json index c79dc63c2c..025f0f8a7c 100644 --- a/packages/vite-plugin-tempest/package.json +++ b/packages/vite-plugin-tempest/package.json @@ -1,7 +1,7 @@ { "name": "vite-plugin-tempest", "type": "module", - "version": "2.12.0", + "version": "2.13.0", "author": "Enzo Innocenzi", "license": "MIT", "sideEffects": false, diff --git a/packages/vite-plugin-tempest/src/config.ts b/packages/vite-plugin-tempest/src/config.ts index ea16f2339d..0a2e1b41e8 100644 --- a/packages/vite-plugin-tempest/src/config.ts +++ b/packages/vite-plugin-tempest/src/config.ts @@ -52,7 +52,11 @@ async function loadConfigurationFromTempestConsole(): Promise): { * Resolve the host name from the environment. */ function resolveHostFromEnv(env: Record): string | undefined { - if (env.VITE_DEV_SERVER_KEY) { - return env.VITE_DEV_SERVER_KEY - } - try { return new URL(env.BASE_URI).host } catch { diff --git a/packages/vite/src/PrefetchConfig.php b/packages/vite/src/PrefetchConfig.php index a156bae303..ef3ffbd4ab 100644 --- a/packages/vite/src/PrefetchConfig.php +++ b/packages/vite/src/PrefetchConfig.php @@ -13,7 +13,7 @@ public function __construct( public PrefetchStrategy $strategy = PrefetchStrategy::NONE, /** - * Number of assets to prefech concurrently when using the waterfall strategy. + * Number of assets to prefetch concurrently when using the waterfall strategy. */ public int $concurrent = 3, diff --git a/src/Tempest/Framework/Commands/DatabaseSeedCommand.php b/src/Tempest/Framework/Commands/DatabaseSeedCommand.php index 033a834929..1c79e48f3b 100644 --- a/src/Tempest/Framework/Commands/DatabaseSeedCommand.php +++ b/src/Tempest/Framework/Commands/DatabaseSeedCommand.php @@ -36,6 +36,12 @@ public function __invoke( return; } + if ($this->seederConfig->seeders === []) { + $this->console->info('No seeders are configured.'); + + return; + } + if (count($this->seederConfig->seeders) === 1) { $this->runSeeder($this->seederConfig->seeders[0], $database); return; diff --git a/src/Tempest/Framework/Commands/MetaViewComponentCommand.php b/src/Tempest/Framework/Commands/MetaViewComponentCommand.php index 7a877500f7..1ce98f187c 100644 --- a/src/Tempest/Framework/Commands/MetaViewComponentCommand.php +++ b/src/Tempest/Framework/Commands/MetaViewComponentCommand.php @@ -60,20 +60,13 @@ private function makeData(ViewComponent $viewComponent): ImmutableArray private function resolveViewComponent(string $viewComponent): ?ViewComponent { if (is_file($viewComponent)) { - foreach ($this->viewConfig->viewComponents as $registeredViewComponent) { - if ($registeredViewComponent->file !== $viewComponent) { - continue; - } - - $viewComponent = $registeredViewComponent; - - break; - } - } else { - $viewComponent = $this->viewConfig->viewComponents[$viewComponent] ?? null; + return array_find( + array: $this->viewConfig->viewComponents, + callback: fn ($registeredViewComponent) => $registeredViewComponent->file === $viewComponent, + ); } - return $viewComponent; + return $this->viewConfig->viewComponents[$viewComponent] ?? null; } private function resolveSlots(ViewComponent $viewComponent): ImmutableArray diff --git a/src/Tempest/Framework/Testing/IntegrationTest.php b/src/Tempest/Framework/Testing/IntegrationTest.php index 08ea0e93d4..fb085ac682 100644 --- a/src/Tempest/Framework/Testing/IntegrationTest.php +++ b/src/Tempest/Framework/Testing/IntegrationTest.php @@ -16,7 +16,6 @@ use Tempest\Console\OutputBuffer; use Tempest\Console\Testing\ConsoleTester; use Tempest\Container\GenericContainer; -use Tempest\Core\AppConfig; use Tempest\Core\ExceptionTester; use Tempest\Core\FrameworkKernel; use Tempest\Core\Kernel; @@ -52,8 +51,6 @@ abstract class IntegrationTest extends TestCase /** @var \Tempest\Discovery\DiscoveryLocation[] */ protected array $discoveryLocations = []; - protected AppConfig $appConfig; - protected Kernel $kernel; protected GenericContainer $container; @@ -127,8 +124,6 @@ protected function setupKernel(): self $container = $this->kernel->container; $this->container = $container; - $this->appConfig = $this->container->get(className: AppConfig::class); - return $this; } @@ -228,8 +223,6 @@ protected function tearDown(): void /** @phpstan-ignore-next-line */ unset($this->discoveryLocations); /** @phpstan-ignore-next-line */ - unset($this->appConfig); - /** @phpstan-ignore-next-line */ unset($this->kernel); /** @phpstan-ignore-next-line */ unset($this->container); diff --git a/tests/Integration/Auth/OAuth/TestingOAuthClientTest.php b/tests/Integration/Auth/OAuth/TestingOAuthClientTest.php index 1037817627..87279d839b 100644 --- a/tests/Integration/Auth/OAuth/TestingOAuthClientTest.php +++ b/tests/Integration/Auth/OAuth/TestingOAuthClientTest.php @@ -5,6 +5,7 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\TestWith; use Tempest\Auth\Authentication\Authenticatable; +use Tempest\Auth\Exceptions\OAuthStateWasInvalid; use Tempest\Auth\OAuth\Config\GitHubOAuthConfig; use Tempest\Auth\OAuth\OAuthClient; use Tempest\Auth\OAuth\OAuthUser; @@ -263,6 +264,69 @@ public function abstracted_flow(): void $this->assertEquals('Jane Developer', $user->full_name); $this->assertEquals('janedev', $user->username); } + + #[Test] + public function state_is_cleared_after_authentication(): void + { + $this->database->reset(migrate: false); + $this->database->migrate(CreateMigrationsTable::class, CreateUsersTable::class); + + $this->container->config(new GitHubOAuthConfig( + clientId: 'foo', + clientSecret: 'bar', // @mago-expect lint:no-literal-password + redirectTo: '/oauth/github', + )); + + $client = $this->oauth->fake($this->user); + + $client->createRedirect(); + + $this->assertNotNull($client->getState()); + + $request = new GenericRequest( + method: Method::GET, + uri: Uri\set_query('/oauth/callback', code: 'auth-code', state: $client->getState()), + ); + + $client->authenticate( + $request, + static fn (OAuthUser $user): User => query(User::class)->updateOrCreate( + ['github_id' => $user->id], + ['email' => $user->email ?? '', 'full_name' => $user->name ?? '', 'username' => $user->nickname ?? ''], + ), + ); + + $this->assertNull($client->getState()); + } + + #[Test] + public function state_is_cleared_even_when_validation_fails(): void + { + $this->container->config(new GitHubOAuthConfig( + clientId: 'foo', + clientSecret: 'bar', // @mago-expect lint:no-literal-password + redirectTo: '/oauth/github', + )); + + $client = $this->oauth->fake($this->user); + + $client->createRedirect(); + + $this->assertNotNull($client->getState()); + + $request = new GenericRequest( + method: Method::GET, + uri: Uri\set_query('/oauth/callback', code: 'auth-code', state: 'invalid-state'), + ); + + try { + $client->authenticate($request, static fn (OAuthUser $user): User => new User('', '', '', '')); + } catch (OAuthStateWasInvalid) { + // @mago-expect lint:no-empty-catch-clause + } + + $this->assertNull($client->getState()); + } } final class User implements Authenticatable diff --git a/tests/Integration/Console/Commands/CompleteCommandTest.php b/tests/Integration/Console/Commands/CompleteCommandTest.php index 12462a380f..a61125e253 100644 --- a/tests/Integration/Console/Commands/CompleteCommandTest.php +++ b/tests/Integration/Console/Commands/CompleteCommandTest.php @@ -4,6 +4,7 @@ namespace Tests\Tempest\Integration\Console\Commands; +use PHPUnit\Framework\Attributes\Test; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; /** @@ -11,20 +12,22 @@ */ final class CompleteCommandTest extends FrameworkIntegrationTestCase { - public function test_complete_commands(): void + #[Test] + public function complete_commands(): void { $this->console ->complete() - ->assertSee('tail:server' . PHP_EOL) + ->assertSee('migrate:up' . PHP_EOL) ->assertSee('schedule:run' . PHP_EOL); } - public function test_complete_arguments(): void + #[Test] + public function complete_arguments(): void { $this->console - ->complete('tail:') - ->assertSee('tail:server' . PHP_EOL) - ->assertSee('tail:project' . PHP_EOL) - ->assertSee('tail:debug' . PHP_EOL); + ->complete('migrate:') + ->assertSee('migrate:down' . PHP_EOL) + ->assertSee('migrate:up' . PHP_EOL) + ->assertSee('migrate:rehash' . PHP_EOL); } } diff --git a/tests/Integration/Log/GenericLoggerTest.php b/tests/Integration/Log/GenericLoggerTest.php index 8120b8707c..ffcab0b465 100644 --- a/tests/Integration/Log/GenericLoggerTest.php +++ b/tests/Integration/Log/GenericLoggerTest.php @@ -6,16 +6,24 @@ use Monolog\Level; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\PostCondition; +use PHPUnit\Framework\Attributes\PreCondition; +use PHPUnit\Framework\Attributes\Test; use Psr\Log\LogLevel as PsrLogLevel; use ReflectionClass; +use Tempest\Core\AppConfig; +use Tempest\DateTime\Duration; use Tempest\EventBus\EventBus; use Tempest\Log\Channels\AppendLogChannel; -use Tempest\Log\Channels\DailyLogChannel; -use Tempest\Log\Channels\WeeklyLogChannel; +use Tempest\Log\Config\DailyLogConfig; +use Tempest\Log\Config\MultipleChannelsLogConfig; +use Tempest\Log\Config\NullLogConfig; +use Tempest\Log\Config\SimpleLogConfig; +use Tempest\Log\Config\WeeklyLogConfig; use Tempest\Log\GenericLogger; -use Tempest\Log\LogConfig; use Tempest\Log\LogLevel; use Tempest\Log\MessageLogged; +use Tempest\Support\Filesystem; use Tests\Tempest\Integration\FrameworkIntegrationTestCase; /** @@ -23,118 +31,121 @@ */ final class GenericLoggerTest extends FrameworkIntegrationTestCase { - public function test_append_log_channel_works(): void - { - $filePath = __DIR__ . '/logs/tempest.log'; - - $config = new LogConfig( - channels: [ - new AppendLogChannel($filePath), - ], - ); - - $logger = new GenericLogger($config, $this->container->get(EventBus::class)); + private EventBus $bus { + get => $this->container->get(EventBus::class); + } - $logger->info('test'); + private AppConfig $appConfig { + get => $this->container->get(AppConfig::class); + } - $this->assertFileExists($filePath); + #[PreCondition] + protected function configure(): void + { + Filesystem\ensure_directory_empty(__DIR__ . '/logs'); + } - $this->assertStringContainsString('test', file_get_contents($filePath)); + #[PostCondition] + protected function cleanup(): void + { + Filesystem\delete_directory(__DIR__ . '/logs'); } - protected function tearDown(): void + #[Test] + public function simple_log_config(): void { - $files = glob(__DIR__ . '/logs/*.log'); + $filePath = __DIR__ . '/logs/tempest.log'; - foreach ($files as $file) { - if (is_file($file)) { - unlink($file); - } - } + $config = new SimpleLogConfig($filePath, prefix: 'tempest'); + + $logger = new GenericLogger($config, $this->appConfig, $this->bus); + $logger->info('test'); + + $this->assertFileExists($filePath); + $this->assertStringContainsString('test', Filesystem\read_file($filePath)); } - public function test_daily_log_channel_works(): void + #[Test] + public function daily_log_config(): void { + $clock = $this->clock(); $filePath = __DIR__ . '/logs/tempest-' . date('Y-m-d') . '.log'; + $config = new DailyLogConfig(__DIR__ . '/logs/tempest.log', prefix: 'tempest'); - $config = new LogConfig( - channels: [ - new DailyLogChannel(__DIR__ . '/logs/tempest.log'), - ], - ); - - $logger = new GenericLogger($config, $this->container->get(EventBus::class)); - + $logger = new GenericLogger($config, $this->appConfig, $this->bus); $logger->info('test'); $this->assertFileExists($filePath); + $this->assertStringContainsString('test', Filesystem\read_file($filePath)); + + $clock->plus(Duration::day()); + $logger = new GenericLogger($config, $this->appConfig, $this->bus); + $logger->info('test'); - $this->assertStringContainsString('test', file_get_contents($filePath)); + $clock->plus(Duration::days(2)); + $logger = new GenericLogger($config, $this->appConfig, $this->bus); + $logger->info('test'); } - public function test_weekly_log_channel_works(): void + #[Test] + public function weekly_log_config(): void { $filePath = __DIR__ . '/logs/tempest-' . date('Y-W') . '.log'; + $config = new WeeklyLogConfig(__DIR__ . '/logs/tempest.log', prefix: 'tempest'); - $config = new LogConfig( - channels: [ - new WeeklyLogChannel(__DIR__ . '/logs/tempest.log'), - ], - ); - - $logger = new GenericLogger($config, $this->container->get(EventBus::class)); - + $logger = new GenericLogger($config, $this->appConfig, $this->bus); $logger->info('test'); $this->assertFileExists($filePath); - - $this->assertStringContainsString('test', file_get_contents($filePath)); + $this->assertStringContainsString('test', Filesystem\read_file($filePath)); } - public function test_multiple_same_log_channels_works(): void + #[Test] + public function multiple_same_log_channels(): void { $filePath = __DIR__ . '/logs/multiple-tempest1.log'; $secondFilePath = __DIR__ . '/logs/multiple-tempest2.log'; - $config = new LogConfig( + $config = new MultipleChannelsLogConfig( channels: [ new AppendLogChannel($filePath), new AppendLogChannel($secondFilePath), ], + prefix: 'tempest', ); - $logger = new GenericLogger($config, $this->container->get(EventBus::class)); + $logger = new GenericLogger($config, $this->appConfig, $this->bus); $logger->info('test'); $this->assertFileExists($filePath); - $this->assertStringContainsString('test', file_get_contents($filePath)); + $this->assertStringContainsString('test', Filesystem\read_file($filePath)); $this->assertFileExists($secondFilePath); - $this->assertStringContainsString('test', file_get_contents($secondFilePath)); + $this->assertStringContainsString('test', Filesystem\read_file($secondFilePath)); } + #[Test] #[DataProvider('psrLogLevelProvider')] #[DataProvider('monologLevelProvider')] #[DataProvider('tempestLevelProvider')] - public function test_log_levels(mixed $level, string $expected): void + public function log_levels(mixed $level, string $expected): void { $filePath = __DIR__ . '/logs/tempest.log'; - $config = new LogConfig( + $config = new SimpleLogConfig( + path: $filePath, prefix: 'tempest', - channels: [ - new AppendLogChannel($filePath), - ], ); - $logger = new GenericLogger($config, $this->container->get(EventBus::class)); + $logger = new GenericLogger($config, $this->appConfig, $this->bus); $logger->log($level, 'test'); $this->assertFileExists($filePath); - $this->assertStringContainsString('tempest.' . $expected, file_get_contents($filePath)); + $this->assertStringContainsString('tempest.' . $expected, Filesystem\read_file($filePath)); } + #[Test] #[DataProvider('tempestLevelProvider')] - public function test_message_logged_emitted(LogLevel $level, string $_expected): void + public function message_logged_emitted(LogLevel $level, string $_expected): void { $eventBus = $this->container->get(EventBus::class); @@ -144,28 +155,26 @@ public function test_message_logged_emitted(LogLevel $level, string $_expected): $this->assertSame(['foo' => 'bar'], $event->context); }); - $logger = new GenericLogger(new LogConfig(), $eventBus); + $logger = new GenericLogger(new NullLogConfig(), $this->appConfig, $this->bus); $logger->log($level, 'This is a log message of level: ' . $level->value, context: ['foo' => 'bar']); } - public function test_different_log_levels_works(): void + #[Test] + public function different_log_levels(): void { $filePath = __DIR__ . '/logs/tempest.log'; - $config = new LogConfig( + $config = new SimpleLogConfig( + path: $filePath, prefix: 'tempest', - channels: [ - new AppendLogChannel($filePath), - ], ); - $logger = new GenericLogger($config, $this->container->get(EventBus::class)); + $logger = new GenericLogger($config, $this->appConfig, $this->bus); $logger->critical('critical'); $logger->debug('debug'); $this->assertFileExists($filePath); - $content = file_get_contents($filePath); - $this->assertStringContainsString('critical', $content); - $this->assertStringContainsString('debug', $content); + $this->assertStringContainsString('critical', Filesystem\read_file($filePath)); + $this->assertStringContainsString('debug', Filesystem\read_file($filePath)); } public static function tempestLevelProvider(): array diff --git a/tests/Integration/Log/LogConfigTest.php b/tests/Integration/Log/LogConfigTest.php deleted file mode 100644 index ada19f1a1d..0000000000 --- a/tests/Integration/Log/LogConfigTest.php +++ /dev/null @@ -1,23 +0,0 @@ -container->get(LogConfig::class); - - $this->assertSame(root_path('log/debug.log'), $logConfig->debugLogPath); - } -}