File Coverage

blib/lib/Dancer2/Test.pm
Criterion Covered Total %
statement 229 283 80.9
branch 64 106 60.3
condition 7 31 22.5
subroutine 36 42 85.7
pod 15 15 100.0
total 351 477 73.5


line stmt bran cond sub pod time code
1             package Dancer2::Test;
2             # ABSTRACT: Useful routines for testing Dancer2 apps
3             $Dancer2::Test::VERSION = '2.0.1';
4 3     3   390263 use strict;
  3         7  
  3         126  
5 3     3   17 use warnings;
  3         5  
  3         229  
6              
7 3     3   24 use Carp qw;
  3         7  
  3         214  
8 3     3   772 use Test::More;
  3         136997  
  3         43  
9 3     3   1179 use Test::Builder;
  3         6  
  3         69  
10 3     3   710 use URI::Escape;
  3         2849  
  3         259  
11 3     3   838 use Data::Dumper;
  3         11760  
  3         230  
12 3     3   1213 use File::Temp;
  3         25897  
  3         348  
13 3     3   725 use Ref::Util qw;
  3         3381  
  3         224  
14              
15 3     3   39 use parent 'Exporter';
  3         7  
  3         26  
16             our @EXPORT = qw(
17             dancer_response
18              
19             response_content_is
20             response_content_isnt
21             response_content_is_deeply
22             response_content_like
23             response_content_unlike
24              
25             response_status_is
26             response_status_isnt
27              
28             response_headers_include
29             response_headers_are_deeply
30              
31             response_is_file
32              
33             route_exists
34             route_doesnt_exist
35              
36             is_pod_covered
37             route_pod_coverage
38              
39             );
40              
41             #dancer1 also has read_logs, response_redirect_location_is
42             #cf. https://github.com/PerlDancer2/Dancer22/issues/25
43              
44 3     3   1022 use Dancer2::Core::Dispatcher;
  3         10  
  3         113  
45 3     3   20 use Dancer2::Core::Request;
  3         6  
  3         559  
46              
47             # singleton to store all the apps
48             my $_dispatcher = Dancer2::Core::Dispatcher->new;
49              
50             # prevent deprecation warnings
51             our $NO_WARN = 0;
52              
53             # can be called with the ($method, $path, $option) triplet,
54             # or can be fed a request object directly, or can be fed
55             # a single string, assumed to be [ GET => $string ]
56             # or can be fed a response (which is passed through without
57             # any modification)
58             sub dancer_response {
59 39 50   39 1 7618 croak 'DEPRECATED: Dancer2::Test. Please use Plack::Test instead'
60             unless $NO_WARN;
61              
62 39         114 _find_dancer_apps_for_dispatcher();
63              
64             # useful for the high-level tests
65 39 100       382 return $_[0] if ref $_[0] eq 'Dancer2::Core::Response';
66              
67 31 100       164 my ( $request, $env ) =
68             ref $_[0] eq 'Dancer2::Core::Request'
69             ? _build_env_from_request(@_)
70             : _build_request_from_env(@_);
71              
72             # override the set_request so it actually sets our request instead
73             {
74             ## no critic qw(TestingAndDebugging::ProhibitNoWarnings)
75 3     3   22 no warnings qw;
  3         5  
  3         13351  
  31         48  
76             *Dancer2::Core::App::set_request = sub {
77 31     31   75 my $self = shift;
78 31         611 $self->_set_request( $request );
79 31         1060 $_->set_request( $request ) for @{ $self->defined_engines };
  31         71  
80 31         408 };
81             }
82              
83             # since the response is a PSGI response
84             # we create a Response object which was originally expected
85 31         1415 my $psgi_response = $_dispatcher->dispatch($env);
86 31         811 return Dancer2::Core::Response->new(
87             status => $psgi_response->[0],
88             headers => $psgi_response->[1],
89             content => $psgi_response->[2][0],
90             );
91             }
92              
93              
94              
95             sub _build_request_from_env {
96              
97             # arguments can be passed as the triplet
98             # or as a arrayref, or as a simple string
99             my ( $method, $path, $options ) =
100             @_ > 1 ? @_
101 28 100   28   132 : is_arrayref($_[0]) ? @{ $_[0] }
  10 100       33  
102             : ( GET => $_[0], {} );
103              
104 28         333 my $env = {
105             %ENV,
106             REQUEST_METHOD => uc($method),
107             PATH_INFO => $path,
108             QUERY_STRING => '',
109             'psgi.url_scheme' => 'http',
110             SERVER_PROTOCOL => 'HTTP/1.0',
111             SERVER_NAME => 'localhost',
112             SERVER_PORT => 3000,
113             HTTP_HOST => 'localhost',
114             HTTP_USER_AGENT => "Dancer2::Test simulator v " . Dancer2->VERSION,
115             };
116              
117 28 100       128 if ( defined $options->{params} ) {
118 3         5 my @params;
119 3         3 while ( my ( $p, $value ) = each %{ $options->{params} } ) {
  6         184  
120 3 100       8 if ( is_arrayref($value) ) {
121 2         6 for my $v (@$value) {
122 4         78 push @params,
123             uri_escape_utf8($p) . '=' . uri_escape_utf8($v);
124             }
125             }
126             else {
127 1         7 push @params,
128             uri_escape_utf8($p) . '=' . uri_escape_utf8($value);
129             }
130             }
131 3         13 $env->{QUERY_STRING} = join( '&', @params );
132             }
133              
134 28         147 my $request = Dancer2::Core::Request->new( env => $env );
135              
136             # body
137 28 50       107 $request->body( $options->{body} ) if exists $options->{body};
138              
139             # headers
140 28 100       74 if ( $options->{headers} ) {
141 2         2 for my $header ( @{ $options->{headers} } ) {
  2         5  
142 4         5 my ( $name, $value ) = @{$header};
  4         8  
143 4         11 $request->header( $name => $value );
144 4 100       372 if ( $name =~ /^cookie$/i ) {
145 2         6 $env->{HTTP_COOKIE} = $value;
146             }
147             }
148             }
149              
150             # files
151 28 100       79 if ( $options->{files} ) {
152 2         4 for my $file ( @{ $options->{files} } ) {
  2         7  
153 2         4 my $headers = $file->{headers};
154 2   50     13 $headers->{'Content-Type'} ||= 'text/plain';
155              
156 2         21 my $temp = File::Temp->new();
157 2 100       1965 if ( $file->{data} ) {
158 1         25 print $temp $file->{data};
159 1         74 close($temp);
160             }
161             else {
162 1         483 require File::Copy;
163 1         2987 File::Copy::copy( $file->{filename}, $temp );
164             }
165              
166             my $upload = Dancer2::Core::Request::Upload->new(
167             filename => $file->{filename},
168 2         319 size => -s $temp->filename,
169             tempname => $temp->filename,
170             headers => $headers,
171             );
172              
173             ## keep temp_fh in scope so it doesn't get deleted too early
174             ## But will get deleted by the time the test is finished.
175 2         4493 $upload->{temp_fh} = $temp;
176              
177 2         13 $request->uploads->{ $file->{name} } = $upload;
178             }
179             }
180              
181             # content-type
182 28 50       60 if ( $options->{content_type} ) {
183 0         0 $request->content_type( $options->{content_type} );
184             }
185              
186 28         85 return ( $request, $env );
187             }
188              
189             sub _build_env_from_request {
190 10     10   29 my ($request) = @_;
191              
192 10         45 my $env = {
193             REQUEST_METHOD => $request->method,
194             PATH_INFO => $request->path,
195             QUERY_STRING => '',
196             'psgi.url_scheme' => 'http',
197             SERVER_PROTOCOL => 'HTTP/1.0',
198             SERVER_NAME => 'localhost',
199             SERVER_PORT => 3000,
200             HTTP_HOST => 'localhost',
201             HTTP_USER_AGENT => "Dancer2::Test simulator v" . Dancer2->VERSION,
202             };
203              
204             # TODO
205 10 50       38 if ( my $params = $request->{_query_params} ) {
206 0         0 my @params;
207 0         0 while ( my ( $p, $value ) = each %{$params} ) {
  0         0  
208 0 0       0 if ( is_arrayref($value) ) {
209 0         0 for my $v (@$value) {
210 0         0 push @params,
211             uri_escape_utf8($p) . '=' . uri_escape_utf8($v);
212             }
213             }
214             else {
215 0         0 push @params,
216             uri_escape_utf8($p) . '=' . uri_escape_utf8($value);
217             }
218             }
219 0         0 $env->{QUERY_STRING} = join( '&', @params );
220             }
221              
222             # TODO files
223              
224 10         29 return ( $request, $env );
225             }
226              
227             sub response_status_is {
228 4     4 1 2359 my ( $req, $status, $test_name ) = @_;
229 4 50       11 carp 'DEPRECATED: Dancer2::Test. Please use Plack::Test instead'
230             unless $NO_WARN;
231              
232 4   33     17 $test_name ||= "response status is $status for " . _req_label($req);
233              
234 4         15 my $response = dancer_response($req);
235              
236 4         102 my $tb = Test::Builder->new;
237 4         20 local $Test::Builder::Level = $Test::Builder::Level + 1;
238 4         12 $tb->is_eq( $response->[0], $status, $test_name );
239             }
240              
241             sub _find_route_match {
242 7 100   7   243 my ( $request, $env ) =
243             ref $_[0] eq 'Dancer2::Core::Request'
244             ? _build_env_from_request(@_)
245             : _build_request_from_env(@_);
246              
247 7         7 for my $app (@{$_dispatcher->apps}) {
  7         191  
248 7         46 for my $route (@{$app->routes->{lc($request->method)}}) {
  7         97  
249 3 50       40 if ( $route->match($request) ) {
250 3         101 return 1;
251             }
252             }
253             }
254 4         84 return 0;
255             }
256              
257             sub route_exists {
258 3 50   3 1 1323 carp 'DEPRECATED: Dancer2::Test. Please use Plack::Test instead'
259             unless $NO_WARN;
260              
261 3         18 my $tb = Test::Builder->new;
262 3         23 local $Test::Builder::Level = $Test::Builder::Level + 1;
263 3         10 $tb->ok( _find_route_match($_[0]), $_[1]);
264             }
265              
266             sub route_doesnt_exist {
267 4 50   4 1 1157 carp 'DEPRECATED: Dancer2::Test. Please use Plack::Test instead'
268             unless $NO_WARN;
269              
270 4         19 my $tb = Test::Builder->new;
271 4         24 local $Test::Builder::Level = $Test::Builder::Level + 1;
272 4         10 $tb->ok( !_find_route_match($_[0]), $_[1]);
273             }
274              
275             sub response_status_isnt {
276 4     4 1 2459 my ( $req, $status, $test_name ) = @_;
277              
278 4 50       11 carp 'DEPRECATED: Dancer2::Test. Please use Plack::Test instead'
279             unless $NO_WARN;
280              
281 4   33     19 $test_name ||= "response status is not $status for " . _req_label($req);
282              
283 4         13 my $response = dancer_response($req);
284              
285 4         88 my $tb = Test::Builder->new;
286 4         22 local $Test::Builder::Level = $Test::Builder::Level + 1;
287 4         11 $tb->isnt_eq( $response->[0], $status, $test_name );
288             }
289              
290             {
291             # Map comparison operator names to human-friendly ones
292             my %cmp_name = (
293             is_eq => "is",
294             isnt_eq => "is not",
295             like => "matches",
296             unlike => "doesn't match",
297             );
298              
299             sub _cmp_response_content {
300 16     16   42 my ( $req, $want, $test_name, $cmp ) = @_;
301              
302 16 100       45 if ( @_ == 3 ) {
303 8         11 $cmp = $test_name;
304 8         16 $test_name = $cmp_name{$cmp};
305 8         33 $test_name =
306             "response content $test_name $want for " . _req_label($req);
307             }
308              
309 16         55 my $response = dancer_response($req);
310              
311 16         513 my $tb = Test::Builder->new;
312 16         102 local $Test::Builder::Level = $Test::Builder::Level + 1;
313 16         55 $tb->$cmp( $response->[2][0], $want, $test_name );
314             }
315             }
316              
317             sub response_content_is {
318 4 50   4 1 3266 carp 'DEPRECATED: Dancer2::Test. Please use Plack::Test instead'
319             unless $NO_WARN;
320 4         12 local $Test::Builder::Level = $Test::Builder::Level + 1;
321 4         15 _cmp_response_content( @_, 'is_eq' );
322             }
323              
324             sub response_content_isnt {
325 4 50   4 1 3159 carp 'DEPRECATED: Dancer2::Test. Please use Plack::Test instead'
326             unless $NO_WARN;
327 4         10 local $Test::Builder::Level = $Test::Builder::Level + 1;
328 4         13 _cmp_response_content( @_, 'isnt_eq' );
329             }
330              
331             sub response_content_like {
332 4 50   4 1 2749 carp 'DEPRECATED: Dancer2::Test. Please use Plack::Test instead'
333             unless $NO_WARN;
334 4         9 local $Test::Builder::Level = $Test::Builder::Level + 1;
335 4         11 _cmp_response_content( @_, 'like' );
336             }
337              
338             sub response_content_unlike {
339 4 50   4 1 2113 carp 'DEPRECATED: Dancer2::Test. Please use Plack::Test instead'
340             unless $NO_WARN;
341 4         8 local $Test::Builder::Level = $Test::Builder::Level + 1;
342 4         10 _cmp_response_content( @_, 'unlike' );
343             }
344              
345             sub response_content_is_deeply {
346 0     0 1 0 my ( $req, $matcher, $test_name ) = @_;
347 0 0       0 carp 'DEPRECATED: Dancer2::Test. Please use Plack::Test instead'
348             unless $NO_WARN;
349 0   0     0 $test_name ||= "response content looks good for " . _req_label($req);
350              
351 0         0 local $Test::Builder::Level = $Test::Builder::Level + 1;
352 0         0 my $response = _req_to_response($req);
353 0         0 is_deeply $response->[2][0], $matcher, $test_name;
354             }
355              
356             sub response_is_file {
357 0     0 1 0 my ( $req, $test_name ) = @_;
358 0 0       0 carp 'DEPRECATED: Dancer2::Test. Please use Plack::Test instead'
359             unless $NO_WARN;
360 0   0     0 $test_name ||= "a file is returned for " . _req_label($req);
361              
362 0         0 my $response = _get_file_response($req);
363 0         0 my $tb = Test::Builder->new;
364 0         0 local $Test::Builder::Level = $Test::Builder::Level + 1;
365 0         0 return $tb->ok( defined($response), $test_name );
366             }
367              
368             sub response_headers_are_deeply {
369 0     0 1 0 my ( $req, $expected, $test_name ) = @_;
370 0 0       0 carp 'DEPRECATED: Dancer2::Test. Please use Plack::Test instead'
371             unless $NO_WARN;
372 0   0     0 $test_name ||= "headers are as expected for " . _req_label($req);
373              
374 0         0 local $Test::Builder::Level = $Test::Builder::Level + 1;
375 0         0 my $response = dancer_response( _expand_req($req) );
376              
377 0         0 is_deeply(
378             _sort_headers( $response->[1] ),
379             _sort_headers($expected), $test_name
380             );
381             }
382              
383             sub response_headers_include {
384 4     4 1 14 my ( $req, $expected, $test_name ) = @_;
385 4 50       14 carp 'DEPRECATED: Dancer2::Test. Please use Plack::Test instead'
386             unless $NO_WARN;
387 4   33     22 $test_name ||= "headers include expected data for " . _req_label($req);
388 4         28 my $tb = Test::Builder->new;
389              
390 4         33 my $response = dancer_response($req);
391 4         81 local $Test::Builder::Level = $Test::Builder::Level + 1;
392              
393 4 50       13 print STDERR "Headers are: "
394             . Dumper( $response->[1] )
395             . "\n Expected to find header: "
396             . Dumper($expected)
397             if !$tb->ok(
398             _include_in_headers( $response->[1], $expected ),
399             $test_name
400             );
401             }
402              
403             sub route_pod_coverage {
404              
405 2     2 1 5400 require Pod::Simple::Search;
406 2         9028 require Pod::Simple::SimpleTree;
407              
408 2         53157 my $all_routes = {};
409              
410 2         7 foreach my $app ( @{ $_dispatcher->apps } ) {
  2         86  
411 2         85 my $routes = $app->routes;
412 2         337 my $available_routes = [];
413 2         25 foreach my $method ( sort { $b cmp $a } keys %$routes ) {
  22         1012  
414 12         20 foreach my $r ( @{ $routes->{$method} } ) {
  12         34  
415              
416             # we don't need pod coverage for head
417 16 100       45 next if $method eq 'head';
418 10         51 push @$available_routes, $method . ' ' . $r->spec_route;
419             }
420             }
421             ## copy dereferenced array
422 2 50       23 $all_routes->{ $app->name }{routes} = [@$available_routes]
423             if @$available_routes;
424              
425             # Pod::Simple v3.30 excluded the current directory even when in @INC.
426             # include the current directory as a search path; its backwards compatible
427             # with previous version.
428 2         5 my $undocumented_routes = [];
429 2         24 my $file = Pod::Simple::Search->new->find( $app->name, '.' );
430 2 50       1306 if ($file) {
431 2         14 $all_routes->{ $app->name }{has_pod} = 1;
432 2         20 my $parser = Pod::Simple::SimpleTree->new->parse_file($file);
433 2         14925 my $pod_dataref = $parser->root;
434 2         20 my $found_routes = {};
435 2         11 for ( my $i = 0; $i < @$available_routes; $i++ ) {
436              
437 10         23 my $r = $available_routes->[$i];
438 10         23 my $app_string = lc $r;
439 10         24 $app_string =~ s/\*/_REPLACED_STAR_/g;
440              
441 10         27 for ( my $idx = 0; $idx < @$pod_dataref; $idx++ ) {
442 380         719 my $pod_part = $pod_dataref->[$idx];
443              
444 380 100       755 next if !is_arrayref($pod_part);
445 360         590 foreach my $ref_part (@$pod_part) {
446 1140 100       2507 is_arrayref($ref_part)
447             and push @$pod_dataref, $ref_part;
448             }
449              
450 360         713 my $pod_string = lc $pod_part->[2];
451 360         1108 $pod_string =~ s/['|"|\s]+/ /g;
452 360         782 $pod_string =~ s/\s$//g;
453 360         648 $pod_string =~ s/\*/_REPLACED_STAR_/g;
454 360 100       1676 if ( $pod_string =~ m/^$app_string$/ ) {
455 34         80 $found_routes->{$app_string} = 1;
456 34         90 next;
457             }
458             }
459 10 50       114 if ( !$found_routes->{$app_string} ) {
460 0         0 push @$undocumented_routes, $r;
461             }
462             }
463             }
464             else { ### no POD found
465 0         0 $all_routes->{ $app->name }{has_pod} = 0;
466             }
467 2 50 33     34 if (@$undocumented_routes) {
    50          
468             $all_routes->{ $app->name }{undocumented_routes} =
469 0         0 $undocumented_routes;
470             }
471             elsif ( !$all_routes->{ $app->name }{has_pod}
472 0         0 && @{ $all_routes->{ $app->name }{routes} } )
473             {
474             ## copy dereferenced array
475             $all_routes->{ $app->name }{undocumented_routes} =
476 0         0 [ @{ $all_routes->{ $app->name }{routes} } ];
  0         0  
477             }
478             }
479              
480 2         14 return $all_routes;
481             }
482              
483             sub is_pod_covered {
484 1     1 1 262896 my ($test_name) = @_;
485              
486 1   50     6 $test_name ||= "is pod covered";
487 1         4 my $route_pod_coverage = route_pod_coverage();
488              
489 1         15 my $tb = Test::Builder->new;
490 1         18 local $Test::Builder::Level = $Test::Builder::Level + 1;
491              
492 1         2 foreach my $app ( @{ $_dispatcher->apps } ) {
  1         48  
493             my %undocumented_route =
494 0         0 ( map { $_ => 1 }
495 1         13 @{ $route_pod_coverage->{ $app->name }{undocumented_routes} } );
  1         23  
496             $tb->subtest(
497             $app->name . $test_name,
498             sub {
499 1     1   1923 foreach my $route (
500 1         9 @{ $route_pod_coverage->{ $app->name }{routes} } )
501             {
502 5         4085 ok( !$undocumented_route{$route}, "$route is documented" );
503             }
504             }
505 1         17 );
506             }
507             }
508              
509             sub import {
510 2     2   29 my ( $class, %options ) = @_;
511              
512 2         6 my @applications;
513 2 100       12 if ( ref $options{apps} eq ref( [] ) ) {
514 1         3 @applications = @{ $options{apps} };
  1         4  
515             }
516             else {
517 1         5 my ( $caller, $script ) = caller;
518              
519             # if no app is passed, assume the caller is one.
520 1 50       16 @applications = ($caller) if $caller->can('dancer_app');
521             }
522              
523             # register the apps to the test dispatcher
524             $_dispatcher->apps( [ map {
525 2         6 $_->dancer_app->finish();
  2         17  
526 2         11 $_->dancer_app;
527             } @applications ] );
528              
529 2         270562 $class->export_to_level( 1, $class, @EXPORT );
530             }
531              
532             # private
533              
534             sub _req_label {
535 20     20   58 my $req = shift;
536              
537             return
538             ref $req eq 'Dancer2::Core::Response' ? 'response object'
539             : ref $req eq 'Dancer2::Core::Request'
540 20 100       116 ? join( ' ', map { $req->$_ } qw/ method path / )
  10 100       65  
    100          
541             : is_arrayref($req) ? join( ' ', @$req )
542             : "GET $req";
543             }
544              
545             sub _expand_req {
546 0     0   0 my $req = shift;
547 0 0       0 return is_arrayref($req) ? @$req : ( 'GET', $req );
548             }
549              
550             # Sort arrayref of headers (turn it into a list of arrayrefs, sort by the header
551             # & value, then turn it back into an arrayref)
552             sub _sort_headers {
553 0     0   0 my @originalheaders = @{ shift() }; # take a copy we can modify
  0         0  
554 0         0 my @headerpairs;
555 0         0 while ( my ( $header, $value ) = splice @originalheaders, 0, 2 ) {
556 0         0 push @headerpairs, [ $header, $value ];
557             }
558              
559             # We have an array of arrayrefs holding header => value pairs; sort them by
560             # header then value, and return them flattened back into an arrayref
561             return [
562 0         0 map {@$_}
563 0 0       0 sort { $a->[0] cmp $b->[0] || $a->[1] cmp $b->[1] } @headerpairs
  0         0  
564             ];
565             }
566              
567             # make sure the given header sublist is included in the full headers array
568             sub _include_in_headers {
569 4     4   6 my ( $full_headers, $expected_subset ) = @_;
570              
571             # walk through all the expected header pairs, make sure
572             # they exist with the same value in the full_headers list
573             # return false as soon as one is not.
574 4         9 for ( my $i = 0; $i < scalar(@$expected_subset); $i += 2 ) {
575 4         10 my ( $name, $value ) =
576             ( $expected_subset->[$i], $expected_subset->[ $i + 1 ] );
577 4 50       9 return 0
578             unless _check_header( $full_headers, $name, $value );
579             }
580              
581             # we've found all the expected pairs in the $full_headers list
582 4         17 return 1;
583             }
584              
585             sub _check_header {
586 4     4   6 my ( $headers, $key, $value ) = @_;
587 4         8 for ( my $i = 0; $i < scalar(@$headers); $i += 2 ) {
588 4         7 my ( $name, $val ) = ( $headers->[$i], $headers->[ $i + 1 ] );
589 4 50 33     39 return 1 if $name eq $key && $value eq $val;
590             }
591 0         0 return 0;
592             }
593              
594             sub _req_to_response {
595 0     0   0 my $req = shift;
596              
597             # already a response object
598 0 0       0 return $req if ref $req eq 'Dancer2::Core::Response';
599              
600 0 0       0 return dancer_response( is_arrayref($req) ? @$req : ( 'GET', $req ) );
601             }
602              
603             # make sure we have at least one app in the dispatcher, and if not,
604             # we must have at this point an app within the caller
605             sub _find_dancer_apps_for_dispatcher {
606 39 50   39   56 return if scalar( @{ $_dispatcher->apps } );
  39         1162  
607              
608 0           for ( my $deep = 0; $deep < 5; $deep++ ) {
609 0           my $caller = caller($deep);
610 0 0 0       next if !$caller || !$caller->can('dancer_app');
611              
612 0           return $_dispatcher->apps( [ $caller->dancer_app ] );
613             }
614              
615 0           croak "Unable to find a Dancer2 app, did you use Dancer2 in your test?";
616             }
617              
618             1;
619              
620             __END__