//! Integration coverage for the handshake and camera-selection contract. //! //! Scope: exercise the real client handshake against a local server and the //! server camera selection logic that feeds the handshake response. //! Targets: `client/src/handshake.rs`, `server/src/handshake.rs`, and //! `server/src/camera.rs`. //! Why: the handshake path is the narrow entrypoint that decides whether the //! client and server agree on output mode, so it deserves a centralized //! contract test. use lesavka_client::handshake::{HandshakeProbe, PeerCaps, negotiate, probe}; use lesavka_common::lesavka::{ Empty, HandshakeSet, handshake_server::{Handshake, HandshakeServer}, }; use serial_test::serial; use std::net::TcpListener; use std::time::Duration; use temp_env::with_var; use tokio::runtime::Runtime; use tokio::sync::oneshot; use tonic::{Request, Response, Status, transport::Server}; async fn negotiate_against_service(service: S) -> PeerCaps where S: Handshake + Send + Sync + 'static, { let listener = TcpListener::bind("127.0.0.1:0").expect("bind local handshake listener"); let addr = listener.local_addr().expect("listener addr"); drop(listener); let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); let server = tokio::spawn(async move { Server::builder() .add_service(HandshakeServer::new(service)) .serve_with_shutdown(addr, async move { let _ = shutdown_rx.await; }) .await .expect("serve handshake server"); }); tokio::time::sleep(Duration::from_millis(50)).await; let caps = negotiate(&format!("http://{addr}")).await; let _ = shutdown_tx.send(()); let _ = server.await; caps } async fn probe_against_service(service: S) -> HandshakeProbe where S: Handshake + Send + Sync + 'static, { let listener = TcpListener::bind("127.0.0.1:0").expect("bind local handshake listener"); let addr = listener.local_addr().expect("listener addr"); drop(listener); let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); let server = tokio::spawn(async move { Server::builder() .add_service(HandshakeServer::new(service)) .serve_with_shutdown(addr, async move { let _ = shutdown_rx.await; }) .await .expect("serve handshake server"); }); tokio::time::sleep(Duration::from_millis(50)).await; let result = probe(&format!("http://{addr}")).await; let _ = shutdown_tx.send(()); let _ = server.await; result } async fn negotiate_against_local_server() -> PeerCaps { negotiate_against_service(lesavka_server::handshake::HandshakeSvc).await } fn assert_default_caps(caps: &PeerCaps) { assert!(!caps.camera); assert!(!caps.microphone); assert_eq!(caps.server_version, None); assert_eq!(caps.camera_output, None); assert_eq!(caps.camera_codec, None); assert_eq!(caps.camera_width, None); assert_eq!(caps.camera_height, None); assert_eq!(caps.camera_fps, None); assert_eq!(caps.eye_width, None); assert_eq!(caps.eye_height, None); assert_eq!(caps.eye_fps, None); } fn assert_default_probe(probe_result: &HandshakeProbe) { assert_default_caps(&probe_result.caps); assert_eq!(probe_result.rtt_ms, None); assert!(!probe_result.reachable); } struct UnimplementedHandshakeSvc; #[tonic::async_trait] impl Handshake for UnimplementedHandshakeSvc { async fn get_capabilities( &self, _req: Request, ) -> Result, Status> { Err(Status::unimplemented("handshake disabled")) } } struct InternalErrorHandshakeSvc; #[tonic::async_trait] impl Handshake for InternalErrorHandshakeSvc { async fn get_capabilities( &self, _req: Request, ) -> Result, Status> { Err(Status::internal("boom")) } } struct SparseHandshakeSvc; #[tonic::async_trait] impl Handshake for SparseHandshakeSvc { async fn get_capabilities( &self, _req: Request, ) -> Result, Status> { Ok(Response::new(HandshakeSet { camera: true, microphone: false, server_version: String::new(), camera_output: String::new(), camera_codec: String::new(), camera_width: 0, camera_height: 0, camera_fps: 0, eye_width: 0, eye_height: 0, eye_fps: 0, })) } } struct SlowHandshakeSvc; #[tonic::async_trait] impl Handshake for SlowHandshakeSvc { async fn get_capabilities( &self, _req: Request, ) -> Result, Status> { tokio::time::sleep(Duration::from_secs(6)).await; Ok(Response::new(HandshakeSet::default())) } } #[test] #[serial] fn handshake_returns_uvc_caps_with_explicit_dimensions_and_fps() { with_var("LESAVKA_CAM_OUTPUT", Some("uvc"), || { with_var("LESAVKA_UVC_WIDTH", Some("1024"), || { with_var("LESAVKA_UVC_HEIGHT", Some("576"), || { with_var("LESAVKA_UVC_FPS", Some("12"), || { let rt = Runtime::new().expect("create runtime"); let caps = rt.block_on(negotiate_against_local_server()); assert!(caps.camera); assert!(caps.microphone); assert_eq!( caps.server_version, Some(lesavka_server::VERSION.to_string()) ); assert_eq!(caps.camera_output, Some(String::from("uvc"))); assert_eq!(caps.camera_codec, Some(String::from("mjpeg"))); assert_eq!(caps.camera_width, Some(1024)); assert_eq!(caps.camera_height, Some(576)); assert_eq!(caps.camera_fps, Some(12)); }); }); }); }); } #[test] #[serial] fn handshake_uses_uvc_interval_when_fps_is_unset() { with_var("LESAVKA_CAM_OUTPUT", Some("uvc"), || { with_var("LESAVKA_UVC_WIDTH", Some("640"), || { with_var("LESAVKA_UVC_HEIGHT", Some("360"), || { with_var("LESAVKA_UVC_INTERVAL", Some("250000"), || { let rt = Runtime::new().expect("create runtime"); let caps = rt.block_on(negotiate_against_local_server()); assert_eq!(caps.camera_output, Some(String::from("uvc"))); assert_eq!(caps.camera_codec, Some(String::from("mjpeg"))); assert_eq!(caps.camera_width, Some(640)); assert_eq!(caps.camera_height, Some(360)); assert_eq!(caps.camera_fps, Some(40)); assert!(caps.camera); assert!(caps.microphone); }); }); }); }); } #[test] #[serial] fn handshake_returns_hdmi_caps_with_h264_codec() { with_var("LESAVKA_CAM_OUTPUT", Some("hdmi"), || { with_var("LESAVKA_DISABLE_UAC", Some("1"), || { let rt = Runtime::new().expect("create runtime"); let caps = rt.block_on(negotiate_against_local_server()); assert_eq!(caps.camera_output, Some(String::from("hdmi"))); assert_eq!(caps.camera_codec, Some(String::from("h264"))); assert_eq!(caps.camera_fps, Some(30)); assert!(!caps.microphone); assert!(caps.camera); assert!(matches!(caps.camera_width, Some(1280) | Some(1920))); assert!(matches!(caps.camera_height, Some(720) | Some(1080))); }); }); } #[test] #[serial] fn handshake_auto_mode_falls_back_to_a_valid_camera_configuration() { with_var("LESAVKA_CAM_OUTPUT", Some("auto"), || { let rt = Runtime::new().expect("create runtime"); let caps = rt.block_on(negotiate_against_local_server()); match caps.camera_output.as_deref() { Some("uvc") => { assert_eq!(caps.camera_codec.as_deref(), Some("mjpeg")); assert!(matches!(caps.camera_width, Some(1280) | Some(640))); assert!(matches!(caps.camera_height, Some(720) | Some(360))); } Some("hdmi") => { assert_eq!(caps.camera_codec.as_deref(), Some("h264")); assert!(matches!(caps.camera_width, Some(1280) | Some(1920))); assert!(matches!(caps.camera_height, Some(720) | Some(1080))); } other => panic!("unexpected camera output: {other:?}"), } assert!(caps.camera); }); } #[test] #[serial] fn handshake_returns_defaults_when_server_is_unreachable() { let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener"); let addr = listener.local_addr().expect("listener addr"); drop(listener); let rt = Runtime::new().expect("create runtime"); let caps = rt.block_on(negotiate(&format!("http://{addr}"))); assert_default_caps(&caps); } #[test] #[serial] fn handshake_returns_defaults_when_service_is_unimplemented() { let rt = Runtime::new().expect("create runtime"); let caps = rt.block_on(negotiate_against_service(UnimplementedHandshakeSvc)); assert_default_caps(&caps); } #[test] #[serial] fn handshake_returns_defaults_when_service_returns_internal_error() { let rt = Runtime::new().expect("create runtime"); let caps = rt.block_on(negotiate_against_service(InternalErrorHandshakeSvc)); assert_default_caps(&caps); } #[test] #[serial] fn handshake_maps_empty_optional_fields_to_none() { let rt = Runtime::new().expect("create runtime"); let caps = rt.block_on(negotiate_against_service(SparseHandshakeSvc)); assert!(caps.camera); assert!(!caps.microphone); assert_eq!(caps.camera_output, None); assert_eq!(caps.camera_codec, None); assert_eq!(caps.camera_width, None); assert_eq!(caps.camera_height, None); assert_eq!(caps.camera_fps, None); assert_eq!(caps.eye_width, None); assert_eq!(caps.eye_height, None); assert_eq!(caps.eye_fps, None); } #[test] #[serial] fn handshake_times_out_slow_capabilities_call() { let rt = Runtime::new().expect("create runtime"); let caps = rt.block_on(negotiate_against_service(SlowHandshakeSvc)); assert_default_caps(&caps); } #[test] #[serial] fn handshake_probe_reports_reachable_caps_and_rtt() { with_var("LESAVKA_CAM_OUTPUT", Some("uvc"), || { let rt = Runtime::new().expect("create runtime"); let probe_result = rt.block_on(probe_against_service( lesavka_server::handshake::HandshakeSvc, )); assert!(probe_result.reachable); assert!(probe_result.rtt_ms.is_some_and(|rtt| rtt >= 0.0)); assert!(probe_result.caps.camera); assert!(probe_result.caps.microphone); assert_eq!(probe_result.caps.camera_output.as_deref(), Some("uvc")); assert_eq!(probe_result.caps.camera_codec.as_deref(), Some("mjpeg")); }); } #[test] #[serial] fn handshake_probe_marks_unimplemented_service_reachable() { let rt = Runtime::new().expect("create runtime"); let probe_result = rt.block_on(probe_against_service(UnimplementedHandshakeSvc)); assert!(probe_result.reachable); assert!(probe_result.rtt_ms.is_some()); assert_default_caps(&probe_result.caps); } #[test] #[serial] fn handshake_probe_marks_internal_error_service_reachable() { let rt = Runtime::new().expect("create runtime"); let probe_result = rt.block_on(probe_against_service(InternalErrorHandshakeSvc)); assert!(probe_result.reachable); assert!(probe_result.rtt_ms.is_some()); assert_default_caps(&probe_result.caps); } #[test] #[serial] fn handshake_probe_maps_empty_optional_fields_to_none() { let rt = Runtime::new().expect("create runtime"); let probe_result = rt.block_on(probe_against_service(SparseHandshakeSvc)); assert!(probe_result.reachable); assert!(probe_result.caps.camera); assert!(!probe_result.caps.microphone); assert_eq!(probe_result.caps.camera_output, None); assert_eq!(probe_result.caps.camera_codec, None); assert_eq!(probe_result.caps.camera_width, None); assert_eq!(probe_result.caps.camera_height, None); assert_eq!(probe_result.caps.camera_fps, None); } #[test] #[serial] fn handshake_probe_returns_defaults_when_server_is_unreachable() { let listener = TcpListener::bind("127.0.0.1:0").expect("bind local listener"); let addr = listener.local_addr().expect("listener addr"); drop(listener); let rt = Runtime::new().expect("create runtime"); let probe_result = rt.block_on(probe(&format!("http://{addr}"))); assert_default_probe(&probe_result); } #[test] #[serial] fn handshake_probe_returns_defaults_for_invalid_endpoint() { let rt = Runtime::new().expect("create runtime"); let probe_result = rt.block_on(probe("not a uri")); assert_default_probe(&probe_result); } #[test] #[serial] fn handshake_probe_times_out_slow_capabilities_call() { let rt = Runtime::new().expect("create runtime"); let probe_result = rt.block_on(probe_against_service(SlowHandshakeSvc)); assert_default_probe(&probe_result); } #[test] #[serial] fn handshake_service_direct_call_reports_capabilities() { with_var("LESAVKA_CAM_OUTPUT", Some("uvc"), || { let rt = Runtime::new().expect("create runtime"); let response = rt.block_on(async { lesavka_server::handshake::HandshakeSvc .get_capabilities(Request::new(Empty {})) .await .expect("handshake service response") .into_inner() }); assert!(response.camera); assert!(response.microphone); assert_eq!(response.camera_output, "uvc"); assert_eq!(response.camera_codec, "mjpeg"); assert!(response.camera_width > 0); assert!(response.camera_height > 0); assert!(response.camera_fps > 0); assert!(response.eye_width > 0); assert!(response.eye_height > 0); assert!(response.eye_fps > 0); let _ = lesavka_server::handshake::HandshakeSvc::server(); }); }