use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use async_trait::async_trait;
use crate::transport::{Transport, TransportError};
pub struct CapturingTransport {
inner: Arc<dyn Transport>,
out_dir: PathBuf,
}
impl CapturingTransport {
pub fn new(inner: Arc<dyn Transport>, out_dir: impl Into<PathBuf>) -> Self {
Self {
inner,
out_dir: out_dir.into(),
}
}
}
#[async_trait]
impl Transport for CapturingTransport {
async fn soap_post(
&self,
url: &str,
action: &str,
body: String,
) -> Result<String, TransportError> {
let name = safe_action_name(action);
let req_path = self.out_dir.join(format!("{name}.req.xml"));
let resp_path = self.out_dir.join(format!("{name}.resp.xml"));
if let Err(e) = fs::create_dir_all(&self.out_dir) {
eprintln!(
"CapturingTransport: failed to create {:?}: {e}",
self.out_dir
);
}
if let Err(e) = fs::write(&req_path, &body) {
eprintln!("CapturingTransport: failed to write {req_path:?}: {e}");
}
let result = self.inner.soap_post(url, action, body).await;
if let Ok(ref resp) = result {
if let Err(e) = fs::write(&resp_path, resp) {
eprintln!("CapturingTransport: failed to write {resp_path:?}: {e}");
}
}
result
}
}
pub struct FixtureTransport {
dir: PathBuf,
}
impl FixtureTransport {
pub fn new(dir: impl Into<PathBuf>) -> Self {
Self { dir: dir.into() }
}
}
#[async_trait]
impl Transport for FixtureTransport {
async fn soap_post(
&self,
_url: &str,
action: &str,
_body: String,
) -> Result<String, TransportError> {
let name = safe_action_name(action);
let path = self.dir.join(format!("{name}.resp.xml"));
match fs::read_to_string(&path) {
Ok(s) => Ok(s),
Err(_) => Err(TransportError::HttpStatus {
status: 404,
body: format!("fixture not found: {}", path.display()),
}),
}
}
}
fn safe_action_name(action: &str) -> String {
let last = action.rsplit('/').next().unwrap_or(action);
let name: String = last
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
.collect();
if name.is_empty() {
"Unnamed".to_string()
} else {
name
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::OnvifClient;
use crate::mock::MockTransport;
use std::sync::atomic::{AtomicU64, Ordering};
static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
fn tmp_dir(label: &str) -> PathBuf {
let id = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
let d = std::env::temp_dir().join(format!(
"oxvif-fixtures-{}-{}-{label}",
std::process::id(),
id,
));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn safe_action_name_strips_url_and_specials() {
assert_eq!(
safe_action_name("http://www.onvif.org/ver10/media/wsdl/GetProfiles"),
"GetProfiles"
);
assert_eq!(safe_action_name("Simple"), "Simple");
assert_eq!(safe_action_name("with spaces!"), "withspaces");
assert_eq!(safe_action_name("https://x/"), "Unnamed");
}
#[tokio::test]
async fn capturing_then_replay_yields_identical_response() {
let dir = tmp_dir("roundtrip");
let inner: Arc<dyn Transport> = Arc::new(MockTransport::new());
let cap = CapturingTransport::new(inner.clone(), &dir);
let client = OnvifClient::new("http://mock").with_transport(Arc::new(cap));
let caps_recorded = client
.get_capabilities()
.await
.expect("mock returns Capabilities");
assert!(dir.join("GetCapabilities.req.xml").exists());
assert!(dir.join("GetCapabilities.resp.xml").exists());
let fix = FixtureTransport::new(&dir);
let client2 = OnvifClient::new("http://replay").with_transport(Arc::new(fix));
let caps_replayed = client2
.get_capabilities()
.await
.expect("fixture replay returns Capabilities");
assert_eq!(
caps_recorded.device.url.as_deref(),
caps_replayed.device.url.as_deref()
);
assert_eq!(
caps_recorded.media.url.as_deref(),
caps_replayed.media.url.as_deref()
);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn missing_fixture_returns_404() {
let dir = tmp_dir("missing");
let fix = FixtureTransport::new(&dir);
let result = fix
.soap_post(
"http://test",
"http://www.onvif.org/ver10/device/wsdl/GetCapabilities",
"<body/>".into(),
)
.await;
match result {
Err(TransportError::HttpStatus { status, body }) => {
assert_eq!(status, 404);
assert!(body.contains("GetCapabilities.resp.xml"));
}
other => panic!("expected 404 HttpStatus, got {other:?}"),
}
let _ = std::fs::remove_dir_all(&dir);
}
}