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