PAGI-Server

 view release on metacpan or  search on metacpan

t/02-streaming.t  view on Meta::CPAN

use strict;
use warnings;
use Test2::V0;
use IO::Async::Loop;
use Net::Async::HTTP;
use Future::AsyncAwait;

use PAGI::Server;

plan skip_all => "Server integration tests not supported on Windows" if $^O eq 'MSWin32';

# Step 2: Streaming Responses and Disconnect Handling
# Tests for examples/02-streaming-response/app.pl

my $loop = IO::Async::Loop->new;

# Test 1: Streaming response with multiple chunks
subtest 'Streaming response uses chunked Transfer-Encoding' => sub {
    my $simple_streaming_app = async sub  {
        my ($scope, $receive, $send) = @_;
        die "Unsupported: $scope->{type}" if $scope->{type} ne 'http';

        while (1) {
            my $event = await $receive->();
            last if $event->{type} ne 'http.request';
            last unless $event->{more};
        }

        await $send->({
            type    => 'http.response.start',
            status  => 200,
            headers => [['content-type', 'text/plain']],
        });

        await $send->({ type => 'http.response.body', body => "Chunk 1\n", more => 1 });
        await $send->({ type => 'http.response.body', body => "Chunk 2\n", more => 1 });
        await $send->({ type => 'http.response.body', body => "Chunk 3\n", more => 0 });
    };

    my $server = PAGI::Server->new(
        app   => $simple_streaming_app,
        host  => '127.0.0.1',
        port  => 0,
        quiet => 1,
    );

    $loop->add($server);
    $server->listen->get;

    my $port = $server->port;

    my $http = Net::Async::HTTP->new;
    $loop->add($http);

    my $response = $http->GET("http://127.0.0.1:$port/")->get;

    is($response->code, 200, 'Response status is 200 OK');
    is($response->header('Transfer-Encoding'), 'chunked', 'Response uses chunked Transfer-Encoding');

    my $body = $response->decoded_content;
    like($body, qr/Chunk 1.*Chunk 2.*Chunk 3/s, 'Response body contains all chunks in order');

    $server->shutdown->get;
    $loop->remove($server);
};

# Test 2: Multiple body chunks arrive in order
subtest 'Multiple http.response.body events work correctly' => sub {
    my $streaming_app = async sub  {
        my ($scope, $receive, $send) = @_;
        die "Unsupported: $scope->{type}" if $scope->{type} ne 'http';

        while (1) {
            my $event = await $receive->();
            last if $event->{type} ne 'http.request';
            last unless $event->{more};
        }

        await $send->({
            type    => 'http.response.start',
            status  => 200,
            headers => [['content-type', 'text/plain']],
        });

        await $send->({ type => 'http.response.body', body => "First\n", more => 1 });
        await $send->({ type => 'http.response.body', body => "Second\n", more => 1 });
        await $send->({ type => 'http.response.body', body => "Third\n", more => 0 });
    };

    my $server = PAGI::Server->new(
        app   => $streaming_app,
        host  => '127.0.0.1',
        port  => 0,
        quiet => 1,
    );

    $loop->add($server);
    $server->listen->get;

    my $port = $server->port;

    my $http = Net::Async::HTTP->new;
    $loop->add($http);

    my $response = $http->GET("http://127.0.0.1:$port/")->get;

    is($response->code, 200, 'Response status is 200');
    my $body = $response->decoded_content;
    like($body, qr/First\nSecond\nThird\n/, 'Body contains all chunks in order');

    $server->shutdown->get;
    $loop->remove($server);
};

# Test 3: Streaming without trailers terminates correctly
subtest 'Streaming without trailers terminates correctly' => sub {
    my $no_trailer_app = async sub  {
        my ($scope, $receive, $send) = @_;
        die "Unsupported: $scope->{type}" if $scope->{type} ne 'http';

        while (1) {
            my $event = await $receive->();
            last if $event->{type} ne 'http.request';
            last unless $event->{more};
        }

        await $send->({
            type    => 'http.response.start',
            status  => 200,
            headers => [['content-type', 'text/plain']],
        });

        await $send->({ type => 'http.response.body', body => "Data 1\n", more => 1 });
        await $send->({ type => 'http.response.body', body => "Data 2\n", more => 0 });
    };

    my $server = PAGI::Server->new(
        app   => $no_trailer_app,
        host  => '127.0.0.1',
        port  => 0,
        quiet => 1,
    );

    $loop->add($server);
    $server->listen->get;

    my $port = $server->port;

    my $http = Net::Async::HTTP->new;
    $loop->add($http);

t/02-streaming.t  view on Meta::CPAN

    $server->shutdown->get;
    $loop->remove($server);
};

# Test 4: Streaming with trailers (verify body content)
# Note: Net::Async::HTTP doesn't easily expose trailers, but we verify the body
# Trailers are verified manually with: curl -s -D - http://localhost:5000/
subtest 'Streaming with trailers - body content correct' => sub {
    my $trailer_app = async sub  {
        my ($scope, $receive, $send) = @_;
        die "Unsupported: $scope->{type}" if $scope->{type} ne 'http';

        while (1) {
            my $event = await $receive->();
            last if $event->{type} ne 'http.request';
            last unless $event->{more};
        }

        await $send->({
            type     => 'http.response.start',
            status   => 200,
            headers  => [['content-type', 'text/plain']],
            trailers => 1,
        });

        await $send->({ type => 'http.response.body', body => "Body content\n", more => 0 });

        await $send->({
            type    => 'http.response.trailers',
            headers => [['x-test-trailer', 'trailer-value']],
        });
    };

    my $server = PAGI::Server->new(
        app   => $trailer_app,
        host  => '127.0.0.1',
        port  => 0,
        quiet => 1,
    );

    $loop->add($server);
    $server->listen->get;

    my $port = $server->port;

    my $http = Net::Async::HTTP->new;
    $loop->add($http);

    my $response = $http->GET("http://127.0.0.1:$port/")->get;

    is($response->code, 200, 'Response status is 200');
    is($response->header('Transfer-Encoding'), 'chunked', 'Response uses chunked encoding');
    my $body = $response->decoded_content;
    like($body, qr/Body content/, 'Body content is correct');

    $server->shutdown->get;
    $loop->remove($server);
};

# Test 5: Client disconnect detection
subtest 'Client disconnect stops streaming app' => sub {
    my $disconnect_detected = 0;
    my $chunks_sent = 0;

    my $slow_streaming_app = async sub  {
        my ($scope, $receive, $send) = @_;
        die "Unsupported: $scope->{type}" if $scope->{type} ne 'http';

        my $loop = IO::Async::Loop->new;

        # Drain request body
        while (1) {
            my $event = await $receive->();
            last if $event->{type} ne 'http.request';
            last unless $event->{more};
        }

        await $send->({
            type    => 'http.response.start',
            status  => 200,
            headers => [['content-type', 'text/plain']],
        });

        # Start watching for disconnect
        my $disconnect_future = $receive->();

        for my $i (1..5) {
            # Check if client disconnected
            if ($disconnect_future->is_ready) {
                $disconnect_detected = 1;
                last;
            }

            $chunks_sent++;
            await $send->({ type => 'http.response.body', body => "Chunk $i\n", more => ($i < 5) ? 1 : 0 });

            # Wait between chunks (allows disconnect to be detected)
            if ($i < 5 && $loop) {
                await $loop->delay_future(after => 0.2);
            }
        }
    };

    my $server = PAGI::Server->new(
        app   => $slow_streaming_app,
        host  => '127.0.0.1',
        port  => 0,
        quiet => 1,
    );

    $loop->add($server);
    $server->listen->get;

    my $port = $server->port;

    # Use raw socket connection so we can disconnect mid-stream
    require IO::Socket::INET;
    my $sock = IO::Socket::INET->new(
        PeerHost => '127.0.0.1',
        PeerPort => $port,
        Proto    => 'tcp',
        Timeout  => 5,
    );

    ok($sock, 'Connected to server');

    # Send HTTP request
    print $sock "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n";

    # Read just the headers and first chunk
    my $data = '';
    $sock->blocking(0);

    # Give server time to start streaming
    my $timeout = time + 2;
    while (time < $timeout) {
        $loop->loop_once(0.1);
        my $buf;
        my $n = $sock->sysread($buf, 4096);
        if (defined $n && $n > 0) {
            $data .= $buf;
            # Once we have some body data, disconnect
            if ($data =~ /Chunk 1/) {
                last;
            }
        }
    }

    like($data, qr/HTTP\/1\.1 200/, 'Received HTTP response');
    like($data, qr/Chunk 1/, 'Received first chunk');

    # Close connection abruptly
    close($sock);

    # Give server time to detect disconnect and finish
    $loop->loop_once(0.5);

    # The app should have detected disconnect before sending all 5 chunks
    # Note: Due to timing, the app may or may not have detected the disconnect
    # depending on how fast the TCP close propagates. We'll verify the mechanism works.
    ok($chunks_sent >= 1, "Sent at least 1 chunk (sent $chunks_sent)");

    $server->shutdown->get;
    $loop->remove($server);
};

done_testing;



( run in 0.588 second using v1.01-cache-2.11-cpan-995e09ba956 )