package Plack::Test::Suite;
use strict;
use warnings;
use Digest::MD5;
use File::ShareDir;
use HTTP::Request;
use HTTP::Request::Common;
use LWP::UserAgent;
use Test::More;
use Test::TCP;
use Plack::Loader;
use Plack::Middleware::Lint;
use Plack::Util;
use Plack::Request;
use Try::Tiny;
my $share_dir = try { File::ShareDir::dist_dir('Plack') } || 'share';
# 0: test name
# 1: request generator coderef.
# 2: request handler
# 3: test case for response
our @TEST = (
[
'GET',
sub {
my $cb = shift;
my $res = $cb->(GET "http://127.0.0.1/?name=miyagawa");
is $res->code, 200;
is $res->header('content_type'), 'text/plain';
is $res->content, 'Hello, name=miyagawa';
},
sub {
my $env = shift;
return [
200,
[ 'Content-Type' => 'text/plain', ],
[ 'Hello, ' . $env->{QUERY_STRING} ],
];
},
],
[
'POST',
sub {
my $cb = shift;
my $res = $cb->(POST "http://127.0.0.1/", [name => 'tatsuhiko']);
is $res->code, 200;
is $res->header('Client-Content-Length'), 14;
is $res->header('Client-Content-Type'), 'application/x-www-form-urlencoded';
is $res->header('content_type'), 'text/plain';
is $res->content, 'Hello, name=tatsuhiko';
},
sub {
my $env = shift;
my $body;
$env->{'psgi.input'}->read($body, $env->{CONTENT_LENGTH});
return [
200,
[ 'Content-Type' => 'text/plain',
'Client-Content-Length' => $env->{CONTENT_LENGTH},
'Client-Content-Type' => $env->{CONTENT_TYPE},
],
[ 'Hello, ' . $body ],
];
},
],
[
'big POST',
sub {
my $cb = shift;
my $chunk = "abcdefgh" x 12000;
my $req = HTTP::Request->new(POST => "http://127.0.0.1");
$req->content_length(length $chunk);
$req->content_type('application/octet-stream');
$req->content($chunk);
my $res = $cb->($req);
is $res->code, 200;
is $res->header('Client-Content-Length'), length $chunk;
is length $res->content, length $chunk;
is Digest::MD5::md5_hex($res->content), Digest::MD5::md5_hex($chunk);
},
sub {
my $env = shift;
my $len = $env->{CONTENT_LENGTH};
my $body = '';
my $spin;
while ($len > 0) {
my $rc = $env->{'psgi.input'}->read($body, $env->{CONTENT_LENGTH}, length $body);
$len -= $rc;
last if $spin++ > 2000;
}
return [
200,
[ 'Content-Type' => 'text/plain',
'Client-Content-Length' => $env->{CONTENT_LENGTH},
'Client-Content-Type' => $env->{CONTENT_TYPE},
],
[ $body ],
];
},
],
[
'psgi.url_scheme',
sub {
my $cb = shift;
my $res = $cb->(POST "http://127.0.0.1/");
is $res->code, 200;
is $res->header('content_type'), 'text/plain';
is $res->content, 'http';
},
sub {
my $env = $_[0];
return [
200,
[ 'Content-Type' => 'text/plain', ],
[ $env->{'psgi.url_scheme'} ],
];
},
],
[
'return glob',
sub {
my $cb = shift;
my $res = $cb->(GET "http://127.0.0.1/");
is $res->code, 200;
is $res->header('content_type'), 'text/plain';
like $res->content, qr/^package /;
like $res->content, qr/END_MARK_FOR_TESTING$/;
},
sub {
my $env = shift;
open my $fh, '<', __FILE__ or die $!;
return [
200,
[ 'Content-Type' => 'text/plain', ],
$fh,
];
},
],
[
'filehandle',
sub {
my $cb = shift;
my $res = $cb->(GET "http://127.0.0.1/foo.jpg");
is $res->code, 200;
is $res->header('content_type'), 'image/jpeg';
is length $res->content, 4745;
},
sub {
my $env = shift;
open my $fh, '<', "$share_dir/face.jpg";
return [
200,
[ 'Content-Type' => 'image/jpeg', 'Content-Length' => -s $fh ],
$fh
];
},
],
[
'bigger file',
sub {
my $cb = shift;
my $res = $cb->(GET "http://127.0.0.1/baybridge.jpg");
is $res->code, 200;
is $res->header('content_type'), 'image/jpeg';
is length $res->content, 79838;
is Digest::MD5::md5_hex($res->content), '983726ae0e4ce5081bef5fb2b7216950';
},
sub {
my $env = shift;
open my $fh, '<', "$share_dir/baybridge.jpg";
binmode $fh;
return [
200,
[ 'Content-Type' => 'image/jpeg', 'Content-Length' => -s $fh ],
$fh
];
},
],
[
'handle HTTP-Header',
sub {
my $cb = shift;
my $res = $cb->(GET "http://127.0.0.1/foo/?dankogai=kogaidan", Foo => "Bar");
is $res->code, 200;
is $res->header('content_type'), 'text/plain';
is $res->content, 'Bar';
},
sub {
my $env = shift;
return [
200,
[ 'Content-Type' => 'text/plain', ],
[$env->{HTTP_FOO}],
];
},
],
[
'handle HTTP-Cookie',
sub {
my $cb = shift;
my $res = $cb->(GET "http://127.0.0.1/foo/?dankogai=kogaidan", Cookie => "foo");
is $res->code, 200;
is $res->header('content_type'), 'text/plain';
is $res->content, 'foo';
},
sub {
my $env = shift;
return [
200,
[ 'Content-Type' => 'text/plain', ],
[$env->{HTTP_COOKIE}],
];
},
],
[
'validate env',
sub {
my $cb = shift;
my $res = $cb->(GET "http://127.0.0.1/foo/?dankogai=kogaidan");
is $res->code, 200;
is $res->header('content_type'), 'text/plain';
is $res->content, join("\n",
'REQUEST_METHOD:GET',
'PATH_INFO:/foo/',
'QUERY_STRING:dankogai=kogaidan',
'SERVER_NAME:127.0.0.1',
"SERVER_PORT:" . $res->request->uri->port,
)."\n";
},
sub {
my $env = shift;
my $body;
$body .= $_ . ':' . $env->{$_} . "\n" for qw/REQUEST_METHOD PATH_INFO QUERY_STRING SERVER_NAME SERVER_PORT/;
return [
200,
[ 'Content-Type' => 'text/plain', ],
[$body],
];
},
],
[
'% encoding in PATH_INFO',
sub {
my $cb = shift;
my $res = $cb->(GET "http://127.0.0.1/foo/bar%2cbaz");
is $res->content, "/foo/bar,baz", "PATH_INFO should be decoded per RFC 3875";
},
sub {
my $env = shift;
return [
200,
[ 'Content-Type' => 'text/plain', ],
[ $env->{PATH_INFO} ],
];
},
],
[
'% double encoding in PATH_INFO',
sub {
my $cb = shift;
my $res = $cb->(GET "http://127.0.0.1/foo/bar%252cbaz");
is $res->content, "/foo/bar%2cbaz", "PATH_INFO should be decoded only once, per RFC 3875";
},
sub {
my $env = shift;
return [
200,
[ 'Content-Type' => 'text/plain', ],
[ $env->{PATH_INFO} ],
];
},
],
[
'SERVER_PROTOCOL is required',
sub {
my $cb = shift;
my $res = $cb->(GET "http://127.0.0.1/foo/?dankogai=kogaidan");
is $res->code, 200;
is $res->header('content_type'), 'text/plain';
like $res->content, qr{^HTTP/1\.[01]$};
},
sub {
my $env = shift;
return [
200,
[ 'Content-Type' => 'text/plain', ],
[$env->{SERVER_PROTOCOL}],
];
},
],
[
'SCRIPT_NAME should not be undef',
sub {
my $cb = shift;
my $res = $cb->(GET "http://127.0.0.1/foo/?dankogai=kogaidan");
is $res->content, 1;
},
sub {
my $env = shift;
my $cont = defined $env->{'SCRIPT_NAME'};
return [
200,
[ 'Content-Type' => 'text/plain', ],
[$cont],
];
},
],
[
'call close after read IO::Handle-like',
sub {
my $cb = shift;
my $res = $cb->(GET "http://127.0.0.1/call_close");
is($res->content, '1234');
},
sub {
my $env = shift;
{
our $closed = -1;
sub CalledClose::new { $closed = 0; my $i=0; bless \$i, 'CalledClose' }
sub CalledClose::getline {
my $self = shift;
return $$self++ < 4 ? $$self : undef;
}
sub CalledClose::close { ::ok(1, 'closed') if defined &::ok }
}
return [
200,
[ 'Content-Type' => 'text/plain', ],
CalledClose->new(),
];
},
],
[
'has errors',
sub {
my $cb = shift;
my $res = $cb->(GET "http://127.0.0.1/has_errors");
is $res->content, 1;
},
sub {
my $env = shift;
my $err = $env->{'psgi.errors'};
my $has_errors = defined $err;
return [
200,
[ 'Content-Type' => 'text/plain', ],
[$has_errors]
];
},
],
[
'status line',
sub {
my $cb = shift;
my $res = $cb->(GET "http://127.0.0.1/foo/?dankogai=kogaidan");
is($res->status_line, '200 OK');
},
sub {
my $env = shift;
return [
200,
[ 'Content-Type' => 'text/plain', ],
[1]
];
},
],
[
'Do not crash when the app dies',
sub {
my $cb = shift;
my $res = $cb->(GET "http://127.0.0.1/");
is $res->code, 500;
},
sub {
my $env = shift;
open my $io, '>', \my $error;
$env->{'psgi.errors'} = $io;
die "Throwing an exception from app handler. Server shouldn't crash.";
},
],
[
'multi headers (request)',
sub {
my $cb = shift;
my $req = HTTP::Request->new(
GET => "http://127.0.0.1/",
);
$req->push_header(Foo => "bar");
$req->push_header(Foo => "baz");
my $res = $cb->($req);
like($res->content, qr/^bar,\s*baz$/);
},
sub {
my $env = shift;
return [
200,
[ 'Content-Type' => 'text/plain', ],
[ $env->{HTTP_FOO} ]
];
},
],
[
'multi headers (response)',
sub {
my $cb = shift;
my $res = $cb->(HTTP::Request->new(GET => "http://127.0.0.1/"));
my $foo = $res->header('X-Foo');
like $foo, qr/foo,\s*bar,\s*baz/;
},
sub {
my $env = shift;
return [
200,
[ 'Content-Type' => 'text/plain', 'X-Foo', 'foo', 'X-Foo', 'bar, baz' ],
[ 'hi' ]
];
},
],
[
'Do not set $env->{COOKIE}',
sub {
my $cb = shift;
my $req = HTTP::Request->new(
GET => "http://127.0.0.1/",
);
$req->push_header(Cookie => "foo=bar");
my $res = $cb->($req);
is($res->header('X-Cookie'), 0);
is $res->content, 'foo=bar';
},
sub {
my $env = shift;
return [
200,
[ 'Content-Type' => 'text/plain', 'X-Cookie' => $env->{COOKIE} ? 1 : 0 ],
[ $env->{HTTP_COOKIE} ]
];
},
],
[
'no entity headers on 304',
sub {
my $cb = shift;
my $res = $cb->(GET "http://127.0.0.1/");
is $res->code, 304;
is $res->content, '';
ok ! defined $res->header('content_type'), "No Content-Type";
ok ! defined $res->header('content_length'), "No Content-Length";
ok ! defined $res->header('transfer_encoding'), "No Transfer-Encoding";
},
sub {
my $env = shift;
return [ 304, [], [] ];
},
],
[
'REQUEST_URI is set',
sub {
my $cb = shift;
my $res = $cb->(GET "http://127.0.0.1/foo/bar%20baz%73?x=a");
is $res->content, '/foo/bar%20baz%73?x=a';
},
sub {
my $env = shift;
return [ 200, [ 'Content-Type' => 'text/plain' ], [ $env->{REQUEST_URI} ] ];
},
],
[
'filehandle with path()',
sub {
my $cb = shift;
my $res = $cb->(GET "http://127.0.0.1/foo.jpg");
is $res->code, 200;
is $res->header('content_type'), 'image/jpeg';
is length $res->content, 4745;
},
sub {
my $env = shift;
open my $fh, '<', "$share_dir/face.jpg";
Plack::Util::set_io_path($fh, "$share_dir/face.jpg");
return [
200,
[ 'Content-Type' => 'image/jpeg', 'Content-Length' => -s $fh ],
$fh
];
},
],
[
'a big header value > 128 bytes',
sub {
my $cb = shift;
my $req = GET "http://127.0.0.1/";
my $v = ("abcdefgh" x 16);
$req->header('X-Foo' => $v);
my $res = $cb->($req);
is $res->code, 200;
is $res->content, $v;
},
sub {
my $env = shift;
return [
200,
[ 'Content-Type' => 'text/plain' ],
[ $env->{HTTP_X_FOO} ],
];
},
],
[
'coderef res',
sub {
my $cb = shift;
my $res = $cb->(GET "http://127.0.0.1/?name=miyagawa");
return if $res->code == 501;
is $res->code, 200;
is $res->header('content_type'), 'text/plain';
is $res->content, 'Hello, name=miyagawa';
},
sub {
my $env = shift;
$env->{'psgi.streaming'} or return [ 501, ['Content-Type','text/plain'], [] ];
return sub {
my $respond = shift;
$respond->([
200,
[ 'Content-Type' => 'text/plain', ],
[ 'Hello, ' . $env->{QUERY_STRING} ],
]);
}
},
],
[
'coderef streaming',
sub {
my $cb = shift;
my $res = $cb->(GET "http://127.0.0.1/?name=miyagawa");
return if $res->code == 501;
is $res->code, 200;
is $res->header('content_type'), 'text/plain';
is $res->content, 'Hello, name=miyagawa';
},
sub {
my $env = shift;
$env->{'psgi.streaming'} or return [ 501, ['Content-Type','text/plain'], [] ];
return sub {
my $respond = shift;
my $writer = $respond->([
200,
[ 'Content-Type' => 'text/plain', ],
]);
$writer->write("Hello, ");
$writer->write($env->{QUERY_STRING});
$writer->close();
}
},
],
[
'CRLF output and FCGI parse bug',
sub {
my $cb = shift;
my $res = $cb->(GET "http://127.0.0.1/");
is $res->header("Foo"), undef;
is $res->content, "Foo: Bar\r\n\r\nHello World";
},
sub {
return [ 200, [ "Content-Type", "text/plain" ], [ "Foo: Bar\r\n\r\nHello World" ] ];
},
],
[
'test 404',
sub {
my $cb = shift;
my $res = $cb->(GET "http://127.0.0.1/");
is $res->code, 404;
is $res->content, 'Not Found';
},
sub {
return [ 404, [ "Content-Type", "text/plain" ], [ "Not Found" ] ];
},
],
[
'request->input seekable',
sub {
my $cb = shift;
my $req = HTTP::Request->new(POST => "http://127.0.0.1/");
$req->content("body");
$req->content_type('text/plain');
$req->content_length(4);
my $res = $cb->($req);
is $res->content, 'body';
},
sub {
my $req = Plack::Request->new(shift);
return [ 200, [ "Content-Type", "text/plain" ], [ $req->content ] ];
},
],
[
'request->content on GET',
sub {
my $cb = shift;
my $res = $cb->(GET "http://127.0.0.1/");
ok $res->is_success;
},
sub {
my $req = Plack::Request->new(shift);
$req->content;
return [ 200, [ "Content-Type", "text/plain" ], [ "OK" ] ];
},
],
);
sub runtests {
my($class, $runner) = @_;
for my $test (@TEST) {
$runner->(@$test);
}
}
sub run_server_tests {
my($class, $server, $server_port, $http_port, %args) = @_;
if (ref $server ne 'CODE') {
my $server_class = $server;
$server = sub {
my($port, $app) = @_;
my $server = Plack::Loader->load($server_class, port => $port, host => "127.0.0.1", %args);
$app = Plack::Middleware::Lint->wrap($app);
$server->run($app);
}
}
test_tcp(
client => sub {
my $port = shift;
my $ua = LWP::UserAgent->new;
for my $i (0..$#TEST) {
my $test = $TEST[$i];
note $test->[0];
my $cb = sub {
my $req = shift;
$req->uri->port($http_port || $port);
$req->header('X-Plack-Test' => $i);
return $ua->request($req);
};
$test->[1]->($cb);
}
},
server => sub {
my $port = shift;
my $app = $class->test_app_handler;
$server->($port, $app);
},
port => $server_port,
);
}
sub test_app_handler {
return sub {
my $env = shift;
$TEST[$env->{HTTP_X_PLACK_TEST}][2]->($env);
};
}
1;
__END__
=head1 NAME
Plack::Test::Suite - Test suite for Plack handlers
=head1 SYNOPSIS
use Test::More;
use Plack::Test::Suite;
Plack::Test::Suite->run_server_tests('Your::Handler');
done_testing;
=head1 DESCRIPTION
Plack::Test::Suite is a test suite to test a new PSGI server
implementation. It automatically loads a new handler environment and
uses LWP to send HTTP requests to the local server to make sure your
handler implements the PSGI specification correctly.
Note that the handler name doesn't include the C<Plack::Handler::>
prefix, i.e. if you have a new Plack handler Plack::Handler::Foo, your
test script would look like:
Plack::Test::Suite->run_server_tests('Foo');
=head1 AUTHOR
Tokuhiro Matsuno
Tatsuhiko Miyagawa
Kazuho Oku
=cut
END_MARK_FOR_TESTING