parlov-elicit 0.5.0

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

use super::{ScdProblemDetailsConsumer, ScdProblemDetailsProducer, TECHNIQUE};
use crate::chain::{Consumer, Producer, ProducerOutput, ProducerOutputKind};
use crate::test_utils::minimal_ctx;
use crate::types::ProbeSpec;
use bytes::Bytes;
use http::{HeaderMap, HeaderValue};
use parlov_core::ResponseClass;

fn content_type(value: &'static str) -> HeaderMap {
    let mut h = HeaderMap::new();
    h.insert(http::header::CONTENT_TYPE, HeaderValue::from_static(value));
    h
}

fn problem_json_body() -> Bytes {
    Bytes::from_static(
        br#"{"type":"urn:err:missing","errors":[{"field":"name"},{"field":"email"}]}"#,
    )
}

fn plain_json_body() -> Bytes {
    Bytes::from_static(br#"{"errors":[{"field":"title"}]}"#)
}

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

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

#[test]
fn problem_details_producer_does_not_admit_not_found() {
    assert!(!ScdProblemDetailsProducer.admits(ResponseClass::Other));
}

#[test]
fn problem_details_producer_extracts_from_problem_json_body() {
    let headers = content_type("application/problem+json");
    let body = problem_json_body();
    let out = ScdProblemDetailsProducer.extract_with_body(
        ResponseClass::StructuredError,
        &headers,
        &body,
    );
    match out {
        Some(ProducerOutput::ProblemDetails {
            required_fields,
            error_type,
        }) => {
            assert!(
                required_fields.contains(&"name".to_owned()),
                "must contain field 'name'; got {required_fields:?}"
            );
            assert!(
                required_fields.contains(&"email".to_owned()),
                "must contain field 'email'; got {required_fields:?}"
            );
            assert_eq!(error_type, Some("urn:err:missing".to_owned()));
        }
        other => panic!("expected ProblemDetails, got {other:?}"),
    }
}

#[test]
fn problem_details_producer_extracts_from_plain_json_body() {
    let headers = content_type("application/json");
    let body = plain_json_body();
    let out = ScdProblemDetailsProducer.extract_with_body(
        ResponseClass::StructuredError,
        &headers,
        &body,
    );
    match out {
        Some(ProducerOutput::ProblemDetails {
            required_fields, ..
        }) => {
            assert_eq!(required_fields, vec!["title".to_owned()]);
        }
        other => panic!("expected ProblemDetails, got {other:?}"),
    }
}

#[test]
fn problem_details_producer_returns_none_on_non_json_content_type() {
    let headers = content_type("text/html");
    let out = ScdProblemDetailsProducer.extract_with_body(
        ResponseClass::StructuredError,
        &headers,
        &problem_json_body(),
    );
    assert!(out.is_none(), "non-JSON content type must yield None");
}

#[test]
fn problem_details_producer_returns_none_on_empty_body() {
    let headers = content_type("application/problem+json");
    let out = ScdProblemDetailsProducer.extract_with_body(
        ResponseClass::StructuredError,
        &headers,
        &Bytes::new(),
    );
    assert!(out.is_none(), "empty body must yield None");
}

#[test]
fn problem_details_producer_returns_none_on_malformed_json() {
    let headers = content_type("application/json");
    let body = Bytes::from_static(b"{not valid json");
    let out = ScdProblemDetailsProducer.extract_with_body(
        ResponseClass::StructuredError,
        &headers,
        &body,
    );
    assert!(out.is_none(), "malformed JSON must yield None");
}

#[test]
fn problem_details_producer_returns_none_when_no_errors_and_no_type() {
    let headers = content_type("application/json");
    let body = Bytes::from_static(br#"{"message":"something went wrong"}"#);
    let out = ScdProblemDetailsProducer.extract_with_body(
        ResponseClass::StructuredError,
        &headers,
        &body,
    );
    assert!(
        out.is_none(),
        "body without errors[] or type must yield None"
    );
}

#[test]
fn problem_details_producer_extracts_type_uri_without_errors() {
    let headers = content_type("application/problem+json");
    let body = Bytes::from_static(br#"{"type":"urn:problem:rate-limited"}"#);
    let out = ScdProblemDetailsProducer.extract_with_body(
        ResponseClass::StructuredError,
        &headers,
        &body,
    );
    match out {
        Some(ProducerOutput::ProblemDetails {
            required_fields,
            error_type,
        }) => {
            assert!(
                required_fields.is_empty(),
                "no errors[] means empty required_fields"
            );
            assert_eq!(error_type, Some("urn:problem:rate-limited".to_owned()));
        }
        other => panic!("expected ProblemDetails, got {other:?}"),
    }
}

#[test]
fn problem_details_consumer_needs_problem_details() {
    assert_eq!(
        ScdProblemDetailsConsumer.needs(),
        ProducerOutputKind::ProblemDetails
    );
}

#[test]
fn problem_details_consumer_generates_one_spec_with_required_fields() {
    let output = ProducerOutput::ProblemDetails {
        required_fields: vec!["quantity".to_owned(), "item_id".to_owned()],
        error_type: None,
    };
    let specs = ScdProblemDetailsConsumer.generate(&minimal_ctx(), &output);
    assert_eq!(
        specs.len(),
        1,
        "must generate exactly one spec; got {}",
        specs.len()
    );
}

#[test]
fn problem_details_consumer_body_contains_all_required_fields() {
    let output = ProducerOutput::ProblemDetails {
        required_fields: vec!["quantity".to_owned(), "item_id".to_owned()],
        error_type: None,
    };
    let specs = ScdProblemDetailsConsumer.generate(&minimal_ctx(), &output);
    let ProbeSpec::Pair(pair) = &specs[0] else {
        panic!("expected Pair variant");
    };
    let body_bytes = pair.baseline.body.as_ref().expect("body must be set");
    let body: serde_json::Value =
        serde_json::from_slice(body_bytes).expect("body must be valid JSON");
    assert!(
        body.get("quantity").is_some(),
        "body must contain 'quantity'"
    );
    assert!(body.get("item_id").is_some(), "body must contain 'item_id'");
}

#[test]
fn problem_details_consumer_returns_empty_when_no_required_fields() {
    let output = ProducerOutput::ProblemDetails {
        required_fields: vec![],
        error_type: Some("urn:problem:rate-limited".to_owned()),
    };
    let specs = ScdProblemDetailsConsumer.generate(&minimal_ctx(), &output);
    assert!(
        specs.is_empty(),
        "empty required_fields must produce no specs"
    );
}

#[test]
fn problem_details_consumer_returns_empty_on_wrong_output_variant() {
    let output = ProducerOutput::Location("https://example.com".to_owned());
    let specs = ScdProblemDetailsConsumer.generate(&minimal_ctx(), &output);
    assert!(
        specs.is_empty(),
        "wrong output variant must produce no specs"
    );
}

use proptest::prelude::*;

proptest! {
    /// Any `errors[].field` array with ≥1 entries yields `ProblemDetails` with matching fields.
    #[test]
    fn producer_any_nonempty_errors_array_yields_problem_details(
        fields in prop::collection::vec("[a-z]{1,16}", 1..=8),
    ) {
        let errors_json: String = fields.iter()
            .map(|f| format!(r#"{{"field":"{f}"}}"#))
            .collect::<Vec<_>>()
            .join(",");
        let body_str = format!(r#"{{"errors":[{errors_json}]}}"#);
        let body = Bytes::from(body_str);
        let mut headers = HeaderMap::new();
        headers.insert(
            http::header::CONTENT_TYPE,
            HeaderValue::from_static("application/json"),
        );
        let out = ScdProblemDetailsProducer.extract_with_body(
            ResponseClass::StructuredError,
            &headers,
            &body,
        );
        let Some(ProducerOutput::ProblemDetails { required_fields, .. }) = out else {
            panic!("expected ProblemDetails output");
        };
        for f in &fields {
            prop_assert!(
                required_fields.contains(f),
                "field {f} must appear in required_fields; got {required_fields:?}"
            );
        }
    }

    /// Consumer with non-empty `required_fields` always generates exactly 1 spec.
    #[test]
    fn consumer_nonempty_fields_yields_exactly_one_spec(
        fields in prop::collection::vec("[a-z]{1,16}", 1..=8),
    ) {
        let output = ProducerOutput::ProblemDetails {
            required_fields: fields,
            error_type: None,
        };
        let specs = ScdProblemDetailsConsumer.generate(&minimal_ctx(), &output);
        prop_assert_eq!(specs.len(), 1);
    }
}

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

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