Skip to content

Commit e5f86f4

Browse files
committed
test: cover both brotli and gzip compression paths; verify uniform OS token
Split Compression size-gate + codec selection into a testable compressWith() seam (no behavior change) so both the brotli path and the gzip fallback are covered deterministically regardless of whether the PECL brotli extension is loaded. Add a unit-brotli CI job so the real brotli round-trip runs in CI, and guard the gzip tests to skip on zlib-less builds. Add an AIX->aix case asserting the User-Agent OS token matches the reference JS os.platform() token. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019xbC2QoA4QKyvhC1d7gYap
1 parent 304abc7 commit e5f86f4

5 files changed

Lines changed: 176 additions & 9 deletions

File tree

.github/workflows/php-integration-tests.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,37 @@ concurrency:
2525
cancel-in-progress: true
2626

2727
jobs:
28+
# Offline unit tests on a build WITH the PECL brotli extension, so the real brotli codec round-trip
29+
# (testRealBrotliRoundTripsWhenExtensionPresent) actually runs instead of self-skipping. The main
30+
# `test` job deliberately omits brotli, so between the two jobs both request-compression codecs
31+
# (brotli and the gzip fallback) get genuine real-codec coverage in CI. This job needs no API token.
32+
unit-brotli:
33+
runs-on: ubuntu-latest
34+
steps:
35+
- name: Checkout
36+
uses: actions/checkout@v4
37+
38+
- name: Set up PHP (with brotli)
39+
uses: shivammathur/setup-php@v2
40+
with:
41+
php-version: '8.1'
42+
extensions: mbstring, json, curl, zlib, brotli
43+
coverage: none
44+
tools: composer:v2
45+
46+
- name: Install dependencies
47+
run: composer install --no-interaction --prefer-dist --no-progress
48+
49+
- name: Fail if brotli extension missing
50+
run: |
51+
if ! php -r "exit(extension_loaded('brotli') ? 0 : 1);"; then
52+
echo "::error::brotli extension failed to load; the real brotli codec test would silently skip."
53+
exit 1
54+
fi
55+
56+
- name: Unit tests (brotli path)
57+
run: vendor/bin/phpunit --testsuite unit
58+
2859
test:
2960
runs-on: ubuntu-latest
3061
steps:

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
- The `User-Agent` OS token now reports the short lowercase platform identifier (e.g. `linux`,
1010
`darwin`, `win32`), matching the reference JS client's `os.platform()` token, instead of the
1111
upper-cased `PHP_OS_FAMILY` value.
12+
- Both request-compression codecs are now covered by deterministic tests: the brotli path (its
13+
preference over gzip and its output) and the gzip fallback are each exercised regardless of whether
14+
the host PHP build has the PECL `brotli` extension loaded. No behavior change.
1215

1316
## 0.1.1
1417

src/Internal/Compression.php

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,32 +45,80 @@ final class Compression
4545
* @return array{0: string, 1: string}|null
4646
*/
4747
public static function maybeCompress(string $body): ?array
48+
{
49+
return self::compressWith($body, self::brotliEncoder(), self::gzipEncoder());
50+
}
51+
52+
/**
53+
* Size gate plus codec selection, split out from {@see maybeCompress} so both the brotli and the
54+
* gzip path can be exercised by tests regardless of which extensions the host PHP build loaded.
55+
*
56+
* Brotli ({@code br}) is preferred over gzip when its encoder is available; each encoder returns
57+
* the compressed bytes as a string, or a non-string on failure, in which case the next codec is
58+
* tried. Returns {@code null} when the body is below {@see MIN_COMPRESS_BYTES} or no codec
59+
* succeeds. A {@code null} encoder means that codec is unavailable and is skipped.
60+
*
61+
* @param (callable(string): mixed)|null $brotli brotli encoder, or {@code null} when the PECL brotli extension is absent
62+
* @param (callable(string): mixed)|null $gzip gzip encoder, or {@code null} when zlib is absent
63+
* @return array{0: string, 1: string}|null
64+
*/
65+
public static function compressWith(string $body, ?callable $brotli, ?callable $gzip): ?array
4866
{
4967
if (strlen($body) < self::MIN_COMPRESS_BYTES) {
5068
return null;
5169
}
5270

53-
if (function_exists('brotli_compress')) {
54-
// Called indirectly: brotli_compress only exists when the PECL brotli extension is
55-
// loaded, so a direct call would be an unresolved reference for static analysis on the
56-
// (common) PHP builds without the extension.
57-
$brotliCompress = 'brotli_compress';
58-
$compressed = $brotliCompress($body, self::BROTLI_QUALITY);
71+
if ($brotli !== null) {
72+
$compressed = $brotli($body);
5973
if (is_string($compressed)) {
6074
return ['br', $compressed];
6175
}
6276
}
6377

64-
if (function_exists('gzencode')) {
65-
$compressed = gzencode($body);
66-
if ($compressed !== false) {
78+
if ($gzip !== null) {
79+
$compressed = $gzip($body);
80+
if (is_string($compressed)) {
6781
return ['gzip', $compressed];
6882
}
6983
}
7084

7185
return null;
7286
}
7387

88+
/**
89+
* The brotli encoder for this build, or {@code null} when the PECL {@code brotli} extension is not
90+
* loaded. Frequently absent, since brotli is not part of PHP's standard distribution.
91+
*
92+
* @return (callable(string): mixed)|null
93+
*/
94+
private static function brotliEncoder(): ?callable
95+
{
96+
if (!function_exists('brotli_compress')) {
97+
return null;
98+
}
99+
100+
// Called indirectly: brotli_compress only exists when the PECL brotli extension is loaded, so
101+
// a direct call would be an unresolved reference for static analysis on the (common) PHP
102+
// builds without the extension.
103+
$brotliCompress = 'brotli_compress';
104+
return static fn (string $body) => $brotliCompress($body, self::BROTLI_QUALITY);
105+
}
106+
107+
/**
108+
* The gzip encoder for this build, or {@code null} when {@code gzencode} (zlib) is unavailable.
109+
* Ships with PHP's standard {@code zlib} extension, so it is the near-universal fallback.
110+
*
111+
* @return (callable(string): mixed)|null
112+
*/
113+
private static function gzipEncoder(): ?callable
114+
{
115+
if (!function_exists('gzencode')) {
116+
return null;
117+
}
118+
119+
return static fn (string $body) => gzencode($body);
120+
}
121+
74122
private function __construct()
75123
{
76124
}

tests/Unit/CompressionTest.php

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,90 @@ public function testPrefersBrotliWhenAvailableElseGzip(): void
6767
self::assertSame($expected, $encoding);
6868
}
6969

70+
public function testGzipPathWhenBrotliUnavailable(): void
71+
{
72+
if (!function_exists('gzencode')) {
73+
self::markTestSkipped('zlib (gzencode) not available in this PHP build');
74+
}
75+
// Deterministically exercise the gzip fallback (brotli encoder absent) without depending on
76+
// the host lacking the PECL brotli extension.
77+
$original = str_repeat('payload-', 500);
78+
$result = Compression::compressWith($original, null, static fn (string $b) => gzencode($b));
79+
self::assertNotNull($result);
80+
[$encoding, $data] = $result;
81+
self::assertSame('gzip', $encoding);
82+
self::assertLessThan(strlen($original), strlen($data));
83+
self::assertSame($original, gzdecode($data));
84+
}
85+
86+
public function testBrotliPathIsPreferredWhenAvailable(): void
87+
{
88+
// Deterministically exercise the brotli path (and its preference over gzip) without depending
89+
// on the host having the PECL brotli extension: inject a stand-in brotli encoder and assert it
90+
// is chosen and its output used, while a real gzip encoder is also available.
91+
$original = str_repeat('payload-', 500);
92+
$marker = 'BR:' . $original;
93+
$result = Compression::compressWith(
94+
$original,
95+
static fn (string $b) => 'BR:' . $b,
96+
static fn (string $b) => gzencode($b),
97+
);
98+
self::assertNotNull($result);
99+
[$encoding, $data] = $result;
100+
self::assertSame('br', $encoding);
101+
self::assertSame($marker, $data);
102+
}
103+
104+
public function testRealBrotliRoundTripsWhenExtensionPresent(): void
105+
{
106+
if (!function_exists('brotli_compress')) {
107+
self::markTestSkipped('PECL brotli extension not loaded');
108+
}
109+
// When the real extension is present, verify the actual brotli codec produces decodable bytes.
110+
$original = str_repeat('payload-', 500);
111+
$result = Compression::maybeCompress($original);
112+
self::assertNotNull($result);
113+
[$encoding, $data] = $result;
114+
self::assertSame('br', $encoding);
115+
self::assertLessThan(strlen($original), strlen($data));
116+
self::assertSame($original, self::decode('br', $data));
117+
}
118+
119+
public function testFallsBackToGzipWhenBrotliEncoderFails(): void
120+
{
121+
if (!function_exists('gzencode')) {
122+
self::markTestSkipped('zlib (gzencode) not available in this PHP build');
123+
}
124+
// A brotli encoder that fails (returns a non-string) must not abort compression: gzip is used.
125+
$original = str_repeat('payload-', 500);
126+
$result = Compression::compressWith(
127+
$original,
128+
static fn (string $b) => false,
129+
static fn (string $b) => gzencode($b),
130+
);
131+
self::assertNotNull($result);
132+
[$encoding, $data] = $result;
133+
self::assertSame('gzip', $encoding);
134+
self::assertSame($original, gzdecode($data));
135+
}
136+
137+
public function testReturnsNullWhenNoCodecAvailable(): void
138+
{
139+
$original = str_repeat('payload-', 500);
140+
self::assertNull(Compression::compressWith($original, null, null));
141+
}
142+
143+
public function testSmallBodyIsNotCompressedEvenWithCodecs(): void
144+
{
145+
// The size gate applies before codec selection, so a below-threshold body is never compressed.
146+
$small = str_repeat('a', Compression::MIN_COMPRESS_BYTES - 1);
147+
self::assertNull(Compression::compressWith(
148+
$small,
149+
static fn (string $b) => 'BR:' . $b,
150+
static fn (string $b) => gzencode($b),
151+
));
152+
}
153+
70154
private static function decode(string $encoding, string $data): string
71155
{
72156
if ($encoding === 'br') {

tests/Unit/PlatformTest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ public static function osCases(): array
2727
'openbsd' => ['OpenBSD', 'openbsd'],
2828
'netbsd' => ['NetBSD', 'netbsd'],
2929
'solaris/sunos' => ['SunOS', 'sunos'],
30+
'aix' => ['AIX', 'aix'],
3031
'cygwin' => ['CYGWIN_NT-10.0-19045', 'cygwin'],
3132
];
3233
}

0 commit comments

Comments
 (0)