use std::collections::HashMap; use std::net::SocketAddr; use insta360_link_protocol::{ AudioCaptureMode, CameraModel, CompositionStyle, ExtendFunction, FilterMode, Gesture, GestureFunction, TrackSpeed, VideoMode, }; use insta360_link_server::{ build_router, new_mock_state, AudioRequest, CameraStateSnapshot, ImageRequest, ModeRequest, PresetRequest, PtzRequest, SystemRequest, TrackingRequest, }; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; /// Binds an ephemeral TCP listener on 127.0.0.1:0, attaches the axum router /// powered by `new_mock_state(model)`, spawns the server onto the tokio runtime, /// and returns the bound socket address. async fn start_test_server(model: CameraModel) -> SocketAddr { let state = new_mock_state(model); let app = build_router(state); let listener = TcpListener::bind("127.0.0.1:0") .await .expect("Failed to bind ephemeral TCP listener"); let addr = listener .local_addr() .expect("Failed to retrieve local socket address"); tokio::spawn(async move { let _ = axum::serve(listener, app).await; }); addr } /// Sends an HTTP/1.1 request directly over a raw TCP connection (`TcpStream`), /// eliminating external HTTP client crate requirements. /// /// Handles chunked reading, `Content-Length` framing, and connection termination. /// Returns `(status_code, headers, body)`. async fn send_http_request( addr: SocketAddr, method: &str, path: &str, body: Option<&str>, ) -> (u16, HashMap, String) { let mut stream = TcpStream::connect(addr) .await .expect("Failed to connect TCP stream to test server"); let mut req = format!("{method} {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n"); if let Some(b) = body { req.push_str("Content-Type: application/json\r\n"); req.push_str(&format!("Content-Length: {}\r\n", b.len())); } req.push_str("\r\n"); if let Some(b) = body { req.push_str(b); } stream .write_all(req.as_bytes()) .await .expect("Failed to write HTTP request to TCP stream"); stream.flush().await.expect("Failed to flush TCP stream"); let mut response_buf = Vec::new(); let mut chunk = [0u8; 4096]; loop { let n = stream .read(&mut chunk) .await .expect("Failed to read HTTP response chunk from TCP stream"); if n == 0 { break; } response_buf.extend_from_slice(&chunk[..n]); if let Some(pos) = response_buf.windows(4).position(|w| w == b"\r\n\r\n") { let header_str = String::from_utf8_lossy(&response_buf[..pos]); let mut content_len = None; for line in header_str.split("\r\n") { if let Some((k, v)) = line.split_once(':') { if k.trim().eq_ignore_ascii_case("content-length") { if let Ok(cl) = v.trim().parse::() { content_len = Some(cl); } } } } if let Some(cl) = content_len { if response_buf.len() >= pos + 4 + cl { break; } } } } let header_end = response_buf .windows(4) .position(|w| w == b"\r\n\r\n") .expect("Malformed HTTP response: missing header delimiter CRLF CRLF"); let header_bytes = &response_buf[..header_end]; let body_bytes = &response_buf[header_end + 4..]; let header_str = std::str::from_utf8(header_bytes).expect("Non-UTF8 HTTP headers"); let mut lines = header_str.split("\r\n"); let status_line = lines.next().expect("Missing HTTP status line"); let status_code: u16 = status_line .split_whitespace() .nth(1) .expect("Missing status code in HTTP status line") .parse() .expect("Invalid integer status code in HTTP response"); let mut headers = HashMap::new(); for line in lines { if let Some((k, v)) = line.split_once(':') { headers.insert(k.trim().to_ascii_lowercase(), v.trim().to_string()); } } let body_str = String::from_utf8_lossy(body_bytes).to_string(); (status_code, headers, body_str) } /// Helper for GET requests. async fn send_http_get(addr: SocketAddr, path: &str) -> (u16, HashMap, String) { send_http_request(addr, "GET", path, None).await } /// Helper for JSON POST requests. async fn send_http_json( addr: SocketAddr, method: &str, path: &str, body: &str, ) -> (u16, HashMap, String) { send_http_request(addr, method, path, Some(body)).await } #[test] fn test_request_structs_exports() { // Verify that all documented request structs deserialize properly let _ptz: PtzRequest = serde_json::from_str( r#"{"pan_arcsec": 36000, "tilt_arcsec": -18000, "zoom": 250}"#, ) .expect("PtzRequest deserialization failed"); let _mode: ModeRequest = serde_json::from_str( r#"{"mode": "DeskView", "pitch": -450, "zoom": 150}"#, ) .expect("ModeRequest deserialization failed"); let _preset: PresetRequest = serde_json::from_str( r#"{"action": "save", "index": 3}"#, ) .expect("PresetRequest deserialization failed"); let _img: ImageRequest = serde_json::from_str( r#"{"brightness": 75, "contrast": 65, "wb_auto": false, "wb_kelvin": 5600, "hdr": true, "filter_mode": "Warm"}"#, ) .expect("ImageRequest deserialization failed"); let _track: TrackingRequest = serde_json::from_str( r#"{"track_speed": "Fast", "composition_style": "WholeBody", "gesture_binding": ["Palm", "AutoFraming"]}"#, ) .expect("TrackingRequest deserialization failed"); let _audio: AudioRequest = serde_json::from_str( r#"{"audio_mode": "VoiceSuppression"}"#, ) .or_else(|_| serde_json::from_str(r#"{"mode": "VoiceSuppression"}"#)) .expect("AudioRequest deserialization failed"); let _sys: SystemRequest = serde_json::from_str( r#"{"privacy_mode": true, "horizontal_correction": true}"#, ) .expect("SystemRequest deserialization failed"); } /// 1. GET / returns 200 OK with text/html containing the embedded Web Remote Control UI. #[tokio::test] async fn test_web_remote_control_ui() { let addr = start_test_server(CameraModel::Link2).await; let (status, headers, body) = send_http_get(addr, "/").await; assert_eq!(status, 200, "GET / did not return 200 OK"); let content_type = headers .get("content-type") .expect("Missing Content-Type header on GET /"); assert!( content_type.contains("text/html"), "Content-Type should be text/html, got '{content_type}'" ); assert!( body.contains("") || body.contains("") || body.contains("") || body.contains("") || body.contains("