File Coverage

blib/lib/Dancer2/Test.pm
Criterion Covered Total %
statement 216 286 75.5
branch 60 106 56.6
condition 5 31 16.1
subroutine 34 43 79.0
pod 15 15 100.0
total 330 481 68.6


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