fix(pdf): give both drivers one contract, and fix what that was hiding (#727)

PdfDriver and ResponseStream existed but nothing implemented them. The factory
returned the vendor dompdf wrapper for one driver and a bespoke class for the
other, so the two were never held to the same shape. Three things had slipped
through that gap.

Report PDFs answered 403 for everyone. The five report routes carry no company
header, so ScopeBouncer is not in their middleware stack and the ability scope
was never set; 'view-financial-reports' is stored scoped to a company, so the
check could not pass. They now scope to the company named in the URL. The policy
still checks membership, so this grants nothing new. Also firstOrFail() on the
hash lookup, so an unknown company is a 404 rather than a 500 on a null.

Report downloads were fatal on Gotenberg. GotenbergPdfResponse had no download(),
and the report controllers are its only callers. Added, alongside stream() and
output(), with the whole set now on the interface.

Streamed documents carried an HTTP preamble. GeneratesPdfTrait wrapped
$pdf->stream() -- already a Response -- in another response()->make(), which
stringified it and prepended "HTTP/1.0 200 OK" plus headers to the file. Readers
scan the first kilobyte for %PDF so nobody noticed, but the bytes were malformed.
Passing ->output() fixes it, and the render test now asserts the position.

Two driver-parity settings, both checked against a real gotenberg:8 rather than
inferred: emulateScreenMediaType(), because Chromium defaults to print media
while config/dompdf.php renders as screen, so a @media print rule applied on one
driver and not the other; and printBackground(), which turns out to affect only
the root background, since Chromium paints element backgrounds either way. No
stock template sets a body background, so that one changes nothing today and is
here to keep custom templates consistent across drivers.

Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF
This commit is contained in:
Darko Gjorgjijoski
2026-08-01 12:43:31 +02:00
committed by GitHub
parent a8cbcda835
commit 6cb754da60
18 changed files with 379 additions and 37 deletions

View File

@@ -0,0 +1,59 @@
<?php
use App\Support\Pdf\DompdfDriver;
use App\Support\Pdf\DompdfResponse;
use App\Support\Pdf\GotenbergPdfDriver;
use App\Support\Pdf\GotenbergPdfResponse;
use App\Support\Pdf\PdfDriver;
use App\Support\Pdf\PdfDriverFactory;
use App\Support\Pdf\ResponseStream;
/**
* PdfDriver and ResponseStream existed but nothing implemented them, so the
* factory could hand back the raw dompdf wrapper for one driver and a bespoke
* class for the other. That is how the report controllers came to call
* ->download() on a Gotenberg response that had no such method: a fatal error on
* every report download, invisible because no type ever asserted the two were
* interchangeable. These tests are that assertion.
*/
test('the factory returns a PdfDriver for every supported driver', function (string $driver) {
expect(PdfDriverFactory::create($driver))->toBeInstanceOf(PdfDriver::class);
})->with(['dompdf', 'gotenberg']);
test('the factory rejects an unknown driver', function () {
expect(fn () => PdfDriverFactory::create('wkhtmltopdf'))
->toThrow(InvalidArgumentException::class, 'Invalid PdfDriver requested');
});
test('every driver implements the driver contract', function (string $class) {
expect(is_subclass_of($class, PdfDriver::class))->toBeTrue();
})->with([DompdfDriver::class, GotenbergPdfDriver::class]);
/**
* The report controllers call stream(), download() and output() with no
* arguments, so each has to be callable bare on either driver's response.
*/
test('every response implements the full response contract', function (string $class) {
expect(is_subclass_of($class, ResponseStream::class))->toBeTrue();
foreach (['stream', 'download', 'output'] as $method) {
expect(method_exists($class, $method))->toBeTrue();
$required = (new ReflectionMethod($class, $method))->getNumberOfRequiredParameters();
expect($required)->toBe(0, "{$class}::{$method}() must be callable without arguments");
}
})->with([DompdfResponse::class, GotenbergPdfResponse::class]);
test('dompdf renders a real pdf through the contract', function () {
$pdf = (new DompdfDriver)->loadView('app.pdf.partials.fonts');
expect($pdf)->toBeInstanceOf(ResponseStream::class)
->and($pdf->output())->toStartWith('%PDF-');
});
test('dompdf offers the document as an attachment when downloaded', function () {
$response = (new DompdfDriver)->loadView('app.pdf.partials.fonts')->download('expenses.pdf');
expect($response->headers->get('content-disposition'))->toContain('attachment')
->and($response->headers->get('content-disposition'))->toContain('expenses.pdf');
});