From 0637f83daaf6669770305c7b9aade6b2b05b46e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Frosteg=C3=A5rd?= Date: Sun, 9 Aug 2020 00:26:26 +0200 Subject: [PATCH] aquatic_ws: rewrite failing serialization, add tests Use different approach to action fields and info_hash vec, fixing failing tests --- aquatic_ws/src/lib/handler.rs | 20 +- aquatic_ws/src/lib/network/mod.rs | 2 +- aquatic_ws_load_test/src/utils.rs | 4 +- aquatic_ws_protocol/src/lib.rs | 352 +++++++++++++++-------- aquatic_ws_protocol/src/serde_helpers.rs | 183 ++++-------- 5 files changed, 311 insertions(+), 250 deletions(-) diff --git a/aquatic_ws/src/lib/handler.rs b/aquatic_ws/src/lib/handler.rs index f6f037b..0738293 100644 --- a/aquatic_ws/src/lib/handler.rs +++ b/aquatic_ws/src/lib/handler.rs @@ -184,6 +184,7 @@ pub fn handle_announce_requests( } let middleman_offer = MiddlemanOfferToPeer { + action: AnnounceAction, info_hash: request.info_hash, peer_id: request.peer_id, offer: offer.offer, @@ -205,6 +206,7 @@ pub fn handle_announce_requests( .get(&answer_receiver_id) { let middleman_answer = MiddlemanAnswerToPeer { + action: AnnounceAction, peer_id: request.peer_id, info_hash: request.info_hash, answer, @@ -219,6 +221,7 @@ pub fn handle_announce_requests( } let response = OutMessage::AnnounceResponse(AnnounceResponse { + action: AnnounceAction, info_hash: request.info_hash, complete: torrent_data.num_seeders, incomplete: torrent_data.num_leechers, @@ -236,12 +239,19 @@ pub fn handle_scrape_requests( messages_out: &mut Vec<(ConnectionMeta, OutMessage)>, requests: Drain<(ConnectionMeta, ScrapeRequest)>, ){ - messages_out.extend(requests.map(|(meta, request)| { - let num_to_take = request.info_hashes.len().min( + for (meta, request) in requests { + let info_hashes = if let Some(info_hashes) = request.info_hashes { + info_hashes.as_vec() + } else { + continue + }; + + let num_to_take = info_hashes.len().min( config.protocol.max_scrape_torrents ); let mut response = ScrapeResponse { + action: ScrapeAction, files: HashMap::with_capacity(num_to_take), }; @@ -253,7 +263,7 @@ pub fn handle_scrape_requests( // If request.info_hashes is empty, don't return scrape for all // torrents, even though reference server does it. It is too expensive. - for info_hash in request.info_hashes.into_iter().take(num_to_take){ + for info_hash in info_hashes.into_iter().take(num_to_take){ if let Some(torrent_data) = torrent_map.get(&info_hash){ let stats = ScrapeStatistics { complete: torrent_data.num_seeders, @@ -265,6 +275,6 @@ pub fn handle_scrape_requests( } } - (meta, OutMessage::ScrapeResponse(response)) - })); + messages_out.push((meta, OutMessage::ScrapeResponse(response))); + } } \ No newline at end of file diff --git a/aquatic_ws/src/lib/network/mod.rs b/aquatic_ws/src/lib/network/mod.rs index 9fb2301..87b7e8d 100644 --- a/aquatic_ws/src/lib/network/mod.rs +++ b/aquatic_ws/src/lib/network/mod.rs @@ -193,7 +193,7 @@ pub fn run_handshakes_and_read_messages( match established_ws.ws.read_message(){ Ok(ws_message) => { - if let Some(in_message) = InMessage::from_ws_message(ws_message){ + if let Ok(in_message) = InMessage::from_ws_message(ws_message){ let naive_peer_addr = established_ws.peer_addr; let converted_peer_ip = convert_ipv4_mapped_ipv6( naive_peer_addr.ip() diff --git a/aquatic_ws_load_test/src/utils.rs b/aquatic_ws_load_test/src/utils.rs index 047f6b5..c4e33a4 100644 --- a/aquatic_ws_load_test/src/utils.rs +++ b/aquatic_ws_load_test/src/utils.rs @@ -80,6 +80,7 @@ fn create_announce_request( .copy_from_slice(&connection_id.to_ne_bytes()); InMessage::AnnounceRequest(AnnounceRequest { + action: AnnounceAction, info_hash: state.info_hashes[info_hash_index], peer_id, bytes_left: Some(bytes_left), @@ -108,7 +109,8 @@ fn create_scrape_request( } InMessage::ScrapeRequest(ScrapeRequest { - info_hashes: scrape_hashes, + action: ScrapeAction, + info_hashes: Some(ScrapeRequestInfoHashes::Multiple(scrape_hashes)), }) } diff --git a/aquatic_ws_protocol/src/lib.rs b/aquatic_ws_protocol/src/lib.rs index 681f93b..008be43 100644 --- a/aquatic_ws_protocol/src/lib.rs +++ b/aquatic_ws_protocol/src/lib.rs @@ -1,11 +1,64 @@ +use anyhow::Context; use hashbrown::HashMap; -use serde::{Serialize, Deserialize}; +use serde::{Serialize, Deserialize, Serializer, Deserializer}; mod serde_helpers; use serde_helpers::*; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AnnounceAction; + + +impl Serialize for AnnounceAction { + fn serialize( + &self, + serializer: S + ) -> Result + where S: Serializer + { + serializer.serialize_str("announce") + } +} + + +impl<'de> Deserialize<'de> for AnnounceAction { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_str(AnnounceActionVisitor) + } +} + + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ScrapeAction; + + +impl Serialize for ScrapeAction { + fn serialize( + &self, + serializer: S + ) -> Result + where S: Serializer + { + serializer.serialize_str("scrape") + } +} + + +impl<'de> Deserialize<'de> for ScrapeAction { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_str(ScrapeActionVisitor) + } +} + + #[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)] #[serde(transparent)] pub struct PeerId( @@ -67,6 +120,7 @@ impl Default for AnnounceEvent { /// action = "announce" #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct MiddlemanOfferToPeer { + pub action: AnnounceAction, /// Peer id of peer sending offer /// Note: if equal to client peer_id, client ignores offer pub peer_id: PeerId, @@ -83,6 +137,7 @@ pub struct MiddlemanOfferToPeer { /// Action field should be 'announce' #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct MiddlemanAnswerToPeer { + pub action: AnnounceAction, /// Note: if equal to client peer_id, client ignores answer pub peer_id: PeerId, pub info_hash: InfoHash, @@ -101,6 +156,7 @@ pub struct AnnounceRequestOffer { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AnnounceRequest { + pub action: AnnounceAction, pub info_hash: InfoHash, pub peer_id: PeerId, /// Just called "left" in protocol. Is set to None in some cases, such as @@ -136,6 +192,7 @@ pub struct AnnounceRequest { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AnnounceResponse { + pub action: AnnounceAction, pub info_hash: InfoHash, /// Client checks if this is null, not clear why pub complete: usize, @@ -145,17 +202,32 @@ pub struct AnnounceResponse { } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ScrapeRequestInfoHashes { + Single(InfoHash), + Multiple(Vec), +} + + +impl ScrapeRequestInfoHashes { + pub fn as_vec(self) -> Vec { + match self { + Self::Single(info_hash) => vec![info_hash], + Self::Multiple(info_hashes) => info_hashes, + } + } +} + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ScrapeRequest { + pub action: ScrapeAction, // If omitted, scrape for all torrents, apparently // There is some kind of parsing here too which accepts a single info hash // and puts it into a vector - #[serde( - rename = "info_hash", - deserialize_with = "deserialize_info_hashes", - default - )] - pub info_hashes: Vec, + #[serde(rename = "info_hash")] + pub info_hashes: Option, } @@ -169,48 +241,15 @@ pub struct ScrapeStatistics { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ScrapeResponse { + pub action: ScrapeAction, pub files: HashMap, // Looks like `flags` field is ignored in reference client // pub flags: HashMap, } -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum Action { - Announce, - Scrape -} - - -/// Helper for serializing and deserializing messages -#[derive(Debug, Clone, Serialize, Deserialize)] -struct ActionWrapper { - pub action: Action, - #[serde(flatten)] - pub inner: T, -} - - -impl ActionWrapper { - #[inline] - pub fn announce(t: T) -> Self { - Self { - action: Action::Announce, - inner: t - } - } - #[inline] - pub fn scrape(t: T) -> Self { - Self { - action: Action::Scrape, - inner: t - } - } -} - - -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] pub enum InMessage { AnnounceRequest(AnnounceRequest), ScrapeRequest(ScrapeRequest), @@ -218,49 +257,26 @@ pub enum InMessage { impl InMessage { - /// Try parsing as announce request first. If that fails, try parsing as - /// scrape request, or return None #[inline] - pub fn from_ws_message(ws_message: tungstenite::Message) -> Option { + pub fn from_ws_message(ws_message: tungstenite::Message) -> ::anyhow::Result { use tungstenite::Message::{Text, Binary}; let text = match ws_message { - Text(text) => Some(text), - Binary(bytes) => String::from_utf8(bytes).ok(), - _ => None - }?; + Text(text) => text, + Binary(bytes) => String::from_utf8(bytes)?, + _ => return Err(anyhow::anyhow!("Message is neither text nor bytes")), + }; - let res: Result, _> = serde_json::from_str(&text); - - if let Ok(ActionWrapper { action: Action::Announce, inner }) = res { - return Some(InMessage::AnnounceRequest(inner)); - } - - let res: Result, _> = serde_json::from_str(&text); - - if let Ok(ActionWrapper { action: Action::Scrape, inner }) = res { - return Some(InMessage::ScrapeRequest(inner)); - } - - None + ::serde_json::from_str(&text).context("serialize with serde") } pub fn to_ws_message(&self) -> ::tungstenite::Message { - let text = match self { - InMessage::AnnounceRequest(r) => { - serde_json::to_string(&ActionWrapper::announce(r)).unwrap() - }, - InMessage::ScrapeRequest(r) => { - serde_json::to_string(&ActionWrapper::scrape(r)).unwrap() - }, - }; - - ::tungstenite::Message::from(text) + ::tungstenite::Message::from(::serde_json::to_string(&self).unwrap()) } } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(untagged)] pub enum OutMessage { AnnounceResponse(AnnounceResponse), @@ -273,30 +289,7 @@ pub enum OutMessage { impl OutMessage { #[inline] pub fn into_ws_message(self) -> tungstenite::Message { - let json = match self { - Self::AnnounceResponse(message) => { - serde_json::to_string( - &ActionWrapper::announce(message) - ).unwrap() - }, - Self::Offer(message) => { - serde_json::to_string( - &ActionWrapper::announce(message) - ).unwrap() - }, - Self::Answer(message) => { - serde_json::to_string( - &ActionWrapper::announce(message) - ).unwrap() - }, - Self::ScrapeResponse(message) => { - serde_json::to_string( - &ActionWrapper::scrape(message) - ).unwrap() - }, - }; - - tungstenite::Message::from(json) + ::tungstenite::Message::from(::serde_json::to_string(&self).unwrap()) } #[inline] @@ -308,7 +301,7 @@ impl OutMessage { let text = match message { Text(text) => text, Binary(bytes) => String::from_utf8(bytes)?, - _ => return Err(anyhow::anyhow!("message type not supported")), + _ => return Err(anyhow::anyhow!("Message is neither text nor bytes")), }; if text.contains("answer"){ @@ -381,6 +374,7 @@ mod tests { impl Arbitrary for MiddlemanOfferToPeer { fn arbitrary(g: &mut G) -> Self { Self { + action: AnnounceAction, peer_id: Arbitrary::arbitrary(g), info_hash: Arbitrary::arbitrary(g), offer_id: Arbitrary::arbitrary(g), @@ -392,6 +386,7 @@ mod tests { impl Arbitrary for MiddlemanAnswerToPeer { fn arbitrary(g: &mut G) -> Self { Self { + action: AnnounceAction, peer_id: Arbitrary::arbitrary(g), info_hash: Arbitrary::arbitrary(g), offer_id: Arbitrary::arbitrary(g), @@ -434,6 +429,7 @@ mod tests { .map(|offers| offers.len()); Self { + action: AnnounceAction, info_hash: Arbitrary::arbitrary(g), peer_id: Arbitrary::arbitrary(g), bytes_left: Arbitrary::arbitrary(g), @@ -450,6 +446,7 @@ mod tests { impl Arbitrary for AnnounceResponse { fn arbitrary(g: &mut G) -> Self { Self { + action: AnnounceAction, info_hash: Arbitrary::arbitrary(g), complete: Arbitrary::arbitrary(g), incomplete: Arbitrary::arbitrary(g), @@ -461,11 +458,23 @@ mod tests { impl Arbitrary for ScrapeRequest { fn arbitrary(g: &mut G) -> Self { Self { + action: ScrapeAction, info_hashes: Arbitrary::arbitrary(g), } } } + + impl Arbitrary for ScrapeRequestInfoHashes { + fn arbitrary(g: &mut G) -> Self { + if Arbitrary::arbitrary(g) { + ScrapeRequestInfoHashes::Multiple(Arbitrary::arbitrary(g)) + } else { + ScrapeRequestInfoHashes::Single(Arbitrary::arbitrary(g)) + } + } + } + impl Arbitrary for ScrapeStatistics { fn arbitrary(g: &mut G) -> Self { Self { @@ -481,6 +490,7 @@ mod tests { let files: Vec<(InfoHash, ScrapeStatistics)> = Arbitrary::arbitrary(g); Self { + action: ScrapeAction, files: files.into_iter().collect(), } } @@ -508,28 +518,144 @@ mod tests { } #[quickcheck] - fn test_serialize_deserialize_in_message(in_message_1: InMessage){ - dbg!(in_message_1.clone()); - + fn quickcheck_serde_identity_in_message(in_message_1: InMessage) -> bool { let ws_message = in_message_1.to_ws_message(); - dbg!(ws_message.clone()); - let in_message_2 = InMessage::from_ws_message(ws_message).unwrap(); - dbg!(in_message_2.clone()); + let in_message_2 = InMessage::from_ws_message(ws_message.clone()).unwrap(); - assert_eq!(in_message_1, in_message_2); + let success = in_message_1 == in_message_2; + + if !success { + dbg!(in_message_1); + dbg!(in_message_2); + if let ::tungstenite::Message::Text(text) = ws_message { + println!("{}", text); + } + } + + success } #[quickcheck] - fn test_serialize_deserialize_out_message(out_message_1: OutMessage){ - dbg!(out_message_1.clone()); - + fn quickcheck_serde_identity_out_message(out_message_1: OutMessage) -> bool { let ws_message = out_message_1.clone().into_ws_message(); - dbg!(ws_message.clone()); - let out_message_2 = OutMessage::from_ws_message(ws_message).unwrap(); - dbg!(out_message_2.clone()); + let out_message_2 = OutMessage::from_ws_message(ws_message.clone()).unwrap(); - assert_eq!(out_message_1, out_message_2); + let success = out_message_1 == out_message_2; + + if !success { + dbg!(out_message_1); + dbg!(out_message_2); + if let ::tungstenite::Message::Text(text) = ws_message { + println!("{}", text); + } + } + + success + } + + fn info_hash_from_bytes(bytes: &[u8]) -> InfoHash { + let mut arr = [0u8; 20]; + + assert!(bytes.len() == 20); + + arr.copy_from_slice(&bytes[..]); + + InfoHash(arr) + } + + #[test] + fn test_deserialize_info_hashes_vec(){ + let input = r#"{ + "action": "scrape", + "info_hash": ["aaaabbbbccccddddeeee", "aaaabbbbccccddddeeee"] + }"#; + + let info_hashes = ScrapeRequestInfoHashes::Multiple( + vec![ + info_hash_from_bytes(b"aaaabbbbccccddddeeee"), + info_hash_from_bytes(b"aaaabbbbccccddddeeee"), + ] + ); + + let expected = ScrapeRequest { + action: ScrapeAction, + info_hashes: Some(info_hashes) + }; + + let observed: ScrapeRequest = serde_json::from_str(input).unwrap(); + + assert_eq!(expected, observed); + } + + #[test] + fn test_deserialize_info_hashes_str(){ + let input = r#"{ + "action": "scrape", + "info_hash": "aaaabbbbccccddddeeee" + }"#; + + let info_hashes = ScrapeRequestInfoHashes::Single( + info_hash_from_bytes(b"aaaabbbbccccddddeeee") + ); + + let expected = ScrapeRequest { + action: ScrapeAction, + info_hashes: Some(info_hashes) + }; + + let observed: ScrapeRequest = serde_json::from_str(input).unwrap(); + + assert_eq!(expected, observed); + } + + #[test] + fn test_deserialize_info_hashes_null(){ + let input = r#"{ + "action": "scrape", + "info_hash": null + }"#; + + let expected = ScrapeRequest { + action: ScrapeAction, + info_hashes: None + }; + + let observed: ScrapeRequest = serde_json::from_str(input).unwrap(); + + assert_eq!(expected, observed); + } + + #[test] + fn test_deserialize_info_hashes_missing(){ + let input = r#"{ + "action": "scrape" + }"#; + + let expected = ScrapeRequest { + action: ScrapeAction, + info_hashes: None + }; + + let observed: ScrapeRequest = serde_json::from_str(input).unwrap(); + + assert_eq!(expected, observed); + } + + #[quickcheck] + fn quickcheck_serde_identity_info_hashes(info_hashes: ScrapeRequestInfoHashes) -> bool { + let json = ::serde_json::to_string(&info_hashes).unwrap(); + + println!("{}", json); + + let deserialized: ScrapeRequestInfoHashes = ::serde_json::from_str(&json).unwrap(); + + let success = info_hashes == deserialized; + + if !success { + } + + success } } \ No newline at end of file diff --git a/aquatic_ws_protocol/src/serde_helpers.rs b/aquatic_ws_protocol/src/serde_helpers.rs index f7cea32..ac2b20f 100644 --- a/aquatic_ws_protocol/src/serde_helpers.rs +++ b/aquatic_ws_protocol/src/serde_helpers.rs @@ -1,13 +1,55 @@ -use serde::{Serializer, Deserializer, de::{Visitor, SeqAccess}}; +use serde::{Serializer, Deserializer, de::Visitor}; -use super::InfoHash; +use super::{AnnounceAction, ScrapeAction}; + + +pub struct AnnounceActionVisitor; + + +impl<'de> Visitor<'de> for AnnounceActionVisitor { + type Value = AnnounceAction; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("string with value 'announce'") + } + + fn visit_str(self, v: &str) -> Result + where E: ::serde::de::Error, { + if v == "announce" { + Ok(AnnounceAction) + } else { + Err(E::custom("value is not 'announce'")) + } + } +} + + +pub struct ScrapeActionVisitor; + + +impl<'de> Visitor<'de> for ScrapeActionVisitor { + type Value = ScrapeAction; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("string with value 'scrape'") + } + + fn visit_str(self, v: &str) -> Result + where E: ::serde::de::Error, { + if v == "scrape" { + Ok(ScrapeAction) + } else { + Err(E::custom("value is not 'scrape'")) + } + } +} pub fn serialize_20_bytes( data: &[u8; 20], serializer: S ) -> Result where S: Serializer { - let text: String = data.iter().map(|byte| *byte as char).collect(); + let text: String = data.iter().map(|byte| char::from(*byte)).collect(); serializer.serialize_str(&text) } @@ -71,69 +113,11 @@ pub fn deserialize_20_bytes<'de, D>( } -pub struct InfoHashVecVisitor; - - -impl<'de> Visitor<'de> for InfoHashVecVisitor { - type Value = Vec; - - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - formatter.write_str("string or array of strings consisting of 20 bytes") - } - - #[inline] - fn visit_str(self, value: &str) -> Result - where E: ::serde::de::Error, - { - match TwentyByteVisitor::visit_str::(TwentyByteVisitor, value){ - Ok(arr) => Ok(vec![InfoHash(arr)]), - Err(err) => Err(E::custom(format!("got string, but {}", err))) - } - } - - #[inline] - fn visit_seq(self, mut seq: A) -> Result - where A: SeqAccess<'de> - { - let mut info_hashes: Self::Value = Vec::new(); - - while let Ok(Some(value)) = seq.next_element::<&str>(){ - let arr = TwentyByteVisitor::visit_str( - TwentyByteVisitor, value - )?; - - info_hashes.push(InfoHash(arr)); - } - - Ok(info_hashes) - } - - #[inline] - fn visit_none(self) -> Result - where E: ::serde::de::Error - { - Ok(vec![]) - } -} - - -/// Empty vector is returned if value is null or any invalid info hash -/// is present -#[inline] -pub fn deserialize_info_hashes<'de, D>( - deserializer: D -) -> Result, D::Error> - where D: Deserializer<'de>, -{ - Ok(deserializer.deserialize_any(InfoHashVecVisitor).unwrap_or_default()) -} - - #[cfg(test)] mod tests { - use serde::Deserialize; + use quickcheck_macros::quickcheck; - use super::*; + use crate::InfoHash; fn info_hash_from_bytes(bytes: &[u8]) -> InfoHash { let mut arr = [0u8; 20]; @@ -175,73 +159,12 @@ mod tests { assert_eq!(info_hash, info_hash_2); } - #[derive(Debug, PartialEq, Eq, Deserialize)] - struct Test { - #[serde(deserialize_with = "deserialize_info_hashes", default)] - info_hashes: Vec, + #[quickcheck] + fn quickcheck_serde_20_bytes(info_hash: InfoHash) -> bool { + let out = serde_json::to_string(&info_hash).unwrap(); + let info_hash_2 = serde_json::from_str(&out).unwrap(); + + info_hash == info_hash_2 } - - #[test] - fn test_deserialize_info_hashes_vec(){ - let input = r#"{ - "info_hashes": ["aaaabbbbccccddddeeee", "aaaabbbbccccddddeeee"] - }"#; - - let expected = Test { - info_hashes: vec![ - info_hash_from_bytes(b"aaaabbbbccccddddeeee"), - info_hash_from_bytes(b"aaaabbbbccccddddeeee"), - ] - }; - - let observed: Test = serde_json::from_str(input).unwrap(); - - assert_eq!(observed, expected); - } - - #[test] - fn test_deserialize_info_hashes_str(){ - let input = r#"{ - "info_hashes": "aaaabbbbccccddddeeee" - }"#; - - let expected = Test { - info_hashes: vec![ - info_hash_from_bytes(b"aaaabbbbccccddddeeee"), - ] - }; - - let observed: Test = serde_json::from_str(input).unwrap(); - - assert_eq!(observed, expected); - } - - #[test] - fn test_deserialize_info_hashes_null(){ - let input = r#"{ - "info_hashes": null - }"#; - - let expected = Test { - info_hashes: vec![] - }; - - let observed: Test = serde_json::from_str(input).unwrap(); - - assert_eq!(observed, expected); - } - - #[test] - fn test_deserialize_info_hashes_missing(){ - let input = r#"{}"#; - - let expected = Test { - info_hashes: vec![] - }; - - let observed: Test = serde_json::from_str(input).unwrap(); - - assert_eq!(observed, expected); - } } \ No newline at end of file