Skip to main content

tarn/
model.rs

1use indexmap::IndexMap;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5/// Source location pointing at a YAML node (a step's `name:` key or an
6/// assertion operator key). All fields are 1-based so they line up with
7/// what editors and JSON reports already use elsewhere.
8///
9/// The field name and shape are fixed by the public JSON report schema and
10/// consumed by the VS Code extension via `schemaGuards.ts`; do not rename.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
12pub struct Location {
13    /// Absolute path of the source file (matches `FileResult.file`).
14    pub file: String,
15    /// 1-based line number.
16    pub line: usize,
17    /// 1-based column number.
18    pub column: usize,
19}
20
21/// Runtime HTTP transport settings shared by run and bench commands.
22#[derive(Debug, Clone, Default, PartialEq, Eq)]
23pub struct HttpTransportConfig {
24    pub proxy: Option<String>,
25    pub no_proxy: Option<String>,
26    pub cacert: Option<String>,
27    pub cert: Option<String>,
28    pub key: Option<String>,
29    pub insecure: bool,
30    pub http_version: Option<HttpVersionPreference>,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum HttpVersionPreference {
35    Http1_1,
36    Http2,
37}
38
39impl HttpTransportConfig {
40    /// Merge project defaults with CLI overrides. CLI wins when provided.
41    pub fn merge(project: &Self, cli: &Self) -> Self {
42        Self {
43            proxy: cli.proxy.clone().or_else(|| project.proxy.clone()),
44            no_proxy: cli.no_proxy.clone().or_else(|| project.no_proxy.clone()),
45            cacert: cli.cacert.clone().or_else(|| project.cacert.clone()),
46            cert: cli.cert.clone().or_else(|| project.cert.clone()),
47            key: cli.key.clone().or_else(|| project.key.clone()),
48            insecure: cli.insecure || project.insecure,
49            http_version: cli.http_version.or(project.http_version),
50        }
51    }
52
53    pub fn has_custom_transport(&self) -> bool {
54        self.proxy.is_some()
55            || self.no_proxy.is_some()
56            || self.cacert.is_some()
57            || self.cert.is_some()
58            || self.key.is_some()
59            || self.insecure
60            || self.http_version.is_some()
61    }
62}
63
64#[derive(Debug, Deserialize, Clone, PartialEq, Eq)]
65pub struct RedactionConfig {
66    #[serde(default = "default_redacted_headers")]
67    pub headers: Vec<String>,
68    #[serde(default = "default_redaction_replacement")]
69    pub replacement: String,
70    #[serde(default, rename = "env")]
71    pub env_vars: Vec<String>,
72    #[serde(default)]
73    pub captures: Vec<String>,
74}
75
76impl Default for RedactionConfig {
77    fn default() -> Self {
78        Self {
79            headers: default_redacted_headers(),
80            replacement: default_redaction_replacement(),
81            env_vars: Vec::new(),
82            captures: Vec::new(),
83        }
84    }
85}
86
87impl RedactionConfig {
88    /// Append extra header names to the effective redaction list without
89    /// removing any existing entries. All names are normalized to lowercase
90    /// so header matching stays case-insensitive, and duplicates (by
91    /// lowercase comparison) are skipped so the list stays tidy.
92    ///
93    /// This is the single merge point used by the `--redact-header` CLI
94    /// flag: callers can widen an already-resolved `RedactionConfig`
95    /// (from defaults + `tarn.config.yaml` + test file) without mutating
96    /// any persisted configuration.
97    pub fn merge_headers<I, S>(&mut self, extra: I)
98    where
99        I: IntoIterator<Item = S>,
100        S: AsRef<str>,
101    {
102        for name in extra {
103            let trimmed = name.as_ref().trim();
104            if trimmed.is_empty() {
105                continue;
106            }
107            let normalized = trimmed.to_ascii_lowercase();
108            if !self
109                .headers
110                .iter()
111                .any(|existing| existing.eq_ignore_ascii_case(&normalized))
112            {
113                self.headers.push(normalized);
114            }
115        }
116    }
117}
118
119fn default_redacted_headers() -> Vec<String> {
120    vec![
121        "authorization".into(),
122        "cookie".into(),
123        "set-cookie".into(),
124        "x-api-key".into(),
125        "x-auth-token".into(),
126    ]
127}
128
129fn default_redaction_replacement() -> String {
130    "***".into()
131}
132
133/// Top-level test file structure matching .tarn.yaml format.
134///
135/// Supports two modes:
136/// 1. Simple (flat steps): `steps:` at the top level
137/// 2. Full (named tests): `tests:` map with named test groups
138#[derive(Debug, Deserialize, Clone)]
139pub struct TestFile {
140    /// Schema version (optional, defaults to "1")
141    pub version: Option<String>,
142
143    /// Human-readable name for this test file
144    pub name: String,
145
146    /// Optional description
147    pub description: Option<String>,
148
149    /// Tags for filtering
150    #[serde(default)]
151    pub tags: Vec<String>,
152
153    /// Optional OpenAPI operation ids this file exercises. Consumed by
154    /// `tarn impact` to map changed operations to tests; absent by default
155    /// so adopting the field is additive and never required.
156    #[serde(default)]
157    pub openapi_operation_ids: Option<Vec<String>>,
158
159    /// Inline environment variables with defaults
160    #[serde(default)]
161    pub env: HashMap<String, String>,
162
163    /// Report-time redaction settings for sensitive headers
164    #[serde(alias = "redact")]
165    pub redaction: Option<RedactionConfig>,
166
167    /// Default headers/settings applied to every request
168    pub defaults: Option<Defaults>,
169
170    /// Setup steps run once before all tests
171    #[serde(default)]
172    pub setup: Vec<Step>,
173
174    /// Teardown steps run once after all tests (even on failure)
175    #[serde(default)]
176    pub teardown: Vec<Step>,
177
178    /// Named test groups (full format)
179    #[serde(default)]
180    pub tests: IndexMap<String, TestGroup>,
181
182    /// Flat steps (simple format — mutually exclusive with `tests`)
183    #[serde(default)]
184    pub steps: Vec<Step>,
185
186    /// Cookie handling mode: "auto" (default), "off", or "per-test"
187    #[serde(default)]
188    pub cookies: Option<CookieMode>,
189
190    /// When `true`, this file must never run concurrently with other work
191    /// under `--parallel`. Used for suites that share mutable state (DB
192    /// fixtures, global counters, singletons). The scheduler partitions
193    /// serial-only files onto a single worker so they run sequentially
194    /// after the parallel-safe set completes.
195    #[serde(default)]
196    pub serial_only: bool,
197
198    /// Optional resource group name. Files sharing the same group run on
199    /// the same worker (serialized within the group, parallelized across
200    /// groups). Useful for "all the postgres tests" or "all the S3 tests"
201    /// where a shared external resource forces serial ordering per
202    /// resource but allows resource groups to run in parallel with each
203    /// other.
204    #[serde(default)]
205    pub group: Option<String>,
206}
207
208/// File-level cookie handling mode.
209///
210/// - `Auto` (default) — single file-scoped jar shared across setup, tests, teardown.
211/// - `Off` — cookies disabled entirely for the file.
212/// - `PerTest` — the default jar is cleared between named tests so subset runs
213///   and flaky suites never see session state from a prior test. Setup and
214///   teardown still share the file-level jar. Named jars are unaffected.
215#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
216pub enum CookieMode {
217    #[default]
218    Auto,
219    Off,
220    PerTest,
221}
222
223impl<'de> Deserialize<'de> for CookieMode {
224    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
225    where
226        D: serde::Deserializer<'de>,
227    {
228        let value = String::deserialize(deserializer)?;
229        match value.as_str() {
230            "auto" => Ok(CookieMode::Auto),
231            "off" => Ok(CookieMode::Off),
232            "per-test" => Ok(CookieMode::PerTest),
233            other => Err(serde::de::Error::custom(format!(
234                "cookies must be \"auto\", \"off\", or \"per-test\" (got \"{}\")",
235                other
236            ))),
237        }
238    }
239}
240
241/// A named group of test steps.
242#[derive(Debug, Deserialize, Clone)]
243pub struct TestGroup {
244    pub description: Option<String>,
245
246    #[serde(default)]
247    pub tags: Vec<String>,
248
249    #[serde(default)]
250    pub steps: Vec<Step>,
251
252    /// When `true`, this named test must never run concurrently with other
253    /// work under `--parallel`. Since Tarn's parallelism unit is the file,
254    /// a single `serial_only` test effectively pins the containing file
255    /// to the serial bucket — this preserves the existing per-file
256    /// isolation invariant (setup/teardown, cookie jars, captures are
257    /// file-scoped).
258    #[serde(default)]
259    pub serial_only: bool,
260}
261
262/// A single test step: one HTTP request + optional capture + assertions.
263#[derive(Debug, Deserialize, Clone)]
264pub struct Step {
265    pub name: String,
266
267    /// Optional human-readable description for this step.
268    /// Supports multi-line values via YAML block scalars (`|`, `>`).
269    /// Rendered below the step name in human output and included in
270    /// the JSON report under the step node.
271    pub description: Option<String>,
272
273    pub request: Request,
274
275    /// Captures from the response (JSONPath or header with optional regex)
276    #[serde(default)]
277    pub capture: HashMap<String, CaptureSpec>,
278
279    /// Assertions on the response
280    #[serde(rename = "assert")]
281    pub assertions: Option<Assertion>,
282
283    /// Conditionally run this step only when the interpolated expression is
284    /// truthy. Empty / unset / `"false"` / `"0"` / `"null"` are falsy; any
285    /// other non-empty value is truthy. Mutually exclusive with `unless`.
286    #[serde(default, rename = "if")]
287    pub run_if: Option<String>,
288
289    /// Conditionally run this step only when the interpolated expression is
290    /// falsy (inverse of `if`). Mutually exclusive with `if`.
291    pub unless: Option<String>,
292
293    /// Number of retries on failure (0 = no retries)
294    #[serde(default)]
295    pub retries: Option<u32>,
296
297    /// Step-level timeout in milliseconds (overrides defaults)
298    pub timeout: Option<u64>,
299
300    /// Step-level connect timeout in milliseconds (overrides defaults)
301    #[serde(alias = "connect-timeout")]
302    pub connect_timeout: Option<u64>,
303
304    /// Whether this step should follow redirects (overrides defaults)
305    #[serde(alias = "follow-redirects")]
306    pub follow_redirects: Option<bool>,
307
308    /// Maximum redirects to follow for this step (overrides defaults)
309    #[serde(alias = "max-redirs")]
310    pub max_redirs: Option<u32>,
311
312    /// Delay before executing this step (e.g., "500ms", "2s")
313    pub delay: Option<String>,
314
315    /// Polling configuration: re-execute until condition is met
316    pub poll: Option<PollConfig>,
317
318    /// Lua script to run after HTTP response for custom validation
319    pub script: Option<String>,
320
321    /// Per-step cookie control:
322    /// - omitted or `true`: use the default cookie jar
323    /// - `false`: skip cookies entirely for this step
324    /// - `"jar-name"`: use a named cookie jar (for multi-user scenarios)
325    pub cookies: Option<StepCookies>,
326
327    /// When true, record request/response details for this step in the
328    /// report even when the step passes. This is a per-step opt-in
329    /// equivalent of the global `--verbose-responses` CLI flag, useful
330    /// for keeping one hot debugging step loud without enabling verbose
331    /// capture across the whole file.
332    #[serde(default)]
333    pub debug: bool,
334
335    /// Source location of the step's `name:` node in the original YAML.
336    /// Populated by `parser::parse_str` after deserialization so downstream
337    /// consumers can anchor runtime results on the exact source range.
338    #[serde(skip)]
339    pub location: Option<Location>,
340
341    /// Source locations of individual assertion keys, indexed by the same
342    /// string used in `AssertionResult::assertion` (e.g. `"status"`,
343    /// `"duration"`, `"headers.content-type"`, `"body $.name"`).
344    /// Populated by `parser::parse_str` after deserialization.
345    #[serde(skip)]
346    pub assertion_locations: HashMap<String, Location>,
347}
348
349/// Step-level cookie control.
350#[derive(Debug, Clone, PartialEq)]
351pub enum StepCookies {
352    /// Enable (true) or disable (false) the default cookie jar.
353    Enabled(bool),
354    /// Use a named cookie jar.
355    Named(String),
356}
357
358impl<'de> Deserialize<'de> for StepCookies {
359    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
360    where
361        D: serde::Deserializer<'de>,
362    {
363        let value = serde_yaml::Value::deserialize(deserializer)?;
364        match value {
365            serde_yaml::Value::Bool(b) => Ok(StepCookies::Enabled(b)),
366            serde_yaml::Value::String(s) => Ok(StepCookies::Named(s)),
367            _ => Err(serde::de::Error::custom(
368                "cookies must be true, false, or a jar name string",
369            )),
370        }
371    }
372}
373
374/// Capture specification: either a simple JSONPath string or an extended capture.
375///
376/// The extended variant is boxed so [`CaptureSpec`] itself stays
377/// pointer-sized. Without the box, clippy's `large_enum_variant`
378/// check fires because every `JsonPath(String)` value would reserve
379/// space for the much larger [`ExtendedCapture`] struct. Boxing
380/// keeps the discriminant slot small and leaves extension room for
381/// future capture fields without every simple capture paying the
382/// price in memory.
383#[derive(Debug, Deserialize, Clone)]
384#[serde(untagged)]
385pub enum CaptureSpec {
386    /// Simple JSONPath: "$.token"
387    JsonPath(String),
388    /// Extended capture: from header or JSONPath with optional regex
389    Extended(Box<ExtendedCapture>),
390}
391
392/// Extended capture specification supporting multiple response sources with optional regex extraction.
393#[derive(Debug, Deserialize, Clone, Default)]
394pub struct ExtendedCapture {
395    /// Capture from a response header (case-insensitive lookup)
396    pub header: Option<String>,
397    /// Capture from a response cookie by cookie name
398    pub cookie: Option<String>,
399    /// Capture from body via JSONPath (explicit form)
400    pub jsonpath: Option<String>,
401    /// Capture from the whole response body string
402    pub body: Option<bool>,
403    /// Capture from the HTTP response status code
404    pub status: Option<bool>,
405    /// Capture from the final response URL after redirects
406    pub url: Option<bool>,
407    /// Optional regex to extract a sub-match (capture group 1)
408    pub regex: Option<String>,
409    /// Predicate filter for arrays: when `jsonpath` yields an array,
410    /// keep only elements whose every field equals (or satisfies the
411    /// nested operator map for) the corresponding entry in this
412    /// mapping. Combined with `first`/`last`/`count` transforms this
413    /// replaces brittle `$[0]` captures from shared list endpoints with
414    /// identity-based selection (NAZ-341).
415    #[serde(default, rename = "where")]
416    pub where_predicate: Option<serde_yaml::Value>,
417    /// When true, a missing source (JSONPath with no match, header not
418    /// present, regex with no match, etc.) leaves the capture explicitly
419    /// unset instead of failing the step. Downstream interpolation of
420    /// `{{ capture.X }}` where X was optional-and-unset produces a
421    /// distinct "declared optional and not set" error instead of the
422    /// generic unresolved-template error.
423    #[serde(default)]
424    pub optional: Option<bool>,
425    /// Default value used when the source yields no match. Implies
426    /// `optional: true` — if a default is supplied, missing values never
427    /// fail the step.
428    ///
429    /// Deserialized through [`deserialize_default_value`] so that
430    /// `default: null` in YAML becomes `Some(DefaultValue(Null))` —
431    /// a bare `Option<serde_yaml::Value>` would treat YAML null the
432    /// same way it treats "field absent" and silently drop the user's
433    /// "I want a literal null fallback" intent.
434    #[serde(default, deserialize_with = "deserialize_default_value")]
435    pub default: Option<DefaultValue>,
436    /// Only attempt the capture when the response matches this gate.
437    /// When present and unmet, the capture is skipped the same way an
438    /// optional-unset capture would be (variable unset, no error).
439    #[serde(default)]
440    pub when: Option<CaptureWhen>,
441}
442
443/// Response-shape predicate used by `capture.when` to decide whether a
444/// capture should attempt extraction. Today this only gates on status
445/// code (reusing the existing `StatusAssertion`), but the struct shape
446/// leaves room for future dimensions (e.g. header matchers) without a
447/// breaking YAML change.
448#[derive(Debug, Deserialize, Clone)]
449pub struct CaptureWhen {
450    /// Status matcher identical in shape to the assertion `status:`
451    /// field — exact code (`201`), shorthand range (`"2xx"`), or
452    /// complex spec (`{ in: [200, 201] }`, `{ gte: 400, lt: 500 }`).
453    pub status: Option<StatusAssertion>,
454}
455
456/// Transparent wrapper around the raw `serde_yaml::Value` a user
457/// supplies for a `default:`. The wrapper exists so that `default:
458/// null` deserializes to `Some(DefaultValue(Null))` — a bare
459/// `Option<serde_yaml::Value>` field collapses the YAML null into
460/// serde's `None`, which would silently drop the user's intent.
461#[derive(Debug, Clone, PartialEq)]
462pub struct DefaultValue(pub serde_yaml::Value);
463
464impl<'de> Deserialize<'de> for DefaultValue {
465    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
466    where
467        D: serde::Deserializer<'de>,
468    {
469        let value = serde_yaml::Value::deserialize(deserializer)?;
470        Ok(DefaultValue(value))
471    }
472}
473
474impl DefaultValue {
475    /// Borrow the inner YAML value. Runtime code converts it to a
476    /// JSON value via the capture module's `yaml_to_json` helper.
477    pub fn as_value(&self) -> &serde_yaml::Value {
478        &self.0
479    }
480}
481
482/// Custom deserializer for `default:` that treats a literal YAML null
483/// as `Some(DefaultValue(Null))` rather than `None`. Without this,
484/// `Option::deserialize` would collapse both "field absent" and
485/// "field present but null" into the same `None`, silently dropping
486/// the user's "I want a literal null fallback" intent.
487fn deserialize_default_value<'de, D>(deserializer: D) -> Result<Option<DefaultValue>, D::Error>
488where
489    D: serde::Deserializer<'de>,
490{
491    // When a `default:` key appears, always return `Some(...)`.
492    // `#[serde(default)]` on the field still covers the "key absent"
493    // path, so this deserializer only ever runs when the user wrote
494    // `default: <anything>` in their YAML.
495    let value = serde_yaml::Value::deserialize(deserializer)?;
496    Ok(Some(DefaultValue(value)))
497}
498
499/// Polling configuration: re-execute step until a condition is met.
500#[derive(Debug, Deserialize, Clone)]
501pub struct PollConfig {
502    /// Assertions that must pass for polling to stop
503    pub until: Assertion,
504    /// Time between attempts (e.g., "2s", "500ms")
505    pub interval: String,
506    /// Maximum number of polling attempts
507    pub max_attempts: u32,
508}
509
510/// HTTP request definition.
511#[derive(Debug, Deserialize, Clone)]
512pub struct Request {
513    pub method: String,
514    pub url: String,
515
516    #[serde(default)]
517    pub headers: HashMap<String, String>,
518
519    /// Optional auth helper. Explicit Authorization headers still win.
520    pub auth: Option<AuthConfig>,
521
522    /// Request body — can be any JSON-compatible value
523    pub body: Option<serde_json::Value>,
524
525    /// URL-encoded form body sent as application/x-www-form-urlencoded
526    #[serde(default)]
527    pub form: Option<IndexMap<String, String>>,
528
529    /// GraphQL query (syntactic sugar; translates to JSON POST body)
530    pub graphql: Option<GraphqlRequest>,
531
532    /// Multipart form data for file uploads
533    pub multipart: Option<MultipartBody>,
534}
535
536/// First-class auth helper for common bearer/basic cases.
537#[derive(Debug, Deserialize, Clone)]
538pub struct AuthConfig {
539    /// Bearer token value (without the `Bearer ` prefix)
540    pub bearer: Option<String>,
541    /// Basic auth credentials
542    pub basic: Option<BasicAuthConfig>,
543}
544
545/// Basic auth credentials.
546#[derive(Debug, Deserialize, Clone)]
547pub struct BasicAuthConfig {
548    pub username: String,
549    pub password: String,
550}
551
552/// GraphQL query definition.
553#[derive(Debug, Deserialize, Clone)]
554pub struct GraphqlRequest {
555    /// The GraphQL query or mutation string
556    pub query: String,
557    /// Variables to pass to the query
558    #[serde(default)]
559    pub variables: Option<serde_json::Value>,
560    /// Operation name (when query contains multiple operations)
561    pub operation_name: Option<String>,
562}
563
564/// Multipart form data body for file uploads.
565#[derive(Debug, Deserialize, Clone)]
566pub struct MultipartBody {
567    /// Text form fields
568    #[serde(default)]
569    pub fields: Vec<FormField>,
570    /// File upload fields
571    #[serde(default)]
572    pub files: Vec<FileField>,
573}
574
575/// A text field in a multipart form.
576#[derive(Debug, Deserialize, Clone)]
577pub struct FormField {
578    pub name: String,
579    pub value: String,
580}
581
582/// A file field in a multipart form.
583#[derive(Debug, Deserialize, Clone)]
584pub struct FileField {
585    /// Form field name
586    pub name: String,
587    /// Path to the file (relative to test file)
588    pub path: String,
589    /// MIME content type (e.g., "image/jpeg")
590    pub content_type: Option<String>,
591    /// Override filename sent in the form
592    pub filename: Option<String>,
593}
594
595/// Default settings applied to every request in a file.
596#[derive(Debug, Deserialize, Clone)]
597pub struct Defaults {
598    #[serde(default)]
599    pub headers: HashMap<String, String>,
600
601    /// Default auth helper applied when a step does not set request.auth.
602    pub auth: Option<AuthConfig>,
603
604    /// Default timeout in milliseconds
605    pub timeout: Option<u64>,
606
607    /// Default connect timeout in milliseconds
608    #[serde(alias = "connect-timeout")]
609    pub connect_timeout: Option<u64>,
610
611    /// Default redirect-following behavior
612    #[serde(alias = "follow-redirects")]
613    pub follow_redirects: Option<bool>,
614
615    /// Default maximum redirects to follow
616    #[serde(alias = "max-redirs")]
617    pub max_redirs: Option<u32>,
618
619    /// Default retries for all steps
620    pub retries: Option<u32>,
621
622    /// Default delay before each request (e.g., "100ms", "1s")
623    pub delay: Option<String>,
624}
625
626/// Assertion block for a step.
627#[derive(Debug, Deserialize, Clone)]
628pub struct Assertion {
629    /// Expected HTTP status code (exact, shorthand range, or complex)
630    pub status: Option<StatusAssertion>,
631
632    /// Response time assertion (e.g., "< 500ms")
633    pub duration: Option<String>,
634
635    /// Redirect assertions against the final response URL and redirect count
636    pub redirect: Option<RedirectAssertion>,
637
638    /// Header assertions
639    pub headers: Option<HashMap<String, String>>,
640
641    /// Body assertions via JSONPath
642    pub body: Option<IndexMap<String, serde_yaml::Value>>,
643}
644
645/// Redirect assertions for a followed response chain.
646#[derive(Debug, Deserialize, Clone)]
647pub struct RedirectAssertion {
648    /// Expected final URL after redirects
649    pub url: Option<String>,
650    /// Expected number of followed redirects
651    pub count: Option<u32>,
652}
653
654/// Status code assertion: exact match, shorthand range, or complex spec.
655#[derive(Debug, Deserialize, Clone)]
656#[serde(untagged)]
657pub enum StatusAssertion {
658    /// Exact match: `status: 200`
659    Exact(u16),
660    /// Shorthand range: `status: "2xx"`, `status: "4xx"`
661    Shorthand(String),
662    /// Complex: `status: { in: [200, 201] }` or `status: { gte: 400, lt: 500 }`
663    Complex(StatusSpec),
664}
665
666/// Complex status code specification with ranges and sets.
667#[derive(Debug, Deserialize, Clone)]
668pub struct StatusSpec {
669    /// Set of allowed status codes
670    #[serde(rename = "in")]
671    pub in_set: Option<Vec<u16>>,
672    /// Greater than or equal
673    pub gte: Option<u16>,
674    /// Greater than
675    pub gt: Option<u16>,
676    /// Less than or equal
677    pub lte: Option<u16>,
678    /// Less than
679    pub lt: Option<u16>,
680}
681
682#[cfg(test)]
683mod tests {
684    use super::*;
685
686    #[test]
687    fn deserialize_minimal_test_file() {
688        let yaml = r#"
689name: Health check
690steps:
691  - name: GET /health
692    request:
693      method: GET
694      url: "http://localhost:3000/health"
695    assert:
696      status: 200
697"#;
698        let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
699        assert_eq!(tf.name, "Health check");
700        assert_eq!(tf.steps.len(), 1);
701        assert_eq!(tf.steps[0].name, "GET /health");
702        assert_eq!(tf.steps[0].request.method, "GET");
703        assert_eq!(tf.steps[0].request.url, "http://localhost:3000/health");
704        assert!(matches!(
705            tf.steps[0].assertions.as_ref().unwrap().status,
706            Some(StatusAssertion::Exact(200))
707        ));
708    }
709
710    #[test]
711    fn deserialize_full_test_file() {
712        let yaml = r#"
713version: "1"
714name: "User CRUD"
715description: "Tests CRUD lifecycle"
716tags: [crud, users]
717env:
718  base_url: "http://localhost:3000"
719defaults:
720  headers:
721    Content-Type: "application/json"
722  timeout: 5000
723setup:
724  - name: Auth
725    request:
726      method: POST
727      url: "http://localhost:3000/auth"
728      body:
729        email: "admin@test.com"
730    capture:
731      token: "$.token"
732    assert:
733      status: 200
734teardown:
735  - name: Cleanup
736    request:
737      method: POST
738      url: "http://localhost:3000/cleanup"
739tests:
740  create_user:
741    description: "Create a user"
742    tags: [smoke]
743    steps:
744      - name: Create
745        request:
746          method: POST
747          url: "http://localhost:3000/users"
748          headers:
749            Authorization: "Bearer token"
750          body:
751            name: "Jane"
752        capture:
753          user_id: "$.id"
754        assert:
755          status: 201
756          duration: "< 500ms"
757          headers:
758            content-type: contains "application/json"
759          body:
760            "$.name": "Jane"
761"#;
762        let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
763        assert_eq!(tf.version, Some("1".into()));
764        assert_eq!(tf.name, "User CRUD");
765        assert_eq!(tf.description, Some("Tests CRUD lifecycle".into()));
766        assert_eq!(tf.tags, vec!["crud", "users"]);
767        assert_eq!(tf.env.get("base_url").unwrap(), "http://localhost:3000");
768
769        // Defaults
770        let defaults = tf.defaults.as_ref().unwrap();
771        assert_eq!(
772            defaults.headers.get("Content-Type").unwrap(),
773            "application/json"
774        );
775        assert_eq!(defaults.timeout, Some(5000));
776
777        // Setup
778        assert_eq!(tf.setup.len(), 1);
779        assert_eq!(tf.setup[0].name, "Auth");
780        assert!(matches!(
781            tf.setup[0].capture.get("token"),
782            Some(CaptureSpec::JsonPath(p)) if p == "$.token"
783        ));
784
785        // Teardown
786        assert_eq!(tf.teardown.len(), 1);
787
788        // Tests
789        assert_eq!(tf.tests.len(), 1);
790        let test = tf.tests.get("create_user").unwrap();
791        assert_eq!(test.description, Some("Create a user".into()));
792        assert_eq!(test.tags, vec!["smoke"]);
793        assert_eq!(test.steps.len(), 1);
794
795        let step = &test.steps[0];
796        assert_eq!(step.name, "Create");
797        assert_eq!(step.request.method, "POST");
798        assert!(step.request.body.is_some());
799        assert!(matches!(
800            step.capture.get("user_id"),
801            Some(CaptureSpec::JsonPath(p)) if p == "$.id"
802        ));
803
804        let assertions = step.assertions.as_ref().unwrap();
805        assert!(matches!(
806            assertions.status,
807            Some(StatusAssertion::Exact(201))
808        ));
809        assert_eq!(assertions.duration, Some("< 500ms".into()));
810        assert!(assertions.headers.is_some());
811        assert!(assertions.body.is_some());
812    }
813
814    #[test]
815    fn deserialize_step_without_assertions() {
816        let yaml = r#"
817name: Fire and forget
818steps:
819  - name: Trigger webhook
820    request:
821      method: POST
822      url: "http://localhost:3000/webhook"
823      body:
824        event: "deploy"
825"#;
826        let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
827        assert_eq!(tf.steps.len(), 1);
828        assert!(tf.steps[0].assertions.is_none());
829    }
830
831    #[test]
832    fn deserialize_redirect_assertion() {
833        let yaml = r#"
834name: Redirect assertions
835steps:
836  - name: Follow chain
837    request:
838      method: GET
839      url: "http://localhost:3000/redirect"
840    assert:
841      redirect:
842        url: "http://localhost:3000/final"
843        count: 2
844"#;
845        let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
846        let redirect = tf.steps[0]
847            .assertions
848            .as_ref()
849            .and_then(|a| a.redirect.as_ref())
850            .unwrap();
851        assert_eq!(redirect.url.as_deref(), Some("http://localhost:3000/final"));
852        assert_eq!(redirect.count, Some(2));
853    }
854
855    #[test]
856    fn deserialize_empty_optional_fields() {
857        let yaml = r#"
858name: Minimal
859steps:
860  - name: Simple GET
861    request:
862      method: GET
863      url: "http://localhost:3000"
864"#;
865        let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
866        assert!(tf.version.is_none());
867        assert!(tf.description.is_none());
868        assert!(tf.tags.is_empty());
869        assert!(tf.env.is_empty());
870        assert!(tf.defaults.is_none());
871        assert!(tf.setup.is_empty());
872        assert!(tf.teardown.is_empty());
873        assert!(tf.tests.is_empty());
874    }
875
876    #[test]
877    fn deserialize_request_with_headers_and_body() {
878        let yaml = r#"
879name: test
880steps:
881  - name: POST with JSON body
882    request:
883      method: POST
884      url: "http://localhost:3000/users"
885      headers:
886        Authorization: "Bearer xyz"
887        X-Custom: "hello"
888      body:
889        name: "Alice"
890        tags: ["a", "b"]
891        nested:
892          key: "value"
893"#;
894        let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
895        let req = &tf.steps[0].request;
896        assert_eq!(req.headers.get("Authorization").unwrap(), "Bearer xyz");
897        assert_eq!(req.headers.get("X-Custom").unwrap(), "hello");
898
899        let body = req.body.as_ref().unwrap();
900        assert_eq!(body["name"], "Alice");
901        assert_eq!(body["tags"][0], "a");
902        assert_eq!(body["nested"]["key"], "value");
903    }
904
905    #[test]
906    fn deserialize_request_with_auth_helper() {
907        let yaml = r#"
908name: auth
909steps:
910  - name: GET
911    request:
912      method: GET
913      url: "http://localhost:3000/me"
914      auth:
915        bearer: "{{ env.token }}"
916"#;
917        let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
918        let auth = tf.steps[0].request.auth.as_ref().unwrap();
919        assert_eq!(auth.bearer.as_deref(), Some("{{ env.token }}"));
920        assert!(auth.basic.is_none());
921    }
922
923    #[test]
924    fn deserialize_defaults_with_basic_auth_helper() {
925        let yaml = r#"
926name: auth
927defaults:
928  auth:
929    basic:
930      username: "demo"
931      password: "{{ env.password }}"
932steps:
933  - name: GET
934    request:
935      method: GET
936      url: "http://localhost:3000/me"
937"#;
938        let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
939        let auth = tf.defaults.as_ref().unwrap().auth.as_ref().unwrap();
940        let basic = auth.basic.as_ref().unwrap();
941        assert_eq!(basic.username, "demo");
942        assert_eq!(basic.password, "{{ env.password }}");
943    }
944
945    #[test]
946    fn tests_preserve_insertion_order() {
947        let yaml = r#"
948name: Order test
949tests:
950  third_test:
951    steps:
952      - name: step
953        request:
954          method: GET
955          url: "http://localhost:3000"
956  first_test:
957    steps:
958      - name: step
959        request:
960          method: GET
961          url: "http://localhost:3000"
962  second_test:
963    steps:
964      - name: step
965        request:
966          method: GET
967          url: "http://localhost:3000"
968"#;
969        let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
970        let keys: Vec<&String> = tf.tests.keys().collect();
971        assert_eq!(keys, vec!["third_test", "first_test", "second_test"]);
972    }
973
974    #[test]
975    fn deserialize_body_assertions_with_various_types() {
976        let yaml = r#"
977name: Assertion types
978steps:
979  - name: Check types
980    request:
981      method: GET
982      url: "http://localhost:3000"
983    assert:
984      status: 200
985      body:
986        "$.string": "hello"
987        "$.number": 42
988        "$.bool": true
989        "$.null_field": null
990        "$.complex":
991          type: string
992          contains: "sub"
993"#;
994        let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
995        let body = tf.steps[0]
996            .assertions
997            .as_ref()
998            .unwrap()
999            .body
1000            .as_ref()
1001            .unwrap();
1002        assert_eq!(body.len(), 5);
1003        assert!(body.contains_key("$.string"));
1004        assert!(body.contains_key("$.number"));
1005        assert!(body.contains_key("$.bool"));
1006        assert!(body.contains_key("$.null_field"));
1007        assert!(body.contains_key("$.complex"));
1008    }
1009
1010    // --- New tests for extended captures ---
1011
1012    #[test]
1013    fn deserialize_header_capture() {
1014        let yaml = r#"
1015name: Header capture test
1016steps:
1017  - name: Login
1018    request:
1019      method: POST
1020      url: "http://localhost:3000/login"
1021    capture:
1022      session_token:
1023        header: "set-cookie"
1024        regex: "session_token=([^;]+)"
1025      user_id: "$.id"
1026"#;
1027        let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1028        let cap = &tf.steps[0].capture;
1029        assert!(matches!(cap.get("user_id"), Some(CaptureSpec::JsonPath(p)) if p == "$.id"));
1030        match cap.get("session_token") {
1031            Some(CaptureSpec::Extended(ext)) => {
1032                assert_eq!(ext.header.as_deref(), Some("set-cookie"));
1033                assert_eq!(ext.cookie, None);
1034                assert_eq!(ext.body, None);
1035                assert_eq!(ext.status, None);
1036                assert_eq!(ext.url, None);
1037                assert_eq!(ext.regex.as_deref(), Some("session_token=([^;]+)"));
1038            }
1039            other => panic!("Expected Extended capture, got {:?}", other),
1040        }
1041    }
1042
1043    #[test]
1044    fn deserialize_status_capture() {
1045        let yaml = r#"
1046name: Status capture test
1047steps:
1048  - name: Health
1049    request:
1050      method: GET
1051      url: "http://localhost:3000/health"
1052    capture:
1053      status_code:
1054        status: true
1055"#;
1056        let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1057        let cap = &tf.steps[0].capture;
1058        match cap.get("status_code") {
1059            Some(CaptureSpec::Extended(ext)) => {
1060                assert_eq!(ext.header, None);
1061                assert_eq!(ext.cookie, None);
1062                assert_eq!(ext.jsonpath, None);
1063                assert_eq!(ext.body, None);
1064                assert_eq!(ext.status, Some(true));
1065                assert_eq!(ext.url, None);
1066                assert_eq!(ext.regex, None);
1067            }
1068            other => panic!("Expected Extended capture, got {:?}", other),
1069        }
1070    }
1071
1072    #[test]
1073    fn deserialize_url_capture() {
1074        let yaml = r#"
1075name: URL capture test
1076steps:
1077  - name: Follow redirect
1078    request:
1079      method: GET
1080      url: "http://localhost:3000/redirect"
1081    capture:
1082      final_url:
1083        url: true
1084"#;
1085        let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1086        let cap = &tf.steps[0].capture;
1087        match cap.get("final_url") {
1088            Some(CaptureSpec::Extended(ext)) => {
1089                assert_eq!(ext.header, None);
1090                assert_eq!(ext.cookie, None);
1091                assert_eq!(ext.jsonpath, None);
1092                assert_eq!(ext.body, None);
1093                assert_eq!(ext.status, None);
1094                assert_eq!(ext.url, Some(true));
1095                assert_eq!(ext.regex, None);
1096            }
1097            other => panic!("Expected Extended capture, got {:?}", other),
1098        }
1099    }
1100
1101    #[test]
1102    fn deserialize_cookie_and_body_capture() {
1103        let yaml = r#"
1104name: Cookie capture test
1105steps:
1106  - name: Cookies
1107    request:
1108      method: GET
1109      url: "http://localhost:3000/cookies"
1110    capture:
1111      session_cookie:
1112        cookie: "session"
1113      body_word:
1114        body: true
1115        regex: "plain (text)"
1116"#;
1117        let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1118        let cap = &tf.steps[0].capture;
1119        match cap.get("session_cookie") {
1120            Some(CaptureSpec::Extended(ext)) => {
1121                assert_eq!(ext.header, None);
1122                assert_eq!(ext.cookie.as_deref(), Some("session"));
1123                assert_eq!(ext.jsonpath, None);
1124                assert_eq!(ext.body, None);
1125                assert_eq!(ext.status, None);
1126                assert_eq!(ext.url, None);
1127                assert_eq!(ext.regex, None);
1128            }
1129            other => panic!("Expected cookie Extended capture, got {:?}", other),
1130        }
1131        match cap.get("body_word") {
1132            Some(CaptureSpec::Extended(ext)) => {
1133                assert_eq!(ext.header, None);
1134                assert_eq!(ext.cookie, None);
1135                assert_eq!(ext.jsonpath, None);
1136                assert_eq!(ext.body, Some(true));
1137                assert_eq!(ext.status, None);
1138                assert_eq!(ext.url, None);
1139                assert_eq!(ext.regex.as_deref(), Some("plain (text)"));
1140            }
1141            other => panic!("Expected body Extended capture, got {:?}", other),
1142        }
1143    }
1144
1145    // --- New tests for status assertion variants ---
1146
1147    #[test]
1148    fn deserialize_status_shorthand() {
1149        let yaml = r#"
1150name: Status range
1151steps:
1152  - name: Check 2xx
1153    request:
1154      method: GET
1155      url: "http://localhost:3000"
1156    assert:
1157      status: "2xx"
1158"#;
1159        let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1160        assert!(matches!(
1161            tf.steps[0].assertions.as_ref().unwrap().status,
1162            Some(StatusAssertion::Shorthand(ref s)) if s == "2xx"
1163        ));
1164    }
1165
1166    #[test]
1167    fn deserialize_status_complex_in() {
1168        let yaml = r#"
1169name: Status set
1170steps:
1171  - name: Check set
1172    request:
1173      method: GET
1174      url: "http://localhost:3000"
1175    assert:
1176      status:
1177        in: [200, 201, 204]
1178"#;
1179        let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1180        match &tf.steps[0].assertions.as_ref().unwrap().status {
1181            Some(StatusAssertion::Complex(spec)) => {
1182                assert_eq!(spec.in_set.as_ref().unwrap(), &vec![200, 201, 204]);
1183            }
1184            other => panic!("Expected Complex status, got {:?}", other),
1185        }
1186    }
1187
1188    #[test]
1189    fn deserialize_status_complex_range() {
1190        let yaml = r#"
1191name: Status range
1192steps:
1193  - name: Check 4xx range
1194    request:
1195      method: GET
1196      url: "http://localhost:3000"
1197    assert:
1198      status:
1199        gte: 400
1200        lt: 500
1201"#;
1202        let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1203        match &tf.steps[0].assertions.as_ref().unwrap().status {
1204            Some(StatusAssertion::Complex(spec)) => {
1205                assert_eq!(spec.gte, Some(400));
1206                assert_eq!(spec.lt, Some(500));
1207            }
1208            other => panic!("Expected Complex status, got {:?}", other),
1209        }
1210    }
1211
1212    // --- Multipart ---
1213
1214    #[test]
1215    fn deserialize_multipart_request() {
1216        let yaml = r#"
1217name: Upload test
1218steps:
1219  - name: Upload photo
1220    request:
1221      method: POST
1222      url: "http://localhost:3000/upload"
1223      multipart:
1224        fields:
1225          - name: "title"
1226            value: "My Photo"
1227        files:
1228          - name: "photo"
1229            path: "./fixtures/test.jpg"
1230            content_type: "image/jpeg"
1231"#;
1232        let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1233        let mp = tf.steps[0].request.multipart.as_ref().unwrap();
1234        assert_eq!(mp.fields.len(), 1);
1235        assert_eq!(mp.fields[0].name, "title");
1236        assert_eq!(mp.fields[0].value, "My Photo");
1237        assert_eq!(mp.files.len(), 1);
1238        assert_eq!(mp.files[0].name, "photo");
1239        assert_eq!(mp.files[0].path, "./fixtures/test.jpg");
1240        assert_eq!(mp.files[0].content_type.as_deref(), Some("image/jpeg"));
1241    }
1242
1243    #[test]
1244    fn deserialize_form_request() {
1245        let yaml = r#"
1246name: Form test
1247steps:
1248  - name: Submit form
1249    request:
1250      method: POST
1251      url: "http://localhost:3000/login"
1252      form:
1253        email: "user@example.com"
1254        password: "secret"
1255"#;
1256        let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1257        let form = tf.steps[0].request.form.as_ref().unwrap();
1258        assert_eq!(
1259            form.get("email").map(String::as_str),
1260            Some("user@example.com")
1261        );
1262        assert_eq!(form.get("password").map(String::as_str), Some("secret"));
1263    }
1264
1265    // --- Default delay ---
1266
1267    #[test]
1268    fn deserialize_defaults_with_delay() {
1269        let yaml = r#"
1270name: Delay test
1271defaults:
1272  delay: "100ms"
1273  timeout: 5000
1274steps:
1275  - name: test
1276    request:
1277      method: GET
1278      url: "http://localhost:3000"
1279"#;
1280        let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1281        assert_eq!(
1282            tf.defaults.as_ref().unwrap().delay.as_deref(),
1283            Some("100ms")
1284        );
1285    }
1286
1287    // --- Cookies ---
1288
1289    #[test]
1290    fn deserialize_step_cookies_false() {
1291        let yaml = r#"
1292name: Step cookies test
1293steps:
1294  - name: No cookies step
1295    cookies: false
1296    request:
1297      method: GET
1298      url: "http://localhost:3000"
1299"#;
1300        let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1301        assert_eq!(tf.steps[0].cookies, Some(StepCookies::Enabled(false)));
1302    }
1303
1304    #[test]
1305    fn deserialize_step_cookies_true() {
1306        let yaml = r#"
1307name: Step cookies test
1308steps:
1309  - name: With cookies
1310    cookies: true
1311    request:
1312      method: GET
1313      url: "http://localhost:3000"
1314"#;
1315        let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1316        assert_eq!(tf.steps[0].cookies, Some(StepCookies::Enabled(true)));
1317    }
1318
1319    #[test]
1320    fn deserialize_step_cookies_named_jar() {
1321        let yaml = r#"
1322name: Step cookies test
1323steps:
1324  - name: Admin step
1325    cookies: "admin"
1326    request:
1327      method: GET
1328      url: "http://localhost:3000"
1329"#;
1330        let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1331        assert_eq!(
1332            tf.steps[0].cookies,
1333            Some(StepCookies::Named("admin".to_string()))
1334        );
1335    }
1336
1337    #[test]
1338    fn deserialize_step_cookies_default_none() {
1339        let yaml = r#"
1340name: Step cookies test
1341steps:
1342  - name: Default step
1343    request:
1344      method: GET
1345      url: "http://localhost:3000"
1346"#;
1347        let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1348        assert_eq!(tf.steps[0].cookies, None);
1349    }
1350
1351    #[test]
1352    fn deserialize_cookies_off() {
1353        let yaml = r#"
1354name: No cookies
1355cookies: "off"
1356steps:
1357  - name: test
1358    request:
1359      method: GET
1360      url: "http://localhost:3000"
1361"#;
1362        let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1363        assert_eq!(tf.cookies, Some(CookieMode::Off));
1364    }
1365
1366    #[test]
1367    fn deserialize_cookies_auto() {
1368        let yaml = r#"
1369name: Auto cookies
1370cookies: "auto"
1371steps:
1372  - name: test
1373    request:
1374      method: GET
1375      url: "http://localhost:3000"
1376"#;
1377        let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1378        assert_eq!(tf.cookies, Some(CookieMode::Auto));
1379    }
1380
1381    #[test]
1382    fn deserialize_cookies_per_test() {
1383        let yaml = r#"
1384name: Per-test cookies
1385cookies: "per-test"
1386tests:
1387  login:
1388    steps:
1389      - name: test
1390        request:
1391          method: GET
1392          url: "http://localhost:3000"
1393"#;
1394        let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1395        assert_eq!(tf.cookies, Some(CookieMode::PerTest));
1396    }
1397
1398    #[test]
1399    fn deserialize_cookies_invalid_value_is_rejected() {
1400        let yaml = r#"
1401name: Bad cookies
1402cookies: "sometimes"
1403steps:
1404  - name: test
1405    request:
1406      method: GET
1407      url: "http://localhost:3000"
1408"#;
1409        let err = serde_yaml::from_str::<TestFile>(yaml).unwrap_err();
1410        assert!(
1411            err.to_string().contains("per-test"),
1412            "error should mention the valid options, got: {err}"
1413        );
1414    }
1415
1416    #[test]
1417    fn deserialize_cookies_default_is_none() {
1418        let yaml = r#"
1419name: Default cookies
1420steps:
1421  - name: test
1422    request:
1423      method: GET
1424      url: "http://localhost:3000"
1425"#;
1426        let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1427        assert_eq!(tf.cookies, None);
1428    }
1429
1430    #[test]
1431    fn deserialize_redaction_config() {
1432        let yaml = r#"
1433name: Redaction config
1434redaction:
1435  headers:
1436    - authorization
1437    - x-session-token
1438  env:
1439    - api_token
1440  captures:
1441    - session
1442  replacement: "[redacted]"
1443steps:
1444  - name: test
1445    request:
1446      method: GET
1447      url: "http://localhost:3000"
1448"#;
1449
1450        let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1451        let redaction = tf.redaction.unwrap();
1452        assert_eq!(redaction.headers, vec!["authorization", "x-session-token"]);
1453        assert_eq!(redaction.env_vars, vec!["api_token"]);
1454        assert_eq!(redaction.captures, vec!["session"]);
1455        assert_eq!(redaction.replacement, "[redacted]");
1456    }
1457
1458    #[test]
1459    fn merge_headers_widens_list_case_insensitively() {
1460        let mut redaction = RedactionConfig {
1461            headers: vec!["authorization".into()],
1462            ..RedactionConfig::default()
1463        };
1464        redaction.merge_headers(["X-Custom-Token", "x-debug"]);
1465        assert_eq!(
1466            redaction.headers,
1467            vec!["authorization", "x-custom-token", "x-debug"]
1468        );
1469    }
1470
1471    #[test]
1472    fn merge_headers_skips_duplicates_ignoring_case() {
1473        let mut redaction = RedactionConfig {
1474            headers: vec!["authorization".into(), "x-api-key".into()],
1475            ..RedactionConfig::default()
1476        };
1477        redaction.merge_headers(["Authorization", "X-API-KEY", "x-new"]);
1478        assert_eq!(
1479            redaction.headers,
1480            vec!["authorization", "x-api-key", "x-new"]
1481        );
1482    }
1483
1484    #[test]
1485    fn merge_headers_trims_and_drops_empty_entries() {
1486        let mut redaction = RedactionConfig::default();
1487        let baseline_len = redaction.headers.len();
1488        redaction.merge_headers(["", "   ", "  X-Trim  "]);
1489        assert_eq!(redaction.headers.len(), baseline_len + 1);
1490        assert!(redaction.headers.iter().any(|h| h == "x-trim"));
1491    }
1492
1493    #[test]
1494    fn merge_headers_never_narrows_existing_list() {
1495        let mut redaction = RedactionConfig {
1496            headers: vec!["authorization".into(), "cookie".into()],
1497            ..RedactionConfig::default()
1498        };
1499        // Empty merge must not drop anything.
1500        redaction.merge_headers(std::iter::empty::<String>());
1501        assert_eq!(redaction.headers, vec!["authorization", "cookie"]);
1502    }
1503
1504    // --- Step-level descriptions (NAZ-243) ---
1505
1506    #[test]
1507    fn deserialize_step_with_description() {
1508        let yaml = r#"
1509name: Step description
1510steps:
1511  - name: GET /health
1512    description: "Verifies the health endpoint stays reachable"
1513    request:
1514      method: GET
1515      url: "http://localhost:3000/health"
1516    assert:
1517      status: 200
1518"#;
1519        let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1520        assert_eq!(tf.steps.len(), 1);
1521        assert_eq!(
1522            tf.steps[0].description.as_deref(),
1523            Some("Verifies the health endpoint stays reachable")
1524        );
1525    }
1526
1527    #[test]
1528    fn deserialize_step_description_missing_defaults_to_none() {
1529        let yaml = r#"
1530name: No description
1531steps:
1532  - name: GET /health
1533    request:
1534      method: GET
1535      url: "http://localhost:3000/health"
1536"#;
1537        let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1538        assert_eq!(tf.steps.len(), 1);
1539        assert!(tf.steps[0].description.is_none());
1540    }
1541
1542    #[test]
1543    fn deserialize_step_with_folded_multiline_description() {
1544        let yaml = r#"
1545name: Multi-line step description
1546steps:
1547  - name: GET /health
1548    description: |
1549      This step hits the health endpoint.
1550      It verifies the service is reachable.
1551    request:
1552      method: GET
1553      url: "http://localhost:3000/health"
1554  - name: GET /status
1555    description: >
1556      Folded description
1557      on two lines.
1558    request:
1559      method: GET
1560      url: "http://localhost:3000/status"
1561"#;
1562        let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1563        assert_eq!(tf.steps.len(), 2);
1564        let literal = tf.steps[0].description.as_deref().unwrap();
1565        assert!(
1566            literal.contains("This step hits the health endpoint.")
1567                && literal.contains("It verifies the service is reachable."),
1568            "literal block should preserve both lines, got: {:?}",
1569            literal
1570        );
1571        assert!(
1572            literal.contains('\n'),
1573            "literal block `|` must keep the newline between lines, got: {:?}",
1574            literal
1575        );
1576        let folded = tf.steps[1].description.as_deref().unwrap();
1577        assert_eq!(folded.trim_end(), "Folded description on two lines.");
1578    }
1579
1580    #[test]
1581    fn deserialize_step_description_inside_named_test() {
1582        let yaml = r#"
1583name: Named tests
1584tests:
1585  smoke:
1586    description: "Smoke tests"
1587    steps:
1588      - name: ping
1589        description: "Ping the service"
1590        request:
1591          method: GET
1592          url: "http://localhost:3000/ping"
1593"#;
1594        let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1595        let test = tf.tests.get("smoke").expect("smoke test group");
1596        assert_eq!(test.description.as_deref(), Some("Smoke tests"));
1597        assert_eq!(test.steps.len(), 1);
1598        assert_eq!(
1599            test.steps[0].description.as_deref(),
1600            Some("Ping the service")
1601        );
1602    }
1603
1604    #[test]
1605    fn file_and_test_level_descriptions_still_deserialize() {
1606        let yaml = r#"
1607name: Suite
1608description: "File-level description"
1609tests:
1610  t1:
1611    description: "Group description"
1612    steps:
1613      - name: step
1614        description: "Step description"
1615        request:
1616          method: GET
1617          url: "http://localhost:3000"
1618"#;
1619        let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1620        assert_eq!(tf.description.as_deref(), Some("File-level description"));
1621        let group = tf.tests.get("t1").unwrap();
1622        assert_eq!(group.description.as_deref(), Some("Group description"));
1623        assert_eq!(
1624            group.steps[0].description.as_deref(),
1625            Some("Step description")
1626        );
1627    }
1628
1629    // --- NAZ-242: optional / default / when on captures, if / unless on steps ---
1630
1631    #[test]
1632    fn deserialize_capture_with_optional_flag() {
1633        let yaml = r#"
1634name: Optional capture
1635steps:
1636  - name: maybe capture
1637    request:
1638      method: GET
1639      url: "http://localhost:3000/users/1"
1640    capture:
1641      middle_name:
1642        jsonpath: "$.middle_name"
1643        optional: true
1644"#;
1645        let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1646        match tf.steps[0].capture.get("middle_name") {
1647            Some(CaptureSpec::Extended(ext)) => {
1648                assert_eq!(ext.optional, Some(true));
1649                assert_eq!(ext.default, None);
1650                assert!(ext.when.is_none());
1651            }
1652            other => panic!("expected Extended capture, got {:?}", other),
1653        }
1654    }
1655
1656    #[test]
1657    fn deserialize_capture_with_default_value_of_various_types() {
1658        let yaml = r#"
1659name: Default capture
1660steps:
1661  - name: step
1662    request:
1663      method: GET
1664      url: "http://localhost:3000"
1665    capture:
1666      count:
1667        jsonpath: "$.count"
1668        default: 0
1669      label:
1670        jsonpath: "$.label"
1671        default: "unnamed"
1672      deleted:
1673        jsonpath: "$.deleted"
1674        default: null
1675"#;
1676        let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1677        let caps = &tf.steps[0].capture;
1678
1679        let count = match caps.get("count") {
1680            Some(CaptureSpec::Extended(ext)) => ext,
1681            other => panic!("expected Extended, got {:?}", other),
1682        };
1683        assert_eq!(
1684            count.default.as_ref().and_then(|v| v.as_value().as_i64()),
1685            Some(0),
1686            "numeric default preserved"
1687        );
1688
1689        let label = match caps.get("label") {
1690            Some(CaptureSpec::Extended(ext)) => ext,
1691            other => panic!("expected Extended, got {:?}", other),
1692        };
1693        assert_eq!(
1694            label.default.as_ref().and_then(|v| v.as_value().as_str()),
1695            Some("unnamed"),
1696            "string default preserved"
1697        );
1698
1699        let deleted = match caps.get("deleted") {
1700            Some(CaptureSpec::Extended(ext)) => ext,
1701            other => panic!("expected Extended, got {:?}", other),
1702        };
1703        assert!(
1704            deleted
1705                .default
1706                .as_ref()
1707                .map(|v| v.as_value().is_null())
1708                .unwrap_or(false),
1709            "null default preserved (got {:?})",
1710            deleted.default
1711        );
1712    }
1713
1714    #[test]
1715    fn deserialize_capture_with_when_status_exact() {
1716        let yaml = r#"
1717name: Conditional capture
1718steps:
1719  - name: create if missing
1720    request:
1721      method: PUT
1722      url: "http://localhost:3000/widgets/1"
1723    capture:
1724      created_id:
1725        jsonpath: "$.id"
1726        when:
1727          status: 201
1728"#;
1729        let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1730        let ext = match tf.steps[0].capture.get("created_id") {
1731            Some(CaptureSpec::Extended(e)) => e,
1732            other => panic!("expected Extended, got {:?}", other),
1733        };
1734        let when = ext.when.as_ref().expect("when present");
1735        match when.status.as_ref().unwrap() {
1736            StatusAssertion::Exact(201) => {}
1737            other => panic!("expected Exact(201), got {:?}", other),
1738        }
1739    }
1740
1741    #[test]
1742    fn deserialize_capture_with_when_status_set_and_range() {
1743        let yaml = r#"
1744name: Conditional capture sets
1745steps:
1746  - name: pick
1747    request:
1748      method: GET
1749      url: "http://localhost:3000/x"
1750    capture:
1751      ok_id:
1752        jsonpath: "$.id"
1753        when:
1754          status:
1755            in: [200, 201]
1756      err_code:
1757        jsonpath: "$.error.code"
1758        when:
1759          status:
1760            gte: 400
1761            lt: 500
1762"#;
1763        let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1764        let caps = &tf.steps[0].capture;
1765
1766        let ok = match caps.get("ok_id") {
1767            Some(CaptureSpec::Extended(e)) => e,
1768            other => panic!("expected Extended, got {:?}", other),
1769        };
1770        match ok.when.as_ref().unwrap().status.as_ref().unwrap() {
1771            StatusAssertion::Complex(spec) => {
1772                assert_eq!(spec.in_set.as_ref().unwrap(), &vec![200, 201]);
1773            }
1774            other => panic!("expected Complex in, got {:?}", other),
1775        }
1776
1777        let err = match caps.get("err_code") {
1778            Some(CaptureSpec::Extended(e)) => e,
1779            other => panic!("expected Extended, got {:?}", other),
1780        };
1781        match err.when.as_ref().unwrap().status.as_ref().unwrap() {
1782            StatusAssertion::Complex(spec) => {
1783                assert_eq!(spec.gte, Some(400));
1784                assert_eq!(spec.lt, Some(500));
1785            }
1786            other => panic!("expected Complex range, got {:?}", other),
1787        }
1788    }
1789
1790    #[test]
1791    fn deserialize_step_with_if_and_unless_fields() {
1792        let yaml = r#"
1793name: Conditional steps
1794steps:
1795  - name: run only if set
1796    if: "{{ capture.request_uuid }}"
1797    request:
1798      method: GET
1799      url: "http://localhost:3000/a"
1800  - name: run only if unset
1801    unless: "{{ capture.request_uuid }}"
1802    request:
1803      method: GET
1804      url: "http://localhost:3000/b"
1805"#;
1806        let tf: TestFile = serde_yaml::from_str(yaml).unwrap();
1807        assert_eq!(
1808            tf.steps[0].run_if.as_deref(),
1809            Some("{{ capture.request_uuid }}")
1810        );
1811        assert!(tf.steps[0].unless.is_none());
1812        assert!(tf.steps[1].run_if.is_none());
1813        assert_eq!(
1814            tf.steps[1].unless.as_deref(),
1815            Some("{{ capture.request_uuid }}")
1816        );
1817    }
1818}