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   5420841 use 5.014;
  16         177  
2              
3             package Mojo::UserAgent::Mockable;
4             $Mojo::UserAgent::Mockable::VERSION = '1.58';
5 16     16   99 use warnings::register;
  16         34  
  16         1902  
6              
7 16     16   97 use Carp;
  16         31  
  16         935  
8 16     16   7294 use JSON::MaybeXS;
  16         50169  
  16         1004  
9 16     16   6678 use Mojolicious 7.22;
  16         3684267  
  16         145  
10 16     16   755 use Mojo::Base 'Mojo::UserAgent';
  16         38  
  16         67  
11 16     16   3325 use Mojo::Util qw/secure_compare/;
  16         38  
  16         791  
12 16     16   8033 use Mojo::UserAgent::Mockable::Proxy;
  16         45  
  16         123  
13 16     16   7688 use Mojo::UserAgent::Mockable::Serializer;
  16         256  
  16         242  
14 16     16   9098 use Mojo::UserAgent::Mockable::Request::Compare;
  16         54  
  16         108  
15 16     16   651 use Mojo::JSON;
  16         36  
  16         620  
16 16     16   87 use Scalar::Util;
  16         31  
  16         33156  
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 1483328 my $class = shift;
67 36         373 my $self = $class->SUPER::new(@_);
68 36         506 my %comparator_args = (
69             ignore_headers => 'all',
70             ignore_body => $self->ignore_body,
71             ignore_userinfo => $self->ignore_userinfo,
72             );
73 36         749 $self->comparator( Mojo::UserAgent::Mockable::Request::Compare->new(%comparator_args) );
74              
75 36         539 $self->{'_launchpid'} = $$;
76 36 50 100     195 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         814 $self->_mode( $self->mode );
89             }
90              
91 36 100       368 if ( $self->mode eq 'playback' ) {
92 19         221 $self->proxy(undef);
93             }
94              
95 36 50 66     406 if ( $self->_mode ne 'passthrough' && !$self->file ) {
96 0         0 croak qq{Error: You must specify a recording file};
97             }
98              
99 36 100       506 if ( $self->_mode ne 'passthrough' ) {
100 30         215 my $mode = lc $self->_mode;
101 30         203 my $mode_init = qq{_init_$mode};
102 30 50       319 if ( !$self->can($mode_init) ) {
103 0         0 croak qq{Error: unsupported mode "$mode"};
104             }
105 30         145 return $self->$mode_init;
106             }
107              
108 6         38 return $self;
109             }
110              
111             sub proxy {
112 194     194 1 13673 my $self = shift;
113 194 100       1163 return $self->SUPER::proxy unless @_;
114              
115 19 50       69 if ( $self->mode eq 'playback' ) {
116 19         291 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 80089907 my ( $self, $file ) = @_;
125 65 50       314 if ( $self->_mode eq 'record' ) {
126 65   66     672 $file ||= $self->file;
127              
128 65         246 my $transactions = $self->{'_transactions'};
129 65         271 $self->_serializer->store( $file, @{$transactions} );
  65         762  
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 7884093 my ( $self, $tx, $cb ) = @_;
138 173 100       798 if ($cb) {
139 49         231 $self->_non_blocking(1);
140             }
141 173         1195 return $self->SUPER::start( $tx, $cb );
142             }
143              
144             sub _init_playback {
145 19     19   69 my $self = shift;
146              
147 19 50       65 if ( !-e $self->file ) {
148 0         0 my $file = $self->file;
149 0         0 croak qq{Playback file $file not found};
150             }
151 19         788 $self->{'_transactions'} = [ $self->_serializer->retrieve( $self->file ) ];
152 19         54 my $recorded_tx_count = scalar @{ $self->{_transactions} };
  19         63  
153              
154 19         144 $self->server->app( $self->_app );
155              
156 19         615 Scalar::Util::weaken($self);
157             $self->on(
158             start => sub {
159 114     114   30884 my ( $ua, $tx ) = @_;
160              
161 114 100       436 my $port = $self->_non_blocking ? $self->server->nb_url->port : $self->server->url->port;
162 114         75554 my $recorded_tx = shift @{ $self->{'_transactions'} };
  114         512  
163              
164 114         524 my ( $this_req, $recorded_req ) = $self->_normalized_req( $tx, $recorded_tx );
165              
166 114 100       1737 if ( $self->comparator->compare( $this_req, $recorded_req ) ) {
167 50         253 $self->_current_txn($recorded_tx);
168              
169 50         1975 $tx->req->url( $tx->req->url->clone );
170 50         4792 $tx->req->url->host('')->scheme('')->port($port);
171             }
172             else {
173 64         123 unshift @{ $self->{'_transactions'} }, $recorded_tx;
  64         221  
174              
175 64         286 my $result = $self->comparator->compare_result;
176 64         641 $self->_current_txn(undef);
177 64 100       500 if ( $self->unrecognized eq 'exception' ) {
    100          
    50          
178 28         718 croak qq{Unrecognized request: $result};
179             }
180             elsif ( $self->unrecognized eq 'null' ) {
181 18         231 $tx->req->headers->header( 'X-MUA-Mockable-Request-Recognized' => 0 );
182 18         930 $tx->req->headers->header( 'X-MUA-Mockable-Request-Match-Exception' => $result );
183 18         575 $tx->req->url->host('')->scheme('')->port($port);
184             }
185             elsif ( $self->unrecognized eq 'fallback' ) {
186             $tx->on(
187             finish => sub {
188 18         3857028 my $self = shift;
189 18         88 $tx->req->headers->header( 'X-MUA-Mockable-Request-Recognized' => 0 );
190 18         799 $tx->req->headers->header( 'X-MUA-Mockable-Request-Match-Exception' => $result );
191             }
192 18         443 );
193             }
194             }
195             }
196 19         314 );
197              
198 19         265 return $self;
199             }
200              
201             sub _normalized_req {
202 116     116   660 my $self = shift;
203 116         336 my ( $tx, $recorded_tx ) = @_;
204              
205 116 100       453 my $request_normalizer = $self->request_normalizer or return ( $tx->req, $recorded_tx->req );
206 11 50       107 croak("The request_normalizer attribute is not a coderef") if ( ref($request_normalizer) ne "CODE" );
207              
208 11         41 my $req = $tx->req->clone;
209 11         2691 my $recorded_req = $recorded_tx->req->clone;
210 11         1988 $request_normalizer->( $req, $recorded_req ); # To be modified in-place
211              
212 11         1115 return ( $req, $recorded_req );
213             }
214              
215             sub _init_record {
216 11     11   48 my $self = shift;
217              
218 11         66 Scalar::Util::weaken($self);
219             $self->on(
220             start => sub {
221 57     57   19846 my $tx = $_[1];
222              
223 57 100       220 if ( $tx->req->proxy ) {
224              
225             # HTTP CONNECT - used for proxy
226 2 100       27 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       15 return if $tx->connection;
231             }
232              
233             $tx->once(
234             finish => sub {
235 56         7810194 my $tx = shift;
236 56         183 push @{ $self->{'_transactions'} }, $tx;
  56         256  
237              
238             # 15: During global destruction the $tx object may no longer exist
239             # so save now
240 56         293 $self->save($self->file);
241             },
242 56         952 );
243 56         1237 1;
244             },
245 11         151 );
246              
247 11         140 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.58
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             The file's contents are pretty-printed and canonicalized (ie hash keys are sorted) so that mocks
387             are easy to read and diffs are minimized.
388              
389             =head2 Playback mode
390              
391             When this class is instantiated, the instance will read the transaction cache from the file
392             specified by L</file>. When a request is first made using the instance, if the request matches
393             that of the first transaction in the cache, the request URL will be rewritten to that of the local
394             host, and the response from the first stored transaction will be returned to the caller. Each
395             subsequent request will be handled similarly, and requests must be made in the same order as they
396             were originally made, i.e. if orignally the request order was A, B, C, with responses A', B', C',
397             requests in order A, C, B will NOT return responses A', C', B'. Request A will correctly return
398             response A', but request C will trigger an error (behavior configurable by the L</unrecognized>
399             option).
400              
401             =head3 Request matching
402              
403             Before comparing the current request with the recorded one, the requests are normalized using the
404             subref in the request_normalizer attribute. The default is no normalization. See above for how to
405             use it.
406              
407             Two requests are considered to be equivalent if they have the same URL (order of query parameters
408             notwithstanding), the same body content, and the same headers.
409              
410             You may also exclude headers from consideration by means of the L</ignore_headers> attribute. Or,
411             you may excluse the request body from consideration by means of the L</ignore_body> attribute.
412              
413             =head1 CAVEATS
414              
415             =head2 Encryption
416              
417             The playback file generated by this module is unencrypted JSON. Treat the playback file as if
418             its contents were being transmitted over an unsecured channel.
419              
420             =head2 Local application server
421              
422             Using this module against a local app, e.g.:
423              
424             my $app = Mojolicious->new;
425             ...
426              
427             my $ua = Mojo::UserAgent::Mockable->new;
428             $ua->server->app($app);
429              
430             Doesn't work, because in playback mode, requests are served from an internal Mojolicious instance.
431             So if you blow that away, the thing stops working, natch. You should instead instantiate
432             L<Mojo::Server::Daemon> and connect to the app via the server's URL, like so:
433              
434             use Mojo::Server::Daemon;
435             use Mojo::IOLoop;
436              
437             my $app = Mojolicious->new;
438             $app->routes->any( ... );
439              
440             my $daemon = Mojo::Server::Daemon->new(
441             app => $app,
442             ioloop => Mojo::IOLoop->singleton,
443             silent => 1,
444             );
445            
446             my $listen = q{http://127.0.0.1};
447             $daemon->listen( [$listen] )->start;
448             my $port = Mojo::IOLoop->acceptor( $daemon->acceptors->[0] )->port;
449             my $url = Mojo::URL->new(qq{$listen:$port})->userinfo('joeblow:foobar');
450            
451             my $output_file = qq{/path/to/file.json};
452            
453             my $mock = Mojo::UserAgent::Mockable->new(ioloop => Mojo::IOLoop->singleton, mode => 'record', file => $output_file);
454             my $tx = $mock->get($url);
455              
456             =head2 Mojolicious::Lite
457              
458             You will often see tests written using L<Mojolicious::Lite> like so:
459              
460             use Mojolicious::Lite;
461              
462             get '/' => sub { ... };
463              
464             post '/foo' => sub { ... };
465              
466             And then, further down:
467              
468             my $ua = Mojo::UserAgent->new;
469              
470             is( $ua->get('/')->res->text, ..., 'Text OK' );
471             Or:
472              
473             use Test::Mojo;
474             my $t = Test::Mojo->new;
475             $t->get_ok('/')->status_is(200)->text_is( ... );
476              
477             And this is all fine. Where it stops being fine is when you have Mojo::UserAgent::Mockable on board:
478              
479             use Mojolicious::Lite;
480              
481             get '/' => sub { ... };
482              
483             post '/foo' => sub { ... };
484            
485             use Test::Mojo;
486             my $t = Test::Mojo->new;
487             my $mock = Mojo::UserAgent::Mockable->new( mode => 'playback', file => ... );
488             $t->get_ok('/')->status_is(200)->text_is( ... );
489              
490             Mojolicious::Lite will replace the current UA's internal application server's application instance
491             (L<Mojo::UserAgent::Server/app>) with the Mojolicious::Lite application. This will break the
492             playback functionality, as this depends on a custom Mojolicious application internal to the module.
493             Instead, define your application in a separate package (not necessarily a separate file), like so:
494              
495             package MyApp;
496             use Mojolicious::Lite;
497             get '/' => sub { ... };
498             post '/foo' => sub { ... };
499              
500             # Actual test application
501             package main;
502              
503             use Mojo::UserAgent::Mockable;
504             use Mojo::Server::Daemon;
505             use Mojo::IOLoop;
506             use Test::Mojo;
507              
508             $app->routes->get('/' => sub { ... });
509             $app->routes->post('/foo' => sub { ... });
510              
511             my $daemon = Mojo::Server::Daemon->new(
512             app => $app,
513             ioloop => Mojo::IOLoop->singleton,
514             silent => 1,
515             );
516              
517             my $listen = q{http://127.0.0.1};
518             $daemon->listen( [$listen] )->start;
519             my $port = Mojo::IOLoop->acceptor( $daemon->acceptors->[0] )->port;
520             my $url = Mojo::URL->new(qq{$listen:$port})->userinfo('joeblow:foobar');
521              
522             my $mock = Mojo::UserAgent::Mockable->new(ioloop => Mojo::IOLoop::singleton, mode => playback, file => ... );
523             my $t = Test::Mojo->new;
524             $t->ua($mock);
525             $mock->get_ok($url->clone->path('/'))->status_is(200)->text_is( ... );
526              
527             You can also do the following (as seen in t/030_basic_authentication.t):
528              
529             use Mojolicious;
530             use Mojo::Server::Daemon;
531             use Mojo::IOLoop;
532              
533             my $app = Mojolicious->new;
534             $app->routes->get('/' => sub { ... });
535             $app->routes->post('/foo' => sub { ... });
536              
537             my $daemon = Mojo::Server::Daemon->new(
538             app => $app,
539             ioloop => Mojo::IOLoop->singleton,
540             silent => 1,
541             );
542              
543             my $listen = q{http://127.0.0.1};
544             $daemon->listen( [$listen] )->start;
545             my $port = Mojo::IOLoop->acceptor( $daemon->acceptors->[0] )->port;
546             my $url = Mojo::URL->new(qq{$listen:$port})->userinfo('joeblow:foobar');
547              
548             my $mock = Mojo::UserAgent::Mockable->new(ioloop => Mojo::IOLoop::singleton, mode => playback, file => ... );
549             my $t = Test::Mojo->new;
550             $t->ua($mock);
551             $t->get_ok('/')->status_is(200)->content_is( ... );
552              
553             =head2 Events
554              
555             The following transaction level events will not be emitted during playback:
556              
557             =over 4
558              
559             =item pre_freeze
560             =item post_freeze
561             =item resume
562              
563             =back
564              
565             =head1 SEE ALSO
566              
567             =over 4
568              
569             =item * L<Mojo::UserAgent>
570             The class being mocked (but not derided, because the whole Mojo thing is really quite clever)
571             =item * L<Mojo::Transaction::HTTP>
572             Where the magic happens
573              
574             =back
575              
576             =head1 CONTRIBUTORS
577              
578             Mike Eve L<https://github.com/ungrim97>
579              
580             Phineas J. Whoopee L<https://github.com/antoniel123>
581              
582             Marc Murray L<https://github.com/marcmurray>
583              
584             Steve Wagner C<< <truroot at gmail.com> >>
585              
586             Joel Berger C<< <joel.a.berger at gmail.com> >>
587              
588             Dan Book C<< <grinnz at grinnz.com> >>
589              
590             Stefan Adams C<< <stefan@borgia.com> >>
591              
592             Mohammad Anwar C<< mohammad.anwar@yahoo.com >>
593              
594             Johan Lindstrom C<< johanl@cpan.org >>
595              
596             David Cantrell C<< david@cantrell.org.uk >>
597              
598             Everyone on #mojo on irc.perl.org
599              
600             =head1 AUTHOR
601              
602             Kit Peters <popefelix@gmail.com>
603              
604             =head1 COPYRIGHT AND LICENSE
605              
606             This software is copyright (c) 2021 by Kit Peters.
607              
608             This is free software; you can redistribute it and/or modify it under
609             the same terms as the Perl 5 programming language system itself.
610              
611             =cut