//! HTTP client for the WAM (Whack-a-Mole) ticket manager. //! //! WAM runs on the tailnet — no auth required. The MNW server creates tickets //! for operational events that need human attention (stale refunds, dead //! webhooks, etc.). use serde::Serialize; /// WAM ticket creation client. #[derive(Clone)] pub struct WamClient { http: reqwest::Client, base_url: String, } #[derive(Serialize)] struct CreateTicketRequest<'a> { title: &'a str, #[serde(skip_serializing_if = "Option::is_none")] body: Option<&'a str>, priority: &'a str, source: &'a str, #[serde(skip_serializing_if = "Option::is_none")] source_ref: Option<&'a str>, } impl WamClient { pub fn new(base_url: String) -> Self { let http = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(5)) .connect_timeout(std::time::Duration::from_secs(3)) .build() .expect("WAM HTTP client"); Self { http, base_url } } /// Return the ticket endpoint URL. pub fn ticket_url(&self) -> String { format!("{}/tickets", self.base_url.trim_end_matches('/')) } /// Create a ticket in WAM. Errors are logged but never propagated — WAM /// is a best-effort notification channel, not a critical path. pub async fn create_ticket( &self, title: &str, body: Option<&str>, priority: &str, source: &str, source_ref: Option<&str>, ) { let url = self.ticket_url(); let req = CreateTicketRequest { title, body, priority, source, source_ref, }; match self.http.post(&url).json(&req).send().await { Ok(resp) if resp.status().is_success() => { tracing::info!(title, source, "WAM ticket created"); } Ok(resp) => { tracing::warn!( status = %resp.status(), title, source, "WAM ticket creation returned non-success" ); } Err(e) => { tracing::warn!(error = %e, title, source, "WAM unreachable"); } } } } #[cfg(test)] mod tests { use super::*; #[test] fn ticket_url_construction() { let client = WamClient::new("http://100.120.174.96:7890".to_string()); assert_eq!(client.ticket_url(), "http://100.120.174.96:7890/tickets"); } #[test] fn ticket_url_strips_trailing_slash() { let client = WamClient::new("http://localhost:7890/".to_string()); assert_eq!(client.ticket_url(), "http://localhost:7890/tickets"); } #[test] fn request_serialization_full() { let req = CreateTicketRequest { title: "Test ticket", body: Some("Details here"), priority: "high", source: "test-source", source_ref: Some("ref-123"), }; let json = serde_json::to_value(&req).unwrap(); assert_eq!(json["title"], "Test ticket"); assert_eq!(json["body"], "Details here"); assert_eq!(json["priority"], "high"); assert_eq!(json["source"], "test-source"); assert_eq!(json["source_ref"], "ref-123"); } #[test] fn request_serialization_skips_none_fields() { let req = CreateTicketRequest { title: "Minimal", body: None, priority: "low", source: "test", source_ref: None, }; let json = serde_json::to_value(&req).unwrap(); assert_eq!(json["title"], "Minimal"); assert!(json.get("body").is_none()); assert!(json.get("source_ref").is_none()); } #[tokio::test] async fn create_ticket_unreachable_does_not_panic() { // WAM is fire-and-forget — unreachable server should not panic let client = WamClient::new("http://127.0.0.1:1".to_string()); client.create_ticket("test", None, "low", "test", None).await; // If we get here, the error was swallowed correctly } }