File Coverage

blib/lib/Mojo/UserAgent/Mockable.pm
Criterion Covered Total %
statement 118 132 89.3
branch 32 46 69.5
condition 9 12 75.0
subroutine 21 22 95.4
pod 4 4 100.0
total 184 216 85.1


line stmt bran cond sub pod time code
1 16     16   5194676 use 5.014;
  16         175  
2              
3             package Mojo::UserAgent::Mockable;
4             $Mojo::UserAgent::Mockable::VERSION = '1.57';
5 16     16   112 use warnings::register;
  16         38  
  16         2093  
6              
7 16     16   116 use Carp;
  16         35  
  16         959  
8 16     16   7548 use JSON::MaybeXS;
  16         55120  
  16         1043  
9 16     16   6885 use Mojolicious 7.22;
  16         3845926  
  16         146  
10 16     16   751 use Mojo::Base 'Mojo::UserAgent';
  16         43  
  16         60  
11 16     16   3177 use Mojo::Util qw/secure_compare/;
  16         43  
  16         784  
12 16     16   7667 use Mojo::UserAgent::Mockable::Proxy;
  16         59  
  16         136  
13 16     16   7878 use Mojo::UserAgent::Mockable::Serializer;
  16         232  
  16         221  
14 16     16   8667 use Mojo::UserAgent::Mockable::Request::Compare;
  16         56  
  16         104  
15 16     16   668 use Mojo::JSON;
  16         42  
  16         634  
16 16     16   138 use Scalar::Util;
  16         36  
  16         32395  
17              
18             # ABSTRACT: A Mojo User-Agent that can record and play back requests without Internet connectivity, similar to LWP::UserAgent::Mockable
19              
20              
21             has 'mode' => 'passthrough';
22             has 'file';
23             has 'unrecognized' => 'exception';
24             has 'request_normalizer';
25             has '_serializer' => sub { Mojo::UserAgent::Mockable::Serializer->new };
26             has 'comparator';
27             has 'ignore_headers' => sub { [] };
28             has 'ignore_body';
29             has 'ignore_userinfo';
30             has '_mode';
31             has '_current_txn';
32             has '_compare_result';
33             has '_non_blocking';
34              
35             # Internal Mojolicious app that handles transaction playback
36             has '_app' => sub {
37             my $self = shift;
38             my $app = Mojolicious->new;
39             $app->routes->any(
40             '/*any' => { any => '' } => sub {
41             my $c = shift;
42             my $tx = $c->tx;
43              
44             my $txn = $self->_current_txn;
45             if ($txn) {
46             $self->cookie_jar->collect($txn);
47             $tx->res( $txn->res );
48             $tx->res->headers->header( 'X-MUA-Mockable-Regenerated' => 1 );
49             $c->rendered( $txn->res->code );
50             }
51             else {
52             for my $header ( keys %{ $tx->req->headers->to_hash } ) {
53             if ( $header =~ /^X-MUA-Mockable/ ) {
54             my $val = $tx->req->headers->header($header);
55             $tx->res->headers->header( $header, $val );
56             }
57             }
58             $c->render( text => '' );
59             }
60             },
61             );
62             $app;
63             };
64              
65             sub new {
66 36     36 1 1861479 my $class = shift;
67 36         291 my $self = $class->SUPER::new(@_);
68 36         469 my %comparator_args = (
69             ignore_headers => 'all',
70             ignore_body => $self->ignore_body,
71             ignore_userinfo => $self->ignore_userinfo,
72             );
73 36         783 $self->comparator( Mojo::UserAgent::Mockable::Request::Compare->new(%comparator_args) );
74              
75 36         549 $self->{'_launchpid'} = $$;
76 36 50 100     172 if ( $self->mode eq 'lwp-ua-mockable' ) {
    50 66        
77 0         0 $self->_mode( $ENV{'LWP_UA_MOCK'} );
78 0 0       0 if ( $self->file ) {
79 0         0 croak qq{Do not specify 'file' when 'mode' is set to 'lwp-ua-mockable'. Use the LWP_UA_MOCK_FILE }
80             . q{environment var instead.};
81             }
82 0         0 $self->file( $ENV{'LWP_UA_MOCK_FILE'} );
83             }
84             elsif ( $self->mode ne 'record' && $self->mode ne 'playback' && $self->mode ne 'passthrough' ) {
85 0         0 croak q{Invalid mode. Must be one of 'lwp-ua-mockable', 'record', 'playback', or 'passthrough'};
86             }
87             else {
88 36         804 $self->_mode( $self->mode );
89             }
90              
91 36 100       376 if ( $self->mode eq 'playback' ) {
92 19         148 $self->proxy(undef);
93             }
94              
95 36 50 66     389 if ( $self->_mode ne 'passthrough' && !$self->file ) {
96 0         0 croak qq{Error: You must specify a recording file};
97             }
98              
99 36 100       459 if ( $self->_mode ne 'passthrough' ) {
100 30         191 my $mode = lc $self->_mode;
101 30         188 my $mode_init = qq{_init_$mode};
102 30 50       268 if ( !$self->can($mode_init) ) {
103 0         0 croak qq{Error: unsupported mode "$mode"};
104             }
105 30         135 return $self->$mode_init;
106             }
107              
108 6         45 return $self;
109             }
110              
111             sub proxy {
112 194     194 1 12366 my $self = shift;
113 194 100       953 return $self->SUPER::proxy unless @_;
114              
115 19 50       65 if ( $self->mode eq 'playback' ) {
116 19         250 return $self->SUPER::proxy( Mojo::UserAgent::Mockable::Proxy->new );
117             }
118             else {
119 0         0 return $self->SUPER::proxy(@_);
120             }
121             }
122              
123             sub save {
124 65     65 1 40072449 my ( $self, $file ) = @_;
125 65 50       273 if ( $self->_mode eq 'record' ) {
126 65   66     617 $file ||= $self->file;
127              
128 65         214 my $transactions = $self->{'_transactions'};
129 65         269 $self->_serializer->store( $file, @{$transactions} );
  65         703  
130             }
131             else {
132 0 0       0 carp 'save() only works in record mode' if warnings::enabled;
133             }
134             }
135              
136             sub start {
137 173     173 1 8836410 my ( $self, $tx, $cb ) = @_;
138 173 100       667 if ($cb) {
139 49         173 $self->_non_blocking(1);
140             }
141 173         1056 return $self->SUPER::start( $tx, $cb );
142             }
143              
144             sub _init_playback {
145 19     19   49 my $self = shift;
146              
147 19 50       60 if ( !-e $self->file ) {
148 0         0 my $file = $self->file;
149 0         0 croak qq{Playback file $file not found};
150             }
151 19         692 $self->{'_transactions'} = [ $self->_serializer->retrieve( $self->file ) ];
152 19         63 my $recorded_tx_count = scalar @{ $self->{_transactions} };
  19         61  
153              
154 19         128 $self->server->app( $self->_app );
155              
156 19         337 Scalar::Util::weaken($self);
157             $self->on(
158             start => sub {
159 114     114   26851 my ( $ua, $tx ) = @_;
160              
161 114 100       389 my $port = $self->_non_blocking ? $self->server->nb_url->port : $self->server->url->port;
162 114         72208 my $recorded_tx = shift @{ $self->{'_transactions'} };
  114         370  
163              
164 114         441 my ( $this_req, $recorded_req ) = $self->_normalized_req( $tx, $recorded_tx );
165              
166 114 100       1478 if ( $self->comparator->compare( $this_req, $recorded_req ) ) {
167 50         179 $self->_current_txn($recorded_tx);
168              
169 50         1455 $tx->req->url( $tx->req->url->clone );
170 50         4365 $tx->req->url->host('')->scheme('')->port($port);
171             }
172             else {
173 64         114 unshift @{ $self->{'_transactions'} }, $recorded_tx;
  64         237  
174              
175 64         199 my $result = $self->comparator->compare_result;
176 64         595 $self->_current_txn(undef);
177 64 100       475 if ( $self->unrecognized eq 'exception' ) {
    100          
    50          
178 28         568 croak qq{Unrecognized request: $result};
179             }
180             elsif ( $self->unrecognized eq 'null' ) {
181 18         192 $tx->req->headers->header( 'X-MUA-Mockable-Request-Recognized' => 0 );
182 18         726 $tx->req->headers->header( 'X-MUA-Mockable-Request-Match-Exception' => $result );
183 18         553 $tx->req->url->host('')->scheme('')->port($port);
184             }
185             elsif ( $self->unrecognized eq 'fallback' ) {
186             $tx->on(
187             finish => sub {
188 18         3717694 my $self = shift;
189 18         93 $tx->req->headers->header( 'X-MUA-Mockable-Request-Recognized' => 0 );
190 18         835 $tx->req->headers->header( 'X-MUA-Mockable-Request-Match-Exception' => $result );
191             }
192 18         443 );
193             }
194             }
195             }
196 19         324 );
197              
198 19         238 return $self;
199             }
200              
201             sub _normalized_req {
202 116     116   616 my $self = shift;
203 116         274 my ( $tx, $recorded_tx ) = @_;
204              
205 116 100       372 my $request_normalizer = $self->request_normalizer or return ( $tx->req, $recorded_tx->req );
206 11 50       84 croak("The request_normalizer attribute is not a coderef") if ( ref($request_normalizer) ne "CODE" );
207              
208 11         34 my $req = $tx->req->clone;
209 11         2369 my $recorded_req = $recorded_tx->req->clone;
210 11         2132 $request_normalizer->( $req, $recorded_req ); # To be modified in-place
211              
212 11         1053 return ( $req, $recorded_req );
213             }
214              
215             sub _init_record {
216 11     11   28 my $self = shift;
217              
218 11         54 Scalar::Util::weaken($self);
219             $self->on(
220             start => sub {
221 57     57   18953 my $tx = $_[1];
222              
223 57 100       204 if ( $tx->req->proxy ) {
224              
225             # HTTP CONNECT - used for proxy
226 2 100       41 return if $tx->req->method eq 'CONNECT';
227              
228             # If the TX has a connection assigned, then the request is a copy of the request
229             # that initiated the proxy connection
230 1 50       17 return if $tx->connection;
231             }
232              
233             $tx->once(
234             finish => sub {
235 56         7452069 my $tx = shift;
236 56         159 push @{ $self->{'_transactions'} }, $tx;
  56         219  
237              
238             # 15: During global destruction the $tx object may no longer exist
239             # so save now
240 56         269 $self->save($self->file);
241             },
242 56         917 );
243 56         1425 1;
244             },
245 11         129 );
246              
247 11         118 return $self;
248             }
249              
250             sub _load_transactions {
251 0     0     my ($self) = @_;
252              
253 0           my @transactions = $self->_serializer->retrieve( $self->file );
254 0           return \@transactions;
255             }
256              
257             1;
258              
259             __END__
260              
261             =pod
262              
263             =encoding UTF-8
264              
265             =head1 NAME
266              
267             Mojo::UserAgent::Mockable - A Mojo User-Agent that can record and play back requests without Internet connectivity, similar to LWP::UserAgent::Mockable
268              
269             =head1 VERSION
270              
271             version 1.57
272              
273             =head1 SYNOPSIS
274              
275             my $ua = Mojo::UserAgent::Mockable->new( mode => 'record', file => '/path/to/file' );
276             my $tx = $ua->get($url);
277              
278             # Then later...
279             my $ua = Mojo::UserAgent::Mockable->new( mode => 'playback', file => '/path/to/file' );
280            
281             my $tx = $ua->get($url);
282             # This is the same content as above. The saved response is returned, and no HTTP request is
283             # sent to the remote host.
284             my $reconstituted_content = $tx->res->body;
285              
286             =head1 ATTRIBUTES
287              
288             =head2 mode
289              
290             Mode to operate in. One of:
291              
292             =over 4
293              
294             =item passthrough
295              
296             Operates like L<Mojo::UserAgent> in all respects. No recording or playback happen.
297              
298             =item record
299              
300             Records all transactions made with this instance to the file specified by L</file>.
301              
302             =item playback
303              
304             Plays back transactions recorded in the file specified by L</file>
305              
306             =item lwp-ua-mockable
307              
308             Works like L<LWP::UserAgent::Mockable>. Set the LWP_UA_MOCK environment variable to 'playback',
309             'record', or 'passthrough', and the LWP_UA_MOCK_FILE environment variable to the recording file.
310              
311             =back
312              
313             =head2 file
314              
315             File to record to / play back from.
316              
317             =head2 unrecognized
318              
319             What to do on an unexpected request. One of:
320              
321             =over 4
322              
323             =item exception
324              
325             Throw an exception (i.e. die).
326              
327             =item null
328              
329             Return a response with empty content
330              
331             =item fallback
332              
333             Process the request as if this instance were in "passthrough" mode and perform the HTTP request normally.
334              
335             =back
336              
337             =head2 ignore_headers
338              
339             Request header names to ignore when comparing a request made with this class to a stored request in
340             playback mode. Specify 'all' to remove any headers from consideration. By default, the 'Connection',
341             'Host', 'Content-Length', and 'User-Agent' headers are ignored.
342              
343             =head2 ignore_body
344              
345             Ignore the request body entirely when comparing a request made with this class to a stored request
346             in playback mode.
347              
348             =head2 ignore_userinfo
349              
350             Ignore the userinfo portion of the request URL's when comparing a request to a potential counterpart in playback mode.
351              
352             =head2 request_normalizer
353              
354             Optional subref. This is for when the requests require a more nuanced comparison (although it will
355             be used in conjunction with the previous attributes).
356              
357             The subref takes two parameters: the current Mojo::Message::Request and the recorded one. The subref
358             should modify these request objects in-place so that they match each other for the parts where your
359             code doesn't care, e.g. set an id or timestamp to the same value in both requests.
360              
361             The return value is ignored, so a typical subref to ignore differences in any numerical id parts of
362             the query path could look like this
363              
364             request_normalizer => sub {
365             my ($req, $recorded_req) = @_;
366             for ($req, $recorded_req) {
367             $_->url->path( $_->url->path =~ s|/\d+\b|/123|gr );
368             }
369             },
370              
371             =head1 METHODS
372              
373             =head2 save
374              
375             In record mode, save the transaction cache to the file specified by L</file> for later playback.
376              
377             =head1 THEORY OF OPERATION
378              
379             =head2 Recording mode
380              
381             For the life of a given instance of this class, all transactions made using that instance will be
382             serialized and stored in memory. When the instance goes out of scope, or at any time L</save> is
383             called, the transaction cache will be written to the file specfied by L</file> in JSON format.
384             Transactions are stored in the cache in the order they were made.
385              
386             =head2 Playback mode
387              
388             When this class is instantiated, the instance will read the transaction cache from the file
389             specified by L</file>. When a request is first made using the instance, if the request matches
390             that of the first transaction in the cache, the request URL will be rewritten to that of the local
391             host, and the response from the first stored transaction will be returned to the caller. Each
392             subsequent request will be handled similarly, and requests must be made in the same order as they
393             were originally made, i.e. if orignally the request order was A, B, C, with responses A', B', C',
394             requests in order A, C, B will NOT return responses A', C', B'. Request A will correctly return
395             response A', but request C will trigger an error (behavior configurable by the L</unrecognized>
396             option).
397              
398             =head3 Request matching
399              
400             Before comparing the current request with the recorded one, the requests are normalized using the
401             subref in the request_normalizer attribute. The default is no normalization. See above for how to
402             use it.
403              
404             Two requests are considered to be equivalent if they have the same URL (order of query parameters
405             notwithstanding), the same body content, and the same headers.
406              
407             You may also exclude headers from consideration by means of the L</ignore_headers> attribute. Or,
408             you may excluse the request body from consideration by means of the L</ignore_body> attribute.
409              
410             =head1 CAVEATS
411              
412             =head2 Encryption
413              
414             The playback file generated by this module is unencrypted JSON. Treat the playback file as if
415             its contents were being transmitted over an unsecured channel.
416              
417             =head2 Local application server
418              
419             Using this module against a local app, e.g.:
420              
421             my $app = Mojolicious->new;
422             ...
423              
424             my $ua = Mojo::UserAgent::Mockable->new;
425             $ua->server->app($app);
426              
427             Doesn't work, because in playback mode, requests are served from an internal Mojolicious instance.
428             So if you blow that away, the thing stops working, natch. You should instead instantiate
429             L<Mojo::Server::Daemon> and connect to the app via the server's URL, like so:
430              
431             use Mojo::Server::Daemon;
432             use Mojo::IOLoop;
433              
434             my $app = Mojolicious->new;
435             $app->routes->any( ... );
436              
437             my $daemon = Mojo::Server::Daemon->new(
438             app => $app,
439             ioloop => Mojo::IOLoop->singleton,
440             silent => 1,
441             );
442            
443             my $listen = q{http://127.0.0.1};
444             $daemon->listen( [$listen] )->start;
445             my $port = Mojo::IOLoop->acceptor( $daemon->acceptors->[0] )->port;
446             my $url = Mojo::URL->new(qq{$listen:$port})->userinfo('joeblow:foobar');
447            
448             my $output_file = qq{/path/to/file.json};
449            
450             my $mock = Mojo::UserAgent::Mockable->new(ioloop => Mojo::IOLoop->singleton, mode => 'record', file => $output_file);
451             my $tx = $mock->get($url);
452              
453             =head2 Mojolicious::Lite
454              
455             You will often see tests written using L<Mojolicious::Lite> like so:
456              
457             use Mojolicious::Lite;
458              
459             get '/' => sub { ... };
460              
461             post '/foo' => sub { ... };
462              
463             And then, further down:
464              
465             my $ua = Mojo::UserAgent->new;
466              
467             is( $ua->get('/')->res->text, ..., 'Text OK' );
468             Or:
469              
470             use Test::Mojo;
471             my $t = Test::Mojo->new;
472             $t->get_ok('/')->status_is(200)->text_is( ... );
473              
474             And this is all fine. Where it stops being fine is when you have Mojo::UserAgent::Mockable on board:
475              
476             use Mojolicious::Lite;
477              
478             get '/' => sub { ... };
479              
480             post '/foo' => sub { ... };
481            
482             use Test::Mojo;
483             my $t = Test::Mojo->new;
484             my $mock = Mojo::UserAgent::Mockable->new( mode => 'playback', file => ... );
485             $t->get_ok('/')->status_is(200)->text_is( ... );
486              
487             Mojolicious::Lite will replace the current UA's internal application server's application instance
488             (L<Mojo::UserAgent::Server/app>) with the Mojolicious::Lite application. This will break the
489             playback functionality, as this depends on a custom Mojolicious application internal to the module.
490             Instead, define your application in a separate package (not necessarily a separate file), like so:
491              
492             package MyApp;
493             use Mojolicious::Lite;
494             get '/' => sub { ... };
495             post '/foo' => sub { ... };
496              
497             # Actual test application
498             package main;
499              
500             use Mojo::UserAgent::Mockable;
501             use Mojo::Server::Daemon;
502             use Mojo::IOLoop;
503             use Test::Mojo;
504              
505             $app->routes->get('/' => sub { ... });
506             $app->routes->post('/foo' => sub { ... });
507              
508             my $daemon = Mojo::Server::Daemon->new(
509             app => $app,
510             ioloop => Mojo::IOLoop->singleton,
511             silent => 1,
512             );
513              
514             my $listen = q{http://127.0.0.1};
515             $daemon->listen( [$listen] )->start;
516             my $port = Mojo::IOLoop->acceptor( $daemon->acceptors->[0] )->port;
517             my $url = Mojo::URL->new(qq{$listen:$port})->userinfo('joeblow:foobar');
518              
519             my $mock = Mojo::UserAgent::Mockable->new(ioloop => Mojo::IOLoop::singleton, mode => playback, file => ... );
520             my $t = Test::Mojo->new;
521             $t->ua($mock);
522             $mock->get_ok($url->clone->path('/'))->status_is(200)->text_is( ... );
523              
524             You can also do the following (as seen in t/030_basic_authentication.t):
525              
526             use Mojolicious;
527             use Mojo::Server::Daemon;
528             use Mojo::IOLoop;
529              
530             my $app = Mojolicious->new;
531             $app->routes->get('/' => sub { ... });
532             $app->routes->post('/foo' => sub { ... });
533              
534             my $daemon = Mojo::Server::Daemon->new(
535             app => $app,
536             ioloop => Mojo::IOLoop->singleton,
537             silent => 1,
538             );
539              
540             my $listen = q{http://127.0.0.1};
541             $daemon->listen( [$listen] )->start;
542             my $port = Mojo::IOLoop->acceptor( $daemon->acceptors->[0] )->port;
543             my $url = Mojo::URL->new(qq{$listen:$port})->userinfo('joeblow:foobar');
544              
545             my $mock = Mojo::UserAgent::Mockable->new(ioloop => Mojo::IOLoop::singleton, mode => playback, file => ... );
546             my $t = Test::Mojo->new;
547             $t->ua($mock);
548             $t->get_ok('/')->status_is(200)->content_is( ... );
549              
550             =head2 Events
551              
552             The following transaction level events will not be emitted during playback:
553              
554             =over 4
555              
556             =item pre_freeze
557             =item post_freeze
558             =item resume
559              
560             =back
561              
562             =head1 SEE ALSO
563              
564             =over 4
565              
566             =item * L<Mojo::UserAgent>
567             The class being mocked (but not derided, because the whole Mojo thing is really quite clever)
568             =item * L<Mojo::Transaction::HTTP>
569             Where the magic happens
570              
571             =back
572              
573             =head1 CONTRIBUTORS
574              
575             Mike Eve L<https://github.com/ungrim97>
576              
577             Phineas J. Whoopee L<https://github.com/antoniel123>
578              
579             Marc Murray L<https://github.com/marcmurray>
580              
581             Steve Wagner C<< <truroot at gmail.com> >>
582              
583             Joel Berger C<< <joel.a.berger at gmail.com> >>
584              
585             Dan Book C<< <grinnz at grinnz.com> >>
586              
587             Stefan Adams C<< <stefan@borgia.com> >>
588              
589             Mohammad Anwar C<< mohammad.anwar@yahoo.com >>
590              
591             Johan Lindstrom C<< johanl@cpan.org >>
592              
593             Everyone on #mojo on irc.perl.org
594              
595             =head1 AUTHOR
596              
597             Kit Peters <popefelix@gmail.com>
598              
599             =head1 COPYRIGHT AND LICENSE
600              
601             This software is copyright (c) 2019 by Kit Peters.
602              
603             This is free software; you can redistribute it and/or modify it under
604             the same terms as the Perl 5 programming language system itself.
605              
606             =cut