use infinity_config::ProjectConfig; use infinity_host::{HostApiPort, SimulationHostService}; use infinity_host_api::HostApiServer; use serde_json::Value; use std::collections::HashMap; use std::io::{Read, Write}; use std::net::{Shutdown, SocketAddr, TcpStream}; use std::sync::Arc; use std::time::Duration; fn sample_project() -> ProjectConfig { ProjectConfig::from_toml_str(include_str!("../../../config/project.example.toml")) .expect("project config must parse") } fn start_server() -> HostApiServer { let service: Arc = SimulationHostService::spawn_shared(sample_project()); HostApiServer::bind("127.0.0.1:0", service).expect("server must bind") } struct HttpResponse { status_code: u16, headers: HashMap, body: String, } #[test] fn root_serves_creative_console_shell() { let server = start_server(); let response = send_http_request(server.local_addr(), "GET", "/", None); assert_eq!(response.status_code, 200); assert!(response .headers .get("content-type") .expect("content-type header") .starts_with("text/html")); assert!(response.body.contains("Infinity Vis / Creative Surface")); assert!(response.body.contains("/app.js")); server.shutdown(); } #[test] fn snapshot_endpoint_is_versioned_and_separates_state_and_preview() { let server = start_server(); let response = send_http_request(server.local_addr(), "GET", "/api/v1/snapshot", None); let body: Value = serde_json::from_str(&response.body).expect("snapshot json must parse"); assert_eq!(response.status_code, 200); assert_eq!(body["api_version"], "v1"); assert_eq!(body["state"]["system"]["project_name"], "Infinity Vis"); assert_eq!(body["state"]["nodes"].as_array().map(Vec::len), Some(6)); assert_eq!(body["preview"]["panels"].as_array().map(Vec::len), Some(18)); assert!(body["state"]["active_scene"]["pattern_id"].is_string()); server.shutdown(); } #[test] fn catalog_presets_and_groups_endpoints_return_expected_lists() { let server = start_server(); let catalog = send_http_request(server.local_addr(), "GET", "/api/v1/catalog", None); let presets = send_http_request(server.local_addr(), "GET", "/api/v1/presets", None); let groups = send_http_request(server.local_addr(), "GET", "/api/v1/groups", None); let catalog_body: Value = serde_json::from_str(&catalog.body).expect("catalog json"); let preset_body: Value = serde_json::from_str(&presets.body).expect("preset json"); let group_body: Value = serde_json::from_str(&groups.body).expect("group json"); assert_eq!(catalog.status_code, 200); assert!(catalog_body["patterns"] .as_array() .expect("patterns array") .iter() .any(|pattern| pattern["pattern_id"] == "walking_pixel")); assert!(preset_body["presets"] .as_array() .expect("presets array") .iter() .any(|preset| preset["preset_id"] == "ocean_gradient")); assert!(group_body["groups"] .as_array() .expect("groups array") .iter() .any(|group| group["group_id"] == "top_panels")); server.shutdown(); } #[test] fn command_endpoint_applies_state_changes_and_rejects_invalid_payload() { let server = start_server(); let response = send_http_request( server.local_addr(), "POST", "/api/v1/command", Some( r#"{ "request_id": "contract-blackout", "command": { "type": "set_blackout", "payload": { "enabled": true } } }"#, ), ); let response_body: Value = serde_json::from_str(&response.body).expect("command response must parse"); let snapshot = send_http_request(server.local_addr(), "GET", "/api/v1/snapshot", None); let snapshot_body: Value = serde_json::from_str(&snapshot.body).expect("snapshot json"); assert_eq!(response.status_code, 200); assert_eq!(response_body["accepted"], true); assert_eq!(response_body["request_id"], "contract-blackout"); assert_eq!(snapshot_body["state"]["global"]["blackout"], true); let invalid = send_http_request( server.local_addr(), "POST", "/api/v1/command", Some(r#"{"command":{"type":"set_blackout","payload":{}}}"#), ); let invalid_body: Value = serde_json::from_str(&invalid.body).expect("invalid response must parse"); assert_eq!(invalid.status_code, 400); assert_eq!(invalid_body["api_version"], "v1"); assert_eq!(invalid_body["error"]["code"], "invalid_command"); server.shutdown(); } #[test] fn websocket_stream_emits_snapshot_preview_and_event_messages() { let server = start_server(); let mut stream = open_websocket(server.local_addr()); let first_frame = read_websocket_text_frame(&mut stream); let first_payload: Value = serde_json::from_str(&first_frame).expect("first ws frame"); assert_eq!(first_payload["message"]["type"], "snapshot"); let second_frame = read_websocket_text_frame(&mut stream); let second_payload: Value = serde_json::from_str(&second_frame).expect("second ws frame"); assert_eq!(second_payload["message"]["type"], "preview"); let _ = send_http_request( server.local_addr(), "POST", "/api/v1/command", Some( r#"{ "request_id": "contract-event", "command": { "type": "set_blackout", "payload": { "enabled": true } } }"#, ), ); let mut saw_event = false; for _ in 0..8 { let frame = read_websocket_text_frame(&mut stream); let payload: Value = serde_json::from_str(&frame).expect("ws event frame"); if payload["message"]["type"] == "event" { saw_event = true; assert!(payload["message"]["payload"]["message"] .as_str() .expect("event message") .contains("blackout")); break; } } assert!(saw_event, "expected websocket event after command"); let _ = stream.shutdown(Shutdown::Both); server.shutdown(); } fn send_http_request(addr: SocketAddr, method: &str, path: &str, body: Option<&str>) -> HttpResponse { let body = body.unwrap_or(""); let request = format!( "{method} {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", body.as_bytes().len(), body, host = addr ); let mut stream = TcpStream::connect(addr).expect("http connection"); stream .set_read_timeout(Some(Duration::from_secs(3))) .expect("read timeout"); stream.write_all(request.as_bytes()).expect("write request"); stream.shutdown(Shutdown::Write).expect("shutdown write"); let mut raw = Vec::new(); stream.read_to_end(&mut raw).expect("read response"); parse_http_response(&raw) } fn parse_http_response(raw: &[u8]) -> HttpResponse { let delimiter = raw .windows(4) .position(|window| window == b"\r\n\r\n") .expect("http header delimiter"); let header_text = String::from_utf8(raw[..delimiter].to_vec()).expect("header utf8"); let body = String::from_utf8(raw[delimiter + 4..].to_vec()).expect("body utf8"); let mut lines = header_text.lines(); let status_line = lines.next().expect("status line"); let status_code = status_line .split_whitespace() .nth(1) .expect("status code") .parse::() .expect("valid status code"); let headers = lines .filter_map(|line| line.split_once(':')) .map(|(key, value)| (key.trim().to_ascii_lowercase(), value.trim().to_string())) .collect::>(); HttpResponse { status_code, headers, body, } } fn open_websocket(addr: SocketAddr) -> TcpStream { let mut stream = TcpStream::connect(addr).expect("websocket connection"); stream .set_read_timeout(Some(Duration::from_secs(3))) .expect("read timeout"); let request = format!( "GET /api/v1/stream HTTP/1.1\r\nHost: {host}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n", host = addr ); stream.write_all(request.as_bytes()).expect("write handshake"); let header = read_until_header_end(&mut stream); let header_text = String::from_utf8(header).expect("handshake utf8"); assert!(header_text.starts_with("HTTP/1.1 101")); assert!(header_text.contains("Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=")); stream } fn read_until_header_end(stream: &mut TcpStream) -> Vec { let mut buffer = Vec::new(); loop { let mut byte = [0u8; 1]; let read = stream.read(&mut byte).expect("read handshake"); assert!(read > 0, "unexpected eof while reading handshake"); buffer.push(byte[0]); if buffer.windows(4).any(|window| window == b"\r\n\r\n") { let end = buffer .windows(4) .position(|window| window == b"\r\n\r\n") .expect("header end") + 4; return buffer[..end].to_vec(); } } } fn read_websocket_text_frame(stream: &mut TcpStream) -> String { let mut header = [0u8; 2]; stream.read_exact(&mut header).expect("frame header"); assert_eq!(header[0] & 0x0f, 0x1, "expected text frame"); let payload_len = match header[1] & 0x7f { len @ 0..=125 => len as usize, 126 => { let mut extended = [0u8; 2]; stream.read_exact(&mut extended).expect("extended payload"); u16::from_be_bytes(extended) as usize } 127 => { let mut extended = [0u8; 8]; stream.read_exact(&mut extended).expect("extended payload"); u64::from_be_bytes(extended) as usize } _ => unreachable!("masked length bit should already be stripped"), }; let mut payload = vec![0u8; payload_len]; stream.read_exact(&mut payload).expect("frame payload"); String::from_utf8(payload).expect("frame utf8") }