parlov-elicit 0.5.0

Elicitation engine: strategy selection and probe plan generation for parlov.
Documentation
//! Unit and property tests for `ScdResourceIdProducer` and `ScdResourceIdConsumer`.

use super::{ScdResourceIdConsumer, ScdResourceIdProducer, TECHNIQUE};
use crate::chain::{Consumer, Producer, ProducerOutput, ProducerOutputKind};
use crate::test_utils::ctx_method_destructive;
use crate::types::ProbeSpec;
use http::{HeaderMap, HeaderValue, Method};
use parlov_core::{ResponseClass, Vector};

// --- ScdResourceIdProducer unit tests ---

#[test]
fn resource_id_producer_admits_success() {
    assert!(ScdResourceIdProducer.admits(ResponseClass::Success));
}

#[test]
fn resource_id_producer_does_not_admit_redirect() {
    assert!(!ScdResourceIdProducer.admits(ResponseClass::Redirect));
}

#[test]
fn resource_id_producer_does_not_admit_structured_error() {
    assert!(!ScdResourceIdProducer.admits(ResponseClass::StructuredError));
}

#[test]
fn resource_id_producer_extracts_id_from_location_header() {
    let mut headers = HeaderMap::new();
    headers.insert(
        http::header::LOCATION,
        HeaderValue::from_static("/items/456"),
    );
    let out = ScdResourceIdProducer.extract(ResponseClass::Success, &headers);
    assert_eq!(
        out,
        Some(ProducerOutput::ResourceId("456".to_owned())),
        "must extract last path segment as resource ID"
    );
}

#[test]
fn resource_id_producer_extracts_uuid_id() {
    let mut headers = HeaderMap::new();
    headers.insert(
        http::header::LOCATION,
        HeaderValue::from_static("/api/v1/resources/550e8400-e29b-41d4-a716-446655440000"),
    );
    let out = ScdResourceIdProducer.extract(ResponseClass::Success, &headers);
    assert_eq!(
        out,
        Some(ProducerOutput::ResourceId(
            "550e8400-e29b-41d4-a716-446655440000".to_owned()
        ))
    );
}

#[test]
fn resource_id_producer_returns_none_when_no_location() {
    let out = ScdResourceIdProducer.extract(ResponseClass::Success, &HeaderMap::new());
    assert!(out.is_none(), "absent Location must yield None");
}

#[test]
fn resource_id_producer_returns_none_for_root_location() {
    let mut headers = HeaderMap::new();
    headers.insert(http::header::LOCATION, HeaderValue::from_static("/"));
    let out = ScdResourceIdProducer.extract(ResponseClass::Success, &headers);
    assert!(out.is_none(), "root-only Location must yield None");
}

#[test]
fn resource_id_producer_returns_none_for_empty_last_segment() {
    let mut headers = HeaderMap::new();
    headers.insert(http::header::LOCATION, HeaderValue::from_static("/items/"));
    let out = ScdResourceIdProducer.extract(ResponseClass::Success, &headers);
    assert!(
        out.is_none(),
        "trailing-slash Location with empty last segment must yield None"
    );
}

#[test]
fn resource_id_producer_strips_query_string() {
    let mut headers = HeaderMap::new();
    headers.insert(
        http::header::LOCATION,
        HeaderValue::from_static("/items/99?version=1"),
    );
    let out = ScdResourceIdProducer.extract(ResponseClass::Success, &headers);
    assert_eq!(out, Some(ProducerOutput::ResourceId("99".to_owned())));
}

// --- ScdResourceIdConsumer unit tests ---

#[test]
fn resource_id_consumer_needs_resource_id() {
    assert_eq!(
        ScdResourceIdConsumer.needs(),
        ProducerOutputKind::ResourceId
    );
}

#[test]
fn resource_id_consumer_generates_three_specs() {
    let output = ProducerOutput::ResourceId("42".to_owned());
    let specs = ScdResourceIdConsumer.generate(&ctx_method_destructive(), &output);
    assert_eq!(
        specs.len(),
        3,
        "must generate GET, PATCH, DELETE; got {}",
        specs.len()
    );
}

#[test]
fn resource_id_consumer_get_spec_targets_resource_url() {
    let output = ProducerOutput::ResourceId("42".to_owned());
    let ctx = ctx_method_destructive();
    let specs = ScdResourceIdConsumer.generate(&ctx, &output);
    let get_pair = specs
        .iter()
        .find_map(|s| {
            if let ProbeSpec::Pair(p) = s {
                if p.baseline.method == Method::GET {
                    return Some(p);
                }
            }
            None
        })
        .expect("GET spec must exist");
    assert!(
        get_pair.baseline.url.contains("42"),
        "GET baseline URL must embed extracted ID '42'; got {}",
        get_pair.baseline.url
    );
    assert!(
        get_pair.probe.url.contains(&ctx.probe_id),
        "GET probe URL must embed probe_id; got {}",
        get_pair.probe.url
    );
}

#[test]
fn resource_id_consumer_delete_spec_targets_resource_url() {
    let output = ProducerOutput::ResourceId("42".to_owned());
    let ctx = ctx_method_destructive();
    let specs = ScdResourceIdConsumer.generate(&ctx, &output);
    let del_pair = specs
        .iter()
        .find_map(|s| {
            if let ProbeSpec::Pair(p) = s {
                if p.baseline.method == Method::DELETE {
                    return Some(p);
                }
            }
            None
        })
        .expect("DELETE spec must exist");
    assert!(
        del_pair.baseline.url.contains("42"),
        "DELETE baseline URL must embed extracted ID; got {}",
        del_pair.baseline.url
    );
    assert!(
        del_pair.baseline.body.is_none(),
        "DELETE baseline must have no body"
    );
    assert!(
        del_pair.probe.body.is_none(),
        "DELETE probe must have no body"
    );
}

#[test]
fn resource_id_consumer_patch_has_body() {
    let output = ProducerOutput::ResourceId("42".to_owned());
    let specs = ScdResourceIdConsumer.generate(&ctx_method_destructive(), &output);
    let patch_pair = specs
        .iter()
        .find_map(|s| {
            if let ProbeSpec::Pair(p) = s {
                if p.baseline.method == Method::PATCH {
                    return Some(p);
                }
            }
            None
        })
        .expect("PATCH spec must exist");
    assert!(
        patch_pair.baseline.body.is_some(),
        "PATCH baseline must have a body"
    );
    assert!(
        patch_pair.probe.body.is_some(),
        "PATCH probe must have a body"
    );
}

#[test]
fn resource_id_consumer_returns_empty_when_wrong_output_variant() {
    let output = ProducerOutput::Location("https://example.com/items/42".to_owned());
    let specs = ScdResourceIdConsumer.generate(&ctx_method_destructive(), &output);
    assert!(
        specs.is_empty(),
        "non-ResourceId output must produce no specs"
    );
}

#[test]
fn resource_id_consumer_technique_is_status_code_diff() {
    let output = ProducerOutput::ResourceId("1".to_owned());
    let specs = ScdResourceIdConsumer.generate(&ctx_method_destructive(), &output);
    assert_eq!(specs[0].technique().vector, Vector::StatusCodeDiff);
}

// --- Property tests ---

use proptest::prelude::*;

// Any Location path with a non-empty final segment (no trailing slash) must yield
// a `ResourceId` whose value equals that final segment.
proptest! {
    #[test]
    fn producer_any_non_empty_last_segment_yields_resource_id(
        prefix in "[a-z]{1,8}(/[a-z0-9]{1,8}){0,3}",
        id in "[a-z0-9]{1,36}",
    ) {
        let location = format!("/{prefix}/{id}");
        let Ok(val) = HeaderValue::from_str(&location) else { return Ok(()); };
        let mut headers = HeaderMap::new();
        headers.insert(http::header::LOCATION, val);
        let out = ScdResourceIdProducer.extract(ResponseClass::Success, &headers);
        prop_assert_eq!(out, Some(ProducerOutput::ResourceId(id)));
    }
}

// For any valid resource ID string, `generate` always returns exactly 3 specs.
proptest! {
    #[test]
    fn consumer_any_resource_id_yields_three_specs(
        id in "[a-z0-9]{1,36}",
    ) {
        let output = ProducerOutput::ResourceId(id);
        let specs = ScdResourceIdConsumer.generate(&ctx_method_destructive(), &output);
        prop_assert_eq!(specs.len(), 3);
    }
}

// For any valid resource ID, all 3 specs must be `ProbeSpec::Pair` variants.
proptest! {
    #[test]
    fn consumer_all_specs_are_pairs(id in "[a-z0-9]{1,36}") {
        let output = ProducerOutput::ResourceId(id);
        let specs = ScdResourceIdConsumer.generate(&ctx_method_destructive(), &output);
        for spec in &specs {
            prop_assert!(matches!(spec, ProbeSpec::Pair(_)), "all specs must be Pair");
        }
    }
}

#[test]
fn normalization_weight_is_0_12() {
    assert_eq!(TECHNIQUE.normalization_weight, Some(0.12));
}

#[test]
fn inverted_signal_weight_is_none() {
    assert_eq!(TECHNIQUE.inverted_signal_weight, None);
}