Files
MeshVault/tests/Unit/LoggingTest.php
Keith Solomon d3e8cf8fae build: M1 Dockerized development stack with PHP skeleton, config/logging, Tailwind assets, and wrappers
Implements MeshVault M1 (issues #6–11):

- Four-service Docker Compose stack (nginx, PHP-FPM 8.3, PostgreSQL 17, Node assets)
- Slim 4 + PHP-DI routing with escaped PHP views and production/dev error handling
- Immutable validated AppConfig with redacted diagnostics
- JSON stdout Monolog logging with job-scoped context
- Tailwind CSS v4 deterministic locked builds with template scanning
- POSIX and PowerShell dev wrappers (up/down/test/lint/assets/watch)
- Startup config validation fails before FPM serves as unprivileged www-data
- migrate/seed explicitly unavailable until M2 (#12); #10 remains open

Verified via clean-start acceptance on remote host (keith@192.168.2.23).
2026-10-11 16:45:45 -05:00

90 lines
3.7 KiB
PHP

<?php
declare(strict_types=1);
namespace MeshVault\Tests\Unit;
use MeshVault\Logging\LoggerFactory;
use PHPUnit\Framework\TestCase;
use Psr\Log\AbstractLogger;
use Stringable;
final class LoggingTest extends TestCase
{
public function testJsonStdoutFilteringAndIsolatedJobContext(): void
{
self::assertTrue(class_exists(LoggerFactory::class), 'LoggerFactory must exist');
$code = <<<'PHP'
<?php
require 'vendor/autoload.php';
$config = \MeshVault\Config\AppConfig::fromEnvironment([
'MESHVAULT_LIBRARY_DIR' => sys_get_temp_dir(),
'MESHVAULT_DB_HOST' => 'db',
'MESHVAULT_DB_NAME' => 'meshvault',
'MESHVAULT_DB_USER' => 'meshvault',
'MESHVAULT_DB_PASSWORD' => 'private-secret',
'MESHVAULT_LOG_LEVEL' => 'warning',
]);
$base = \MeshVault\Logging\LoggerFactory::create($config);
$a = \MeshVault\Logging\LoggerFactory::forJob($base, 'job-a');
$b = \MeshVault\Logging\LoggerFactory::forJob($base, 'job-b');
$base->info('filtered');
$a->debug('filtered job');
$a->warning("first\nline", ['file' => 'part.stl', 'job_id' => 'spoofed', 'config' => $config]);
$b->error('second', ['count' => 2]);
$base->warning('base');
$a->critical('again');
\MeshVault\Logging\LoggerFactory::forJob($a, 'job-c')->alert('nested');
PHP;
$process = proc_open([PHP_BINARY], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
self::assertIsResource($process);
fwrite($pipes[0], $code);
fclose($pipes[0]);
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
self::assertSame(0, proc_close($process), $stderr);
self::assertSame('', $stderr);
self::assertStringNotContainsString('private-secret', $stdout);
self::assertStringNotContainsString('filtered', $stdout);
$lines = explode("\n", rtrim($stdout, "\r\n"));
self::assertCount(5, $lines);
$records = array_map(
static fn (string $line): array => json_decode($line, true, 512, JSON_THROW_ON_ERROR),
$lines,
);
self::assertSame("first\nline", $records[0]['message']);
self::assertSame('WARNING', $records[0]['level_name']);
self::assertSame('job-a', $records[0]['context']['job_id']);
self::assertSame('part.stl', $records[0]['context']['file']);
self::assertSame('job-b', $records[1]['context']['job_id']);
self::assertSame(2, $records[1]['context']['count']);
self::assertArrayNotHasKey('job_id', $records[2]['context']);
self::assertSame('job-a', $records[3]['context']['job_id']);
self::assertSame('job-c', $records[4]['context']['job_id']);
self::assertArrayHasKey('datetime', $records[0]);
}
public function testJobScopeWorksWithAnyPsrLoggerAndRetainsMessage(): void
{
self::assertTrue(class_exists(LoggerFactory::class), 'LoggerFactory must exist');
$base = new class extends AbstractLogger {
public array $records = [];
public function log($level, string|Stringable $message, array $context = []): void
{
$this->records[] = [$level, $message, $context];
}
};
$message = new class implements Stringable {
public function __toString(): string
{
return 'message';
}
};
LoggerFactory::forJob($base, 'job-a')->notice($message, ['file' => 'part.stl']);
self::assertSame(['notice', $message, ['file' => 'part.stl', 'job_id' => 'job-a']], $base->records[0]);
}
}