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.002001';
3 13     13   1950392 use strict;
  13         22  
  13         472  
4 13     13   64 use warnings;
  13         43  
  13         551  
5 13     13   627 use Future::AsyncAwait;
  13         18030  
  13         72  
6 13     13   648 use Carp qw(croak);
  13         20  
  13         646  
7              
8 13     13   4943 use PAGI::Test::ConnectionState;
  13         35  
  13         589  
9 13     13   4729 use PAGI::Test::Response;
  13         27  
  13         636  
10 13     13   3888 use PAGI::Utils ();
  13         86  
  13         78504  
11              
12              
13             sub new {
14 80     80 1 1946980 my ($class, %args) = @_;
15              
16 80 50       265 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     297 raise_app_exceptions => $args{raise_app_exceptions} // 0,
      100        
      100        
24             started => 0,
25             }, $class;
26             }
27              
28 54     54 1 4994 sub get { shift->_request('GET', @_) }
29 2     2 1 173 sub head { shift->_request('HEAD', @_) }
30 1     1 1 188 sub delete { shift->_request('DELETE', @_) }
31 12     12 1 676 sub post { shift->_request('POST', @_) }
32 2     2 1 176 sub put { shift->_request('PUT', @_) }
33 2     2 1 550 sub patch { shift->_request('PATCH', @_) }
34 2     2 1 529 sub options { shift->_request('OPTIONS', @_) }
35              
36             # Cookie management
37             sub cookies {
38 1     1 1 2 my ($self) = @_;
39 1         6 return $self->{cookies};
40             }
41              
42             sub cookie {
43 1     1 1 345 my ($self, $name) = @_;
44 1         4 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         2 $self->{cookies} = {};
56 1         2 return $self;
57             }
58              
59             sub _request {
60 75     75   175 my ($self, $method, $path, %opts) = @_;
61              
62 75   50     149 $path //= '/';
63              
64             # Handle json option
65 75 100       256 if (exists $opts{json}) {
    100          
    50          
66 5         866 require JSON::MaybeXS;
67 5         16519 $opts{body} = JSON::MaybeXS::encode_json($opts{json});
68 5         40 _set_header(\$opts{headers}, 'Content-Type', 'application/json', 0);
69 5         14 _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         19 my $pairs = _normalize_pairs($opts{form});
74 6         11 my @encoded;
75 6         11 for my $pair (@$pairs) {
76 14         33 my $key = _url_encode($pair->[0]);
77 14   50     47 my $val = _url_encode($pair->[1] // '');
78 14         41 push @encoded, "$key=$val";
79             }
80 6         26 $opts{body} = join('&', @encoded);
81 6         21 _set_header(\$opts{headers}, 'Content-Type', 'application/x-www-form-urlencoded', 0);
82 6         19 _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         186 my $scope = $self->_build_scope($method, $path, \%opts);
91              
92             # Build receive (returns request body)
93 73   100     227 my $body = $opts{body} // '';
94 73         79 my $receive_called = 0;
95 21     21   8557 my $receive = async sub {
96 21 50       38 if (!$receive_called) {
97 21         28 $receive_called = 1;
98 21         186 return { type => 'http.request', body => $body, more => 0 };
99             }
100 0         0 return { type => 'http.disconnect' };
101 73         255 };
102              
103             # Build send (captures response)
104 73         73 my @events;
105 121     121   4122 my $send = async sub {
106 121         147 my ($event) = @_;
107 121         304 my %captured = %$event;
108              
109 121 50       264 if (my $conn = $scope->{'pagi.connection'}) {
110             $conn->_mark_response_started
111 121 100 50     382 if ($captured{type} // '') eq 'http.response.start';
112             }
113              
114 121 100 50     282 if (($captured{type} // '') eq 'http.response.body') {
115 60 100 66     215 if ($method eq 'HEAD') {
    100          
116 1         2 $captured{body} = '';
117 1         2 delete @captured{qw(fh file offset length)};
118             }
119             elsif (exists $captured{fh} || exists $captured{file}) {
120 5         11 $captured{body} = $self->_response_body_bytes(\%captured);
121 5         12 delete @captured{qw(fh file offset length)};
122             }
123             }
124              
125 121         549 push @events, \%captured;
126 73         240 };
127              
128             # Call app (with exception handling like real server)
129 73         188 my $exception;
130 73         92 eval {
131 73         194 $self->{app}->($scope, $receive, $send)->get;
132             };
133 73 100       3732 if ($@) {
134 13         16 $exception = $@;
135 13 100       24 if ($self->{raise_app_exceptions}) {
136 1         13 die $exception;
137             }
138             # Mimic server behavior: return 500 response
139 12 50       22 if (my $conn = $scope->{'pagi.connection'}) {
140 12         26 $conn->_mark_response_started; # the 500 IS a response
141 12         25 $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         116 my $has_response_start = grep { $_->{type} eq 'http.response.start' } @events;
  120         228  
153 60 100       93 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       107 if (my $conn = $scope->{'pagi.connection'}) {
160 59         134 $conn->_mark_complete;
161             }
162              
163             # Parse response from captured events
164 59         167 return $self->_build_response(\@events);
165             }
166              
167             sub _build_scope {
168 75     75   128 my ($self, $method, $path, $opts) = @_;
169              
170             # Parse query string from path
171 75         94 my $query_string = '';
172 75 100       222 if ($path =~ s/\?(.*)$//) {
173 2         8 $query_string = $1;
174             }
175              
176             # Add query params if provided (appended to path query string)
177 75 100       150 if ($opts->{query}) {
178 6         16 my $pairs = _normalize_pairs($opts->{query});
179 6         10 my @encoded;
180 6         11 for my $pair (@$pairs) {
181 11         21 my $key = _url_encode($pair->[0]);
182 11   50     30 my $val = _url_encode($pair->[1] // '');
183 11         34 push @encoded, "$key=$val";
184             }
185 6         17 my $new_params = join('&', @encoded);
186 6 100       27 $query_string = $query_string ? "$query_string&$new_params" : $new_params;
187             }
188              
189             # Build headers using helper
190 75         274 my $headers = $self->_build_headers($opts->{headers});
191              
192 73         511 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       184 $scope->{state} = $self->{state} if $self->{state};
209              
210 73         127 return $scope;
211             }
212              
213             sub _build_response {
214 59     59   89 my ($self, $events) = @_;
215              
216 59         63 my $status = 200;
217 59         51 my @headers;
218 59         61 my $body = '';
219 59         48 my $response_started = 0;
220 59         77 my $body_complete = 0;
221              
222 59         84 for my $event (@$events) {
223 120   50     184 my $type = $event->{type} // '';
224              
225 120 100       255 if ($type eq 'http.response.start') {
    50          
226 60 100       97 next if $response_started;
227 59         89 $response_started = 1;
228 59   50     148 $status = $event->{status} // 200;
229 59   50     62 @headers = @{$event->{headers} // []};
  59         157  
230             }
231             elsif ($type eq 'http.response.body') {
232 60 50       82 next unless $response_started;
233 60 100       83 next if $body_complete;
234              
235 59         101 $body .= $self->_response_body_bytes($event);
236              
237 59   100     132 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         67 for my $h (@headers) {
244 61 100       153 if (lc($h->[0]) eq 'set-cookie') {
245 1 50       6 if ($h->[1] =~ /^([^=]+)=([^;]*)/) {
246 1         47 $self->{cookies}{$1} = $2;
247             }
248             }
249             }
250              
251 59         234 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   79 my ($self, $event) = @_;
260              
261 64 100 50     204 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     11 );
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     13 );
277             }
278              
279 0         0 return '';
280             }
281              
282             sub _read_file_bytes {
283 2     2   5 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     9 seek($fh, $offset, 0)
289             or croak "Cannot seek file response '$path': $!"
290             if $offset;
291              
292 2         5 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   7 my ($fh, $offset, $length) = @_;
300              
301 3 100 33     6 seek($fh, $offset, 0)
302             or croak "Cannot seek filehandle response: $!"
303             if $offset;
304              
305 3         13 return _slurp_fh_bytes($fh, $length);
306             }
307              
308             sub _slurp_fh_bytes {
309 5     5   6 my ($fh, $length) = @_;
310              
311 5         8 my $content = '';
312 5         5 my $remaining = $length;
313              
314 5         3 while (1) {
315 10         8 my $to_read = 65536;
316 10 100       14 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         41 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       11 last if $bytes_read == 0;
325              
326 5         7 $content .= $chunk;
327 5 100       8 $remaining -= $bytes_read if defined $remaining;
328             }
329              
330 5         11 return $content;
331             }
332              
333             sub websocket {
334 14     14 1 1374 my ($self, $path, @rest) = @_;
335              
336 14         3173 require PAGI::Test::WebSocket;
337              
338             # Handle both: websocket($path, $callback) and websocket($path, %opts)
339             # and websocket($path, %opts, $callback)
340 14         29 my ($callback, %opts);
341 14 100 66     87 if (@rest == 1 && ref($rest[0]) eq 'CODE') {
    100 33        
    50          
342 8         12 $callback = $rest[0];
343             } elsif (@rest % 2 == 0) {
344 4         8 %opts = @rest;
345             } elsif (@rest % 2 == 1 && ref($rest[-1]) eq 'CODE') {
346 2         4 $callback = pop @rest;
347 2         6 %opts = @rest;
348             }
349              
350 14   50     34 $path //= '/';
351              
352             # Parse query string from path
353 14         23 my $query_string = '';
354 14 100       49 if ($path =~ s/\?(.*)$//) {
355 1         4 $query_string = $1;
356             }
357              
358             # Build headers
359 14         41 my @headers = (['host', 'testserver']);
360              
361             # Add client default headers (normalized)
362 14         45 my $default_pairs = _normalize_pairs($self->{headers});
363 14         29 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       32 if ($opts{headers}) {
369 2         5 my $request_pairs = _normalize_pairs($opts{headers});
370 2         6 my %replace_keys = map { lc($_->[0]) => 1 } @$request_pairs;
  3         11  
371              
372             # Filter out replaced headers from existing
373 2         5 @headers = grep { !$replace_keys{$_->[0]} } @headers;
  2         6  
374              
375             # Add request headers
376 2         4 for my $pair (@$request_pairs) {
377 3         12 push @headers, [lc($pair->[0]), $pair->[1]];
378             }
379             }
380              
381             # Add cookies
382 14 50       17 if (keys %{$self->{cookies}}) {
  14         39  
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     228 subprotocols => $opts{subprotocols} // [],
399             };
400              
401 14 100       40 $scope->{state} = $self->{state} if $self->{state};
402              
403 14         70 my $ws = PAGI::Test::WebSocket->new(app => $self->{app}, scope => $scope);
404 14         82 $ws->_start;
405              
406 14 100       32 if ($callback) {
407 10         12 eval { $callback->($ws) };
  10         27  
408 10         3903 my $err = $@;
409 10 100       32 $ws->close unless $ws->is_closed;
410 10 50       20 die $err if $err;
411 10         128 return;
412             }
413              
414 4         14 return $ws;
415             }
416              
417             sub sse {
418 12     12 1 1088 my ($self, $path, @rest) = @_;
419              
420 12         2801 require PAGI::Test::SSE;
421              
422             # Handle both: sse($path, $callback) and sse($path, %opts)
423             # and sse($path, %opts, $callback)
424 12         29 my ($callback, %opts);
425 12 100 66     105 if (@rest == 1 && ref($rest[0]) eq 'CODE') {
    100 33        
    50          
426 6         11 $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         9 $callback = pop @rest;
431 5         14 %opts = @rest;
432             }
433              
434 12   50     47 $path //= '/';
435              
436             # Parse query string from path
437 12         21 my $query_string = '';
438 12 50       44 if ($path =~ s/\?(.*)$//) {
439 0         0 $query_string = $1;
440             }
441              
442             # Build headers (SSE requires Accept: text/event-stream)
443 12         40 my @headers = (
444             ['host', 'testserver'],
445             ['accept', 'text/event-stream'],
446             );
447              
448             # Add client default headers (normalized)
449 12         52 my $default_pairs = _normalize_pairs($self->{headers});
450 12         29 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         5 my $request_pairs = _normalize_pairs($opts{headers});
457 2         4 my %replace_keys = map { lc($_->[0]) => 1 } @$request_pairs;
  3         12  
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         27 for my $pair (@$request_pairs) {
464 3         14 push @headers, [lc($pair->[0]), $pair->[1]];
465             }
466             }
467              
468             # Add cookies
469 12 50       14 if (keys %{$self->{cookies}}) {
  12         46  
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     69 my $method = uc($opts{method} // 'GET');
477              
478 12         161 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       66 $scope->{state} = $self->{state} if $self->{state};
493              
494 12         80 my $sse = PAGI::Test::SSE->new(app => $self->{app}, scope => $scope);
495 12         36 $sse->_start;
496              
497 12 100       29 if ($callback) {
498 11         16 eval { $callback->($sse) };
  11         32  
499 11         3536 my $err = $@;
500 11 50       30 $sse->close unless $sse->is_closed;
501 11 50       27 die $err if $err;
502 11         166 return;
503             }
504              
505 1         3 return $sse;
506             }
507              
508             sub start {
509 7     7 1 23 my ($self) = @_;
510 7 50       22 return $self if $self->{started};
511 7 50       17 return $self unless $self->{lifespan};
512              
513 7         13 $self->{state} = {};
514              
515             my $scope = {
516             type => 'lifespan',
517             pagi => { version => '0.2', spec_version => '0.2' },
518             state => $self->{state},
519 7         50 };
520              
521 7         13 my $phase = 'startup';
522 7         10 my $pending_future;
523              
524 14     14   118 my $receive = async sub {
525 14 100       27 if ($phase eq 'startup') {
526 7         9 $phase = 'running';
527 7         45 return { type => 'lifespan.startup' };
528             }
529             # Wait for shutdown
530 7         18 $pending_future = Future->new;
531 7         35 return await $pending_future;
532 7         19 };
533              
534 7         9 my $startup_complete = 0;
535 14     14   516 my $send = async sub {
536 14         18 my ($event) = @_;
537 14 100       81 if ($event->{type} eq 'lifespan.startup.complete') {
    50          
538 7         19 $startup_complete = 1;
539             }
540             elsif ($event->{type} eq 'lifespan.shutdown.complete') {
541             # Done
542             }
543 7         29 };
544              
545 7         15 $self->{lifespan_pending} = \$pending_future;
546 7         19 $self->{lifespan_future} = $self->{app}->($scope, $receive, $send);
547              
548             # Pump until startup complete
549 7         682 my $deadline = time + 5;
550 7   33     22 while (!$startup_complete && time < $deadline) {
551             # Just yield - the async code runs synchronously in our setup
552             }
553              
554 7         9 $self->{started} = 1;
555 7         13 return $self;
556             }
557              
558             sub stop {
559 7     7 1 393 my ($self) = @_;
560 7 50       17 return $self unless $self->{started};
561 7 50       17 return $self unless $self->{lifespan};
562              
563             # Resolve the pending future with shutdown event
564 7 50 33     17 if ($self->{lifespan_pending} && ${$self->{lifespan_pending}}) {
  7         19  
565 7         8 ${$self->{lifespan_pending}}->done({ type => 'lifespan.shutdown' });
  7         49  
566             }
567              
568 7         820 $self->{started} = 0;
569 7         9 return $self;
570             }
571              
572 2   50 2 1 742 sub state { shift->{state} // {} }
573              
574             sub run {
575 3     3 1 2620 my ($class, $app, $callback) = @_;
576              
577 3         12 my $client = $class->new(app => $app, lifespan => 1);
578 3         47 $client->start;
579              
580 3         3 eval { $callback->($client) };
  3         10  
581 3         1478 my $err = $@;
582              
583 3         12 $client->stop;
584 3 50       234 die $err if $err;
585             }
586              
587             sub _url_encode {
588 50     50   76 my ($str) = @_;
589 50         104 $str =~ s/([^A-Za-z0-9_\-.])/sprintf("%%%02X", ord($1))/eg;
  5         21  
590 50         85 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   44 my ($headers_ref, $name, $value, $replace) = @_;
600 22   50     46 $replace //= 0;
601              
602 22 100       42 if (!defined $$headers_ref) {
603 9         25 $$headers_ref = { $name => $value };
604 9         17 return;
605             }
606              
607 13 100       42 if (ref($$headers_ref) eq 'HASH') {
    50          
608 9 50       17 if ($replace) {
609 9         33 $$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         6 my $found_idx;
616 4         7 for my $i (0 .. $#{$$headers_ref}) {
  4         40  
617 8 50       29 if (lc($$headers_ref->[$i][0]) eq lc($name)) {
618 0         0 $found_idx = $i;
619 0         0 last;
620             }
621             }
622 4 50       10 if (defined $found_idx) {
623 0 0       0 $$headers_ref->[$found_idx][1] = $value if $replace;
624             } else {
625 4         7 push @{$$headers_ref}, [$name, $value];
  4         18  
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   253 my ($input) = @_;
635 192 100       377 return [] unless defined $input;
636              
637             # Arrayref of pairs: [['key', 'val'], ['key', 'val2']]
638 135 100       339 if (ref($input) eq 'ARRAY') {
639             # Validate it looks like pairs
640 6         13 for my $pair (@$input) {
641 17 100 66     241 croak "Expected arrayref of [key, value] pairs"
642             unless ref($pair) eq 'ARRAY' && @$pair == 2;
643             }
644 5         11 return $input;
645             }
646              
647             # Hash (with scalar or arrayref values)
648 129 100       290 if (ref($input) eq 'HASH') {
649 128         150 my @pairs;
650 128         336 for my $key (sort keys %$input) {
651 43         60 my $val = $input->{$key};
652 43 100       76 if (ref($val) eq 'ARRAY') {
653             # Multiple values for this key
654 10         52 push @pairs, [$key, $_] for @$val;
655             } else {
656             # Single value
657 33   50     95 push @pairs, [$key, $val // ''];
658             }
659             }
660 128         237 return \@pairs;
661             }
662              
663 1         301 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   160 my ($self, $request_headers) = @_;
670              
671 75         94 my @headers;
672              
673             # Default headers
674 75         146 push @headers, ['host', 'testserver'];
675              
676             # Normalize client default headers
677 75         208 my $default_pairs = _normalize_pairs($self->{headers});
678              
679             # Normalize request-specific headers
680 75         101 my $request_pairs = _normalize_pairs($request_headers);
681              
682             # Build set of keys to replace (lowercase)
683 73         89 my %replace_keys;
684 73         101 for my $pair (@$request_pairs) {
685 35         80 $replace_keys{lc($pair->[0])} = 1;
686             }
687              
688             # Add client defaults (skip if being replaced)
689 73         88 for my $pair (@$default_pairs) {
690             push @headers, [lc($pair->[0]), $pair->[1]]
691 6 100       20 unless $replace_keys{lc($pair->[0])};
692             }
693              
694             # Add request-specific headers
695 73         90 for my $pair (@$request_pairs) {
696 35         78 push @headers, [lc($pair->[0]), $pair->[1]];
697             }
698              
699             # Add cookies
700 73 100       115 if (keys %{$self->{cookies}}) {
  73         189  
701 2         3 my $cookie = join('; ', map { "$_=$self->{cookies}{$_}" } sort keys %{$self->{cookies}});
  2         8  
  2         4  
702 2         4 push @headers, ['cookie', $cookie];
703             }
704              
705 73         159 return \@headers;
706             }
707              
708             1;
709              
710             __END__