154 lines
5.6 KiB
PHP
154 lines
5.6 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Tests\Logger;
|
||
|
||
use App\Entity\LogEntry;
|
||
use App\Logger\DatabaseHandler;
|
||
use Doctrine\DBAL\Connection;
|
||
use Doctrine\ORM\Mapping\ClassMetadata;
|
||
use Doctrine\ORM\Mapping\Driver\AttributeDriver;
|
||
use Doctrine\ORM\Mapping\UnderscoreNamingStrategy;
|
||
use Doctrine\Persistence\Mapping\RuntimeReflectionService;
|
||
use Doctrine\Persistence\ObjectManager;
|
||
use Monolog\Level;
|
||
use Monolog\LogRecord;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
/**
|
||
* The handler writes through the DBAL connection instead of the EntityManager, so the column names
|
||
* are spelled out by hand here and can drift away from the LogEntry mapping. These tests pin them
|
||
* to the mapping, and pin the handler to the connection.
|
||
*/
|
||
class DatabaseHandlerTest extends TestCase
|
||
{
|
||
public function testTheHandlerStaysOutOfTheUnitOfWork(): void
|
||
{
|
||
// A log record can be emitted halfway through somebody else's unit of work. Flushing an
|
||
// EntityManager here would write out whatever that unit of work has pending, which is how
|
||
// a half-built User once reached the database with a NULL password.
|
||
$parameters = (new \ReflectionMethod(DatabaseHandler::class, '__construct'))->getParameters();
|
||
|
||
foreach ($parameters as $parameter) {
|
||
$type = $parameter->getType();
|
||
|
||
self::assertInstanceOf(\ReflectionNamedType::class, $type);
|
||
self::assertNotSame(ObjectManager::class, $type->getName());
|
||
self::assertFalse(
|
||
is_a($type->getName(), ObjectManager::class, true),
|
||
'the handler must not be able to flush anybody else’s entities',
|
||
);
|
||
}
|
||
}
|
||
|
||
public function testTheWrittenColumnsMatchTheLogEntryMapping(): void
|
||
{
|
||
$handler = new DatabaseHandler($this->connection($table, $data, $types));
|
||
|
||
$handler->handle($this->record('auth', 'Login', ['email' => '[email protected]']));
|
||
|
||
$metadata = $this->logEntryMetadata();
|
||
|
||
self::assertSame($metadata->getTableName(), $table);
|
||
self::assertSame([], array_diff(array_keys($data), $metadata->getColumnNames()));
|
||
|
||
// Everything the table requires has to be supplied: no default fills these in, and the id
|
||
// is the only column the database generates itself.
|
||
$required = array_diff($metadata->getColumnNames(), ['id']);
|
||
foreach ($required as $column) {
|
||
self::assertArrayHasKey($column, $data);
|
||
}
|
||
|
||
self::assertSame([], array_diff(array_keys($types), array_keys($data)));
|
||
}
|
||
|
||
public function testTheRecordIsWrittenAsIs(): void
|
||
{
|
||
$handler = new DatabaseHandler($this->connection($table, $data, $types));
|
||
|
||
$handler->handle($this->record('bpn', 'Request failed', ['request_id' => 'r-1'], ['error_code' => '853']));
|
||
|
||
self::assertSame('bpn', $data['channel']);
|
||
self::assertSame('Request failed', $data['message']);
|
||
self::assertSame(['request_id' => 'r-1'], $data['context']);
|
||
self::assertSame(['error_code' => '853'], $data['extra']);
|
||
self::assertSame('853', $data['error_code']);
|
||
self::assertInstanceOf(\DateTimeImmutable::class, $data['created_at']);
|
||
}
|
||
|
||
public function testPlaceholdersAreReplacedFromTheContext(): void
|
||
{
|
||
$handler = new DatabaseHandler($this->connection($table, $data, $types));
|
||
|
||
$handler->handle($this->record('core', 'Booking {id} confirmed', ['id' => '4711']));
|
||
|
||
self::assertSame('Booking 4711 confirmed', $data['message']);
|
||
}
|
||
|
||
public function testAnErrorCodeIsOptional(): void
|
||
{
|
||
$handler = new DatabaseHandler($this->connection($table, $data, $types));
|
||
|
||
$handler->handle($this->record('core', 'Login'));
|
||
|
||
self::assertNull($data['error_code']);
|
||
}
|
||
|
||
public function testDebugRecordsAreNotWritten(): void
|
||
{
|
||
$connection = $this->createMock(Connection::class);
|
||
$connection->expects(self::never())->method('insert');
|
||
|
||
$handler = new DatabaseHandler($connection);
|
||
|
||
$handler->handle($this->record('core', 'Noise', level: Level::Debug));
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed>|null $data
|
||
* @param array<string, mixed>|null $types
|
||
*/
|
||
private function connection(?string &$table, ?array &$data, ?array &$types): Connection
|
||
{
|
||
$connection = $this->createMock(Connection::class);
|
||
$connection
|
||
->method('insert')
|
||
->willReturnCallback(
|
||
static function (string $insertTable, array $insertData, array $insertTypes) use (&$table, &$data, &$types): int {
|
||
$table = $insertTable;
|
||
$data = $insertData;
|
||
$types = $insertTypes;
|
||
|
||
return 1;
|
||
}
|
||
)
|
||
;
|
||
|
||
return $connection;
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $context
|
||
* @param array<string, mixed> $extra
|
||
*/
|
||
private function record(string $channel, string $message, array $context = [], array $extra = [], Level $level = Level::Info): LogRecord
|
||
{
|
||
return new LogRecord(new \DateTimeImmutable(), $channel, $level, $message, $context, $extra);
|
||
}
|
||
|
||
/**
|
||
* @return ClassMetadata<LogEntry>
|
||
*/
|
||
private function logEntryMetadata(): ClassMetadata
|
||
{
|
||
/** @var ClassMetadata<LogEntry> $metadata */
|
||
$metadata = new ClassMetadata(LogEntry::class, new UnderscoreNamingStrategy(CASE_LOWER, true));
|
||
$metadata->initializeReflection(new RuntimeReflectionService());
|
||
|
||
(new AttributeDriver([dirname(__DIR__, 2).'/src/Entity']))->loadMetadataForClass(LogEntry::class, $metadata);
|
||
|
||
return $metadata;
|
||
}
|
||
}
|