File Coverage

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              
3 12     12   155122 use strict;
  12         16  
  12         371  
4 12     12   41 use warnings;
  12         14  
  12         466  
5 12     12   55 use Carp 'croak';
  12         46  
  12         13154  
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 82     82 1 175220 my ($class, %args) = @_;
12             return bless {
13             status => $args{status} // 200,
14             headers => $args{headers} // [],
15             body => $args{body} // '',
16             exception => $args{exception},
17 82   50     1490 }, $class;
      100        
      100        
18             }
19              
20             # Status code
21 35     35 1 366 sub status { shift->{status} }
22              
23             # Raw body bytes
24 10     10 1 510 sub content { shift->{body} }
25              
26             # Decoded text based on Content-Type charset
27             sub text {
28 35     35 1 1586 my ($self) = @_;
29 35         70 my $body = $self->{body};
30 35 100 66     146 return $body unless defined $body && length $body;
31              
32             # Parse charset from Content-Type header
33 34   100     67 my $charset = $self->_extract_charset // 'UTF-8';
34              
35 34         278 require Encode;
36 34         265 return Encode::decode($charset, $body, Encode::FB_CROAK());
37             }
38              
39             # Extract charset from Content-Type header
40             sub _extract_charset {
41 34     34   46 my ($self) = @_;
42 34 100       76 my $ct = $self->content_type or return undef;
43              
44             # Match charset=... (with or without quotes)
45 31 100       133 if ($ct =~ /charset\s*=\s*"?([^";,\s]+)"?/i) {
46 6         31 return $1;
47             }
48 25         67 return undef;
49             }
50              
51             # Header lookup (case-insensitive)
52             sub header {
53 44     44 1 1206 my ($self, $name) = @_;
54 44         72 $name = lc($name);
55 44         85 for my $pair (@{$self->{headers}}) {
  44         81  
56 44 100       200 return $pair->[1] if lc($pair->[0]) eq $name;
57             }
58 4         27 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 7 sub is_success { my $s = shift->status; $s >= 200 && $s < 300 }
  4         23  
73 1 50   1 1 2 sub is_redirect { my $s = shift->status; $s >= 300 && $s < 400 }
  1         6  
74 3     3 1 28 sub is_error { my $s = shift->status; $s >= 400 }
  3         39  
75              
76             # Exception from app (if trapped)
77 11     11 1 451 sub exception { shift->{exception} }
78              
79             # Parse body as JSON
80             sub json {
81 24     24 1 96 my ($self) = @_;
82 24         1469 require JSON::MaybeXS;
83              
84 24         18339 my $body = $self->{body};
85 24         30 my $data = eval { JSON::MaybeXS::decode_json($body) };
  24         164  
86              
87 24 100       59 if (my $err = $@) {
88 1         2 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         153 croak "Response body is not valid JSON (status=$status, content-type=$ct)\n"
100             . "Body: $preview\n"
101             . "JSON error: $err";
102             }
103              
104 23         52 return $data;
105             }
106              
107             # Convenience header shortcuts
108 37     37 1 93 sub content_type { shift->header('content-type') }
109 1     1 1 3 sub content_length { shift->header('content-length') }
110 1     1 1 3 sub location { shift->header('location') }
111              
112             1;
113              
114             __END__