use std::{ collections::BTreeMap, sync::{Arc, Mutex}, }; use aria2_rust_pro_core::{ DownloadEngine, DownloadId, EventListener, RuntimeEvent, RuntimeEventKind, }; use super::notification::{ MAX_PENDING_WEBSOCKET_BROADCAST_FRAMES, MAX_PENDING_WEBSOCKET_FRAMES_PER_SESSION, }; use super::*; use crate::{InProcessRpcDispatcher, JsonRpcRequest, RpcValue}; #[test] /// Verifies that notification kinds keep the upstream aria2 WebSocket method names. fn notification_kind_uses_upstream_websocket_method_names() { assert_eq!( RpcNotificationKind::DownloadStarted.method_name(), "aria2.onDownloadStart" ); assert_eq!( RpcNotificationKind::DownloadBtDownloadComplete.method_name(), "aria2.onBtDownloadComplete" ); } #[test] /// Verifies that notification events render the upstream `JSON-RPC` notification shape. fn websocket_event_renders_upstream_jsonrpc_notification_shape() { let event = RpcNotificationEvent { kind: RpcNotificationKind::DownloadComplete, method: String::new(), gid: Some("a1b2c3".to_owned()), payload: None, meta: crate::model::RpcMeta::default(), }; assert_eq!( event.to_websocket_json(), "{\"jsonrpc\":\"2.0\",\"method\":\"aria2.onDownloadComplete\",\"params\":[{\"gid\":\"a1b2c3\"}]}" ); assert_eq!( event.to_websocket_frame(), RpcWebSocketFrame::Text( "{\"jsonrpc\":\"2.0\",\"method\":\"aria2.onDownloadComplete\",\"params\":[{\"gid\":\"a1b2c3\"}]}".to_owned() ) ); } #[test] /// Verifies that object payloads merge with the gid field in notification output. fn websocket_event_merges_gid_with_object_payload() { let event = RpcNotificationEvent { kind: RpcNotificationKind::DownloadError, method: String::new(), gid: Some("deadbeef".to_owned()), payload: Some(RpcValue::Object(BTreeMap::from([( "status".to_owned(), RpcValue::String("error".to_owned()), )]))), meta: crate::model::RpcMeta::default(), }; assert_eq!( event.to_websocket_json(), "{\"jsonrpc\":\"2.0\",\"method\":\"aria2.onDownloadError\",\"params\":[{\"gid\":\"deadbeef\",\"status\":\"error\"}]}" ); } #[test] /// Verifies that one notification frame is emitted for each matching subscription. fn registry_emits_one_frame_per_matching_subscription() { let mut registry = WebSocketNotificationRegistry::default(); registry.subscribe(WebSocketSubscription { id: "sub-a".to_owned(), kind: RpcNotificationKind::DownloadStarted, topic: "aria2.onDownloadStart".to_owned(), }); registry.subscribe(WebSocketSubscription { id: "sub-b".to_owned(), kind: RpcNotificationKind::DownloadStarted, topic: "aria2.onDownloadStart".to_owned(), }); registry.subscribe(WebSocketSubscription { id: "sub-c".to_owned(), kind: RpcNotificationKind::DownloadComplete, topic: "aria2.onDownloadComplete".to_owned(), }); let event = RpcNotificationEvent { kind: RpcNotificationKind::DownloadStarted, method: String::new(), gid: Some("feedface".to_owned()), payload: None, meta: crate::model::RpcMeta::default(), }; let frames = registry.frames_for_event(&event); assert_eq!(frames.len(), 2); assert!(frames.iter().any(|(id, _)| id == "sub-a")); assert!(frames.iter().any(|(id, _)| id == "sub-b")); assert!( frames .iter() .all(|(_, frame)| matches!(frame, RpcWebSocketFrame::Text(_))) ); } #[test] /// Verifies that broadcast queueing fans notification frames out to every connected session. fn session_registry_broadcasts_notification_frames_to_all_sessions() { let mut registry = WebSocketSessionRegistry::default(); registry.connect("sess-a"); registry.connect("sess-b"); let event = RpcNotificationEvent { kind: RpcNotificationKind::DownloadComplete, method: String::new(), gid: Some("abc123".to_owned()), payload: None, meta: crate::model::RpcMeta::default(), }; registry.queue_event_for_all(&event); assert_eq!(registry.pending_count("sess-a"), Some(1)); assert_eq!(registry.pending_count("sess-b"), Some(1)); assert_eq!( registry.pop_frame("sess-a"), Some(RpcWebSocketFrame::Text( "{\"jsonrpc\":\"2.0\",\"method\":\"aria2.onDownloadComplete\",\"params\":[{\"gid\":\"abc123\"}]}".to_owned() )) ); assert_eq!( registry.pop_frame("sess-b"), Some(RpcWebSocketFrame::Text( "{\"jsonrpc\":\"2.0\",\"method\":\"aria2.onDownloadComplete\",\"params\":[{\"gid\":\"abc123\"}]}".to_owned() )) ); } #[test] /// Verifies that bridged broadcast ingress stays bounded and drops the oldest frame first. fn session_registry_enforces_bounded_broadcast_ingress_backpressure() { let mut registry = WebSocketSessionRegistry::default(); registry.connect("sess-a"); for index in 0..(MAX_PENDING_WEBSOCKET_BROADCAST_FRAMES + 8) { registry.queue_broadcast_frame(RpcWebSocketFrame::Ping(vec![ u8::try_from(index % 256).expect("byte should fit"), ])); } assert_eq!( registry.pending_broadcast_count(), MAX_PENDING_WEBSOCKET_BROADCAST_FRAMES ); assert_eq!( registry.pop_frame("sess-a"), Some(RpcWebSocketFrame::Ping(vec![8])), "oldest bridged broadcast frames should be dropped first once the ingress limit is reached" ); } #[test] /// Verifies that bridged broadcast ingress fans out only when a session drain occurs. fn session_registry_drains_broadcast_ingress_into_connected_sessions() { let mut registry = WebSocketSessionRegistry::default(); registry.connect("sess-a"); registry.connect("sess-b"); registry.queue_broadcast_frame(RpcWebSocketFrame::Ping(vec![4, 2])); assert_eq!(registry.pending_count("sess-a"), Some(0)); assert_eq!(registry.pending_count("sess-b"), Some(0)); assert_eq!(registry.pending_broadcast_count(), 1); let sess_a_frames = registry.drain_session_frames("sess-a"); assert_eq!(registry.pending_broadcast_count(), 0); assert_eq!(sess_a_frames, vec![RpcWebSocketFrame::Ping(vec![4, 2])]); assert_eq!( registry.pop_frame("sess-b"), Some(RpcWebSocketFrame::Ping(vec![4, 2])) ); } #[test] /// Verifies targeted queueing, missing-session rejection, and disconnect behavior. fn session_registry_supports_targeted_queue_and_disconnect() { let mut registry = WebSocketSessionRegistry::default(); registry.connect("sess-a"); registry.connect("sess-b"); assert!(registry.queue_frame_for_session("sess-a", RpcWebSocketFrame::Ping(vec![1, 2, 3]),)); assert!(!registry.queue_frame_for_session("sess-missing", RpcWebSocketFrame::Ping(vec![9]),)); assert_eq!(registry.pending_count("sess-a"), Some(1)); assert_eq!(registry.pending_count("sess-b"), Some(0)); assert_eq!( registry.pop_frame("sess-a"), Some(RpcWebSocketFrame::Ping(vec![1, 2, 3])) ); registry.disconnect("sess-b"); assert!(!registry.contains("sess-b")); assert_eq!(registry.len(), 1); } #[test] /// Verifies that the per-session queue is bounded and drops the oldest frame under pressure. fn session_registry_enforces_bounded_pending_frame_backpressure() { let mut registry = WebSocketSessionRegistry::default(); registry.connect("sess-a"); for index in 0..(MAX_PENDING_WEBSOCKET_FRAMES_PER_SESSION + 8) { assert!(registry.queue_frame_for_session( "sess-a", RpcWebSocketFrame::Ping(vec![u8::try_from(index % 256).expect("byte should fit")]), )); } assert_eq!( registry.pending_count("sess-a"), Some(MAX_PENDING_WEBSOCKET_FRAMES_PER_SESSION) ); assert_eq!( registry.pop_frame("sess-a"), Some(RpcWebSocketFrame::Ping(vec![8])), "oldest queued frames should be dropped first once the per-session limit is reached" ); } #[test] /// Verifies that the runtime-event bridge queues real completion events into session frames. fn runtime_event_bridge_queues_real_download_events_into_sessions() { let sessions = Arc::new(Mutex::new(WebSocketSessionRegistry::default())); sessions .lock() .expect("sessions lock should succeed") .connect("sess-a"); let mut bridge = RuntimeEventWebSocketBridge::new(Arc::clone(&sessions)); bridge.on_event( &RuntimeEvent::new(RuntimeEventKind::DownloadCompleted).with_gid(DownloadId::new(0x2a)), ); assert_eq!( sessions .lock() .expect("sessions lock should succeed") .pending_broadcast_count(), 1 ); let frame = sessions .lock() .expect("sessions lock should succeed") .pop_frame("sess-a") .expect("download completion should queue a notification frame"); match frame { RpcWebSocketFrame::Text(text) => { assert!(text.contains("aria2.onDownloadComplete")); assert!(text.contains(r#""gid":"000000000000002a""#)); } other => panic!("unexpected bridged notification frame: {other:?}"), } } #[test] /// Verifies that `addUri` registration alone does not emit a start notification. fn runtime_event_bridge_does_not_treat_add_uri_as_download_start() { let sessions = Arc::new(Mutex::new(WebSocketSessionRegistry::default())); sessions .lock() .expect("sessions lock should succeed") .connect("sess-a"); let bridge = RuntimeEventWebSocketBridge::new(Arc::clone(&sessions)); let mut dispatcher = InProcessRpcDispatcher::new(); dispatcher.register_runtime_listener(bridge); let response = dispatcher.dispatch_json(JsonRpcRequest { jsonrpc: Some("2.0".to_owned()), id: Some(crate::jsonrpc::JsonRpcId::Number(1)), method: "aria2.addUri".to_owned(), params: vec![RpcValue::String("https://example.org/file.iso".to_owned())], meta: crate::model::RpcMeta::default(), }); assert!(response.error.is_none()); assert!( sessions .lock() .expect("sessions lock should succeed") .pop_frame("sess-a") .is_none(), "addUri should not emit aria2.onDownloadStart before the download actually starts" ); } #[test] /// Verifies that start notifications only appear after the scheduler activates the download. fn runtime_event_bridge_emits_start_only_when_scheduler_activates_download() { let sessions = Arc::new(Mutex::new(WebSocketSessionRegistry::default())); sessions .lock() .expect("sessions lock should succeed") .connect("sess-a"); let mut engine = DownloadEngine::new(); engine.register_listener(RuntimeEventWebSocketBridge::new(Arc::clone(&sessions))); let gid = engine.add_uri("https://example.org/file.iso").gid(); assert!( sessions .lock() .expect("sessions lock should succeed") .pop_frame("sess-a") .is_none(), "registration alone should not emit a start notification" ); let _ = engine.schedule_once(); let frame = sessions .lock() .expect("sessions lock should succeed") .pop_frame("sess-a") .expect("scheduler activation should emit start notification"); match frame { RpcWebSocketFrame::Text(text) => { assert!(text.contains("aria2.onDownloadStart")); assert!(text.contains(&format!(r#""gid":"{gid}""#))); } other => panic!("unexpected frame: {other:?}"), } } #[test] /// Verifies that resumed downloads emit start only after they become active again. fn runtime_event_bridge_does_not_emit_start_until_resumed_download_is_active_again() { let sessions = Arc::new(Mutex::new(WebSocketSessionRegistry::default())); sessions .lock() .expect("sessions lock should succeed") .connect("sess-a"); let mut engine = DownloadEngine::new(); engine.register_listener(RuntimeEventWebSocketBridge::new(Arc::clone(&sessions))); let gid = engine.add_uri("https://example.org/file.iso").gid(); let _ = engine.schedule_once(); let _ = sessions .lock() .expect("sessions lock should succeed") .pop_frame("sess-a"); engine.pause(gid).expect("pause should succeed"); let pause = sessions .lock() .expect("sessions lock should succeed") .pop_frame("sess-a") .expect("pause should emit pause notification"); match pause { RpcWebSocketFrame::Text(text) => { assert!(text.contains("aria2.onDownloadPause")); } other => panic!("unexpected pause frame: {other:?}"), } engine .resume(gid) .expect("resume should move download back to waiting"); assert!( sessions .lock() .expect("sessions lock should succeed") .pop_frame("sess-a") .is_none(), "resume should not emit start notification before reactivation" ); let _ = engine.schedule_once(); let frame = sessions .lock() .expect("sessions lock should succeed") .pop_frame("sess-a") .expect("reactivation should emit start notification"); match frame { RpcWebSocketFrame::Text(text) => { assert!(text.contains("aria2.onDownloadStart")); assert!(text.contains(&format!(r#""gid":"{gid}""#))); } other => panic!("unexpected reactivation frame: {other:?}"), } }