File Coverage

blib/lib/PAGI/Test/Response.pm
Criterion Covered Total %
statement 53 60 88.3
branch 14 18 77.7
condition 10 13 76.9
subroutine 17 18 94.4
pod 14 14 100.0
total 108 123 87.8


line stmt bran cond sub pod time code
1             package PAGI::Test::Response;
2             $PAGI::Test::Response::VERSION = '0.002000';
3 14     14   151142 use strict;
  14         20  
  14         450  
4 14     14   65 use warnings;
  14         16  
  14         601  
5 14     14   50 use Carp 'croak';
  14         17  
  14         14284  
6              
7             # Maximum bytes of response body to include in JSON decode error messages
8             our $JSON_ERROR_BODY_LIMIT = 1500;
9              
10             sub new {
11 86     86 1 161383 my ($class, %args) = @_;
12             return bless {
13             status => $args{status} // 200,
14             headers => $args{headers} // [],
15             body => $args{body} // '',
16             exception => $args{exception},
17 86   50     1664 }, $class;
      100        
      100        
18             }
19              
20             # Status code
21 36     36 1 295 sub status { shift->{status} }
22              
23             # Raw body bytes
24 12     12 1 543 sub content { shift->{body} }
25              
26             # Decoded text based on Content-Type charset
27             sub text {
28 35     35 1 1598 my ($self) = @_;
29 35         54 my $body = $self->{body};
30 35 100 66     143 return $body unless defined $body && length $body;
31              
32             # Parse charset from Content-Type header
33 34   100     100 my $charset = $self->_extract_charset // 'UTF-8';
34              
35 34         205 require Encode;
36 34         249 return Encode::decode($charset, $body, Encode::FB_CROAK());
37             }
38              
39             # Extract charset from Content-Type header
40             sub _extract_charset {
41 34     34   44 my ($self) = @_;
42 34 100       6468 my $ct = $self->content_type or return undef;
43              
44             # Match charset=... (with or without quotes)
45 31 100       101 if ($ct =~ /charset\s*=\s*"?([^";,\s]+)"?/i) {
46 4         15 return $1;
47             }
48 27         96 return undef;
49             }
50              
51             # Header lookup (case-insensitive)
52             sub header {
53 43     43 1 1396 my ($self, $name) = @_;
54 43         69 $name = lc($name);
55 43         35 for my $pair (@{$self->{headers}}) {
  43         71  
56 43 100       178 return $pair->[1] if lc($pair->[0]) eq $name;
57             }
58 4         19 return undef;
59             }
60              
61             # All headers as hashref (last value wins for duplicates)
62             sub headers {
63 0     0 1 0 my ($self) = @_;
64 0         0 my %h;
65 0         0 for my $pair (@{$self->{headers}}) {
  0         0  
66 0         0 $h{lc($pair->[0])} = $pair->[1];
67             }
68 0         0 return \%h;
69             }
70              
71             # Status helpers
72 4 50   4 1 9 sub is_success { my $s = shift->status; $s >= 200 && $s < 300 }
  4         26  
73 1 50   1 1 3 sub is_redirect { my $s = shift->status; $s >= 300 && $s < 400 }
  1         6  
74 3     3 1 9 sub is_error { my $s = shift->status; $s >= 400 }
  3         11  
75              
76             # Exception from app (if trapped)
77 11     11 1 370 sub exception { shift->{exception} }
78              
79             # Parse body as JSON
80             sub json {
81 23     23 1 104 my ($self) = @_;
82 23         610 require JSON::MaybeXS;
83              
84 23         6910 my $body = $self->{body};
85 23         33 my $data = eval { JSON::MaybeXS::decode_json($body) };
  23         159  
86              
87 23 100       48 if (my $err = $@) {
88 1         3 my $status = $self->status;
89 1   50     3 my $ct = $self->content_type // '(none)';
90              
91             # Truncate body preview if too long
92 1 50       3 my $preview = defined $body ? $body : '(undef)';
93 1 50       3 if (length($preview) > $JSON_ERROR_BODY_LIMIT) {
94 0         0 $preview = substr($preview, 0, $JSON_ERROR_BODY_LIMIT) . "\n... [truncated, " . length($body) . " bytes total]";
95             }
96              
97 1         10 $err =~ s/\s+at \S+ line \d+\.?\s*$//; # Strip file/line from JSON error
98              
99 1         148 croak "Response body is not valid JSON (status=$status, content-type=$ct)\n"
100             . "Body: $preview\n"
101             . "JSON error: $err";
102             }
103              
104 22         53 return $data;
105             }
106              
107             # Convenience header shortcuts
108 36     36 1 92 sub content_type { shift->header('content-type') }
109 1     1 1 2 sub content_length { shift->header('content-length') }
110 1     1 1 3 sub location { shift->header('location') }
111              
112             1;
113              
114             __END__