#![expect(
clippy::disallowed_types,
reason = "dev/verification tooling over JSON artifacts (the catalogue, results, wire \
exchanges), whose shapes belong to the artifacts and the SUT"
)]
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub const TRANSCRIPT_FILE: &str = "transcript.json";
pub const REDACTED: &str = "«redacted»";
const CREDENTIAL_HEADER: &str = "authorization";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Recording {
#[default]
Off,
On,
}
impl Recording {
#[must_use]
pub fn is_on(self) -> bool {
matches!(self, Self::On)
}
}
impl From<bool> for Recording {
fn from(on: bool) -> Self {
if on { Self::On } else { Self::Off }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RecordedRequest {
pub method: String,
pub url: String,
pub headers: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RecordedResponse {
pub status: u16,
pub headers: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RecordedExchange {
pub seq: u32,
pub row: u32,
pub request: RecordedRequest,
pub response: RecordedResponse,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CaseTranscript {
pub case: crate::ids::CaseId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub format: Option<crate::vocab::FormatName>,
pub exchanges: Vec<RecordedExchange>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunTranscript {
pub sut: crate::party::Sut,
pub schedule_release: String,
pub cases: Vec<CaseTranscript>,
}
impl RunTranscript {
pub fn canonicalize(&mut self) {
self.cases.sort_by(|a, b| {
a.case
.cmp(&b.case)
.then_with(|| a.format.cmp(&b.format))
.then_with(|| a.exchanges.len().cmp(&b.exchanges.len()))
});
for case in &mut self.cases {
for (index, exchange) in case.exchanges.iter_mut().enumerate() {
exchange.seq = u32::try_from(index).unwrap_or(u32::MAX).saturating_add(1);
}
}
}
#[must_use]
pub fn exchange_count(&self) -> usize {
self.cases.iter().map(|case| case.exchanges.len()).sum()
}
}
#[must_use]
pub fn recorded_headers(headers: &BTreeMap<String, String>) -> BTreeMap<String, String> {
headers
.iter()
.map(|(name, value)| {
let name = name.to_ascii_lowercase();
if name == CREDENTIAL_HEADER {
(name, REDACTED.to_owned())
} else {
(name, value.clone())
}
})
.collect()
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::{
CaseTranscript, RecordedExchange, RecordedRequest, RecordedResponse, Recording,
RunTranscript, recorded_headers,
};
fn header_map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(name, value)| ((*name).to_owned(), (*value).to_owned()))
.collect()
}
fn exchange(seq: u32) -> RecordedExchange {
RecordedExchange {
seq,
row: 0,
request: RecordedRequest {
method: String::from("GET"),
url: String::from("http://sut.invalid/ehr"),
headers: BTreeMap::new(),
body: None,
},
response: RecordedResponse {
status: 200,
headers: BTreeMap::new(),
body: None,
},
}
}
fn case_id(id: &str) -> crate::ids::CaseId {
crate::ids::CaseId::parse(id).expect("a well-formed case id")
}
fn transcript(cases: Vec<CaseTranscript>) -> RunTranscript {
RunTranscript {
sut: crate::party::Sut {
name: String::from("example-cdr"),
version: String::from("0.0.0"),
},
schedule_release: String::from("cnf-2.0-w2"),
cases,
}
}
#[test]
fn the_authorization_header_value_is_withheld() {
let recorded = recorded_headers(&header_map(&[
("Authorization", "Basic dXNlcjpwYXNz"),
("Content-Type", "application/json"),
]));
assert_eq!(
recorded.get("authorization").map(String::as_str),
Some(super::REDACTED)
);
assert!(
!recorded
.values()
.any(|value| value.contains("dXNlcjpwYXNz")),
"the credential leaked: {recorded:?}"
);
assert_eq!(
recorded.get("content-type").map(String::as_str),
Some("application/json")
);
}
#[test]
fn canonicalization_orders_by_case_and_renumbers_the_sequence() {
let mut document = transcript(vec![
CaseTranscript {
case: case_id("I_EHR_SERVICE.get_ehr-main"),
format: None,
exchanges: vec![exchange(9), exchange(4)],
},
CaseTranscript {
case: case_id("I_EHR_SERVICE.create_ehr-main"),
format: Some(crate::vocab::FormatName::CanonicalJson),
exchanges: vec![exchange(7)],
},
]);
document.canonicalize();
let ids: Vec<&str> = document.cases.iter().map(|c| c.case.as_str()).collect();
assert_eq!(
ids,
[
"I_EHR_SERVICE.create_ehr-main",
"I_EHR_SERVICE.get_ehr-main"
]
);
let seqs: Vec<u32> = document
.cases
.iter()
.flat_map(|c| c.exchanges.iter().map(|e| e.seq))
.collect();
assert_eq!(seqs, [1, 1, 2]);
assert_eq!(document.exchange_count(), 3);
}
#[expect(
clippy::panic_in_result_fn,
reason = "the Book ch11 Result-returning test shape: assertions panic, plumbing propagates with ? (https://doc.rust-lang.org/book/ch11-01-writing-tests.html)"
)]
#[test]
fn the_document_round_trips() -> Result<(), serde_json::Error> {
let document = transcript(vec![CaseTranscript {
case: case_id("I_EHR_SERVICE.create_ehr-main"),
format: None,
exchanges: vec![exchange(1)],
}]);
let text = serde_json::to_string(&document)?;
let parsed: RunTranscript = serde_json::from_str(&text)?;
assert_eq!(parsed, document);
Ok(())
}
#[test]
fn the_canonical_order_is_total_within_one_case_id() {
let entry = |format: Option<crate::vocab::FormatName>, exchanges: usize| CaseTranscript {
case: case_id("I_EHR_SERVICE.create_ehr-main"),
format,
exchanges: (0..exchanges)
.map(|i| exchange(u32::try_from(i).unwrap_or(0)))
.collect(),
};
let ordered = |entries: Vec<CaseTranscript>| {
let mut document = transcript(entries);
document.canonicalize();
document
.cases
.iter()
.map(|c| (c.format, c.exchanges.len()))
.collect::<Vec<_>>()
};
let by_format = ordered(vec![
entry(Some(crate::vocab::FormatName::CanonicalXml), 1),
entry(Some(crate::vocab::FormatName::CanonicalJson), 1),
entry(None, 1),
]);
assert_eq!(
by_format,
vec![
(None, 1),
(Some(crate::vocab::FormatName::CanonicalJson), 1),
(Some(crate::vocab::FormatName::CanonicalXml), 1),
]
);
let by_count = ordered(vec![entry(None, 3), entry(None, 1), entry(None, 2)]);
assert_eq!(by_count, vec![(None, 1), (None, 2), (None, 3)]);
let reversed = ordered(vec![entry(None, 1), entry(None, 2), entry(None, 3)]);
assert_eq!(by_count, reversed);
}
#[test]
fn recording_is_off_by_default() {
assert_eq!(Recording::default(), Recording::Off);
assert!(!Recording::default().is_on());
assert!(Recording::from(true).is_on());
assert!(!Recording::from(false).is_on());
}
}