瀏覽代碼

Initial release of psr3-logger

michelphp 1 天之前
當前提交
33ab9688cd

+ 4 - 0
.gitignore

@@ -0,0 +1,4 @@
+/.idea/
+/vendor/
+/var/
+composer.lock

+ 225 - 0
README.md

@@ -0,0 +1,225 @@
+# PHP Logger PSR-3
+
+A straightforward logging library for PHP that implements PSR-3, making it easy to integrate logging into your project.
+
+## Installation
+
+Install via [Composer](https://getcomposer.org/):
+
+```bash
+composer require michel/psr3-logger
+```
+
+## Requirements
+
+- PHP 7.4 or higher
+
+## Features
+
+- **PSR-3 Compliant**: Works with any PSR-3 compatible interface.
+- **Multiple Handlers**:
+    - `StreamHandler`: Log to streams (stdout, stderr).
+    - `FileHandler`: Log to a specific file.
+    - `RotatingFileHandler`: Log to files rotated by date.
+    - `SyslogHandler`: Log to system syslog.
+    - `MemoryHandler`: Log to an array (useful for testing).
+    - `GroupHandler`: Send logs to multiple handlers at once.
+- **Log Level Filtering**: Set minimum log levels for each handler.
+- **Custom Formatting**: Easily customize log message format.
+
+---
+
+## Usage
+
+### Basic Usage
+
+```php
+use Michel\Log\Logger;
+use Michel\Log\Handler\StreamHandler;
+use Psr\Log\LogLevel;
+
+// Log to stdout
+$handler = new StreamHandler('php://stdout');
+$logger = new Logger($handler);
+
+$logger->info('Hello World');
+```
+
+### Handlers
+
+#### StreamHandler
+Useful for containerized environments (Docker, Kubernetes).
+
+```php
+use Michel\Log\Handler\StreamHandler;
+
+$handler = new StreamHandler('php://stderr', LogLevel::ERROR);
+```
+
+#### RotatingFileHandler
+Creates a new log file every day.
+
+```php
+use Michel\Log\Handler\RotatingFileHandler;
+
+// Creates logs like /var/log/app-2023-11-24.log
+$handler = new RotatingFileHandler('/var/log/app.log');
+```
+
+#### SyslogHandler
+Logs to the operating system's syslog.
+
+```php
+use Michel\Log\Handler\SyslogHandler;
+
+$handler = new SyslogHandler('my-app');
+```
+
+#### GroupHandler
+Combine multiple handlers.
+
+```php
+use Michel\Log\Handler\GroupHandler;
+use Michel\Log\Handler\StreamHandler;
+use Michel\Log\Handler\RotatingFileHandler;
+
+$handler = new GroupHandler([
+    new StreamHandler('php://stdout'),
+    new RotatingFileHandler('/var/log/app.log')
+]);
+```
+
+### Custom Formatting
+
+You can customize the log format for any handler. The default format is `%timestamp% [%level%]: %message%`.
+
+```php
+$handler = new StreamHandler('php://stdout');
+$handler->setFormat('[%level%] %message%');
+```
+
+### Log Level Filtering
+
+You can set a minimum log level for any handler.
+
+```php
+use Psr\Log\LogLevel;
+
+// Only log ERROR and above
+$handler = new StreamHandler('php://stderr');
+$handler->setLevel(LogLevel::ERROR);
+```
+
+---
+
+# Documentation en Français
+
+Une bibliothèque de journalisation simple pour PHP qui implémente PSR-3, facilitant l'intégration des logs dans votre projet.
+
+## Installation
+
+Installer via [Composer](https://getcomposer.org/):
+
+```bash
+composer require michel/psr3-logger
+```
+
+## Prérequis
+
+- PHP 7.4 ou supérieur
+
+## Fonctionnalités
+
+- **Compatible PSR-3**: Fonctionne avec n'importe quelle interface compatible PSR-3.
+- **Plusieurs Gestionnaires (Handlers)**:
+    - `StreamHandler`: Journaliser vers des flux (stdout, stderr).
+    - `FileHandler`: Journaliser vers un fichier spécifique.
+    - `RotatingFileHandler`: Journaliser vers des fichiers rotatifs par date.
+    - `SyslogHandler`: Journaliser vers le syslog du système.
+    - `MemoryHandler`: Journaliser vers un tableau (utile pour les tests).
+    - `GroupHandler`: Envoyer des logs à plusieurs gestionnaires à la fois.
+- **Filtrage par Niveau de Log**: Définir des niveaux de log minimum pour chaque gestionnaire.
+- **Formatage Personnalisé**: Personnaliser facilement le format des messages de log.
+
+---
+
+## Utilisation
+
+### Utilisation de base
+
+```php
+use Michel\Log\Logger;
+use Michel\Log\Handler\StreamHandler;
+use Psr\Log\LogLevel;
+
+// Journaliser vers stdout
+$handler = new StreamHandler('php://stdout');
+$logger = new Logger($handler);
+
+$logger->info('Bonjour le monde');
+```
+
+### Gestionnaires (Handlers)
+
+#### StreamHandler
+Utile pour les environnements conteneurisés (Docker, Kubernetes).
+
+```php
+use Michel\Log\Handler\StreamHandler;
+
+$handler = new StreamHandler('php://stderr', LogLevel::ERROR);
+```
+
+#### RotatingFileHandler
+Crée un nouveau fichier de log chaque jour.
+
+```php
+use Michel\Log\Handler\RotatingFileHandler;
+
+// Crée des logs comme /var/log/app-2023-11-24.log
+$handler = new RotatingFileHandler('/var/log/app.log');
+```
+
+#### SyslogHandler
+Journalise vers le syslog du système d'exploitation.
+
+```php
+use Michel\Log\Handler\SyslogHandler;
+
+$handler = new SyslogHandler('mon-app');
+```
+
+#### GroupHandler
+Combiner plusieurs gestionnaires.
+
+```php
+use Michel\Log\Handler\GroupHandler;
+use Michel\Log\Handler\StreamHandler;
+use Michel\Log\Handler\RotatingFileHandler;
+
+$handler = new GroupHandler([
+    new StreamHandler('php://stdout'),
+    new RotatingFileHandler('/var/log/app.log')
+]);
+```
+
+### Formatage Personnalisé
+
+Vous pouvez personnaliser le format de log pour n'importe quel gestionnaire. Le format par défaut est `%timestamp% [%level%]: %message%`.
+
+```php
+$handler = new StreamHandler('php://stdout');
+$handler->setFormat('[%level%] %message%');
+```
+
+### Filtrage par Niveau de Log
+
+Vous pouvez définir un niveau de log minimum pour n'importe quel gestionnaire.
+
+```php
+use Psr\Log\LogLevel;
+
+// Ne journaliser que les ERREURS et supérieur
+$handler = new StreamHandler('php://stderr');
+$handler->setLevel(LogLevel::ERROR);
+```

+ 25 - 0
composer.json

@@ -0,0 +1,25 @@
+{
+    "name": "michel/psr3-logger",
+    "description": "A straightforward logging library for PHP that implements PSR-3, making it easy to integrate logging into your project.",
+    "type": "library",
+    "license": "MIT",
+    "authors": [
+        {
+            "name": "F. Michel"
+        }
+    ],
+    "autoload": {
+        "psr-4": {
+            "Michel\\Log\\": "src",
+            "Test\\Michel\\Log\\": "tests"
+        }
+    },
+    "minimum-stability": "alpha",
+    "require": {
+        "php": ">=7.4",
+        "psr/log": "^1.1|^2.0"
+    },
+    "require-dev": {
+        "michel/unitester": "^1.0.0"
+    }
+}

+ 64 - 0
src/Handler/AbstractHandler.php

@@ -0,0 +1,64 @@
+<?php
+
+namespace Michel\Log\Handler;
+
+use Psr\Log\LogLevel;
+
+abstract class AbstractHandler implements HandlerInterface
+{
+    protected const LEVELS = [
+        LogLevel::DEBUG => 100,
+        LogLevel::INFO => 200,
+        LogLevel::NOTICE => 250,
+        LogLevel::WARNING => 300,
+        LogLevel::ERROR => 400,
+        LogLevel::CRITICAL => 500,
+        LogLevel::ALERT => 550,
+        LogLevel::EMERGENCY => 600,
+    ];
+
+    protected int $minLevel = 100;
+    protected string $format = self::DEFAULT_FORMAT;
+
+    public function setLevel($level): void
+    {
+        $this->minLevel = self::LEVELS[$level] ?? 100;
+    }
+
+    public function setFormat(string $format): void
+    {
+        $this->format = $format;
+    }
+
+    public function handle(array $vars): void
+    {
+        $level = strtolower($vars['level']);
+        if (!$this->isHandling($level)) {
+            return;
+        }
+
+        $formatted = $this->format($vars);
+        $this->write($formatted);
+    }
+
+    protected function format(array $vars): string
+    {
+        $output = $this->format;
+        foreach ($vars as $var => $val) {
+            if (is_scalar($val) || (is_object($val) && method_exists($val, '__toString'))) {
+                if ($var === 'level') {
+                    $val = strtoupper($val);
+                }
+                $output = str_replace('%' . $var . '%', (string)$val, $output);
+            }
+        }
+        return $output;
+    }
+
+    protected function isHandling(string $level): bool
+    {
+        return (self::LEVELS[$level] ?? 100) >= $this->minLevel;
+    }
+
+    abstract protected function write(string $formatted): void;
+}

+ 28 - 0
src/Handler/FileHandler.php

@@ -0,0 +1,28 @@
+<?php
+
+namespace Michel\Log\Handler;
+
+use Psr\Log\LogLevel;
+
+class FileHandler extends AbstractHandler
+{
+    private string $filename;
+
+    public function __construct(string $filename, $level = LogLevel::DEBUG)
+    {
+        $this->setLevel($level);
+        $dir = dirname($filename);
+        if (!file_exists($dir)) {
+            $status = mkdir($dir, 0777, true);
+            if ($status === false && !is_dir($dir)) {
+                throw new \UnexpectedValueException(sprintf('There is no existing directory at "%s"', $dir));
+            }
+        }
+        $this->filename = $filename;
+    }
+
+    protected function write(string $formatted): void
+    {
+        file_put_contents($this->filename, $formatted . PHP_EOL, FILE_APPEND);
+    }
+}

+ 34 - 0
src/Handler/GroupHandler.php

@@ -0,0 +1,34 @@
+<?php
+
+namespace Michel\Log\Handler;
+
+use Psr\Log\LogLevel;
+
+class GroupHandler extends AbstractHandler
+{
+    /** @var HandlerInterface[] */
+    private array $handlers;
+
+    public function __construct(array $handlers, $level = LogLevel::DEBUG)
+    {
+        $this->setLevel($level);
+        $this->handlers = $handlers;
+    }
+
+    public function handle(array $vars): void
+    {
+        $level = strtolower($vars['level']);
+        if (!$this->isHandling($level)) {
+            return;
+        }
+
+        foreach ($this->handlers as $handler) {
+            $handler->handle($vars);
+        }
+    }
+
+    protected function write(string $formatted): void
+    {
+        // Not used directly, as handle() delegates to children
+    }
+}

+ 9 - 0
src/Handler/HandlerInterface.php

@@ -0,0 +1,9 @@
+<?php
+
+namespace Michel\Log\Handler;
+
+interface HandlerInterface
+{
+    public const DEFAULT_FORMAT = '%timestamp% [%level%]: %message%';
+    public function handle(array $vars): void;
+}

+ 26 - 0
src/Handler/MemoryHandler.php

@@ -0,0 +1,26 @@
+<?php
+
+namespace Michel\Log\Handler;
+
+use Psr\Log\LogLevel;
+
+final class MemoryHandler extends AbstractHandler
+{
+    private array $storage;
+
+    public function __construct(array &$storage = [], $level = LogLevel::DEBUG)
+    {
+        $this->setLevel($level);
+        // Removed the clearing of storage to allow appending, or we can keep it if strict behavior is needed.
+        // The original code cleared it:
+        // if (!empty($storage)) { $storage = []; }
+        // I will keep the behavior of NOT clearing it, as per plan improvement, but wait,
+        // the plan said "Improvement: Remove auto-clearing".
+        $this->storage = &$storage;
+    }
+
+    protected function write(string $formatted): void
+    {
+        $this->storage[] = $formatted;
+    }
+}

+ 43 - 0
src/Handler/RotatingFileHandler.php

@@ -0,0 +1,43 @@
+<?php
+
+namespace Michel\Log\Handler;
+
+use Psr\Log\LogLevel;
+
+class RotatingFileHandler extends AbstractHandler
+{
+    private string $filename;
+    private string $dateFormat;
+
+    public function __construct(string $filename, $level = LogLevel::DEBUG, string $dateFormat = 'Y-m-d')
+    {
+        $this->setLevel($level);
+        $this->filename = $filename;
+        $this->dateFormat = $dateFormat;
+
+        $dir = dirname($filename);
+        if (!file_exists($dir)) {
+            $status = mkdir($dir, 0777, true);
+            if ($status === false && !is_dir($dir)) {
+                throw new \UnexpectedValueException(sprintf('There is no existing directory at "%s"', $dir));
+            }
+        }
+    }
+
+    protected function write(string $formatted): void
+    {
+        $date = date($this->dateFormat);
+        $filename = $this->getTimedFilename($this->filename, $date);
+        file_put_contents($filename, $formatted . PHP_EOL, FILE_APPEND);
+    }
+
+    private function getTimedFilename(string $filename, string $date): string
+    {
+        $info = pathinfo($filename);
+        $dirname = $info['dirname'] ? $info['dirname'] . DIRECTORY_SEPARATOR : '';
+        $basename = $info['filename'];
+        $extension = isset($info['extension']) ? '.' . $info['extension'] : '';
+
+        return $dirname . $basename . '-' . $date . $extension;
+    }
+}

+ 31 - 0
src/Handler/StreamHandler.php

@@ -0,0 +1,31 @@
+<?php
+
+namespace Michel\Log\Handler;
+
+use Psr\Log\LogLevel;
+
+class StreamHandler extends AbstractHandler
+{
+    /** @var resource */
+    private $stream;
+
+    public function __construct($stream, $level = LogLevel::DEBUG)
+    {
+        $this->setLevel($level);
+        if (is_resource($stream)) {
+            $this->stream = $stream;
+        } elseif (is_string($stream)) {
+            $this->stream = fopen($stream, 'a');
+            if ($this->stream === false) {
+                throw new \UnexpectedValueException(sprintf('The stream or file "%s" could not be opened: failed to open stream', $stream));
+            }
+        } else {
+            throw new \InvalidArgumentException('A stream must either be a resource or a string.');
+        }
+    }
+
+    protected function write(string $formatted): void
+    {
+        fwrite($this->stream, $formatted . PHP_EOL);
+    }
+}

+ 57 - 0
src/Handler/SyslogHandler.php

@@ -0,0 +1,57 @@
+<?php
+
+namespace Michel\Log\Handler;
+
+use Psr\Log\LogLevel;
+
+class SyslogHandler extends AbstractHandler
+{
+    private string $ident;
+    private int $facility;
+
+    public function __construct(string $ident, int $facility = LOG_USER, $level = LogLevel::DEBUG)
+    {
+        $this->setLevel($level);
+        $this->ident = $ident;
+        $this->facility = $facility;
+        openlog($this->ident, LOG_PID | LOG_PERROR, $this->facility);
+    }
+
+    public function handle(array $vars): void
+    {
+        $level = strtolower($vars['level']);
+        if (!$this->isHandling($level)) {
+            return;
+        }
+
+        $formatted = $this->format($vars);
+
+        syslog($this->toSyslogPriority($vars['level']), $formatted);
+    }
+
+    protected function write(string $formatted): void
+    {
+        // Not used as handle() is overridden to access $vars['level']
+    }
+
+    private function toSyslogPriority(string $level): int
+    {
+        $map = [
+            LogLevel::DEBUG => LOG_DEBUG,
+            LogLevel::INFO => LOG_INFO,
+            LogLevel::NOTICE => LOG_NOTICE,
+            LogLevel::WARNING => LOG_WARNING,
+            LogLevel::ERROR => LOG_ERR,
+            LogLevel::CRITICAL => LOG_CRIT,
+            LogLevel::ALERT => LOG_ALERT,
+            LogLevel::EMERGENCY => LOG_EMERG,
+        ];
+
+        return $map[strtolower($level)] ?? LOG_INFO;
+    }
+
+    public function __destruct()
+    {
+        closelog();
+    }
+}

+ 39 - 0
src/Logger.php

@@ -0,0 +1,39 @@
+<?php
+
+namespace Michel\Log;
+
+use Michel\Log\Handler\HandlerInterface;
+use Psr\Log\AbstractLogger;
+
+class Logger extends AbstractLogger
+{
+    protected const DEFAULT_DATETIME_FORMAT = 'c';
+
+    private HandlerInterface $handler;
+
+    public function __construct(HandlerInterface $handler)
+    {
+        $this->handler = $handler;
+    }
+
+    #[\ReturnTypeWillChange]
+    public function log($level, $message, array $context = array())
+    {
+        $this->handler->handle([
+            'message' => self::interpolate((string)$message, $context),
+            'level' => strtoupper($level),
+            'timestamp' => (new \DateTimeImmutable())->format(self::DEFAULT_DATETIME_FORMAT),
+        ]);
+    }
+
+    protected static function interpolate(string $message, array $context = []): string
+    {
+        $replace = [];
+        foreach ($context as $key => $val) {
+            if (is_string($val) || method_exists($val, '__toString')) {
+                $replace['{' . $key . '}'] = $val;
+            }
+        }
+        return strtr($message, $replace);
+    }
+}

+ 65 - 0
tests/FileHandlerTest.php

@@ -0,0 +1,65 @@
+<?php
+
+namespace Test\Michel\Log;
+use Michel\UniTester\TestCase;
+use Psr\Log\LogLevel;
+
+class FileHandlerTest extends TestCase
+{
+
+    private ?string $tmpFile = null;
+
+    protected function setUp(): void
+    {
+        $this->tmpFile = sys_get_temp_dir() . '/log/' . date('Y-m-d') . '.log';
+        if (file_exists($this->tmpFile)) {
+            unlink($this->tmpFile);
+        }
+    }
+
+    protected function tearDown(): void
+    {
+        if (file_exists($this->tmpFile)) {
+            unlink($this->tmpFile);
+        }
+
+        if (is_dir(dirname($this->tmpFile))) {
+            rmdir(dirname($this->tmpFile));
+        }
+    }
+
+    protected function execute(): void
+    {
+       $this->testCreateDir();
+       $this->testWriteInFile();
+    }
+
+
+    public function testCreateDir()
+    {
+        $tmp_dir = dirname($this->tmpFile);
+        if (is_dir($tmp_dir)) {
+            rmdir($tmp_dir);
+        }
+        new \Michel\Log\Handler\FileHandler($this->tmpFile);
+        $this->assertTrue(is_dir($tmp_dir));
+    }
+
+    public function testWriteInFile()
+    {
+        $handler = new \Michel\Log\Handler\FileHandler($this->tmpFile);
+        $vars = [
+            'message' => 'is a test',
+            'level' => strtoupper(LogLevel::INFO),
+            'timestamp' => (new \DateTimeImmutable())->format('c'),
+        ];
+        $handler->handle($vars);
+
+        $this->assertTrue(file_exists($this->tmpFile));
+        $fileObject = new \SplFileObject($this->tmpFile);
+        $line = $fileObject->current();
+        $this->assertEquals($line, sprintf('%s [%s]: %s' . PHP_EOL, $vars['timestamp'], $vars['level'], $vars['message']));
+
+    }
+
+}

+ 49 - 0
tests/GroupHandlerTest.php

@@ -0,0 +1,49 @@
+<?php
+
+namespace Test\Michel\Log;
+
+use Michel\Log\Handler\GroupHandler;
+use Michel\Log\Handler\MemoryHandler;
+use Michel\UniTester\TestCase;
+use Psr\Log\LogLevel;
+
+class GroupHandlerTest extends TestCase
+{
+    protected function setUp(): void
+    {
+        // TODO: Implement setUp() method.
+    }
+
+    protected function tearDown(): void
+    {
+        // TODO: Implement tearDown() method.
+    }
+
+    protected function execute(): void
+    {
+        $this->testHandle();
+    }
+
+    private function testHandle()
+    {
+        $storage1 = [];
+        $storage2 = [];
+        $handler1 = new MemoryHandler($storage1);
+        $handler2 = new MemoryHandler($storage2);
+
+        $groupHandler = new GroupHandler([$handler1, $handler2]);
+
+        $vars = [
+            'level' => LogLevel::INFO,
+            'message' => 'test message',
+            'timestamp' => '2023-01-01 00:00:00'
+        ];
+
+        $groupHandler->handle($vars);
+
+        $this->assertEquals(1, count($storage1));
+        $this->assertEquals(1, count($storage2));
+        $this->assertStringContains($storage1[0], 'test message');
+        $this->assertStringContains($storage2[0], 'test message');
+    }
+}

+ 30 - 0
tests/LoggerTest.php

@@ -0,0 +1,30 @@
+<?php
+
+namespace Test\Michel\Log;
+
+use Michel\Log\Handler\MemoryHandler;
+use Michel\Log\Logger;
+use Michel\UniTester\TestCase;
+use Psr\Log\LogLevel;
+
+class LoggerTest extends TestCase
+{
+
+    protected function setUp(): void
+    {
+        // TODO: Implement setUp() method.
+    }
+
+    protected function tearDown(): void
+    {
+        // TODO: Implement tearDown() method.
+    }
+
+    protected function execute(): void
+    {
+        $storage = [];
+        $logger = new Logger(new MemoryHandler($storage));
+        $logger->log(LogLevel::INFO, 'is a test');
+        $this->assertStringEndsWith($storage[0], '[INFO]: is a test');
+    }
+}

+ 71 - 0
tests/MemoryHandlerTest.php

@@ -0,0 +1,71 @@
+<?php
+
+namespace Test\Michel\Log;
+
+use Michel\Log\Handler\MemoryHandler;
+use Michel\UniTester\TestCase;
+use Psr\Log\LogLevel;
+
+class MemoryHandlerTest extends TestCase
+{
+
+
+    protected function setUp(): void {}
+
+    protected function tearDown(): void {}
+
+    protected function execute(): void
+    {
+        $storage = [];
+        $handler = new MemoryHandler($storage);
+        $vars = [
+            'message' => 'is a test',
+            'level' => strtoupper(LogLevel::INFO),
+            'timestamp' => (new \DateTimeImmutable())->format('c'),
+        ];
+        $handler->handle($vars);
+
+        $this->assertEquals(1, count($storage));
+        $this->assertEquals($storage[0], sprintf('%s [%s]: %s', $vars['timestamp'], $vars['level'], $vars['message']));
+
+        $this->testLogLevel();
+        $this->testCustomFormat();
+    }
+
+    private function testLogLevel()
+    {
+        $storage = [];
+        $handler = new MemoryHandler($storage, LogLevel::ERROR);
+
+        // Should not log INFO
+        $handler->handle([
+            'level' => LogLevel::INFO,
+            'message' => 'info message',
+            'timestamp' => '2023-01-01 00:00:00'
+        ]);
+        $this->assertEquals(0, count($storage));
+
+        // Should log ERROR
+        $handler->handle([
+            'level' => LogLevel::ERROR,
+            'message' => 'error message',
+            'timestamp' => '2023-01-01 00:00:00'
+        ]);
+        $this->assertEquals(1, count($storage));
+    }
+
+    private function testCustomFormat()
+    {
+        $storage = [];
+        $handler = new MemoryHandler($storage);
+        $handler->setFormat('[%level%] %message%');
+
+        $handler->handle([
+            'level' => LogLevel::INFO,
+            'message' => 'custom format',
+            'timestamp' => '2023-01-01 00:00:00'
+        ]);
+
+        $this->assertEquals('[INFO] custom format', $storage[0]);
+    }
+}

+ 55 - 0
tests/RotatingFileHandlerTest.php

@@ -0,0 +1,55 @@
+<?php
+
+namespace Test\Michel\Log;
+
+use Michel\Log\Handler\RotatingFileHandler;
+use Michel\UniTester\TestCase;
+use Psr\Log\LogLevel;
+
+class RotatingFileHandlerTest extends TestCase
+{
+    protected function setUp(): void
+    {
+        // TODO: Implement setUp() method.
+    }
+
+    protected function tearDown(): void
+    {
+        // TODO: Implement tearDown() method.
+    }
+
+    protected function execute(): void
+    {
+        $this->testRotation();
+    }
+
+    private function testRotation()
+    {
+        $dir = sys_get_temp_dir() . '/rotating_logs';
+        if (!file_exists($dir)) {
+            mkdir($dir);
+        }
+
+        $filename = $dir . '/app.log';
+        $handler = new RotatingFileHandler($filename);
+
+        $handler->handle([
+            'level' => LogLevel::INFO,
+            'message' => 'rotating message',
+            'timestamp' => '2023-01-01 00:00:00'
+        ]);
+
+        $date = date('Y-m-d');
+        $expectedFile = $dir . '/app-' . $date . '.log';
+
+        $this->assertTrue(file_exists($expectedFile), 'Log file should exist');
+        $content = file_get_contents($expectedFile);
+        $this->assertStringContains($content, 'rotating message');
+
+        // Cleanup
+        if (file_exists($expectedFile)) {
+            unlink($expectedFile);
+        }
+        rmdir($dir);
+    }
+}

+ 64 - 0
tests/StreamHandlerTest.php

@@ -0,0 +1,64 @@
+<?php
+
+namespace Test\Michel\Log;
+
+use Michel\Log\Handler\StreamHandler;
+use Michel\UniTester\TestCase;
+use Psr\Log\LogLevel;
+
+class StreamHandlerTest extends TestCase
+{
+    protected function setUp(): void
+    {
+        // TODO: Implement setUp() method.
+    }
+
+    protected function tearDown(): void
+    {
+        // TODO: Implement tearDown() method.
+    }
+
+    protected function execute(): void
+    {
+        $this->testWriteToStream();
+        $this->testWriteToFile();
+    }
+
+    private function testWriteToStream()
+    {
+        $stream = fopen('php://memory', 'a+');
+        $handler = new StreamHandler($stream);
+        $handler->handle([
+            'level' => LogLevel::INFO,
+            'message' => 'test message',
+            'timestamp' => '2023-01-01 00:00:00'
+        ]);
+
+        rewind($stream);
+        $content = stream_get_contents($stream);
+        $this->assertStringContains($content, 'test message');
+        $this->assertStringContains($content, '[INFO]');
+    }
+
+    private function testWriteToFile()
+    {
+        $file = sys_get_temp_dir() . '/test_stream_handler.log';
+        if (file_exists($file)) {
+            unlink($file);
+        }
+
+        $handler = new StreamHandler($file);
+        $handler->handle([
+            'level' => LogLevel::ERROR,
+            'message' => 'error message',
+            'timestamp' => '2023-01-01 00:00:00'
+        ]);
+
+        $this->assertTrue(file_exists($file), 'File should exist');
+        $content = file_get_contents($file);
+        $this->assertStringContains($content, 'error message');
+        $this->assertStringContains($content, '[ERROR]');
+
+        unlink($file);
+    }
+}