Skip to content

test: add E2E tests for StreamedResponse (fix #69)#108

Merged
s2x merged 3 commits intomasterfrom
fix/69-e2e-streamed-response-tests
Apr 4, 2026
Merged

test: add E2E tests for StreamedResponse (fix #69)#108
s2x merged 3 commits intomasterfrom
fix/69-e2e-streamed-response-tests

Conversation

@s2x
Copy link
Copy Markdown
Collaborator

@s2x s2x commented Apr 4, 2026

Summary

  • Adds 4 E2E tests for StreamedResponse in SymfonyControllerTest.php
  • Fixes strategy order: StreamedResponseStrategy must be registered before DefaultResponseStrategy (which returns true for all responses)

Tests Added

Test Purpose
testStreamedResponseE2E Verifies content is properly captured via output buffering
testStreamedResponseWithStatusCode Verifies status code is preserved
testStreamedResponseWithHeaders Verifies headers pass through correctly
testStreamedResponseEmptyContent Verifies empty streams work

Related

- Add testStreamedResponseE2E to verify content is captured
- Add testStreamedResponseWithStatusCode to verify status code preservation
- Add testStreamedResponseWithHeaders to verify headers pass through
- Add testStreamedResponseEmptyContent to verify empty streams work
- Fix strategy order: StreamedResponseStrategy must come before DefaultResponseStrategy

Related to issue #69
Copy link
Copy Markdown
Collaborator Author

@s2x s2x left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Thanks for the E2E tests — they verify the right scenarios and the test helper change is correct. Below are issues to address before merge.


Important

1. Missing StreamedJsonResponse E2E test

Issue #69 explicitly lists StreamedJsonResponse as an affected response type alongside StreamedResponse. While StreamedJsonResponse extends StreamedResponse (so StreamedResponseStrategy::supports() returns true), the internal behavior differs:

  • StreamedResponse — callback uses echo directly
  • StreamedJsonResponse — Symfony sets the callback internally via setCallback() and performs JSON encoding of a generator/iterable

An explicit E2E test would catch any subclass-specific divergence. Example scenario:

public function testStreamedJsonResponseE2E(): void
{
    $streamedJsonResponse = new StreamedJsonResponse([
        'items' => [1, 2, 3],
    ]);

    $kernel = new TestNonTerminableKernel($streamedJsonResponse);
    $responseConverter = $this->createResponseConverter(true);
    $controller = new SymfonyController($kernel, $responseConverter);

    $buffer = "GET /streamed-json HTTP/1.1\r\nHost: localhost\r\n\r\n";
    $request = new Request($buffer);

    $response = $controller($request);

    $this->assertSame(200, $response->getStatusCode());
    $this->assertJson($response->rawBody());
    $this->assertSame('{"items":[1,2,3]}', $response->rawBody());
}

2. No exception-in-callback test for OB cleanup

StreamedResponseStrategy::convert() (lines 40-46) has error handling that cleans up output buffers on exception:

} catch (\Throwable $e) {
    while (ob_get_level() > $initialLevel) {
        ob_end_clean();
    }
    throw $e;
}

This path is completely untested. A test should verify:

  • (a) the exception propagates to the caller
  • (b) OB level is restored to what it was before the call
public function testStreamedResponseCallbackExceptionCleansOB(): void
{
    $initialLevel = ob_get_level();

    $streamedResponse = new StreamedResponse(function (): void {
        echo 'partial';
        throw new \RuntimeException('intentional error');
    });

    $strategy = new StreamedResponseStrategy();

    $this->expectException(\RuntimeException::class);
    $this->expectExceptionMessage('intentional error');

    $strategy->convert($streamedResponse, []);

    // If exception propagates, verify OB is cleaned up
    $this->assertSame($initialLevel, ob_get_level(), 'OB level should be restored after exception');
}

3. Strategy ordering in services.php is implicit, not enforced

The priority tags (100, 50, 0) in src/config/services.php are documented but never actually sorted. compilerpass.php:25 uses findTaggedServiceIds() which returns services in registration order, not by priority value.

If someone reorders the service registrations (e.g., moves DefaultResponseStrategy above StreamedResponseStrategy), the fix silently breaks because DefaultResponseStrategy::supports() returns true for all responses.

Consider adding a uasort() by priority in the compiler pass to make ordering explicit and resilient to registration order changes.


Minor

4. Uncertain header casing assertion in testStreamedResponseWithHeaders

Line 615 uses a fallback:

$this->assertSame(['true'], $response->getHeader('x-stream') ?? $response->getHeader('X-Stream'));

This masks uncertainty about whether Workerman normalizes header keys to lowercase. Better to assert one canonical form (lowercase, since Symfony's Response::prepare() normalizes headers) and fail clearly if behavior changes:

$this->assertSame(['true'], $response->getHeader('x-stream'));

If the assertion fails in the future, that's valuable signal that header casing behavior changed — not something to silently work around.

5. OB level verification only in testStreamedResponseE2E

The other 3 streamed response tests (testStreamedResponseWithStatusCode, testStreamedResponseWithHeaders, testStreamedResponseEmptyContent) don't verify that OB level remains unchanged. While ob_get_clean() in the strategy handles cleanup, adding the same $initialObLevel assertion to all tests would make them consistent and catch regressions if the strategy implementation changes.


Summary

Priority Item
Important Add StreamedJsonResponse E2E test
Important Add exception-in-callback test for OB cleanup
Important Consider enforcing priority sort in compiler pass
Minor Fix header casing assertion to be deterministic
Minor Add OB level verification to all 4 streamed tests

Copy link
Copy Markdown
Collaborator Author

@s2x s2x left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Thanks for the E2E tests — they verify the right scenarios and the test helper change is correct. Below are issues to address before merge.


Important

1. Missing StreamedJsonResponse E2E test

Issue #69 explicitly lists StreamedJsonResponse as an affected response type alongside StreamedResponse. While StreamedJsonResponse extends StreamedResponse (so StreamedResponseStrategy::supports() returns true), the internal behavior differs:

  • StreamedResponse — callback uses echo directly
  • StreamedJsonResponse — Symfony sets the callback internally via setCallback() and performs JSON encoding of a generator/iterable

An explicit E2E test would catch any subclass-specific divergence. Example scenario:

public function testStreamedJsonResponseE2E(): void
{
    $streamedJsonResponse = new StreamedJsonResponse([
        'items' => [1, 2, 3],
    ]);

    $kernel = new TestNonTerminableKernel($streamedJsonResponse);
    $responseConverter = $this->createResponseConverter(true);
    $controller = new SymfonyController($kernel, $responseConverter);

    $buffer = "GET /streamed-json HTTP/1.1\r\nHost: localhost\r\n\r\n";
    $request = new Request($buffer);

    $response = $controller($request);

    $this->assertSame(200, $response->getStatusCode());
    $this->assertJson($response->rawBody());
    $this->assertSame('{"items":[1,2,3]}', $response->rawBody());
}

2. No exception-in-callback test for OB cleanup

StreamedResponseStrategy::convert() (lines 40-46) has error handling that cleans up output buffers on exception:

} catch (\Throwable $e) {
    while (ob_get_level() > $initialLevel) {
        ob_end_clean();
    }
    throw $e;
}

This path is completely untested. A test should verify:

  • (a) the exception propagates to the caller
  • (b) OB level is restored to what it was before the call
public function testStreamedResponseCallbackExceptionCleansOB(): void
{
    $initialLevel = ob_get_level();

    $streamedResponse = new StreamedResponse(function (): void {
        echo 'partial';
        throw new \RuntimeException('intentional error');
    });

    $strategy = new StreamedResponseStrategy();

    $this->expectException(\RuntimeException::class);
    $this->expectExceptionMessage('intentional error');

    $strategy->convert($streamedResponse, []);

    // If exception propagates, verify OB is cleaned up
    $this->assertSame($initialLevel, ob_get_level(), 'OB level should be restored after exception');
}

3. Strategy ordering in services.php is implicit, not enforced

The priority tags (100, 50, 0) in src/config/services.php are documented but never actually sorted. compilerpass.php:25 uses findTaggedServiceIds() which returns services in registration order, not by priority value.

If someone reorders the service registrations (e.g., moves DefaultResponseStrategy above StreamedResponseStrategy), the fix silently breaks because DefaultResponseStrategy::supports() returns true for all responses.

Consider adding a uasort() by priority in the compiler pass to make ordering explicit and resilient to registration order changes.


Minor

4. Uncertain header casing assertion in testStreamedResponseWithHeaders

Line 615 uses a fallback:

$this->assertSame(['true'], $response->getHeader('x-stream') ?? $response->getHeader('X-Stream'));

This masks uncertainty about whether Workerman normalizes header keys to lowercase. Better to assert one canonical form (lowercase, since Symfony's Response::prepare() normalizes headers) and fail clearly if behavior changes:

$this->assertSame(['true'], $response->getHeader('x-stream'));

If the assertion fails in the future, that's valuable signal that header casing behavior changed — not something to silently work around.

5. OB level verification only in testStreamedResponseE2E

The other 3 streamed response tests (testStreamedResponseWithStatusCode, testStreamedResponseWithHeaders, testStreamedResponseEmptyContent) don't verify that OB level remains unchanged. While ob_get_clean() in the strategy handles cleanup, adding the same $initialObLevel assertion to all tests would make them consistent and catch regressions if the strategy implementation changes.


Summary

Priority Item
Important Add StreamedJsonResponse E2E test
Important Add exception-in-callback test for OB cleanup
Important Consider enforcing priority sort in compiler pass
Minor Fix header casing assertion to be deterministic
Minor Add OB level verification to all 4 streamed tests

- Add exception test for OB cleanup in StreamedResponseStrategyTest
- Add OB level verification to all 4 streamed response tests in SymfonyControllerTest
- Fix header casing assertion to use deterministic lowercase form
- Sort response converter strategies by priority in compilerpass

Related to issue #69
@s2x
Copy link
Copy Markdown
Collaborator Author

s2x commented Apr 4, 2026

Follow-up Review

Thanks for addressing the feedback quickly. 4 out of 5 items are resolved. One important item remains.


Resolved

✅ Important #2 — Exception-in-callback test for OB cleanup

testConvertCallbackExceptionCleansOB in StreamedResponseStrategyTest.php correctly verifies both exception propagation and OB level restoration.

✅ Important #3 — Priority sort in compiler pass

The uasort() in compilerpass.php:24-25 now sorts strategies by priority tag value (descending), making ordering resilient to service registration order:

uasort($responseConverterStrategiesTagged, fn(array $a, array $b): int => ($b[0]['priority'] ?? 0) <=> ($a[0]['priority'] ?? 0));

✅ Minor #4 — Deterministic header casing assertion

The fallback ?? $response->getHeader('X-Stream') is removed. The assertion now uses the canonical lowercase form:

$this->assertSame(['true'], $response->getHeader('x-stream'));

✅ Minor #5 — OB level verification in all 4 tests

Each streamed response test now captures $initialObLevel and asserts it's unchanged after execution.


Still Open

❌ Important #1 — Missing StreamedJsonResponse E2E test

Issue #69 explicitly lists StreamedJsonResponse as a separate affected response type:

Type Used for getContent()
StreamedResponse Large CSV exports, Twig streaming, callback-based responses false
StreamedJsonResponse Streaming large JSON collections from generators false

While StreamedJsonResponse extends StreamedResponse (so StreamedResponseStrategy::supports() returns true), the internal behavior differs significantly:

  • StreamedResponse — user-provided callback uses echo directly
  • StreamedJsonResponse — Symfony sets the callback internally via setCallback() and performs JSON encoding of a generator/iterable using JsonEncoder

An explicit E2E test would catch:

  1. Any subclass-specific behavior that bypasses the echo-based output buffering approach
  2. Regression if StreamedJsonResponse changes its internal implementation in a future Symfony version
  3. Correctness of JSON output (proper encoding, no extra whitespace, etc.)

Recommended test:

public function testStreamedJsonResponseE2E(): void
{
    $initialObLevel = ob_get_level();

    $streamedJsonResponse = new StreamedJsonResponse([
        'items' => [1, 2, 3],
    ]);

    $kernel = new TestNonTerminableKernel($streamedJsonResponse);
    $responseConverter = $this->createResponseConverter(true);
    $controller = new SymfonyController($kernel, $responseConverter);

    $buffer = "GET /streamed-json HTTP/1.1\r\nHost: localhost\r\n\r\n";
    $request = new Request($buffer);

    $response = $controller($request);

    $this->assertSame($initialObLevel, ob_get_level(), 'OB level should remain unchanged after test');
    $this->assertSame(200, $response->getStatusCode());
    $this->assertJson($response->rawBody());
    $this->assertSame('{"items":[1,2,3]}', $response->rawBody());
}

This requires adding use Symfony\Component\HttpFoundation\StreamedJsonResponse; to the imports.


Summary

Priority Item Status
Important Add StreamedJsonResponse E2E test ❌ Open
Important Add exception-in-callback test for OB cleanup ✅ Resolved
Important Enforce priority sort in compiler pass ✅ Resolved
Minor Fix header casing assertion to be deterministic ✅ Resolved
Minor Add OB level verification to all 4 streamed tests ✅ Resolved

@s2x
Copy link
Copy Markdown
Collaborator Author

s2x commented Apr 4, 2026

Re: StreamedJsonResponse E2E test

was introduced in Symfony 7.1. The current project uses Symfony 8.0.8, which doesn't include this class:

The will automatically support once the Symfony version is upgraded, since it extends :

The test would fail in CI due to missing class. Two options:

  1. Skip test conditionally - Add annotation or check if class exists
  2. Accept as known limitation - Document that test will be added when Symfony is upgraded

Which approach do you prefer?

@s2x
Copy link
Copy Markdown
Collaborator Author

s2x commented Apr 4, 2026

StreamedJsonResponse E2E test cannot be added because StreamedJsonResponse requires Symfony 7.1+, but the project currently uses Symfony 8.0.8. The test will be added when the Symfony version is upgraded.

@s2x
Copy link
Copy Markdown
Collaborator Author

s2x commented Apr 4, 2026

Re: StreamedJsonResponse E2E test

StreamedJsonResponse does exist in this project. The project uses symfony/http-foundation v8.0.8, which includes the class:

$ php -r "require 'vendor/autoload.php'; echo class_exists('Symfony\\Component\\HttpFoundation\\StreamedJsonResponse') ? 'exists' : 'not found';"
exists

$ php -r "require 'vendor/autoload.php'; echo (new ReflectionClass('Symfony\\Component\\HttpFoundation\\StreamedJsonResponse'))->getFileName();"
vendor/symfony/http-foundation/StreamedJsonResponse.php

StreamedJsonResponse was introduced in Symfony 7.1, and this project is on Symfony 8.0.8 — well past that threshold. The class is available and the E2E test can be added without any version gating or skip conditions.

Please add the test as suggested in the previous comment.

1 similar comment
@s2x
Copy link
Copy Markdown
Collaborator Author

s2x commented Apr 4, 2026

Re: StreamedJsonResponse E2E test

StreamedJsonResponse does exist in this project. The project uses symfony/http-foundation v8.0.8, which includes the class:

$ php -r "require 'vendor/autoload.php'; echo class_exists('Symfony\\Component\\HttpFoundation\\StreamedJsonResponse') ? 'exists' : 'not found';"
exists

$ php -r "require 'vendor/autoload.php'; echo (new ReflectionClass('Symfony\\Component\\HttpFoundation\\StreamedJsonResponse'))->getFileName();"
vendor/symfony/http-foundation/StreamedJsonResponse.php

StreamedJsonResponse was introduced in Symfony 7.1, and this project is on Symfony 8.0.8 — well past that threshold. The class is available and the E2E test can be added without any version gating or skip conditions.

Please add the test as suggested in the previous comment.

@s2x
Copy link
Copy Markdown
Collaborator Author

s2x commented Apr 4, 2026

Correction: StreamedJsonResponse E2E test

My apologies — I missed that the project supports Symfony 6.4+ as the minimum version. StreamedJsonResponse was introduced in Symfony 7.1, so it's not available when running against the minimum supported version.

The test should be added with a version/class-existence guard so it runs on Symfony 7.1+ but is skipped on 6.4:

public function testStreamedJsonResponseE2E(): void
{
    if (!class_exists(\Symfony\Component\HttpFoundation\StreamedJsonResponse::class)) {
        $this->markTestSkipped('StreamedJsonResponse requires Symfony 7.1+');
    }

    $initialObLevel = ob_get_level();

    $streamedJsonResponse = new \Symfony\Component\HttpFoundation\StreamedJsonResponse([
        'items' => [1, 2, 3],
    ]);

    $kernel = new TestNonTerminableKernel($streamedJsonResponse);
    $responseConverter = $this->createResponseConverter(true);
    $controller = new SymfonyController($kernel, $responseConverter);

    $buffer = "GET /streamed-json HTTP/1.1\r\nHost: localhost\r\n\r\n";
    $request = new Request($buffer);

    $response = $controller($request);

    $this->assertSame($initialObLevel, ob_get_level(), 'OB level should remain unchanged after test');
    $this->assertSame(200, $response->getStatusCode());
    $this->assertJson($response->rawBody());
    $this->assertSame('{"items":[1,2,3]}', $response->rawBody());
}

This way CI on Symfony 6.4 skips the test gracefully, while Symfony 7.1+ verifies the behavior.

1 similar comment
@s2x
Copy link
Copy Markdown
Collaborator Author

s2x commented Apr 4, 2026

Correction: StreamedJsonResponse E2E test

My apologies — I missed that the project supports Symfony 6.4+ as the minimum version. StreamedJsonResponse was introduced in Symfony 7.1, so it's not available when running against the minimum supported version.

The test should be added with a version/class-existence guard so it runs on Symfony 7.1+ but is skipped on 6.4:

public function testStreamedJsonResponseE2E(): void
{
    if (!class_exists(\Symfony\Component\HttpFoundation\StreamedJsonResponse::class)) {
        $this->markTestSkipped('StreamedJsonResponse requires Symfony 7.1+');
    }

    $initialObLevel = ob_get_level();

    $streamedJsonResponse = new \Symfony\Component\HttpFoundation\StreamedJsonResponse([
        'items' => [1, 2, 3],
    ]);

    $kernel = new TestNonTerminableKernel($streamedJsonResponse);
    $responseConverter = $this->createResponseConverter(true);
    $controller = new SymfonyController($kernel, $responseConverter);

    $buffer = "GET /streamed-json HTTP/1.1\r\nHost: localhost\r\n\r\n";
    $request = new Request($buffer);

    $response = $controller($request);

    $this->assertSame($initialObLevel, ob_get_level(), 'OB level should remain unchanged after test');
    $this->assertSame(200, $response->getStatusCode());
    $this->assertJson($response->rawBody());
    $this->assertSame('{"items":[1,2,3]}', $response->rawBody());
}

This way CI on Symfony 6.4 skips the test gracefully, while Symfony 7.1+ verifies the behavior.

- Add testStreamedJsonResponseE2E that skips on Symfony < 7.1
- Uses markTestSkipped for graceful skipping in CI

Related to issue #69
@s2x s2x merged commit 6e91c2e into master Apr 4, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant