Files
breadcrumb-the-shire/tests/Http/ErrorDataCollectorTest.php
fs ad9101df8e feat: add developer error page with request ID tracking and structured logging
Custom-built error handler for the web channel that shows a tabbed debug
dashboard (exception, request context, environment, SQL queries) in dev mode
and a clean branded error page with copyable request ID in prod mode.
All errors are logged as JSON lines to storage/logs/errors/ for lookup by
request ID. Uses the project's design tokens for visual consistency.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 20:49:22 +01:00

164 lines
5.9 KiB
PHP

<?php
namespace MintyPHP\Tests\Http;
use MintyPHP\Http\ErrorDataCollector;
use MintyPHP\Http\RequestContext;
use PHPUnit\Framework\TestCase;
class ErrorDataCollectorTest extends TestCase
{
private array $serverBackup = [];
protected function setUp(): void
{
$this->serverBackup = $_SERVER;
RequestContext::resetForTests();
}
protected function tearDown(): void
{
$_SERVER = $this->serverBackup;
RequestContext::resetForTests();
}
public function testCollectReturnsAllFourSections(): void
{
$exception = new \RuntimeException('Test exception', 99);
$result = ErrorDataCollector::collect($exception);
$this->assertArrayHasKey('exception', $result);
$this->assertArrayHasKey('request', $result);
$this->assertArrayHasKey('environment', $result);
$this->assertArrayHasKey('queries', $result);
}
public function testExceptionSectionContainsCorrectDetails(): void
{
$exception = new \LogicException('Something wrong', 7);
$result = ErrorDataCollector::collect($exception);
$exc = $result['exception'];
$this->assertSame('LogicException', $exc['class']);
$this->assertSame('Something wrong', $exc['message']);
$this->assertSame(7, $exc['code']);
$this->assertStringContainsString('ErrorDataCollectorTest.php', $exc['file']);
$this->assertIsInt($exc['line']);
$this->assertIsArray($exc['trace']);
$this->assertNotEmpty($exc['trace']);
}
public function testChainedExceptionsAreCollected(): void
{
$inner = new \InvalidArgumentException('inner cause');
$outer = new \RuntimeException('outer', 0, $inner);
$result = ErrorDataCollector::collect($outer);
$this->assertArrayHasKey('previous', $result['exception']);
$this->assertSame('InvalidArgumentException', $result['exception']['previous']['class']);
$this->assertSame('inner cause', $result['exception']['previous']['message']);
}
public function testCodeSnippetIsExtractedForKnownFile(): void
{
$exception = new \RuntimeException('snippet test');
$result = ErrorDataCollector::collect($exception);
$firstFrame = $result['exception']['trace'][0] ?? null;
$this->assertNotNull($firstFrame);
$this->assertArrayHasKey('code_snippet', $firstFrame);
// The snippet should contain lines around the throw site.
$this->assertNotEmpty($firstFrame['code_snippet']);
}
public function testRequestSectionContainsBasicData(): void
{
$_SERVER['REQUEST_METHOD'] = 'POST';
$_SERVER['REQUEST_URI'] = '/admin/users/edit?id=5';
$_SERVER['REMOTE_ADDR'] = '10.0.0.1';
$_SERVER['HTTP_USER_AGENT'] = 'TestAgent/1.0';
$exception = new \RuntimeException('request test');
$result = ErrorDataCollector::collect($exception);
$req = $result['request'];
$this->assertSame('POST', $req['method']);
$this->assertSame('/admin/users/edit?id=5', $req['url']);
$this->assertSame('10.0.0.1', $req['ip']);
$this->assertSame('TestAgent/1.0', $req['user_agent']);
}
public function testHeadersAreRedacted(): void
{
$_SERVER['HTTP_AUTHORIZATION'] = 'Bearer secret-token';
$_SERVER['HTTP_COOKIE'] = 'session=abc123';
$_SERVER['HTTP_X_API_KEY'] = 'my-key';
$_SERVER['HTTP_ACCEPT'] = 'text/html';
$exception = new \RuntimeException('header redaction');
$result = ErrorDataCollector::collect($exception);
$headers = $result['request']['headers'];
$this->assertSame('[REDACTED]', $headers['authorization']);
$this->assertSame('[REDACTED]', $headers['cookie']);
$this->assertSame('[REDACTED]', $headers['x-api-key']);
$this->assertSame('text/html', $headers['accept']);
}
public function testPostParamsAreRedacted(): void
{
$_POST = [
'username' => 'admin',
'password' => 'secret123',
'csrf_token' => 'abc',
'email' => 'test@example.com',
];
$exception = new \RuntimeException('post redaction');
$result = ErrorDataCollector::collect($exception);
$post = $result['request']['post'];
$this->assertSame('admin', $post['username']);
$this->assertSame('[REDACTED]', $post['password']);
$this->assertSame('[REDACTED]', $post['csrf_token']);
$this->assertSame('test@example.com', $post['email']);
$_POST = [];
}
public function testEnvironmentSectionContainsPhpInfo(): void
{
$exception = new \RuntimeException('env test');
$result = ErrorDataCollector::collect($exception);
$env = $result['environment'];
$this->assertSame(PHP_VERSION, $env['php_version']);
$this->assertSame(PHP_SAPI, $env['php_sapi']);
$this->assertIsArray($env['extensions']);
$this->assertNotEmpty($env['extensions']);
$this->assertIsInt($env['memory_current']);
$this->assertIsInt($env['memory_peak']);
}
public function testArgsAreSummarizedByTypeOnly(): void
{
// Trace frames should contain type summaries, not actual argument values.
$exception = new \RuntimeException('args test');
$result = ErrorDataCollector::collect($exception);
foreach ($result['exception']['trace'] as $frame) {
if (isset($frame['args_summary'])) {
foreach ($frame['args_summary'] as $summary) {
$this->assertIsString($summary);
// Should be type descriptions, not raw values.
$this->assertMatchesRegularExpression(
'/^(null|true|false|int|float|string\(\d+\)|array\(\d+\)|resource|unknown|[A-Z][\w\\\\]*)$/',
$summary,
);
}
}
}
}
}