Skip to main content

pi/
vcr.rs

1//! VCR-style recording for HTTP streaming tests.
2//!
3//! This module provides utilities to record and replay real HTTP streaming
4//! responses (e.g., SSE) for deterministic provider tests.
5
6use crate::error::{Error, Result};
7use base64::Engine;
8use base64::engine::general_purpose::STANDARD;
9use chrono::{SecondsFormat, Utc};
10use futures::StreamExt;
11use futures::stream::{self, BoxStream};
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use sha2::{Digest, Sha256};
15#[cfg(test)]
16use std::collections::HashMap;
17use std::collections::HashSet;
18use std::path::{Path, PathBuf};
19use std::sync::Arc;
20use std::sync::atomic::{AtomicUsize, Ordering};
21#[cfg(test)]
22use std::sync::{Mutex, OnceLock};
23use tracing::{debug, info, warn};
24
25pub const VCR_ENV_MODE: &str = "VCR_MODE";
26pub const VCR_ENV_DIR: &str = "VCR_CASSETTE_DIR";
27pub const DEFAULT_CASSETTE_DIR: &str = "tests/fixtures/vcr";
28const CASSETTE_VERSION: &str = "1.0";
29const REDACTED: &str = "[REDACTED]";
30
31#[derive(Debug, Clone, Copy, Default)]
32pub struct RedactionSummary {
33    pub headers_redacted: usize,
34    pub json_fields_redacted: usize,
35}
36
37/// Map from env var name to override value. `Some(val)` overrides to that value,
38/// `None` means the var is explicitly unset (tombstone), preventing fallthrough
39/// to the real process environment.
40#[cfg(test)]
41static TEST_ENV_OVERRIDES: OnceLock<Mutex<HashMap<String, Option<String>>>> = OnceLock::new();
42
43#[cfg(test)]
44fn test_env_overrides() -> &'static Mutex<HashMap<String, Option<String>>> {
45    TEST_ENV_OVERRIDES.get_or_init(|| Mutex::new(HashMap::new()))
46}
47
48fn env_var(name: &str) -> Option<String> {
49    #[cfg(test)]
50    {
51        if let Ok(guard) = test_env_overrides().lock() {
52            if let Some(maybe_value) = guard.get(name) {
53                // Some(val) = override to val; None = explicitly unset (tombstone)
54                return maybe_value.clone();
55            }
56        }
57    }
58    std::env::var(name).ok()
59}
60
61#[cfg(test)]
62fn set_test_env_var(name: &str, value: Option<&str>) -> Option<String> {
63    let mut guard = test_env_overrides()
64        .lock()
65        .unwrap_or_else(std::sync::PoisonError::into_inner);
66    let previous = guard.get(name).and_then(Clone::clone);
67    // Store Some(val) for override or None as tombstone (explicitly unset)
68    guard.insert(name.to_string(), value.map(String::from));
69    previous
70}
71
72#[cfg(test)]
73fn restore_test_env_var(name: &str, previous: Option<String>) {
74    let mut guard = test_env_overrides()
75        .lock()
76        .unwrap_or_else(std::sync::PoisonError::into_inner);
77    match previous {
78        Some(value) => {
79            guard.insert(name.to_string(), Some(value));
80        }
81        None => {
82            // Remove the override entirely (go back to real env)
83            guard.remove(name);
84        }
85    }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "lowercase")]
90pub enum VcrMode {
91    Record,
92    Playback,
93    Auto,
94}
95
96impl VcrMode {
97    pub fn from_env() -> Result<Option<Self>> {
98        let Some(value) = env_var(VCR_ENV_MODE) else {
99            return Ok(None);
100        };
101        let mode = match value.to_ascii_lowercase().as_str() {
102            "record" => Self::Record,
103            "playback" => Self::Playback,
104            "auto" => Self::Auto,
105            _ => {
106                return Err(Error::config(format!(
107                    "Invalid {VCR_ENV_MODE} value: {value}"
108                )));
109            }
110        };
111        Ok(Some(mode))
112    }
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct Cassette {
117    pub version: String,
118    pub test_name: String,
119    pub recorded_at: String,
120    pub interactions: Vec<Interaction>,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct Interaction {
125    pub request: RecordedRequest,
126    pub response: RecordedResponse,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct RecordedRequest {
131    pub method: String,
132    pub url: String,
133    pub headers: Vec<(String, String)>,
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub body: Option<Value>,
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub body_text: Option<String>,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct RecordedResponse {
142    pub status: u16,
143    pub headers: Vec<(String, String)>,
144    pub body_chunks: Vec<String>,
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub body_chunks_base64: Option<Vec<String>>,
147}
148
149impl RecordedResponse {
150    pub fn into_byte_stream(
151        self,
152    ) -> BoxStream<'static, std::result::Result<Vec<u8>, std::io::Error>> {
153        if let Some(chunks) = self.body_chunks_base64 {
154            stream::iter(chunks.into_iter().map(|chunk| {
155                STANDARD
156                    .decode(chunk)
157                    .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))
158            }))
159            .boxed()
160        } else {
161            stream::iter(
162                self.body_chunks
163                    .into_iter()
164                    .map(|chunk| Ok(chunk.into_bytes())),
165            )
166            .boxed()
167        }
168    }
169}
170
171#[derive(Debug, Clone)]
172pub struct VcrRecorder {
173    cassette_path: PathBuf,
174    mode: VcrMode,
175    test_name: String,
176    playback_cursor: Arc<AtomicUsize>,
177}
178
179impl VcrRecorder {
180    pub fn new(test_name: &str) -> Result<Self> {
181        let mode = VcrMode::from_env()?.unwrap_or_else(default_mode);
182        let cassette_dir =
183            env_var(VCR_ENV_DIR).map_or_else(|| PathBuf::from(DEFAULT_CASSETTE_DIR), PathBuf::from);
184        let cassette_name = sanitize_test_name(test_name);
185        let cassette_path = cassette_dir.join(format!("{cassette_name}.json"));
186        let recorder = Self {
187            cassette_path,
188            mode,
189            test_name: test_name.to_string(),
190            playback_cursor: Arc::new(AtomicUsize::new(0)),
191        };
192        info!(
193            mode = ?recorder.mode,
194            cassette_path = %recorder.cassette_path.display(),
195            test_name = %recorder.test_name,
196            "VCR recorder initialized"
197        );
198        Ok(recorder)
199    }
200
201    pub fn new_with(test_name: &str, mode: VcrMode, cassette_dir: impl AsRef<Path>) -> Self {
202        let cassette_name = sanitize_test_name(test_name);
203        let cassette_path = cassette_dir.as_ref().join(format!("{cassette_name}.json"));
204        Self {
205            cassette_path,
206            mode,
207            test_name: test_name.to_string(),
208            playback_cursor: Arc::new(AtomicUsize::new(0)),
209        }
210    }
211
212    pub const fn mode(&self) -> VcrMode {
213        self.mode
214    }
215
216    pub fn cassette_path(&self) -> &Path {
217        &self.cassette_path
218    }
219
220    pub async fn request_streaming_with<F, Fut, S>(
221        &self,
222        request: RecordedRequest,
223        send: F,
224    ) -> Result<RecordedResponse>
225    where
226        F: FnOnce() -> Fut,
227        Fut: std::future::Future<Output = Result<(u16, Vec<(String, String)>, S)>>,
228        S: futures::Stream<Item = std::result::Result<Vec<u8>, std::io::Error>> + Unpin,
229    {
230        let request_key = request_debug_key(&request);
231
232        match self.mode {
233            VcrMode::Playback => {
234                info!(
235                    cassette_path = %self.cassette_path.display(),
236                    request = %request_key,
237                    "VCR playback request"
238                );
239                self.playback(&request)
240            }
241            VcrMode::Record => {
242                info!(
243                    cassette_path = %self.cassette_path.display(),
244                    request = %request_key,
245                    "VCR recording request"
246                );
247                self.record_streaming_with(request, send).await
248            }
249            VcrMode::Auto => {
250                if self.cassette_path.exists() {
251                    info!(
252                        cassette_path = %self.cassette_path.display(),
253                        request = %request_key,
254                        "VCR auto mode: cassette exists, using playback"
255                    );
256                    self.playback(&request)
257                } else {
258                    info!(
259                        cassette_path = %self.cassette_path.display(),
260                        request = %request_key,
261                        "VCR auto mode: cassette missing, recording"
262                    );
263                    self.record_streaming_with(request, send).await
264                }
265            }
266        }
267    }
268
269    pub async fn record_streaming_with<F, Fut, S>(
270        &self,
271        request: RecordedRequest,
272        send: F,
273    ) -> Result<RecordedResponse>
274    where
275        F: FnOnce() -> Fut,
276        Fut: std::future::Future<Output = Result<(u16, Vec<(String, String)>, S)>>,
277        S: futures::Stream<Item = std::result::Result<Vec<u8>, std::io::Error>> + Unpin,
278    {
279        debug!(
280            cassette_path = %self.cassette_path.display(),
281            request = %request_debug_key(&request),
282            "VCR record: sending streaming HTTP request"
283        );
284        let (status, headers, mut stream) = send().await?;
285
286        let mut body_chunks = Vec::new();
287        let mut body_chunks_base64: Option<Vec<String>> = None;
288        let mut body_bytes = 0usize;
289        while let Some(chunk) = stream.next().await {
290            let chunk = chunk.map_err(|e| Error::api(format!("HTTP stream read failed: {e}")))?;
291            if chunk.is_empty() {
292                continue;
293            }
294            body_bytes = body_bytes.saturating_add(chunk.len());
295            if let Some(encoded) = body_chunks_base64.as_mut() {
296                encoded.push(STANDARD.encode(&chunk));
297            } else if let Ok(text) = std::str::from_utf8(&chunk) {
298                body_chunks.push(text.to_string());
299            } else {
300                let mut encoded = Vec::with_capacity(body_chunks.len() + 1);
301                for existing in &body_chunks {
302                    encoded.push(STANDARD.encode(existing.as_bytes()));
303                }
304                encoded.push(STANDARD.encode(&chunk));
305                body_chunks.clear();
306                body_chunks_base64 = Some(encoded);
307            }
308        }
309
310        let recorded = RecordedResponse {
311            status,
312            headers,
313            body_chunks,
314            body_chunks_base64,
315        };
316        let chunk_count = recorded
317            .body_chunks_base64
318            .as_ref()
319            .map_or(recorded.body_chunks.len(), Vec::len);
320
321        info!(
322            cassette_path = %self.cassette_path.display(),
323            status = recorded.status,
324            header_count = recorded.headers.len(),
325            chunk_count,
326            body_bytes,
327            "VCR record: captured streaming response"
328        );
329
330        let mut cassette = if self.cassette_path.exists() {
331            load_cassette(&self.cassette_path)?
332        } else {
333            Cassette {
334                version: CASSETTE_VERSION.to_string(),
335                test_name: self.test_name.clone(),
336                recorded_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
337                interactions: Vec::new(),
338            }
339        };
340        cassette.test_name.clone_from(&self.test_name);
341        cassette.recorded_at = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
342        cassette.interactions.push(Interaction {
343            request,
344            response: recorded.clone(),
345        });
346
347        let redaction = redact_cassette(&mut cassette);
348        info!(
349            cassette_path = %self.cassette_path.display(),
350            headers_redacted = redaction.headers_redacted,
351            json_fields_redacted = redaction.json_fields_redacted,
352            "VCR record: redacted sensitive data"
353        );
354        save_cassette(&self.cassette_path, &cassette)?;
355        info!(
356            cassette_path = %self.cassette_path.display(),
357            "VCR record: saved cassette"
358        );
359
360        Ok(recorded)
361    }
362
363    fn playback(&self, request: &RecordedRequest) -> Result<RecordedResponse> {
364        let cassette = load_cassette(&self.cassette_path)?;
365        let start_index = self.playback_cursor.load(Ordering::SeqCst);
366        let Some((matched_index, interaction)) =
367            find_interaction_from(&cassette, request, start_index)
368        else {
369            let incoming_key = request_debug_key(request);
370            let recorded_keys: Vec<String> = cassette
371                .interactions
372                .iter()
373                .enumerate()
374                .map(|(idx, interaction)| {
375                    format!("[{idx}] {}", request_debug_key(&interaction.request))
376                })
377                .collect();
378
379            warn!(
380                cassette_path = %self.cassette_path.display(),
381                request = %incoming_key,
382                recorded_count = recorded_keys.len(),
383                start_index,
384                "VCR playback: no matching interaction"
385            );
386
387            let mut message = format!(
388                "No matching interaction found in cassette {}.\nIncoming: {incoming_key}\nRecorded interactions ({}):\n",
389                self.cassette_path.display(),
390                recorded_keys.len()
391            );
392            for key in recorded_keys {
393                message.push_str("  ");
394                message.push_str(&key);
395                message.push('\n');
396            }
397
398            // Always dump debug bodies to a file when VCR_DEBUG_BODY_FILE is set
399            if let Ok(debug_path) = std::env::var("VCR_DEBUG_BODY_FILE") {
400                use std::fmt::Write as _;
401
402                let mut debug = String::new();
403                if let Some(body) = &request.body {
404                    let mut redacted = body.clone();
405                    redact_json(&mut redacted);
406                    if let Ok(pretty) = serde_json::to_string_pretty(&redacted) {
407                        debug.push_str("=== INCOMING (redacted) ===\n");
408                        debug.push_str(&pretty);
409                        debug.push('\n');
410                    }
411                }
412                for (idx, interaction) in cassette.interactions.iter().enumerate() {
413                    if let Some(body) = &interaction.request.body {
414                        if let Ok(pretty) = serde_json::to_string_pretty(body) {
415                            let _ = writeln!(debug, "=== RECORDED [{idx}] ===");
416                            debug.push_str(&pretty);
417                            debug.push('\n');
418                        }
419                    }
420                }
421                let _ = std::fs::write(&debug_path, &debug);
422            }
423
424            if env_truthy("VCR_DEBUG_BODY") {
425                use std::fmt::Write as _;
426
427                let mut incoming_body = request.body.clone();
428                if let Some(body) = &mut incoming_body {
429                    redact_json(body);
430                }
431
432                if let Some(body) = &incoming_body {
433                    if let Ok(pretty) = serde_json::to_string_pretty(body) {
434                        message.push_str("\nIncoming JSON body (redacted):\n");
435                        message.push_str(&pretty);
436                        message.push('\n');
437                    }
438                }
439
440                if let Some(body_text) = &request.body_text {
441                    message.push_str("\nIncoming text body:\n");
442                    message.push_str(body_text);
443                    message.push('\n');
444                }
445
446                for (idx, interaction) in cassette.interactions.iter().enumerate() {
447                    if let Some(body) = &interaction.request.body {
448                        if let Ok(pretty) = serde_json::to_string_pretty(body) {
449                            let _ = write!(message, "\nRecorded JSON body [{idx}]:\n");
450                            message.push_str(&pretty);
451                            message.push('\n');
452                        }
453                    }
454
455                    if let Some(body_text) = &interaction.request.body_text {
456                        let _ = write!(message, "\nRecorded text body [{idx}]:\n");
457                        message.push_str(body_text);
458                        message.push('\n');
459                    }
460                }
461            }
462            message.push_str(
463                "Match criteria: method + url + body + body_text (headers ignored). If the request changed, re-record with VCR_MODE=record.",
464            );
465            return Err(Error::config(message));
466        };
467
468        info!(
469            cassette_path = %self.cassette_path.display(),
470            request = %request_debug_key(request),
471            "VCR playback: matched interaction"
472        );
473        self.playback_cursor
474            .store(matched_index + 1, Ordering::SeqCst);
475        Ok(interaction.response.clone())
476    }
477}
478
479fn default_mode() -> VcrMode {
480    if env_truthy("CI") {
481        VcrMode::Playback
482    } else {
483        VcrMode::Auto
484    }
485}
486
487fn env_truthy(name: &str) -> bool {
488    env_var(name).is_some_and(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
489}
490
491fn sanitize_test_name(value: &str) -> String {
492    let mut out = String::with_capacity(value.len());
493    for ch in value.chars() {
494        if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
495            out.push(ch);
496        } else {
497            out.push('_');
498        }
499    }
500    if out.is_empty() {
501        "vcr".to_string()
502    } else {
503        out
504    }
505}
506
507fn load_cassette(path: &Path) -> Result<Cassette> {
508    let content = std::fs::read_to_string(path)
509        .map_err(|e| Error::config(format!("Failed to read cassette {}: {e}", path.display())))?;
510    serde_json::from_str(&content)
511        .map_err(|e| Error::config(format!("Failed to parse cassette {}: {e}", path.display())))
512}
513
514fn save_cassette(path: &Path, cassette: &Cassette) -> Result<()> {
515    if let Some(parent) = path.parent() {
516        std::fs::create_dir_all(parent).map_err(|e| {
517            Error::config(format!(
518                "Failed to create cassette dir {}: {e}",
519                parent.display()
520            ))
521        })?;
522    }
523    let content = serde_json::to_string_pretty(cassette)
524        .map_err(|e| Error::config(format!("Failed to serialize cassette: {e}")))?;
525    std::fs::write(path, content)
526        .map_err(|e| Error::config(format!("Failed to write cassette {}: {e}", path.display())))?;
527    Ok(())
528}
529
530fn find_interaction_from<'a>(
531    cassette: &'a Cassette,
532    request: &RecordedRequest,
533    start: usize,
534) -> Option<(usize, &'a Interaction)> {
535    cassette
536        .interactions
537        .iter()
538        .enumerate()
539        .skip(start)
540        .find(|(_, interaction)| request_matches(&interaction.request, request))
541}
542
543fn request_debug_key(request: &RecordedRequest) -> String {
544    use std::fmt::Write as _;
545
546    let method = request.method.to_ascii_uppercase();
547    let mut out = format!("{method} {}", request.url);
548
549    if let Some(body) = &request.body {
550        let body_bytes = serde_json::to_vec(body).unwrap_or_default();
551        let hash = short_sha256(&body_bytes);
552        let _ = write!(out, " body_sha256={hash}");
553    } else {
554        out.push_str(" body_sha256=<none>");
555    }
556
557    if let Some(body_text) = &request.body_text {
558        let hash = short_sha256(body_text.as_bytes());
559        let _ = write!(
560            out,
561            " body_text_sha256={hash} body_text_len={}",
562            body_text.len()
563        );
564    } else {
565        out.push_str(" body_text_sha256=<none>");
566    }
567
568    out
569}
570
571fn short_sha256(bytes: &[u8]) -> String {
572    use std::fmt::Write as _;
573
574    let digest = Sha256::digest(bytes);
575    let mut out = String::with_capacity(12);
576    for b in &digest[..6] {
577        let _ = write!(out, "{b:02x}");
578    }
579    out
580}
581
582fn request_matches(recorded: &RecordedRequest, incoming: &RecordedRequest) -> bool {
583    if !recorded.method.eq_ignore_ascii_case(&incoming.method) {
584        return false;
585    }
586    if recorded.url != incoming.url {
587        return false;
588    }
589
590    // Redact incoming body to match recorded body state (which is always redacted)
591    let mut incoming_body = incoming.body.clone();
592    if let Some(body) = &mut incoming_body {
593        redact_json(body);
594    }
595
596    if !match_optional_json(recorded.body.as_ref(), incoming_body.as_ref()) {
597        return false;
598    }
599
600    // Treat a missing recorded `body_text` as a wildcard. This is useful for
601    // tests where the JSON body is dynamic (paths, timestamps, etc.) and the
602    // cassette only wants to constrain method+URL (and optionally structured JSON).
603    if let Some(recorded_text) = recorded.body_text.as_ref() {
604        if incoming.body_text.as_deref() != Some(recorded_text) {
605            return false;
606        }
607    }
608
609    true
610}
611
612fn match_optional_json(recorded: Option<&Value>, incoming: Option<&Value>) -> bool {
613    let Some(recorded) = recorded else {
614        // Cassette does not constrain JSON body.
615        return true;
616    };
617    let Some(incoming) = incoming else {
618        return false;
619    };
620    match_json_template(recorded, incoming)
621}
622
623/// Match a recorded JSON "template" against an incoming JSON value.
624///
625/// Semantics:
626/// - Objects: recorded keys must match; incoming may have extra keys.
627///   - A recorded key with `null` matches both missing and `null` incoming keys.
628/// - Arrays: strict length + element matching (order-sensitive).
629/// - Scalars: strict equality.
630fn match_json_template(recorded: &Value, incoming: &Value) -> bool {
631    match (recorded, incoming) {
632        (Value::Object(recorded_obj), Value::Object(incoming_obj)) => {
633            for (key, recorded_value) in recorded_obj {
634                match incoming_obj.get(key) {
635                    Some(incoming_value) => {
636                        if !match_json_template(recorded_value, incoming_value) {
637                            return false;
638                        }
639                    }
640                    None => {
641                        if !recorded_value.is_null() {
642                            return false;
643                        }
644                    }
645                }
646            }
647            true
648        }
649        (Value::Array(recorded_items), Value::Array(incoming_items)) => {
650            if recorded_items.len() != incoming_items.len() {
651                return false;
652            }
653            recorded_items
654                .iter()
655                .zip(incoming_items)
656                .all(|(left, right)| match_json_template(left, right))
657        }
658        _ => recorded == incoming,
659    }
660}
661
662pub fn redact_cassette(cassette: &mut Cassette) -> RedactionSummary {
663    let sensitive_headers = sensitive_header_keys();
664    let mut summary = RedactionSummary::default();
665    for interaction in &mut cassette.interactions {
666        summary.headers_redacted +=
667            redact_headers(&mut interaction.request.headers, &sensitive_headers);
668        summary.headers_redacted +=
669            redact_headers(&mut interaction.response.headers, &sensitive_headers);
670        if let Some(body) = &mut interaction.request.body {
671            summary.json_fields_redacted += redact_json(body);
672        }
673    }
674    summary
675}
676
677fn sensitive_header_keys() -> HashSet<String> {
678    [
679        "authorization",
680        "x-api-key",
681        "api-key",
682        "x-goog-api-key",
683        "x-azure-api-key",
684        "proxy-authorization",
685    ]
686    .iter()
687    .map(ToString::to_string)
688    .collect()
689}
690
691fn redact_headers(headers: &mut Vec<(String, String)>, sensitive: &HashSet<String>) -> usize {
692    let mut count = 0usize;
693    for (name, value) in headers {
694        if sensitive.contains(&name.to_ascii_lowercase()) {
695            count += 1;
696            *value = REDACTED.to_string();
697        }
698    }
699    count
700}
701
702fn redact_json(value: &mut Value) -> usize {
703    match value {
704        Value::Object(map) => {
705            let mut count = 0usize;
706            for (key, entry) in map.iter_mut() {
707                if is_sensitive_key(key) {
708                    *entry = Value::String(REDACTED.to_string());
709                    count += 1;
710                } else {
711                    count += redact_json(entry);
712                }
713            }
714            count
715        }
716        Value::Array(items) => {
717            let mut count = 0usize;
718            for item in items {
719                count += redact_json(item);
720            }
721            count
722        }
723        _ => 0usize,
724    }
725}
726
727fn is_sensitive_key(key: &str) -> bool {
728    let key = key.to_ascii_lowercase();
729    key.contains("api_key")
730        || key.contains("apikey")
731        || key.contains("authorization")
732        // "token" is sensitive when it refers to auth tokens (access_token, id_token, etc),
733        // but many APIs also use fields like "max_tokens"/"prompt_tokens" which are just counts.
734        // Redacting those breaks matching with existing cassettes and is not necessary.
735        || ((key.contains("token") && !key.contains("tokens"))
736            || key.contains("access_tokens")
737            || key.contains("refresh_tokens")
738            || key.contains("id_tokens"))
739        || key.contains("secret")
740        || key.contains("password")
741}
742
743#[cfg(test)]
744mod tests {
745    use super::*;
746    use std::future::Future;
747    use std::sync::{Mutex, OnceLock};
748
749    type ByteStream = BoxStream<'static, std::result::Result<Vec<u8>, std::io::Error>>;
750
751    fn env_test_lock() -> &'static Mutex<()> {
752        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
753        LOCK.get_or_init(|| Mutex::new(()))
754    }
755
756    /// Acquire `env_test_lock`, recovering from poison so that one
757    /// test-thread panic doesn't cascade into every other env-test.
758    fn lock_env() -> std::sync::MutexGuard<'static, ()> {
759        env_test_lock()
760            .lock()
761            .unwrap_or_else(std::sync::PoisonError::into_inner)
762    }
763
764    #[test]
765    fn cassette_round_trip() {
766        let cassette = Cassette {
767            version: CASSETTE_VERSION.to_string(),
768            test_name: "round_trip".to_string(),
769            recorded_at: "2026-02-03T00:00:00.000Z".to_string(),
770            interactions: vec![Interaction {
771                request: RecordedRequest {
772                    method: "POST".to_string(),
773                    url: "https://example.com".to_string(),
774                    headers: vec![("authorization".to_string(), "secret".to_string())],
775                    body: Some(serde_json::json!({"prompt": "hello"})),
776                    body_text: None,
777                },
778                response: RecordedResponse {
779                    status: 200,
780                    headers: vec![("x-api-key".to_string(), "secret".to_string())],
781                    body_chunks: vec!["event: message\n\n".to_string()],
782                    body_chunks_base64: None,
783                },
784            }],
785        };
786
787        let serialized = serde_json::to_string(&cassette).expect("serialize cassette");
788        let parsed: Cassette = serde_json::from_str(&serialized).expect("parse cassette");
789        assert_eq!(parsed.version, CASSETTE_VERSION);
790        assert_eq!(parsed.test_name, "round_trip");
791        assert_eq!(parsed.interactions.len(), 1);
792    }
793
794    #[test]
795    fn matches_interaction_on_method_url_body() {
796        let recorded = RecordedRequest {
797            method: "POST".to_string(),
798            url: "https://example.com".to_string(),
799            headers: vec![],
800            body: Some(serde_json::json!({"a": 1})),
801            body_text: None,
802        };
803        let incoming = RecordedRequest {
804            method: "post".to_string(),
805            url: "https://example.com".to_string(),
806            headers: vec![("x-api-key".to_string(), "secret".to_string())],
807            body: Some(serde_json::json!({"a": 1})),
808            body_text: None,
809        };
810        assert!(request_matches(&recorded, &incoming));
811    }
812
813    #[test]
814    fn oauth_refresh_invalid_matches_after_redaction() {
815        let cassette_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
816            .join("tests/fixtures/vcr/oauth_refresh_invalid.json");
817        let cassette = load_cassette(&cassette_path).expect("load cassette");
818        let recorded = &cassette.interactions.first().expect("interaction").request;
819        let recorded_body = recorded.body.as_ref().expect("recorded body");
820        let client_id = recorded_body
821            .get("client_id")
822            .and_then(serde_json::Value::as_str)
823            .expect("client_id string");
824
825        let incoming = RecordedRequest {
826            method: "POST".to_string(),
827            url: recorded.url.clone(),
828            headers: Vec::new(),
829            body: Some(serde_json::json!({
830                "grant_type": "refresh_token",
831                "client_id": client_id,
832                "refresh_token": "refresh-invalid",
833            })),
834            body_text: None,
835        };
836
837        assert!(request_matches(recorded, &incoming));
838    }
839
840    #[test]
841    fn redacts_sensitive_headers_and_body_fields() {
842        let mut cassette = Cassette {
843            version: CASSETTE_VERSION.to_string(),
844            test_name: "redact".to_string(),
845            recorded_at: "2026-02-03T00:00:00.000Z".to_string(),
846            interactions: vec![Interaction {
847                request: RecordedRequest {
848                    method: "POST".to_string(),
849                    url: "https://example.com".to_string(),
850                    headers: vec![("Authorization".to_string(), "secret".to_string())],
851                    body: Some(serde_json::json!({"api_key": "secret", "nested": {"token": "t"}})),
852                    body_text: None,
853                },
854                response: RecordedResponse {
855                    status: 200,
856                    headers: vec![("x-api-key".to_string(), "secret".to_string())],
857                    body_chunks: vec![],
858                    body_chunks_base64: None,
859                },
860            }],
861        };
862
863        let summary = redact_cassette(&mut cassette);
864
865        let request = &cassette.interactions[0].request;
866        assert_eq!(request.headers[0].1, REDACTED);
867        let body = request.body.as_ref().expect("body exists");
868        assert_eq!(body["api_key"], REDACTED);
869        assert_eq!(body["nested"]["token"], REDACTED);
870        assert_eq!(summary.headers_redacted, 2);
871        assert_eq!(summary.json_fields_redacted, 2);
872    }
873
874    #[test]
875    fn record_and_playback_cycle() {
876        let temp_dir = tempfile::tempdir().expect("temp dir");
877        let cassette_dir = temp_dir.path().to_path_buf();
878
879        let request = RecordedRequest {
880            method: "POST".to_string(),
881            url: "https://example.com".to_string(),
882            headers: vec![("content-type".to_string(), "application/json".to_string())],
883            body: Some(serde_json::json!({"prompt": "hello"})),
884            body_text: None,
885        };
886
887        let recorded = run_async({
888            let cassette_dir = cassette_dir.clone();
889            let request = request.clone();
890            async move {
891                let recorder =
892                    VcrRecorder::new_with("record_playback", VcrMode::Record, &cassette_dir);
893                recorder
894                    .record_streaming_with(request.clone(), || async {
895                        let recorded = RecordedResponse {
896                            status: 200,
897                            headers: vec![(
898                                "content-type".to_string(),
899                                "text/event-stream".to_string(),
900                            )],
901                            body_chunks: vec!["event: message\ndata: ok\n\n".to_string()],
902                            body_chunks_base64: None,
903                        };
904                        Ok((
905                            recorded.status,
906                            recorded.headers.clone(),
907                            recorded.into_byte_stream(),
908                        ))
909                    })
910                    .await
911                    .expect("record")
912            }
913        });
914
915        assert_eq!(recorded.status, 200);
916        assert_eq!(recorded.body_chunks.len(), 1);
917
918        let playback = run_async(async move {
919            let recorder =
920                VcrRecorder::new_with("record_playback", VcrMode::Playback, &cassette_dir);
921            recorder
922                .request_streaming_with::<_, _, ByteStream>(request, || async {
923                    Err(Error::config("Unexpected record in playback mode"))
924                })
925                .await
926                .expect("playback")
927        });
928
929        assert_eq!(playback.body_chunks.len(), 1);
930        assert!(playback.body_chunks[0].contains("event: message"));
931    }
932
933    #[test]
934    fn vcr_mode_from_env_values_and_invalid() {
935        let _lock = lock_env();
936        let previous = set_test_env_var(VCR_ENV_MODE, None);
937        assert_eq!(VcrMode::from_env().expect("unset mode"), None);
938        restore_test_env_var(VCR_ENV_MODE, previous);
939
940        for (raw, expected) in [
941            ("record", VcrMode::Record),
942            ("PLAYBACK", VcrMode::Playback),
943            ("Auto", VcrMode::Auto),
944        ] {
945            let previous = set_test_env_var(VCR_ENV_MODE, Some(raw));
946            assert_eq!(VcrMode::from_env().expect("valid mode"), Some(expected));
947            restore_test_env_var(VCR_ENV_MODE, previous);
948        }
949
950        let previous = set_test_env_var(VCR_ENV_MODE, Some("invalid-mode"));
951        let err = VcrMode::from_env().expect_err("invalid mode should fail");
952        assert!(
953            err.to_string()
954                .contains("Invalid VCR_MODE value: invalid-mode"),
955            "unexpected error: {err}"
956        );
957        restore_test_env_var(VCR_ENV_MODE, previous);
958    }
959
960    #[test]
961    fn auto_mode_records_missing_cassette_then_replays_existing() {
962        let temp_dir = tempfile::tempdir().expect("temp dir");
963        let cassette_dir = temp_dir.path().to_path_buf();
964        let cassette_path = cassette_dir.join("auto_mode_cycle.json");
965
966        let request = RecordedRequest {
967            method: "POST".to_string(),
968            url: "https://example.com/auto".to_string(),
969            headers: vec![("content-type".to_string(), "application/json".to_string())],
970            body: Some(serde_json::json!({"prompt": "first"})),
971            body_text: None,
972        };
973
974        let first = run_async({
975            let request = request.clone();
976            let cassette_dir = cassette_dir.clone();
977            async move {
978                let recorder =
979                    VcrRecorder::new_with("auto_mode_cycle", VcrMode::Auto, cassette_dir);
980                recorder
981                    .request_streaming_with(request, || async {
982                        let recorded = RecordedResponse {
983                            status: 201,
984                            headers: vec![("x-source".to_string(), "record".to_string())],
985                            body_chunks: vec!["chunk-one".to_string()],
986                            body_chunks_base64: None,
987                        };
988                        Ok((
989                            recorded.status,
990                            recorded.headers.clone(),
991                            recorded.into_byte_stream(),
992                        ))
993                    })
994                    .await
995                    .expect("auto record")
996            }
997        });
998
999        assert_eq!(first.status, 201);
1000        assert!(
1001            cassette_path.exists(),
1002            "cassette should be written in auto mode"
1003        );
1004
1005        let replay = run_async({
1006            async move {
1007                let recorder =
1008                    VcrRecorder::new_with("auto_mode_cycle", VcrMode::Auto, cassette_dir);
1009                recorder
1010                    .request_streaming_with::<_, _, ByteStream>(request, || async {
1011                        Err(Error::config(
1012                            "send callback should not run during auto playback",
1013                        ))
1014                    })
1015                    .await
1016                    .expect("auto playback")
1017            }
1018        });
1019
1020        assert_eq!(replay.status, 201);
1021        assert_eq!(replay.body_chunks, vec!["chunk-one".to_string()]);
1022    }
1023
1024    #[test]
1025    fn playback_mismatch_returns_strict_error_with_debug_hashes() {
1026        let temp_dir = tempfile::tempdir().expect("temp dir");
1027        let cassette_dir = temp_dir.path().to_path_buf();
1028
1029        let recorded_request = RecordedRequest {
1030            method: "POST".to_string(),
1031            url: "https://example.com/strict".to_string(),
1032            headers: vec![("content-type".to_string(), "application/json".to_string())],
1033            body: Some(serde_json::json!({"prompt": "expected"})),
1034            body_text: Some("expected-body".to_string()),
1035        };
1036
1037        run_async({
1038            let cassette_dir = cassette_dir.clone();
1039            async move {
1040                let recorder =
1041                    VcrRecorder::new_with("strict_mismatch", VcrMode::Record, cassette_dir);
1042                recorder
1043                    .request_streaming_with(recorded_request, || async {
1044                        let recorded = RecordedResponse {
1045                            status: 200,
1046                            headers: vec![("content-type".to_string(), "text/plain".to_string())],
1047                            body_chunks: vec!["ok".to_string()],
1048                            body_chunks_base64: None,
1049                        };
1050                        Ok((
1051                            recorded.status,
1052                            recorded.headers.clone(),
1053                            recorded.into_byte_stream(),
1054                        ))
1055                    })
1056                    .await
1057                    .expect("record strict cassette")
1058            }
1059        });
1060
1061        let mismatched_request = RecordedRequest {
1062            method: "POST".to_string(),
1063            url: "https://example.com/strict".to_string(),
1064            headers: vec![],
1065            body: Some(serde_json::json!({"prompt": "different"})),
1066            body_text: Some("different-body".to_string()),
1067        };
1068
1069        let err = run_async({
1070            async move {
1071                let recorder =
1072                    VcrRecorder::new_with("strict_mismatch", VcrMode::Playback, cassette_dir);
1073                recorder
1074                    .request_streaming_with::<_, _, ByteStream>(mismatched_request, || async {
1075                        Err(Error::config(
1076                            "send callback should not execute during playback mismatch",
1077                        ))
1078                    })
1079                    .await
1080                    .expect_err("mismatch should fail in playback mode")
1081            }
1082        });
1083
1084        let msg = err.to_string();
1085        assert!(
1086            msg.contains("No matching interaction found in cassette"),
1087            "unexpected error message: {msg}"
1088        );
1089        assert!(msg.contains("Incoming: POST https://example.com/strict"));
1090        assert!(msg.contains("body_sha256="));
1091        assert!(msg.contains("body_text_sha256="));
1092        assert!(msg.contains("Match criteria: method + url + body + body_text"));
1093    }
1094
1095    #[test]
1096    fn test_env_override_helpers_set_and_restore_values() {
1097        const TEST_VAR: &str = "PI_AGENT_VCR_TEST_ENV_OVERRIDE";
1098        let _lock = lock_env();
1099
1100        let original = set_test_env_var(TEST_VAR, None);
1101        assert_eq!(env_var(TEST_VAR), None);
1102
1103        let previous = set_test_env_var(TEST_VAR, Some("override-value"));
1104        assert_eq!(previous, None);
1105        assert_eq!(env_var(TEST_VAR).as_deref(), Some("override-value"));
1106
1107        restore_test_env_var(TEST_VAR, previous);
1108        assert_eq!(env_var(TEST_VAR), None);
1109
1110        restore_test_env_var(TEST_VAR, original);
1111    }
1112
1113    fn run_async<T>(future: impl Future<Output = T> + Send + 'static) -> T
1114    where
1115        T: Send + 'static,
1116    {
1117        let runtime = asupersync::runtime::RuntimeBuilder::new()
1118            .blocking_threads(1, 2)
1119            .build()
1120            .expect("build runtime");
1121        let join = runtime.handle().spawn(future);
1122        runtime.block_on(join)
1123    }
1124
1125    // ─── sanitize_test_name ──────────────────────────────────────────
1126
1127    #[test]
1128    fn sanitize_preserves_alphanumeric_and_dash_underscore() {
1129        assert_eq!(sanitize_test_name("hello-world_123"), "hello-world_123");
1130    }
1131
1132    #[test]
1133    fn sanitize_replaces_special_chars() {
1134        assert_eq!(sanitize_test_name("a/b::c d.e"), "a_b__c_d_e");
1135    }
1136
1137    #[test]
1138    fn sanitize_empty_returns_vcr() {
1139        assert_eq!(sanitize_test_name(""), "vcr");
1140    }
1141
1142    #[test]
1143    fn sanitize_all_special_returns_underscores() {
1144        assert_eq!(sanitize_test_name("..."), "___");
1145    }
1146
1147    #[test]
1148    fn sanitize_unicode_replaced() {
1149        assert_eq!(sanitize_test_name("café"), "caf_");
1150    }
1151
1152    // ─── short_sha256 ────────────────────────────────────────────────
1153
1154    #[test]
1155    fn short_sha256_deterministic() {
1156        let a = short_sha256(b"hello");
1157        let b = short_sha256(b"hello");
1158        assert_eq!(a, b);
1159    }
1160
1161    #[test]
1162    fn short_sha256_length() {
1163        let hash = short_sha256(b"test data");
1164        assert_eq!(hash.len(), 12, "6 bytes = 12 hex chars");
1165    }
1166
1167    #[test]
1168    fn short_sha256_different_inputs() {
1169        let a = short_sha256(b"alpha");
1170        let b = short_sha256(b"beta");
1171        assert_ne!(a, b);
1172    }
1173
1174    #[test]
1175    fn short_sha256_empty_input() {
1176        let hash = short_sha256(b"");
1177        assert_eq!(hash.len(), 12);
1178        // SHA-256 of empty is well-known: e3b0c44298fc...
1179        assert_eq!(&hash[..6], "e3b0c4");
1180    }
1181
1182    // ─── is_sensitive_key ────────────────────────────────────────────
1183
1184    #[test]
1185    fn sensitive_key_api_key() {
1186        assert!(is_sensitive_key("api_key"));
1187        assert!(is_sensitive_key("x_api_key"));
1188        assert!(is_sensitive_key("MY_APIKEY"));
1189    }
1190
1191    #[test]
1192    fn sensitive_key_authorization() {
1193        assert!(is_sensitive_key("authorization"));
1194        assert!(is_sensitive_key("Authorization"));
1195    }
1196
1197    #[test]
1198    fn sensitive_key_token_but_not_tokens() {
1199        // "token" (singular) is sensitive
1200        assert!(is_sensitive_key("access_token"));
1201        assert!(is_sensitive_key("id_token"));
1202        assert!(is_sensitive_key("refresh_token"));
1203        // "tokens" (count) is NOT sensitive...
1204        assert!(!is_sensitive_key("max_tokens"));
1205        assert!(!is_sensitive_key("prompt_tokens"));
1206        assert!(!is_sensitive_key("completion_tokens"));
1207        // ...except the plural versions of auth token names
1208        assert!(is_sensitive_key("access_tokens"));
1209        assert!(is_sensitive_key("refresh_tokens"));
1210    }
1211
1212    #[test]
1213    fn sensitive_key_secret_and_password() {
1214        assert!(is_sensitive_key("client_secret"));
1215        assert!(is_sensitive_key("password"));
1216        assert!(is_sensitive_key("db_password_hash"));
1217    }
1218
1219    #[test]
1220    fn sensitive_key_safe_keys() {
1221        assert!(!is_sensitive_key("model"));
1222        assert!(!is_sensitive_key("content"));
1223        assert!(!is_sensitive_key("messages"));
1224        assert!(!is_sensitive_key("temperature"));
1225    }
1226
1227    // ─── redact_json ─────────────────────────────────────────────────
1228
1229    #[test]
1230    fn redact_json_flat_object() {
1231        let mut val = serde_json::json!({"api_key": "sk-123", "model": "gpt-4"});
1232        let count = redact_json(&mut val);
1233        assert_eq!(count, 1);
1234        assert_eq!(val["api_key"], REDACTED);
1235        assert_eq!(val["model"], "gpt-4");
1236    }
1237
1238    #[test]
1239    fn redact_json_nested() {
1240        let mut val = serde_json::json!({
1241            "config": {
1242                "secret": "hidden",
1243                "name": "test"
1244            }
1245        });
1246        let count = redact_json(&mut val);
1247        assert_eq!(count, 1);
1248        assert_eq!(val["config"]["secret"], REDACTED);
1249        assert_eq!(val["config"]["name"], "test");
1250    }
1251
1252    #[test]
1253    fn redact_json_array_of_objects() {
1254        let mut val = serde_json::json!([
1255            {"api_key": "a"},
1256            {"api_key": "b"},
1257            {"safe": "c"}
1258        ]);
1259        let count = redact_json(&mut val);
1260        assert_eq!(count, 2);
1261        assert_eq!(val[0]["api_key"], REDACTED);
1262        assert_eq!(val[1]["api_key"], REDACTED);
1263        assert_eq!(val[2]["safe"], "c");
1264    }
1265
1266    #[test]
1267    fn redact_json_scalar_returns_zero() {
1268        let mut val = serde_json::json!("just a string");
1269        assert_eq!(redact_json(&mut val), 0);
1270        let mut val = serde_json::json!(42);
1271        assert_eq!(redact_json(&mut val), 0);
1272        let mut val = serde_json::json!(null);
1273        assert_eq!(redact_json(&mut val), 0);
1274    }
1275
1276    #[test]
1277    fn redact_json_empty_object() {
1278        let mut val = serde_json::json!({});
1279        assert_eq!(redact_json(&mut val), 0);
1280    }
1281
1282    // ─── redact_headers ──────────────────────────────────────────────
1283
1284    #[test]
1285    fn redact_headers_case_insensitive() {
1286        let sensitive = sensitive_header_keys();
1287        let mut headers = vec![
1288            ("Authorization".to_string(), "Bearer tok".to_string()),
1289            ("X-Api-Key".to_string(), "key".to_string()),
1290            ("Content-Type".to_string(), "application/json".to_string()),
1291        ];
1292        let count = redact_headers(&mut headers, &sensitive);
1293        assert_eq!(count, 2);
1294        assert_eq!(headers[0].1, REDACTED);
1295        assert_eq!(headers[1].1, REDACTED);
1296        assert_eq!(headers[2].1, "application/json");
1297    }
1298
1299    #[test]
1300    fn redact_headers_empty() {
1301        let sensitive = sensitive_header_keys();
1302        let mut headers = vec![];
1303        assert_eq!(redact_headers(&mut headers, &sensitive), 0);
1304    }
1305
1306    #[test]
1307    fn redact_headers_all_sensitive_keys() {
1308        let sensitive = sensitive_header_keys();
1309        let keys = [
1310            "authorization",
1311            "x-api-key",
1312            "api-key",
1313            "x-goog-api-key",
1314            "x-azure-api-key",
1315            "proxy-authorization",
1316        ];
1317        let mut headers: Vec<(String, String)> = keys
1318            .iter()
1319            .map(|k| (k.to_string(), "secret".to_string()))
1320            .collect();
1321        let count = redact_headers(&mut headers, &sensitive);
1322        assert_eq!(count, 6);
1323        for (_, val) in &headers {
1324            assert_eq!(val, REDACTED);
1325        }
1326    }
1327
1328    // ─── request_debug_key ───────────────────────────────────────────
1329
1330    #[test]
1331    fn request_debug_key_with_body() {
1332        let req = RecordedRequest {
1333            method: "post".to_string(),
1334            url: "https://api.example.com/v1/chat".to_string(),
1335            headers: vec![],
1336            body: Some(serde_json::json!({"prompt": "hello"})),
1337            body_text: None,
1338        };
1339        let key = request_debug_key(&req);
1340        assert!(key.starts_with("POST https://api.example.com/v1/chat"));
1341        assert!(key.contains("body_sha256="));
1342        assert!(key.contains("body_text_sha256=<none>"));
1343    }
1344
1345    #[test]
1346    fn request_debug_key_no_body() {
1347        let req = RecordedRequest {
1348            method: "GET".to_string(),
1349            url: "https://example.com".to_string(),
1350            headers: vec![],
1351            body: None,
1352            body_text: None,
1353        };
1354        let key = request_debug_key(&req);
1355        assert!(key.contains("body_sha256=<none>"));
1356        assert!(key.contains("body_text_sha256=<none>"));
1357    }
1358
1359    #[test]
1360    fn request_debug_key_with_body_text() {
1361        let req = RecordedRequest {
1362            method: "POST".to_string(),
1363            url: "https://example.com".to_string(),
1364            headers: vec![],
1365            body: None,
1366            body_text: Some("raw text body".to_string()),
1367        };
1368        let key = request_debug_key(&req);
1369        assert!(key.contains("body_text_sha256="));
1370        assert!(key.contains("body_text_len=13"));
1371        assert!(!key.contains("body_text_sha256=<none>"));
1372    }
1373
1374    // ─── match_json_template ─────────────────────────────────────────
1375
1376    #[test]
1377    fn json_template_exact_scalar_match() {
1378        let a = serde_json::json!("hello");
1379        let b = serde_json::json!("hello");
1380        assert!(match_json_template(&a, &b));
1381    }
1382
1383    #[test]
1384    fn json_template_scalar_mismatch() {
1385        let a = serde_json::json!("hello");
1386        let b = serde_json::json!("world");
1387        assert!(!match_json_template(&a, &b));
1388    }
1389
1390    #[test]
1391    fn json_template_number_match() {
1392        let a = serde_json::json!(42);
1393        let b = serde_json::json!(42);
1394        assert!(match_json_template(&a, &b));
1395    }
1396
1397    #[test]
1398    fn json_template_object_extra_incoming_keys_ok() {
1399        let recorded = serde_json::json!({"model": "gpt-4"});
1400        let incoming = serde_json::json!({"model": "gpt-4", "extra": "ignored"});
1401        assert!(match_json_template(&recorded, &incoming));
1402    }
1403
1404    #[test]
1405    fn json_template_object_missing_incoming_key_fails() {
1406        let recorded = serde_json::json!({"model": "gpt-4", "required": true});
1407        let incoming = serde_json::json!({"model": "gpt-4"});
1408        assert!(!match_json_template(&recorded, &incoming));
1409    }
1410
1411    #[test]
1412    fn json_template_null_matches_missing_key() {
1413        let recorded = serde_json::json!({"model": "gpt-4", "optional": null});
1414        let incoming = serde_json::json!({"model": "gpt-4"});
1415        assert!(match_json_template(&recorded, &incoming));
1416    }
1417
1418    #[test]
1419    fn json_template_null_matches_null() {
1420        let recorded = serde_json::json!({"field": null});
1421        let incoming = serde_json::json!({"field": null});
1422        assert!(match_json_template(&recorded, &incoming));
1423    }
1424
1425    #[test]
1426    fn json_template_array_same_length_matches() {
1427        let recorded = serde_json::json!([1, 2, 3]);
1428        let incoming = serde_json::json!([1, 2, 3]);
1429        assert!(match_json_template(&recorded, &incoming));
1430    }
1431
1432    #[test]
1433    fn json_template_array_different_length_fails() {
1434        let recorded = serde_json::json!([1, 2]);
1435        let incoming = serde_json::json!([1, 2, 3]);
1436        assert!(!match_json_template(&recorded, &incoming));
1437    }
1438
1439    #[test]
1440    fn json_template_array_element_mismatch_fails() {
1441        let recorded = serde_json::json!([1, 2, 3]);
1442        let incoming = serde_json::json!([1, 99, 3]);
1443        assert!(!match_json_template(&recorded, &incoming));
1444    }
1445
1446    #[test]
1447    fn json_template_nested_object_in_array() {
1448        let recorded = serde_json::json!([{"role": "user"}, {"role": "assistant"}]);
1449        let incoming = serde_json::json!([
1450            {"role": "user", "id": "1"},
1451            {"role": "assistant", "id": "2"}
1452        ]);
1453        assert!(match_json_template(&recorded, &incoming));
1454    }
1455
1456    #[test]
1457    fn json_template_type_mismatch() {
1458        let recorded = serde_json::json!({"a": "string"});
1459        let incoming = serde_json::json!({"a": 42});
1460        assert!(!match_json_template(&recorded, &incoming));
1461    }
1462
1463    // ─── match_optional_json ─────────────────────────────────────────
1464
1465    #[test]
1466    fn optional_json_none_recorded_matches_anything() {
1467        assert!(match_optional_json(None, None));
1468        assert!(match_optional_json(
1469            None,
1470            Some(&serde_json::json!({"anything": true}))
1471        ));
1472    }
1473
1474    #[test]
1475    fn optional_json_some_recorded_none_incoming_fails() {
1476        let recorded = serde_json::json!({"a": 1});
1477        assert!(!match_optional_json(Some(&recorded), None));
1478    }
1479
1480    // ─── request_matches ─────────────────────────────────────────────
1481
1482    #[test]
1483    fn request_matches_method_case_insensitive() {
1484        let recorded = RecordedRequest {
1485            method: "POST".to_string(),
1486            url: "https://x.com".to_string(),
1487            headers: vec![],
1488            body: None,
1489            body_text: None,
1490        };
1491        let incoming = RecordedRequest {
1492            method: "post".to_string(),
1493            url: "https://x.com".to_string(),
1494            headers: vec![],
1495            body: None,
1496            body_text: None,
1497        };
1498        assert!(request_matches(&recorded, &incoming));
1499    }
1500
1501    #[test]
1502    fn request_matches_url_mismatch() {
1503        let recorded = RecordedRequest {
1504            method: "GET".to_string(),
1505            url: "https://a.com".to_string(),
1506            headers: vec![],
1507            body: None,
1508            body_text: None,
1509        };
1510        let incoming = RecordedRequest {
1511            method: "GET".to_string(),
1512            url: "https://b.com".to_string(),
1513            headers: vec![],
1514            body: None,
1515            body_text: None,
1516        };
1517        assert!(!request_matches(&recorded, &incoming));
1518    }
1519
1520    #[test]
1521    fn request_matches_body_text_constraint() {
1522        let recorded = RecordedRequest {
1523            method: "POST".to_string(),
1524            url: "https://x.com".to_string(),
1525            headers: vec![],
1526            body: None,
1527            body_text: Some("expected".to_string()),
1528        };
1529        let mut incoming = recorded.clone();
1530        incoming.body_text = Some("expected".to_string());
1531        assert!(request_matches(&recorded, &incoming));
1532
1533        incoming.body_text = Some("different".to_string());
1534        assert!(!request_matches(&recorded, &incoming));
1535    }
1536
1537    #[test]
1538    fn request_matches_missing_recorded_body_text_is_wildcard() {
1539        let recorded = RecordedRequest {
1540            method: "POST".to_string(),
1541            url: "https://x.com".to_string(),
1542            headers: vec![],
1543            body: None,
1544            body_text: None,
1545        };
1546        let incoming = RecordedRequest {
1547            method: "POST".to_string(),
1548            url: "https://x.com".to_string(),
1549            headers: vec![],
1550            body: None,
1551            body_text: Some("anything".to_string()),
1552        };
1553        assert!(request_matches(&recorded, &incoming));
1554    }
1555
1556    #[test]
1557    fn request_matches_redacts_incoming_body() {
1558        // The cassette body is already redacted; incoming body has real secrets.
1559        let recorded = RecordedRequest {
1560            method: "POST".to_string(),
1561            url: "https://x.com".to_string(),
1562            headers: vec![],
1563            body: Some(serde_json::json!({"api_key": REDACTED, "model": "gpt-4"})),
1564            body_text: None,
1565        };
1566        let incoming = RecordedRequest {
1567            method: "POST".to_string(),
1568            url: "https://x.com".to_string(),
1569            headers: vec![],
1570            body: Some(serde_json::json!({"api_key": "sk-real-secret", "model": "gpt-4"})),
1571            body_text: None,
1572        };
1573        assert!(request_matches(&recorded, &incoming));
1574    }
1575
1576    // ─── find_interaction_from ───────────────────────────────────────
1577
1578    #[test]
1579    fn find_interaction_from_start() {
1580        let cassette = Cassette {
1581            version: "1.0".to_string(),
1582            test_name: "test".to_string(),
1583            recorded_at: "2026-01-01".to_string(),
1584            interactions: vec![
1585                Interaction {
1586                    request: RecordedRequest {
1587                        method: "GET".to_string(),
1588                        url: "https://a.com".to_string(),
1589                        headers: vec![],
1590                        body: None,
1591                        body_text: None,
1592                    },
1593                    response: RecordedResponse {
1594                        status: 200,
1595                        headers: vec![],
1596                        body_chunks: vec!["a".to_string()],
1597                        body_chunks_base64: None,
1598                    },
1599                },
1600                Interaction {
1601                    request: RecordedRequest {
1602                        method: "GET".to_string(),
1603                        url: "https://b.com".to_string(),
1604                        headers: vec![],
1605                        body: None,
1606                        body_text: None,
1607                    },
1608                    response: RecordedResponse {
1609                        status: 201,
1610                        headers: vec![],
1611                        body_chunks: vec!["b".to_string()],
1612                        body_chunks_base64: None,
1613                    },
1614                },
1615            ],
1616        };
1617
1618        let req_b = RecordedRequest {
1619            method: "GET".to_string(),
1620            url: "https://b.com".to_string(),
1621            headers: vec![],
1622            body: None,
1623            body_text: None,
1624        };
1625
1626        let result = find_interaction_from(&cassette, &req_b, 0);
1627        assert!(result.is_some());
1628        let (idx, interaction) = result.unwrap();
1629        assert_eq!(idx, 1);
1630        assert_eq!(interaction.response.status, 201);
1631    }
1632
1633    #[test]
1634    fn find_interaction_from_with_cursor_skip() {
1635        let make_interaction = |url: &str, status: u16| Interaction {
1636            request: RecordedRequest {
1637                method: "POST".to_string(),
1638                url: url.to_string(),
1639                headers: vec![],
1640                body: None,
1641                body_text: None,
1642            },
1643            response: RecordedResponse {
1644                status,
1645                headers: vec![],
1646                body_chunks: vec![],
1647                body_chunks_base64: None,
1648            },
1649        };
1650
1651        let cassette = Cassette {
1652            version: "1.0".to_string(),
1653            test_name: "cursor".to_string(),
1654            recorded_at: "2026-01-01".to_string(),
1655            interactions: vec![
1656                make_interaction("https://x.com", 200),
1657                make_interaction("https://x.com", 201),
1658                make_interaction("https://x.com", 202),
1659            ],
1660        };
1661
1662        let req = RecordedRequest {
1663            method: "POST".to_string(),
1664            url: "https://x.com".to_string(),
1665            headers: vec![],
1666            body: None,
1667            body_text: None,
1668        };
1669
1670        // Start at 0 → finds index 0
1671        let (idx, _) = find_interaction_from(&cassette, &req, 0).unwrap();
1672        assert_eq!(idx, 0);
1673
1674        // Start at 1 → skips index 0, finds index 1
1675        let (idx, interaction) = find_interaction_from(&cassette, &req, 1).unwrap();
1676        assert_eq!(idx, 1);
1677        assert_eq!(interaction.response.status, 201);
1678
1679        // Start at 3 → past end, nothing found
1680        assert!(find_interaction_from(&cassette, &req, 3).is_none());
1681    }
1682
1683    #[test]
1684    fn find_interaction_no_match() {
1685        let cassette = Cassette {
1686            version: "1.0".to_string(),
1687            test_name: "empty".to_string(),
1688            recorded_at: "2026-01-01".to_string(),
1689            interactions: vec![],
1690        };
1691        let req = RecordedRequest {
1692            method: "GET".to_string(),
1693            url: "https://x.com".to_string(),
1694            headers: vec![],
1695            body: None,
1696            body_text: None,
1697        };
1698        assert!(find_interaction_from(&cassette, &req, 0).is_none());
1699    }
1700
1701    // ─── env_truthy ──────────────────────────────────────────────────
1702
1703    #[test]
1704    fn env_truthy_values() {
1705        let _lock = lock_env();
1706        let key = "PI_VCR_TEST_TRUTHY";
1707
1708        for val in ["1", "true", "TRUE", "yes", "YES"] {
1709            let prev = set_test_env_var(key, Some(val));
1710            assert!(env_truthy(key), "expected truthy for '{val}'");
1711            restore_test_env_var(key, prev);
1712        }
1713
1714        for val in ["0", "false", "no", ""] {
1715            let prev = set_test_env_var(key, Some(val));
1716            assert!(!env_truthy(key), "expected falsy for '{val}'");
1717            restore_test_env_var(key, prev);
1718        }
1719
1720        let prev = set_test_env_var(key, None);
1721        assert!(!env_truthy(key), "expected falsy for unset");
1722        restore_test_env_var(key, prev);
1723    }
1724
1725    // ─── default_mode ────────────────────────────────────────────────
1726
1727    #[test]
1728    fn default_mode_ci_is_playback() {
1729        let _lock = lock_env();
1730        let prev = set_test_env_var("CI", Some("true"));
1731        assert_eq!(default_mode(), VcrMode::Playback);
1732        restore_test_env_var("CI", prev);
1733    }
1734
1735    #[test]
1736    fn default_mode_no_ci_is_auto() {
1737        let _lock = lock_env();
1738        let prev = set_test_env_var("CI", None);
1739        assert_eq!(default_mode(), VcrMode::Auto);
1740        restore_test_env_var("CI", prev);
1741    }
1742
1743    // ─── RecordedResponse::into_byte_stream ──────────────────────────
1744
1745    #[test]
1746    fn into_byte_stream_text_chunks() {
1747        let resp = RecordedResponse {
1748            status: 200,
1749            headers: vec![],
1750            body_chunks: vec!["hello ".to_string(), "world".to_string()],
1751            body_chunks_base64: None,
1752        };
1753        let chunks: Vec<Vec<u8>> = run_async(async move {
1754            use futures::StreamExt;
1755            resp.into_byte_stream()
1756                .map(|r| r.expect("chunk"))
1757                .collect()
1758                .await
1759        });
1760        assert_eq!(chunks.len(), 2);
1761        assert_eq!(chunks[0], b"hello ");
1762        assert_eq!(chunks[1], b"world");
1763    }
1764
1765    #[test]
1766    fn into_byte_stream_base64_chunks() {
1767        let chunk1 = STANDARD.encode(b"binary\x00data");
1768        let chunk2 = STANDARD.encode(b"\xff\xfe");
1769        let resp = RecordedResponse {
1770            status: 200,
1771            headers: vec![],
1772            body_chunks: vec![],
1773            body_chunks_base64: Some(vec![chunk1, chunk2]),
1774        };
1775        let chunks: Vec<Vec<u8>> = run_async(async move {
1776            use futures::StreamExt;
1777            resp.into_byte_stream()
1778                .map(|r| r.expect("chunk"))
1779                .collect()
1780                .await
1781        });
1782        assert_eq!(chunks.len(), 2);
1783        assert_eq!(chunks[0], b"binary\x00data");
1784        assert_eq!(chunks[1], b"\xff\xfe");
1785    }
1786
1787    #[test]
1788    fn into_byte_stream_base64_takes_precedence() {
1789        let resp = RecordedResponse {
1790            status: 200,
1791            headers: vec![],
1792            body_chunks: vec!["ignored".to_string()],
1793            body_chunks_base64: Some(vec![STANDARD.encode(b"used")]),
1794        };
1795        let chunks: Vec<Vec<u8>> = run_async(async move {
1796            use futures::StreamExt;
1797            resp.into_byte_stream()
1798                .map(|r| r.expect("chunk"))
1799                .collect()
1800                .await
1801        });
1802        assert_eq!(chunks.len(), 1);
1803        assert_eq!(chunks[0], b"used");
1804    }
1805
1806    #[test]
1807    fn into_byte_stream_empty() {
1808        let resp = RecordedResponse {
1809            status: 200,
1810            headers: vec![],
1811            body_chunks: vec![],
1812            body_chunks_base64: None,
1813        };
1814        let chunks: Vec<Vec<u8>> = run_async(async move {
1815            use futures::StreamExt;
1816            resp.into_byte_stream()
1817                .map(|r| r.expect("chunk"))
1818                .collect()
1819                .await
1820        });
1821        assert!(chunks.is_empty());
1822    }
1823
1824    #[test]
1825    fn into_byte_stream_invalid_base64_errors() {
1826        let resp = RecordedResponse {
1827            status: 200,
1828            headers: vec![],
1829            body_chunks: vec![],
1830            body_chunks_base64: Some(vec!["not-valid-base64!!!".to_string()]),
1831        };
1832        let results: Vec<std::result::Result<Vec<u8>, std::io::Error>> = run_async(async move {
1833            use futures::StreamExt;
1834            resp.into_byte_stream().collect().await
1835        });
1836        assert_eq!(results.len(), 1);
1837        assert!(results[0].is_err());
1838    }
1839
1840    // ─── Cassette serialization ──────────────────────────────────────
1841
1842    #[test]
1843    fn cassette_serde_body_text_omitted_when_none() {
1844        let req = RecordedRequest {
1845            method: "GET".to_string(),
1846            url: "https://x.com".to_string(),
1847            headers: vec![],
1848            body: None,
1849            body_text: None,
1850        };
1851        let json = serde_json::to_string(&req).unwrap();
1852        assert!(!json.contains("body_text"));
1853        assert!(!json.contains("body"));
1854    }
1855
1856    #[test]
1857    fn cassette_serde_body_text_present_when_some() {
1858        let req = RecordedRequest {
1859            method: "GET".to_string(),
1860            url: "https://x.com".to_string(),
1861            headers: vec![],
1862            body: None,
1863            body_text: Some("hello".to_string()),
1864        };
1865        let json = serde_json::to_string(&req).unwrap();
1866        assert!(json.contains("body_text"));
1867        assert!(json.contains("hello"));
1868    }
1869
1870    #[test]
1871    fn cassette_response_base64_omitted_when_none() {
1872        let resp = RecordedResponse {
1873            status: 200,
1874            headers: vec![],
1875            body_chunks: vec!["data".to_string()],
1876            body_chunks_base64: None,
1877        };
1878        let json = serde_json::to_string(&resp).unwrap();
1879        assert!(!json.contains("body_chunks_base64"));
1880    }
1881
1882    #[test]
1883    fn cassette_save_load_round_trip() {
1884        let temp_dir = tempfile::tempdir().expect("temp dir");
1885        let path = temp_dir.path().join("subdir/test.json");
1886        let cassette = Cassette {
1887            version: CASSETTE_VERSION.to_string(),
1888            test_name: "save_load".to_string(),
1889            recorded_at: "2026-02-06T00:00:00.000Z".to_string(),
1890            interactions: vec![Interaction {
1891                request: RecordedRequest {
1892                    method: "POST".to_string(),
1893                    url: "https://api.example.com".to_string(),
1894                    headers: vec![("content-type".to_string(), "application/json".to_string())],
1895                    body: Some(serde_json::json!({"key": "value"})),
1896                    body_text: None,
1897                },
1898                response: RecordedResponse {
1899                    status: 200,
1900                    headers: vec![],
1901                    body_chunks: vec!["ok".to_string()],
1902                    body_chunks_base64: None,
1903                },
1904            }],
1905        };
1906
1907        save_cassette(&path, &cassette).expect("save");
1908        assert!(path.exists());
1909
1910        let loaded = load_cassette(&path).expect("load");
1911        assert_eq!(loaded.version, CASSETTE_VERSION);
1912        assert_eq!(loaded.test_name, "save_load");
1913        assert_eq!(loaded.interactions.len(), 1);
1914        assert_eq!(loaded.interactions[0].request.method, "POST");
1915    }
1916
1917    #[test]
1918    fn load_cassette_missing_file_errors() {
1919        let result = load_cassette(Path::new("/nonexistent/cassette.json"));
1920        assert!(result.is_err());
1921        assert!(result.unwrap_err().to_string().contains("Failed to read"));
1922    }
1923
1924    // ─── redact_cassette integration ─────────────────────────────────
1925
1926    #[test]
1927    fn redact_cassette_multiple_interactions() {
1928        let mut cassette = Cassette {
1929            version: "1.0".to_string(),
1930            test_name: "multi".to_string(),
1931            recorded_at: "now".to_string(),
1932            interactions: vec![
1933                Interaction {
1934                    request: RecordedRequest {
1935                        method: "POST".to_string(),
1936                        url: "https://a.com".to_string(),
1937                        headers: vec![("Authorization".to_string(), "Bearer tok".to_string())],
1938                        body: Some(serde_json::json!({"password": "p1"})),
1939                        body_text: None,
1940                    },
1941                    response: RecordedResponse {
1942                        status: 200,
1943                        headers: vec![("x-api-key".to_string(), "key1".to_string())],
1944                        body_chunks: vec![],
1945                        body_chunks_base64: None,
1946                    },
1947                },
1948                Interaction {
1949                    request: RecordedRequest {
1950                        method: "POST".to_string(),
1951                        url: "https://b.com".to_string(),
1952                        headers: vec![],
1953                        body: Some(serde_json::json!({"client_secret": "s1"})),
1954                        body_text: None,
1955                    },
1956                    response: RecordedResponse {
1957                        status: 200,
1958                        headers: vec![],
1959                        body_chunks: vec![],
1960                        body_chunks_base64: None,
1961                    },
1962                },
1963            ],
1964        };
1965
1966        let summary = redact_cassette(&mut cassette);
1967        assert_eq!(summary.headers_redacted, 2);
1968        assert_eq!(summary.json_fields_redacted, 2);
1969    }
1970
1971    // ─── VcrRecorder accessors ───────────────────────────────────────
1972
1973    #[test]
1974    fn recorder_new_with_sets_mode_and_path() {
1975        let temp_dir = tempfile::tempdir().expect("temp dir");
1976        let recorder = VcrRecorder::new_with("my::test_name", VcrMode::Playback, temp_dir.path());
1977        assert_eq!(recorder.mode(), VcrMode::Playback);
1978        assert!(
1979            recorder
1980                .cassette_path()
1981                .to_string_lossy()
1982                .contains("my__test_name.json")
1983        );
1984    }
1985
1986    // ========================================================================
1987    // Proptest — VCR cassette parser fuzz coverage (FUZZ-P1.7)
1988    // ========================================================================
1989
1990    mod proptest_vcr {
1991        use super::*;
1992        use proptest::prelude::*;
1993
1994        // ── Strategies ──────────────────────────────────────────────────
1995
1996        fn small_string() -> impl Strategy<Value = String> {
1997            prop_oneof![Just(String::new()), "[a-zA-Z0-9_]{1,16}", "[ -~]{0,32}",]
1998        }
1999
2000        fn url_string() -> impl Strategy<Value = String> {
2001            prop_oneof![
2002                Just("https://api.example.com/v1/messages".to_string()),
2003                Just(String::new()),
2004                Just("not-a-url".to_string()),
2005                Just("http://localhost:8080/test?q=1&b=2".to_string()),
2006                "https?://[a-z.]{1,20}/[a-z/]{0,20}",
2007                "[ -~]{0,64}",
2008            ]
2009        }
2010
2011        fn http_method() -> impl Strategy<Value = String> {
2012            prop_oneof![
2013                Just("GET".to_string()),
2014                Just("POST".to_string()),
2015                Just("PUT".to_string()),
2016                Just("DELETE".to_string()),
2017                Just("get".to_string()),
2018                Just("post".to_string()),
2019                "[A-Z]{1,8}",
2020                small_string(),
2021            ]
2022        }
2023
2024        fn header_pair() -> impl Strategy<Value = (String, String)> {
2025            let key = prop_oneof![
2026                Just("Content-Type".to_string()),
2027                Just("Authorization".to_string()),
2028                Just("x-api-key".to_string()),
2029                Just("X-Custom-Header".to_string()),
2030                "[a-zA-Z][a-zA-Z0-9-]{0,20}",
2031            ];
2032            let value = prop_oneof![
2033                Just("application/json".to_string()),
2034                Just("Bearer sk-test-123".to_string()),
2035                small_string(),
2036                // CRLF injection attempt
2037                Just("value\r\nInjected: header".to_string()),
2038            ];
2039            (key, value)
2040        }
2041
2042        fn json_value() -> impl Strategy<Value = Value> {
2043            let leaf = prop_oneof![
2044                Just(Value::Null),
2045                any::<bool>().prop_map(Value::Bool),
2046                any::<i64>().prop_map(|n| Value::Number(n.into())),
2047                small_string().prop_map(Value::String),
2048            ];
2049            leaf.prop_recursive(3, 32, 4, |inner| {
2050                prop_oneof![
2051                    prop::collection::vec(inner.clone(), 0..4).prop_map(Value::Array),
2052                    prop::collection::hash_map("[a-z_]{1,10}", inner, 0..4)
2053                        .prop_map(|m| Value::Object(m.into_iter().collect())),
2054                ]
2055            })
2056        }
2057
2058        fn recorded_request() -> impl Strategy<Value = RecordedRequest> {
2059            (
2060                http_method(),
2061                url_string(),
2062                prop::collection::vec(header_pair(), 0..4),
2063                prop::option::of(json_value()),
2064                prop::option::of(small_string()),
2065            )
2066                .prop_map(|(method, url, headers, body, body_text)| RecordedRequest {
2067                    method,
2068                    url,
2069                    headers,
2070                    body,
2071                    body_text,
2072                })
2073        }
2074
2075        fn base64_chunk() -> impl Strategy<Value = String> {
2076            prop_oneof![
2077                // Valid base64
2078                prop::collection::vec(any::<u8>(), 0..64)
2079                    .prop_map(|bytes| base64::engine::general_purpose::STANDARD.encode(&bytes)),
2080                // Invalid base64
2081                Just("not-valid-base64!!!".to_string()),
2082                Just("====".to_string()),
2083                Just(String::new()),
2084                "[ -~]{0,32}",
2085            ]
2086        }
2087
2088        fn recorded_response() -> impl Strategy<Value = RecordedResponse> {
2089            (
2090                any::<u16>(),
2091                prop::collection::vec(header_pair(), 0..4),
2092                prop::collection::vec(small_string(), 0..4),
2093                prop::option::of(prop::collection::vec(base64_chunk(), 0..4)),
2094            )
2095                .prop_map(|(status, headers, body_chunks, body_chunks_base64)| {
2096                    RecordedResponse {
2097                        status,
2098                        headers,
2099                        body_chunks,
2100                        body_chunks_base64,
2101                    }
2102                })
2103        }
2104
2105        // ── Property tests ──────────────────────────────────────────────
2106
2107        proptest! {
2108            #![proptest_config(ProptestConfig {
2109                cases: 256,
2110                max_shrink_iters: 100,
2111                .. ProptestConfig::default()
2112            })]
2113
2114            /// redact_json is idempotent: redacting twice yields the same result.
2115            #[test]
2116            fn redact_json_is_idempotent(value in json_value()) {
2117                let mut first = value;
2118                redact_json(&mut first);
2119                let mut second = first.clone();
2120                redact_json(&mut second);
2121                assert_eq!(first, second);
2122            }
2123
2124            /// redact_json never panics on arbitrary JSON values.
2125            #[test]
2126            fn redact_json_never_panics(mut value in json_value()) {
2127                let _ = redact_json(&mut value);
2128            }
2129
2130            /// request_matches is reflexive: a request matches itself.
2131            #[test]
2132            fn request_matches_is_reflexive(req in recorded_request()) {
2133                // Redact the request body to simulate cassette state
2134                // (cassettes always store redacted bodies).
2135                let mut cassette_req = req.clone();
2136                if let Some(body) = &mut cassette_req.body {
2137                    redact_json(body);
2138                }
2139                assert!(request_matches(&cassette_req, &req));
2140            }
2141
2142            /// request_matches never panics on arbitrary request pairs.
2143            #[test]
2144            fn request_matches_never_panics(
2145                a in recorded_request(),
2146                b in recorded_request()
2147            ) {
2148                let _ = request_matches(&a, &b);
2149            }
2150
2151            /// match_json_template never panics on arbitrary JSON value pairs.
2152            #[test]
2153            fn match_json_template_never_panics(
2154                a in json_value(),
2155                b in json_value()
2156            ) {
2157                let _ = match_json_template(&a, &b);
2158            }
2159
2160            /// match_json_template is reflexive: a value matches itself.
2161            #[test]
2162            fn match_json_template_is_reflexive(v in json_value()) {
2163                assert!(match_json_template(&v, &v));
2164            }
2165
2166            /// into_byte_stream never panics on arbitrary responses.
2167            #[test]
2168            fn into_byte_stream_never_panics(resp in recorded_response()) {
2169                let stream = resp.into_byte_stream();
2170                run_async(async move {
2171                    use futures::StreamExt;
2172                    let _results: Vec<_> = stream.collect().await;
2173                });
2174            }
2175
2176            /// Cassette serde round-trip: serialize then deserialize preserves
2177            /// the structure.
2178            #[test]
2179            fn cassette_serde_round_trip(
2180                version in small_string(),
2181                test_name in small_string(),
2182                recorded_at in small_string(),
2183                req in recorded_request(),
2184                resp in recorded_response()
2185            ) {
2186                let cassette = Cassette {
2187                    version,
2188                    test_name,
2189                    recorded_at,
2190                    interactions: vec![Interaction {
2191                        request: req,
2192                        response: resp,
2193                    }],
2194                };
2195                let json = serde_json::to_string(&cassette).expect("serialize");
2196                let reparsed: Cassette = serde_json::from_str(&json).expect("deserialize");
2197                assert_eq!(cassette.version, reparsed.version);
2198                assert_eq!(cassette.test_name, reparsed.test_name);
2199                assert_eq!(cassette.recorded_at, reparsed.recorded_at);
2200                assert_eq!(cassette.interactions.len(), reparsed.interactions.len());
2201            }
2202
2203            /// is_sensitive_key never panics on arbitrary strings.
2204            #[test]
2205            fn is_sensitive_key_never_panics(key in "[ -~]{0,64}") {
2206                let _ = is_sensitive_key(&key);
2207            }
2208
2209            /// base64 body_chunks_base64 takes precedence over body_chunks when
2210            /// both are present.
2211            #[test]
2212            fn base64_takes_precedence_over_text(
2213                text_chunks in prop::collection::vec(small_string(), 1..4),
2214                base64_chunks in prop::collection::vec(
2215                    prop::collection::vec(any::<u8>(), 0..32)
2216                        .prop_map(|b| base64::engine::general_purpose::STANDARD.encode(&b)),
2217                    1..4
2218                )
2219            ) {
2220                let expected_bytes: Vec<Vec<u8>> = base64_chunks.iter().map(|c| {
2221                    base64::engine::general_purpose::STANDARD.decode(c).unwrap()
2222                }).collect();
2223
2224                let resp = RecordedResponse {
2225                    status: 200,
2226                    headers: vec![],
2227                    body_chunks: text_chunks,
2228                    body_chunks_base64: Some(base64_chunks),
2229                };
2230                let results: Vec<std::result::Result<Vec<u8>, std::io::Error>> =
2231                    run_async(async move {
2232                        use futures::StreamExt;
2233                        resp.into_byte_stream().collect().await
2234                    });
2235                assert_eq!(results.len(), expected_bytes.len());
2236                for (result, expected) in results.iter().zip(&expected_bytes) {
2237                    assert_eq!(result.as_ref().unwrap(), expected);
2238                }
2239            }
2240        }
2241    }
2242}