File Coverage

blib/lib/PAGI/Test/Client.pm
Criterion Covered Total %
statement 338 356 94.9
branch 129 164 78.6
condition 45 79 56.9
subroutine 40 40 100.0
pod 18 18 100.0
total 570 657 86.7


line stmt bran cond sub pod time code
1             package PAGI::Test::Client;
2             $PAGI::Test::Client::VERSION = '0.002000';
3 13     13   1817397 use strict;
  13         19  
  13         437  
4 13     13   58 use warnings;
  13         41  
  13         517  
5 13     13   480 use Future::AsyncAwait;
  13         17792  
  13         67  
6 13     13   596 use Carp qw(croak);
  13         21  
  13         630  
7              
8 13     13   4720 use PAGI::Test::ConnectionState;
  13         33  
  13         562  
9 13     13   4795 use PAGI::Test::Response;
  13         28  
  13         576  
10 13     13   3940 use PAGI::Utils ();
  13         71  
  13         74833  
11              
12              
13             sub new {
14 80     80 1 1891759 my ($class, %args) = @_;
15              
16 80 50       251 croak "app is required" unless $args{app};
17              
18             return bless {
19             app => PAGI::Utils::to_app($args{app}),
20             headers => $args{headers} // {},
21             cookies => {},
22             lifespan => $args{lifespan} // 0,
23 80   100     268 raise_app_exceptions => $args{raise_app_exceptions} // 0,
      100        
      100        
24             started => 0,
25             }, $class;
26             }
27              
28 54     54 1 4976 sub get { shift->_request('GET', @_) }
29 2     2 1 220 sub head { shift->_request('HEAD', @_) }
30 1     1 1 182 sub delete { shift->_request('DELETE', @_) }
31 12     12 1 666 sub post { shift->_request('POST', @_) }
32 2     2 1 183 sub put { shift->_request('PUT', @_) }
33 2     2 1 567 sub patch { shift->_request('PATCH', @_) }
34 2     2 1 570 sub options { shift->_request('OPTIONS', @_) }
35              
36             # Cookie management
37             sub cookies {
38 1     1 1 3 my ($self) = @_;
39 1         8 return $self->{cookies};
40             }
41              
42             sub cookie {
43 1     1 1 352 my ($self, $name) = @_;
44 1         5 return $self->{cookies}{$name};
45             }
46              
47             sub set_cookie {
48 1     1 1 6 my ($self, $name, $value) = @_;
49 1         3 $self->{cookies}{$name} = $value;
50 1         1 return $self;
51             }
52              
53             sub clear_cookies {
54 1     1 1 2 my ($self) = @_;
55 1         3 $self->{cookies} = {};
56 1         1 return $self;
57             }
58              
59             sub _request {
60 75     75   203 my ($self, $method, $path, %opts) = @_;
61              
62 75   50     127 $path //= '/';
63              
64             # Handle json option
65 75 100       306 if (exists $opts{json}) {
    100          
    50          
66 5         1024 require JSON::MaybeXS;
67 5         16885 $opts{body} = JSON::MaybeXS::encode_json($opts{json});
68 5         21 _set_header(\$opts{headers}, 'Content-Type', 'application/json', 0);
69 5         31 _set_header(\$opts{headers}, 'Content-Length', length($opts{body}), 1);
70             }
71             # Handle form option (supports multi-value)
72             elsif (exists $opts{form}) {
73 6         16 my $pairs = _normalize_pairs($opts{form});
74 6         6 my @encoded;
75 6         9 for my $pair (@$pairs) {
76 14         24 my $key = _url_encode($pair->[0]);
77 14   50     28 my $val = _url_encode($pair->[1] // '');
78 14         24 push @encoded, "$key=$val";
79             }
80 6         15 $opts{body} = join('&', @encoded);
81 6         17 _set_header(\$opts{headers}, 'Content-Type', 'application/x-www-form-urlencoded', 0);
82 6         14 _set_header(\$opts{headers}, 'Content-Length', length($opts{body}), 1);
83             }
84             # Add Content-Length for raw body if not already set
85             elsif (defined $opts{body}) {
86 0         0 _set_header(\$opts{headers}, 'Content-Length', length($opts{body}), 0);
87             }
88              
89             # Build scope
90 75         176 my $scope = $self->_build_scope($method, $path, \%opts);
91              
92             # Build receive (returns request body)
93 73   100     202 my $body = $opts{body} // '';
94 73         97 my $receive_called = 0;
95 21     21   8394 my $receive = async sub {
96 21 50       32 if (!$receive_called) {
97 21         19 $receive_called = 1;
98 21         159 return { type => 'http.request', body => $body, more => 0 };
99             }
100 0         0 return { type => 'http.disconnect' };
101 73         284 };
102              
103             # Build send (captures response)
104 73         74 my @events;
105 121     121   3938 my $send = async sub {
106 121         129 my ($event) = @_;
107 121         272 my %captured = %$event;
108              
109 121 50       244 if (my $conn = $scope->{'pagi.connection'}) {
110             $conn->_mark_response_started
111 121 100 50     322 if ($captured{type} // '') eq 'http.response.start';
112             }
113              
114 121 100 50     245 if (($captured{type} // '') eq 'http.response.body') {
115 60 100 66     214 if ($method eq 'HEAD') {
    100          
116 1         4 $captured{body} = '';
117 1         4 delete @captured{qw(fh file offset length)};
118             }
119             elsif (exists $captured{fh} || exists $captured{file}) {
120 5         13 $captured{body} = $self->_response_body_bytes(\%captured);
121 5         14 delete @captured{qw(fh file offset length)};
122             }
123             }
124              
125 121         554 push @events, \%captured;
126 73         257 };
127              
128             # Call app (with exception handling like real server)
129 73         88 my $exception;
130 73         93 eval {
131 73         172 $self->{app}->($scope, $receive, $send)->get;
132             };
133 73 100       3571 if ($@) {
134 13         14 $exception = $@;
135 13 100       22 if ($self->{raise_app_exceptions}) {
136 1         14 die $exception;
137             }
138             # Mimic server behavior: return 500 response
139 12 50       25 if (my $conn = $scope->{'pagi.connection'}) {
140 12         32 $conn->_mark_response_started; # the 500 IS a response
141 12         39 $conn->_mark_disconnected('server_error');# abnormal end — not on_complete
142             }
143 12         44 return PAGI::Test::Response->new(
144             status => 500,
145             headers => [['content-type', 'text/plain']],
146             body => 'Internal Server Error',
147             exception => $exception,
148             );
149             }
150              
151             # Check for incomplete response (common async mistake)
152 60         94 my $has_response_start = grep { $_->{type} eq 'http.response.start' } @events;
  120         214  
153 60 100       91 unless ($has_response_start) {
154 1         15 die "App returned without sending response. "
155             . "Did you forget to 'await' your \$send calls? "
156             . "See PAGI::Tutorial section on async patterns.\n";
157             }
158              
159 59 50       96 if (my $conn = $scope->{'pagi.connection'}) {
160 59         129 $conn->_mark_complete;
161             }
162              
163             # Parse response from captured events
164 59         134 return $self->_build_response(\@events);
165             }
166              
167             sub _build_scope {
168 75     75   126 my ($self, $method, $path, $opts) = @_;
169              
170             # Parse query string from path
171 75         99 my $query_string = '';
172 75 100       181 if ($path =~ s/\?(.*)$//) {
173 2         5 $query_string = $1;
174             }
175              
176             # Add query params if provided (appended to path query string)
177 75 100       135 if ($opts->{query}) {
178 6         17 my $pairs = _normalize_pairs($opts->{query});
179 6         8 my @encoded;
180 6         6 for my $pair (@$pairs) {
181 11         21 my $key = _url_encode($pair->[0]);
182 11   50     22 my $val = _url_encode($pair->[1] // '');
183 11         22 push @encoded, "$key=$val";
184             }
185 6         13 my $new_params = join('&', @encoded);
186 6 100       19 $query_string = $query_string ? "$query_string&$new_params" : $new_params;
187             }
188              
189             # Build headers using helper
190 75         210 my $headers = $self->_build_headers($opts->{headers});
191              
192 73         529 my $scope = {
193             type => 'http',
194             pagi => { version => '0.2', spec_version => '0.2' },
195             http_version => '1.1',
196             method => $method,
197             scheme => 'http',
198             path => $path,
199             query_string => $query_string,
200             root_path => '',
201             headers => $headers,
202             client => ['127.0.0.1', 12345],
203             server => ['testserver', 80],
204             'pagi.connection' => PAGI::Test::ConnectionState->new,
205             };
206              
207             # Add state if lifespan is enabled
208 73 100       152 $scope->{state} = $self->{state} if $self->{state};
209              
210 73         118 return $scope;
211             }
212              
213             sub _build_response {
214 59     59   93 my ($self, $events) = @_;
215              
216 59         64 my $status = 200;
217 59         52 my @headers;
218 59         59 my $body = '';
219 59         49 my $response_started = 0;
220 59         82 my $body_complete = 0;
221              
222 59         68 for my $event (@$events) {
223 120   50     208 my $type = $event->{type} // '';
224              
225 120 100       251 if ($type eq 'http.response.start') {
    50          
226 60 100       114 next if $response_started;
227 59         76 $response_started = 1;
228 59   50     98 $status = $event->{status} // 200;
229 59   50     61 @headers = @{$event->{headers} // []};
  59         158  
230             }
231             elsif ($type eq 'http.response.body') {
232 60 50       123 next unless $response_started;
233 60 100       82 next if $body_complete;
234              
235 59         121 $body .= $self->_response_body_bytes($event);
236              
237 59   100     135 my $more = $event->{more} // 0;
238 59 50       116 $body_complete = 1 unless $more;
239             }
240             }
241              
242             # Extract Set-Cookie headers and store cookies
243 59         73 for my $h (@headers) {
244 61 100       120 if (lc($h->[0]) eq 'set-cookie') {
245 1 50       52 if ($h->[1] =~ /^([^=]+)=([^;]*)/) {
246 1         6 $self->{cookies}{$1} = $2;
247             }
248             }
249             }
250              
251 59         215 return PAGI::Test::Response->new(
252             status => $status,
253             headers => \@headers,
254             body => $body,
255             );
256             }
257              
258             sub _response_body_bytes {
259 64     64   101 my ($self, $event) = @_;
260              
261 64 100 50     195 return $event->{body} // '' if exists $event->{body};
262              
263 5 100       8 if (exists $event->{file}) {
264             return _read_file_bytes(
265             $event->{file},
266             $event->{offset} // 0,
267             $event->{length},
268 2   100     10 );
269             }
270              
271 3 50       6 if (exists $event->{fh}) {
272             return _read_fh_bytes(
273             $event->{fh},
274             $event->{offset} // 0,
275             $event->{length},
276 3   100     16 );
277             }
278              
279 0         0 return '';
280             }
281              
282             sub _read_file_bytes {
283 2     2   4 my ($path, $offset, $length) = @_;
284              
285 2 50       70 open my $fh, '<:raw', $path
286             or croak "Cannot open file response '$path': $!";
287              
288 2 100 33     12 seek($fh, $offset, 0)
289             or croak "Cannot seek file response '$path': $!"
290             if $offset;
291              
292 2         4 my $content = _slurp_fh_bytes($fh, $length);
293 2         16 close $fh;
294              
295 2         10 return $content;
296             }
297              
298             sub _read_fh_bytes {
299 3     3   6 my ($fh, $offset, $length) = @_;
300              
301 3 100 33     11 seek($fh, $offset, 0)
302             or croak "Cannot seek filehandle response: $!"
303             if $offset;
304              
305 3         9 return _slurp_fh_bytes($fh, $length);
306             }
307              
308             sub _slurp_fh_bytes {
309 5     5   8 my ($fh, $length) = @_;
310              
311 5         5 my $content = '';
312 5         5 my $remaining = $length;
313              
314 5         6 while (1) {
315 10         9 my $to_read = 65536;
316 10 100       13 if (defined $remaining) {
317 4 100       8 last if $remaining <= 0;
318 2 50       5 $to_read = $remaining if $remaining < $to_read;
319             }
320              
321 8         46 my $bytes_read = read($fh, my $chunk, $to_read);
322 8 50       11 croak "Cannot read response body from filehandle: $!"
323             unless defined $bytes_read;
324 8 100       12 last if $bytes_read == 0;
325              
326 5         8 $content .= $chunk;
327 5 100       9 $remaining -= $bytes_read if defined $remaining;
328             }
329              
330 5         13 return $content;
331             }
332              
333             sub websocket {
334 14     14 1 1405 my ($self, $path, @rest) = @_;
335              
336 14         2830 require PAGI::Test::WebSocket;
337              
338             # Handle both: websocket($path, $callback) and websocket($path, %opts)
339             # and websocket($path, %opts, $callback)
340 14         26 my ($callback, %opts);
341 14 100 66     80 if (@rest == 1 && ref($rest[0]) eq 'CODE') {
    100 33        
    50          
342 8         12 $callback = $rest[0];
343             } elsif (@rest % 2 == 0) {
344 4         7 %opts = @rest;
345             } elsif (@rest % 2 == 1 && ref($rest[-1]) eq 'CODE') {
346 2         2 $callback = pop @rest;
347 2         4 %opts = @rest;
348             }
349              
350 14   50     28 $path //= '/';
351              
352             # Parse query string from path
353 14         18 my $query_string = '';
354 14 100       43 if ($path =~ s/\?(.*)$//) {
355 1         2 $query_string = $1;
356             }
357              
358             # Build headers
359 14         31 my @headers = (['host', 'testserver']);
360              
361             # Add client default headers (normalized)
362 14         44 my $default_pairs = _normalize_pairs($self->{headers});
363 14         25 for my $pair (@$default_pairs) {
364 0         0 push @headers, [lc($pair->[0]), $pair->[1]];
365             }
366              
367             # Add request-specific headers (normalized, replace by key)
368 14 100       28 if ($opts{headers}) {
369 2         5 my $request_pairs = _normalize_pairs($opts{headers});
370 2         5 my %replace_keys = map { lc($_->[0]) => 1 } @$request_pairs;
  3         8  
371              
372             # Filter out replaced headers from existing
373 2         4 @headers = grep { !$replace_keys{$_->[0]} } @headers;
  2         6  
374              
375             # Add request headers
376 2         3 for my $pair (@$request_pairs) {
377 3         9 push @headers, [lc($pair->[0]), $pair->[1]];
378             }
379             }
380              
381             # Add cookies
382 14 50       17 if (keys %{$self->{cookies}}) {
  14         37  
383 0         0 my $cookie = join('; ', map { "$_=$self->{cookies}{$_}" } sort keys %{$self->{cookies}});
  0         0  
  0         0  
384 0         0 push @headers, ['cookie', $cookie];
385             }
386              
387             my $scope = {
388             type => 'websocket',
389             pagi => { version => '0.2', spec_version => '0.2' },
390             http_version => '1.1',
391             scheme => 'ws',
392             path => $path,
393             query_string => $query_string,
394             root_path => '',
395             headers => \@headers,
396             client => ['127.0.0.1', 12345],
397             server => ['testserver', 80],
398 14   50     142 subprotocols => $opts{subprotocols} // [],
399             };
400              
401 14 100       32 $scope->{state} = $self->{state} if $self->{state};
402              
403 14         92 my $ws = PAGI::Test::WebSocket->new(app => $self->{app}, scope => $scope);
404 14         31 $ws->_start;
405              
406 14 100       21 if ($callback) {
407 10         12 eval { $callback->($ws) };
  10         19  
408 10         3668 my $err = $@;
409 10 100       26 $ws->close unless $ws->is_closed;
410 10 50       18 die $err if $err;
411 10         120 return;
412             }
413              
414 4         9 return $ws;
415             }
416              
417             sub sse {
418 12     12 1 1146 my ($self, $path, @rest) = @_;
419              
420 12         2478 require PAGI::Test::SSE;
421              
422             # Handle both: sse($path, $callback) and sse($path, %opts)
423             # and sse($path, %opts, $callback)
424 12         25 my ($callback, %opts);
425 12 100 66     87 if (@rest == 1 && ref($rest[0]) eq 'CODE') {
    100 33        
    50          
426 6         9 $callback = $rest[0];
427             } elsif (@rest % 2 == 0) {
428 1         2 %opts = @rest;
429             } elsif (@rest % 2 == 1 && ref($rest[-1]) eq 'CODE') {
430 5         7 $callback = pop @rest;
431 5         11 %opts = @rest;
432             }
433              
434 12   50     23 $path //= '/';
435              
436             # Parse query string from path
437 12         35 my $query_string = '';
438 12 50       38 if ($path =~ s/\?(.*)$//) {
439 0         0 $query_string = $1;
440             }
441              
442             # Build headers (SSE requires Accept: text/event-stream)
443 12         31 my @headers = (
444             ['host', 'testserver'],
445             ['accept', 'text/event-stream'],
446             );
447              
448             # Add client default headers (normalized)
449 12         44 my $default_pairs = _normalize_pairs($self->{headers});
450 12         62 for my $pair (@$default_pairs) {
451 0         0 push @headers, [lc($pair->[0]), $pair->[1]];
452             }
453              
454             # Add request-specific headers (normalized, replace by key)
455 12 100       46 if ($opts{headers}) {
456 2         4 my $request_pairs = _normalize_pairs($opts{headers});
457 2         5 my %replace_keys = map { lc($_->[0]) => 1 } @$request_pairs;
  3         37  
458              
459             # Filter out replaced headers from existing
460 2         4 @headers = grep { !$replace_keys{$_->[0]} } @headers;
  4         10  
461              
462             # Add request headers
463 2         2 for my $pair (@$request_pairs) {
464 3         9 push @headers, [lc($pair->[0]), $pair->[1]];
465             }
466             }
467              
468             # Add cookies
469 12 50       12 if (keys %{$self->{cookies}}) {
  12         34  
470 0         0 my $cookie = join('; ', map { "$_=$self->{cookies}{$_}" } sort keys %{$self->{cookies}});
  0         0  
  0         0  
471 0         0 push @headers, ['cookie', $cookie];
472             }
473              
474             # SSE supports all HTTP methods (GET is default, but POST/PUT work with
475             # modern libraries like fetch-event-source used by htmx4, datastar, etc.)
476 12   100     94 my $method = uc($opts{method} // 'GET');
477              
478 12         173 my $scope = {
479             type => 'sse',
480             pagi => { version => '0.2', spec_version => '0.2' },
481             http_version => '1.1',
482             method => $method,
483             scheme => 'http',
484             path => $path,
485             query_string => $query_string,
486             root_path => '',
487             headers => \@headers,
488             client => ['127.0.0.1', 12345],
489             server => ['testserver', 80],
490             };
491              
492 12 100       48 $scope->{state} = $self->{state} if $self->{state};
493              
494 12         53 my $sse = PAGI::Test::SSE->new(app => $self->{app}, scope => $scope);
495 12         27 $sse->_start;
496              
497 12 100       26 if ($callback) {
498 11         10 eval { $callback->($sse) };
  11         221  
499 11         3115 my $err = $@;
500 11 50       25 $sse->close unless $sse->is_closed;
501 11 50       21 die $err if $err;
502 11         137 return;
503             }
504              
505 1         3 return $sse;
506             }
507              
508             sub start {
509 7     7 1 22 my ($self) = @_;
510 7 50       19 return $self if $self->{started};
511 7 50       16 return $self unless $self->{lifespan};
512              
513 7         12 $self->{state} = {};
514              
515             my $scope = {
516             type => 'lifespan',
517             pagi => { version => '0.2', spec_version => '0.2' },
518             state => $self->{state},
519 7         32 };
520              
521 7         11 my $phase = 'startup';
522 7         6 my $pending_future;
523              
524 14     14   108 my $receive = async sub {
525 14 100       24 if ($phase eq 'startup') {
526 7         12 $phase = 'running';
527 7         38 return { type => 'lifespan.startup' };
528             }
529             # Wait for shutdown
530 7         14 $pending_future = Future->new;
531 7         32 return await $pending_future;
532 7         19 };
533              
534 7         9 my $startup_complete = 0;
535 14     14   484 my $send = async sub {
536 14         18 my ($event) = @_;
537 14 100       59 if ($event->{type} eq 'lifespan.startup.complete') {
    50          
538 7         30 $startup_complete = 1;
539             }
540             elsif ($event->{type} eq 'lifespan.shutdown.complete') {
541             # Done
542             }
543 7         19 };
544              
545 7         13 $self->{lifespan_pending} = \$pending_future;
546 7         27 $self->{lifespan_future} = $self->{app}->($scope, $receive, $send);
547              
548             # Pump until startup complete
549 7         649 my $deadline = time + 5;
550 7   33     19 while (!$startup_complete && time < $deadline) {
551             # Just yield - the async code runs synchronously in our setup
552             }
553              
554 7         11 $self->{started} = 1;
555 7         11 return $self;
556             }
557              
558             sub stop {
559 7     7 1 382 my ($self) = @_;
560 7 50       18 return $self unless $self->{started};
561 7 50       15 return $self unless $self->{lifespan};
562              
563             # Resolve the pending future with shutdown event
564 7 50 33     24 if ($self->{lifespan_pending} && ${$self->{lifespan_pending}}) {
  7         20  
565 7         8 ${$self->{lifespan_pending}}->done({ type => 'lifespan.shutdown' });
  7         31  
566             }
567              
568 7         819 $self->{started} = 0;
569 7         11 return $self;
570             }
571              
572 2   50 2 1 787 sub state { shift->{state} // {} }
573              
574             sub run {
575 3     3 1 2464 my ($class, $app, $callback) = @_;
576              
577 3         13 my $client = $class->new(app => $app, lifespan => 1);
578 3         15 $client->start;
579              
580 3         3 eval { $callback->($client) };
  3         8  
581 3         1551 my $err = $@;
582              
583 3         14 $client->stop;
584 3 50       258 die $err if $err;
585             }
586              
587             sub _url_encode {
588 50     50   43 my ($str) = @_;
589 50         76 $str =~ s/([^A-Za-z0-9_\-.])/sprintf("%%%02X", ord($1))/eg;
  5         21  
590 50         51 return $str;
591             }
592              
593             # Normalize various input formats to arrayref of [key, value] pairs.
594             # Supports:
595             # - Hash with scalar values: { key => 'value' }
596             # Set a header on a headers structure (hashref or arrayref of pairs).
597             # If $replace is true, replaces existing value. Otherwise only sets if not present.
598             sub _set_header {
599 22     22   34 my ($headers_ref, $name, $value, $replace) = @_;
600 22   50     34 $replace //= 0;
601              
602 22 100       60 if (!defined $$headers_ref) {
603 9         21 $$headers_ref = { $name => $value };
604 9         14 return;
605             }
606              
607 13 100       28 if (ref($$headers_ref) eq 'HASH') {
    50          
608 9 50       12 if ($replace) {
609 9         30 $$headers_ref->{$name} = $value;
610             } else {
611 0   0     0 $$headers_ref->{$name} //= $value;
612             }
613             } elsif (ref($$headers_ref) eq 'ARRAY') {
614             # Check if header already exists (case-insensitive)
615 4         5 my $found_idx;
616 4         4 for my $i (0 .. $#{$$headers_ref}) {
  4         8  
617 8 50       14 if (lc($$headers_ref->[$i][0]) eq lc($name)) {
618 0         0 $found_idx = $i;
619 0         0 last;
620             }
621             }
622 4 50       6 if (defined $found_idx) {
623 0 0       0 $$headers_ref->[$found_idx][1] = $value if $replace;
624             } else {
625 4         5 push @{$$headers_ref}, [$name, $value];
  4         9  
626             }
627             }
628             }
629              
630             # - Hash with arrayref values: { key => ['v1', 'v2'] }
631             # - Arrayref of pairs: [['key', 'v1'], ['key', 'v2']]
632             # Returns arrayref of [key, value] pairs.
633             sub _normalize_pairs {
634 192     192   234 my ($input) = @_;
635 192 100       340 return [] unless defined $input;
636              
637             # Arrayref of pairs: [['key', 'val'], ['key', 'val2']]
638 135 100       267 if (ref($input) eq 'ARRAY') {
639             # Validate it looks like pairs
640 6         8 for my $pair (@$input) {
641 17 100 66     152 croak "Expected arrayref of [key, value] pairs"
642             unless ref($pair) eq 'ARRAY' && @$pair == 2;
643             }
644 5         8 return $input;
645             }
646              
647             # Hash (with scalar or arrayref values)
648 129 100       199 if (ref($input) eq 'HASH') {
649 128         118 my @pairs;
650 128         310 for my $key (sort keys %$input) {
651 43         57 my $val = $input->{$key};
652 43 100       56 if (ref($val) eq 'ARRAY') {
653             # Multiple values for this key
654 10         30 push @pairs, [$key, $_] for @$val;
655             } else {
656             # Single value
657 33   50     72 push @pairs, [$key, $val // ''];
658             }
659             }
660 128         249 return \@pairs;
661             }
662              
663 1         198 croak "Expected hashref or arrayref of pairs, got " . ref($input);
664             }
665              
666             # Build headers array, merging defaults with request-specific headers.
667             # Request headers replace client defaults by key (case-insensitive).
668             sub _build_headers {
669 75     75   157 my ($self, $request_headers) = @_;
670              
671 75         75 my @headers;
672              
673             # Default headers
674 75         139 push @headers, ['host', 'testserver'];
675              
676             # Normalize client default headers
677 75         159 my $default_pairs = _normalize_pairs($self->{headers});
678              
679             # Normalize request-specific headers
680 75         96 my $request_pairs = _normalize_pairs($request_headers);
681              
682             # Build set of keys to replace (lowercase)
683 73         77 my %replace_keys;
684 73         94 for my $pair (@$request_pairs) {
685 35         81 $replace_keys{lc($pair->[0])} = 1;
686             }
687              
688             # Add client defaults (skip if being replaced)
689 73         84 for my $pair (@$default_pairs) {
690             push @headers, [lc($pair->[0]), $pair->[1]]
691 6 100       14 unless $replace_keys{lc($pair->[0])};
692             }
693              
694             # Add request-specific headers
695 73         83 for my $pair (@$request_pairs) {
696 35         67 push @headers, [lc($pair->[0]), $pair->[1]];
697             }
698              
699             # Add cookies
700 73 100       62 if (keys %{$self->{cookies}}) {
  73         154  
701 2         3 my $cookie = join('; ', map { "$_=$self->{cookies}{$_}" } sort keys %{$self->{cookies}});
  2         9  
  2         6  
702 2         4 push @headers, ['cookie', $cookie];
703             }
704              
705 73         153 return \@headers;
706             }
707              
708             1;
709              
710             __END__