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   141320 use strict;
  12         17  
  12         375  
4 12     12   40 use warnings;
  12         16  
  12         462  
5 12     12   65 use Carp 'croak';
  12         26  
  12         13884  
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 162202 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     1426 }, $class;
      100        
      100        
18             }
19              
20             # Status code
21 35     35 1 214 sub status { shift->{status} }
22              
23             # Raw body bytes
24 10     10 1 500 sub content { shift->{body} }
25              
26             # Decoded text based on Content-Type charset
27             sub text {
28 35     35 1 1583 my ($self) = @_;
29 35         91 my $body = $self->{body};
30 35 100 66     149 return $body unless defined $body && length $body;
31              
32             # Parse charset from Content-Type header
33 34   100     76 my $charset = $self->_extract_charset // 'UTF-8';
34              
35 34         203 require Encode;
36 34         225 return Encode::decode($charset, $body, Encode::FB_CROAK());
37             }
38              
39             # Extract charset from Content-Type header
40             sub _extract_charset {
41 34     34   47 my ($self) = @_;
42 34 100       75 my $ct = $self->content_type or return undef;
43              
44             # Match charset=... (with or without quotes)
45 31 100       139 if ($ct =~ /charset\s*=\s*"?([^";,\s]+)"?/i) {
46 6         27 return $1;
47             }
48 25         69 return undef;
49             }
50              
51             # Header lookup (case-insensitive)
52             sub header {
53 44     44 1 1179 my ($self, $name) = @_;
54 44         72 $name = lc($name);
55 44         45 for my $pair (@{$self->{headers}}) {
  44         80  
56 44 100       183 return $pair->[1] if lc($pair->[0]) eq $name;
57             }
58 4         33 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 10 sub is_success { my $s = shift->status; $s >= 200 && $s < 300 }
  4         25  
73 1 50   1 1 2 sub is_redirect { my $s = shift->status; $s >= 300 && $s < 400 }
  1         8  
74 3     3 1 33 sub is_error { my $s = shift->status; $s >= 400 }
  3         9  
75              
76             # Exception from app (if trapped)
77 11     11 1 379 sub exception { shift->{exception} }
78              
79             # Parse body as JSON
80             sub json {
81 24     24 1 98 my ($self) = @_;
82 24         1286 require JSON::MaybeXS;
83              
84 24         14906 my $body = $self->{body};
85 24         23 my $data = eval { JSON::MaybeXS::decode_json($body) };
  24         166  
86              
87 24 100       57 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         133 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         56 return $data;
105             }
106              
107             # Convenience header shortcuts
108 37     37 1 101 sub content_type { shift->header('content-type') }
109 1     1 1 3 sub content_length { shift->header('content-length') }
110 1     1 1 2 sub location { shift->header('location') }
111              
112             1;
113              
114             __END__