TenantAtlas/apps/platform/tests/Unit/Pdf/Spec378PdfRenderingGatewayTest.php
ahmido d43ebcb4ee feat(report): implement management report pdf v1 (#449)
Added PDF generation service for management reports as per Spec 378, including Gotenberg integration in docker-compose and configuration updates.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #449
2026-06-14 18:36:07 +00:00

232 lines
8.7 KiB
PHP

<?php
declare(strict_types=1);
use App\Services\Pdf\PdfRenderRequest;
use App\Services\Pdf\PdfRenderResult;
use App\Services\Pdf\PdfRenderingGateway;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
function configureSpec378PdfRenderer(array $overrides = []): void
{
config([
'tenantpilot.pdf_renderer' => array_merge([
'enabled' => true,
'driver' => 'gotenberg',
'base_url' => 'http://gotenberg.test',
'health_path' => '/health',
'html_route' => '/forms/chromium/convert/html',
'timeout_seconds' => 30,
'connect_timeout_seconds' => 5,
'max_html_bytes' => 1024,
'max_asset_bytes' => 512,
'max_output_bytes' => 2048,
'correlation_header' => 'Gotenberg-Trace',
'output_filename' => 'tenantpilot-management-report',
], $overrides),
]);
}
it('keeps the renderer disabled without issuing outbound HTTP', function (): void {
configureSpec378PdfRenderer(['enabled' => false]);
Http::fake();
$result = app(PdfRenderingGateway::class)->renderHtml(new PdfRenderRequest(
html: '<html><body>Management report</body></html>',
correlationId: 'spec378-disabled',
));
expect($result->failed())->toBeTrue()
->and($result->failureCode)->toBe(PdfRenderResult::RENDERER_DISABLED)
->and($result->safeMessage)->toBe('PDF renderer is disabled.')
->and($result->correlationId)->toBe('spec378-disabled');
Http::assertNothingSent();
});
it('checks Gotenberg health with the configured correlation header', function (): void {
configureSpec378PdfRenderer();
Http::fake([
'gotenberg.test/health' => Http::response('OK', 200, [
'Content-Type' => 'text/plain',
'Gotenberg-Trace' => 'spec378-health',
]),
]);
$result = app(PdfRenderingGateway::class)->healthCheck('spec378-health');
expect($result->successful())->toBeTrue()
->and($result->correlationId)->toBe('spec378-health');
Http::assertSent(fn (Request $request): bool => $request->method() === 'GET'
&& $request->url() === 'http://gotenberg.test/health'
&& (($request->headers()['Gotenberg-Trace'][0] ?? null) === 'spec378-health'));
});
it('keeps the Sail Gotenberg service pinned and internal only', function (): void {
$compose = file_get_contents(repo_path('docker-compose.yml'));
$compose = is_string($compose) ? $compose : '';
$gotenbergBlock = Str::between($compose, " gotenberg:\n", "\n pgsql:");
expect($gotenbergBlock)->toContain("image: 'gotenberg/gotenberg:8.34.0-chromium'")
->and($gotenbergBlock)->toContain("API_DISABLE_DOWNLOAD_FROM: 'true'")
->and($gotenbergBlock)->toContain("WEBHOOK_DISABLE: 'true'")
->and($gotenbergBlock)->toContain("CHROMIUM_ALLOW_LIST: '^$'")
->and($gotenbergBlock)->toContain("CHROMIUM_DENY_PRIVATE_IPS: 'true'")
->and($gotenbergBlock)->toContain("CHROMIUM_DENY_PUBLIC_IPS: 'true'")
->and($gotenbergBlock)->not->toContain("\n ports:");
});
it('rejects oversized HTML and unsafe asset names before the renderer call', function (): void {
configureSpec378PdfRenderer([
'max_html_bytes' => 8,
]);
Http::fake();
$tooLarge = app(PdfRenderingGateway::class)->renderHtml(new PdfRenderRequest(
html: '<html>too large</html>',
correlationId: 'spec378-size',
));
expect($tooLarge->failed())->toBeTrue()
->and($tooLarge->failureCode)->toBe(PdfRenderResult::PAYLOAD_TOO_LARGE);
configureSpec378PdfRenderer();
$invalidAsset = app(PdfRenderingGateway::class)->renderHtml(new PdfRenderRequest(
html: '<html><body>Management report</body></html>',
assets: ['assets/logo.png' => 'image-bytes'],
correlationId: 'spec378-asset',
));
expect($invalidAsset->failed())->toBeTrue()
->and($invalidAsset->failureCode)->toBe(PdfRenderResult::INVALID_ASSET);
Http::assertNothingSent();
});
it('renders server generated HTML through the configured Gotenberg route', function (): void {
configureSpec378PdfRenderer();
Http::fake([
'gotenberg.test/forms/chromium/convert/html' => Http::response('%PDF-1.7 rendered', 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'attachment; filename=management-report.pdf',
'Gotenberg-Trace' => 'spec378-render',
]),
]);
$result = app(PdfRenderingGateway::class)->renderHtml(new PdfRenderRequest(
html: '<html><body>Management report</body></html>',
assets: ['logo.png' => 'image-bytes'],
options: [
'printBackground' => true,
'paperWidth' => '8.27',
],
correlationId: 'spec378-render',
));
expect($result->successful())->toBeTrue()
->and($result->pdfBytes)->toBe('%PDF-1.7 rendered')
->and($result->contentType)->toBe('application/pdf')
->and($result->outputFilename)->toBe('management-report.pdf')
->and($result->correlationId)->toBe('spec378-render');
Http::assertSent(fn (Request $request): bool => $request->method() === 'POST'
&& $request->url() === 'http://gotenberg.test/forms/chromium/convert/html'
&& (($request->headers()['Gotenberg-Trace'][0] ?? null) === 'spec378-render')
&& (($request->headers()['Gotenberg-Output-Filename'][0] ?? null) === 'tenantpilot-management-report'));
});
it('maps renderer unavailable failures to safe results', function (): void {
configureSpec378PdfRenderer([
'base_url' => 'http://gotenberg-unavailable.test',
]);
Http::fake([
'gotenberg-unavailable.test/forms/chromium/convert/html' => Http::failedConnection(),
]);
$unavailable = app(PdfRenderingGateway::class)->renderHtml(new PdfRenderRequest(
html: '<html><body>Management report</body></html>',
correlationId: 'spec378-unavailable',
));
expect($unavailable->failed())->toBeTrue()
->and($unavailable->failureCode)->toBe(PdfRenderResult::RENDERER_UNAVAILABLE)
->and($unavailable->safeMessage)->not->toContain('SQLSTATE', 'access token', 'client secret', 'stack trace');
});
it('maps invalid renderer responses to safe results', function (): void {
configureSpec378PdfRenderer([
'base_url' => 'http://gotenberg-invalid.test',
]);
Http::fake([
'gotenberg-invalid.test/forms/chromium/convert/html' => Http::response('not a pdf', 200, [
'Content-Type' => 'text/plain',
'Gotenberg-Trace' => 'spec378-invalid',
]),
]);
$invalid = app(PdfRenderingGateway::class)->renderHtml(new PdfRenderRequest(
html: '<html><body>Management report</body></html>',
correlationId: 'spec378-invalid',
));
expect($invalid->failed())->toBeTrue()
->and($invalid->failureCode)->toBe(PdfRenderResult::INVALID_RESPONSE)
->and($invalid->correlationId)->toBe('spec378-invalid');
});
it('maps renderer HTTP failures and output limits to safe result codes', function (int $status, string $expectedCode): void {
configureSpec378PdfRenderer();
Http::fake([
'gotenberg.test/forms/chromium/convert/html' => Http::response('SQLSTATE raw renderer body', $status, [
'Content-Type' => 'text/plain',
'Gotenberg-Trace' => 'spec378-failure',
]),
]);
$result = app(PdfRenderingGateway::class)->renderHtml(new PdfRenderRequest(
html: '<html><body>Management report</body></html>',
correlationId: 'spec378-failure',
));
expect($result->failed())->toBeTrue()
->and($result->failureCode)->toBe($expectedCode)
->and($result->safeMessage)->not->toContain('SQLSTATE');
})->with([
'invalid request' => [400, PdfRenderResult::INVALID_REQUEST],
'timeout' => [503, PdfRenderResult::RENDERER_TIMEOUT],
'unexpected renderer failure' => [500, PdfRenderResult::RENDERER_FAILED],
]);
it('rejects oversized PDF output after a successful renderer response', function (): void {
configureSpec378PdfRenderer([
'max_output_bytes' => 10,
]);
Http::fake([
'gotenberg.test/forms/chromium/convert/html' => Http::response('%PDF-1.7 document body', 200, [
'Content-Type' => 'application/pdf',
'Gotenberg-Trace' => 'spec378-output',
]),
]);
$result = app(PdfRenderingGateway::class)->renderHtml(new PdfRenderRequest(
html: '<html><body>Management report</body></html>',
correlationId: 'spec378-output',
));
expect($result->failed())->toBeTrue()
->and($result->failureCode)->toBe(PdfRenderResult::OUTPUT_TOO_LARGE)
->and($result->correlationId)->toBe('spec378-output');
});