|
@@ -0,0 +1,627 @@
|
|
|
+//! Tag mod
|
|
|
+//!
|
|
|
+//! An event can tag/reference another public key, or another event.
|
|
|
+//!
|
|
|
+//! It can also use the tag to add all sort of metadata, useful for their own
|
|
|
+//! type
|
|
|
+use super::{filter::TagValue, Id};
|
|
|
+use chrono::{DateTime, Utc};
|
|
|
+use serde::{
|
|
|
+ de::{self, Deserialize, Deserializer},
|
|
|
+ ser::{self, SerializeSeq, Serializer},
|
|
|
+};
|
|
|
+use std::{
|
|
|
+ collections::VecDeque,
|
|
|
+ fmt::{self, Display},
|
|
|
+};
|
|
|
+use url::Url;
|
|
|
+
|
|
|
+/// Marker as defined NIP-10
|
|
|
+#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
+pub enum Marker {
|
|
|
+ /// For top level replies (those replying directly to the root event), only
|
|
|
+ /// the "root" marker should be used
|
|
|
+ Root,
|
|
|
+ /// Denotes the id of the reply event being responded to
|
|
|
+ Reply,
|
|
|
+ /// Denotes a quoted or reposted event id.
|
|
|
+ Mention,
|
|
|
+ /// Unknown marker
|
|
|
+ Unknown(String),
|
|
|
+}
|
|
|
+
|
|
|
+#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
+/// Url content or empty string
|
|
|
+pub enum UrlOrEmpty {
|
|
|
+ /// Url
|
|
|
+ Url(Url),
|
|
|
+ /// Empty string
|
|
|
+ Empty,
|
|
|
+}
|
|
|
+
|
|
|
+impl Display for UrlOrEmpty {
|
|
|
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
|
|
+ match self {
|
|
|
+ Self::Url(url) => write!(f, "{}", url),
|
|
|
+ Self::Empty => write!(f, ""),
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+impl Display for Marker {
|
|
|
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
|
|
+ write!(
|
|
|
+ f,
|
|
|
+ "{}",
|
|
|
+ match self {
|
|
|
+ Self::Root => "root",
|
|
|
+ Self::Reply => "reply",
|
|
|
+ Self::Mention => "mention",
|
|
|
+ Self::Unknown(x) => x,
|
|
|
+ }
|
|
|
+ )
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+impl From<&str> for Marker {
|
|
|
+ fn from(marker: &str) -> Self {
|
|
|
+ match marker.to_ascii_lowercase().as_str() {
|
|
|
+ "root" => Self::Root,
|
|
|
+ "reply" => Self::Reply,
|
|
|
+ "mention" => Self::Mention,
|
|
|
+ _ => Self::Unknown(marker.to_owned()),
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+#[derive(Debug, PartialEq, Eq, Clone)]
|
|
|
+/// Relay access type
|
|
|
+pub enum RelayAccessType {
|
|
|
+ /// Read access
|
|
|
+ Read,
|
|
|
+ /// Write access
|
|
|
+ Write,
|
|
|
+ /// Both read and write access
|
|
|
+ Both,
|
|
|
+}
|
|
|
+
|
|
|
+/// Tags are how events relates to each other.
|
|
|
+///
|
|
|
+/// So far, there are two standard tags, Events and Public Key. This an event
|
|
|
+/// can be related to another, with extra options (like a relayer url, and a pet
|
|
|
+/// name).
|
|
|
+///
|
|
|
+/// Any non standard tag will be parsed as Unknown with a vector of strings as
|
|
|
+/// their parameters
|
|
|
+#[derive(Debug, PartialEq, Eq, Clone)]
|
|
|
+pub enum Tag {
|
|
|
+ /// Tag another event
|
|
|
+ Event(Id, Option<UrlOrEmpty>, Option<Marker>),
|
|
|
+ /// Tag another public key
|
|
|
+ PubKey(Id, Option<UrlOrEmpty>, Option<String>),
|
|
|
+ /// Tag a relay
|
|
|
+ Relay(Url, RelayAccessType),
|
|
|
+ /// Tag a hashtag
|
|
|
+ Hashtag(String),
|
|
|
+ /// Tag with an external content id
|
|
|
+ ExternalContentId(String, Option<Url>),
|
|
|
+ /// Tag with a title
|
|
|
+ Title(String),
|
|
|
+ /// Any non standard tag
|
|
|
+ Unknown(String, Vec<String>),
|
|
|
+ /// Encrypted - NIP-90
|
|
|
+ Encrypted,
|
|
|
+ /// Expiration - NIP-40
|
|
|
+ Expiration(DateTime<Utc>),
|
|
|
+ /// Image - NIP-23, NIP-52, NIP-58
|
|
|
+ Image(Url, Option<String>),
|
|
|
+ /// Zap Goal - NIP-75
|
|
|
+ ZapGoal(Id, Option<UrlOrEmpty>),
|
|
|
+ /// Weird, supported nonetheless
|
|
|
+ Empty,
|
|
|
+}
|
|
|
+
|
|
|
+impl Tag {
|
|
|
+ /// Is the tag a public key?
|
|
|
+ pub fn is_pubkey(&self) -> bool {
|
|
|
+ matches!(self, Self::PubKey(_, _, _))
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Is the tag an event?
|
|
|
+ pub fn is_event(&self) -> bool {
|
|
|
+ matches!(self, Self::Event(_, _, _))
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Get the identifier for the tag
|
|
|
+ pub fn get_identifier(&self) -> &str {
|
|
|
+ match self {
|
|
|
+ Tag::Event(_, _, _) => "e",
|
|
|
+ Tag::PubKey(_, _, _) => "p",
|
|
|
+ Tag::Relay(_, _) => "r",
|
|
|
+ Tag::Hashtag(_) => "t",
|
|
|
+ Tag::Title(_) => "title",
|
|
|
+ Tag::Encrypted => "encrypted",
|
|
|
+ Tag::Image(_, _) => "image",
|
|
|
+ Tag::ZapGoal(_, _) => "goal",
|
|
|
+ Tag::Expiration(_) => "expiration",
|
|
|
+ Tag::ExternalContentId(_, _) => "i",
|
|
|
+ Tag::Unknown(u, _) => u,
|
|
|
+ Tag::Empty => "",
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Get the indexable value of the tag
|
|
|
+ pub fn get_indexable_value(&self) -> Option<TagValue> {
|
|
|
+ match self {
|
|
|
+ Tag::Event(event, _, _) => Some(TagValue::Id(event.clone())),
|
|
|
+ Tag::PubKey(key, _, _) => Some(TagValue::Id(key.clone())),
|
|
|
+ Tag::ExternalContentId(id, _) => Some(TagValue::String(id.clone())),
|
|
|
+ Tag::Hashtag(content) | Tag::Title(content) => Some(TagValue::String(content.clone())),
|
|
|
+ Tag::Relay(url, _) => Some(TagValue::String(url.to_string())),
|
|
|
+ Tag::Unknown(_, args) => {
|
|
|
+ let value = args.get(0).cloned().unwrap_or_default();
|
|
|
+ Some(
|
|
|
+ value
|
|
|
+ .as_str()
|
|
|
+ .try_into()
|
|
|
+ .map(TagValue::Id)
|
|
|
+ .unwrap_or_else(|_| TagValue::String(value)),
|
|
|
+ )
|
|
|
+ }
|
|
|
+ Tag::ZapGoal(event, _) => Some(TagValue::Id(event.clone())),
|
|
|
+ Tag::Image(image_url, _) => Some(TagValue::String(image_url.to_string())),
|
|
|
+ Tag::Empty | Tag::Encrypted | Tag::Expiration(_) => None,
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Converts the tag into bytes if possible.
|
|
|
+ ///
|
|
|
+ /// The bytes can be used to store the tag in a database or to find it in an in-memory index for subscribers
|
|
|
+ pub fn into_bytes(self) -> Option<Vec<u8>> {
|
|
|
+ Some(
|
|
|
+ self.get_indexable_value()?
|
|
|
+ .into_bytes(self.get_identifier()),
|
|
|
+ )
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+impl ser::Serialize for Tag {
|
|
|
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
|
|
+ where
|
|
|
+ S: Serializer,
|
|
|
+ {
|
|
|
+ let mut seq = serializer.serialize_seq(Some(2))?;
|
|
|
+ seq.serialize_element(self.get_identifier())?;
|
|
|
+
|
|
|
+ match self {
|
|
|
+ Tag::Encrypted => {}
|
|
|
+ Tag::Expiration(date) => {
|
|
|
+ seq.serialize_element(&date.timestamp())?;
|
|
|
+ }
|
|
|
+ Tag::Event(event, relayer_url, marker) => {
|
|
|
+ seq.serialize_element(&event.to_string())?;
|
|
|
+ if let Some(relayer) = &relayer_url {
|
|
|
+ seq.serialize_element(&relayer.to_string())?;
|
|
|
+ if let Some(marker) = &marker {
|
|
|
+ seq.serialize_element(&marker.to_string())?;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ Tag::ZapGoal(event, relayer_url) => {
|
|
|
+ seq.serialize_element(&event.to_string())?;
|
|
|
+ if let Some(relayer) = &relayer_url {
|
|
|
+ seq.serialize_element(&relayer.to_string())?;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ Tag::ExternalContentId(content, url) => {
|
|
|
+ seq.serialize_element(content)?;
|
|
|
+ if let Some(url) = url {
|
|
|
+ seq.serialize_element(url)?;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ Tag::Hashtag(content) | Tag::Title(content) => {
|
|
|
+ seq.serialize_element(content)?;
|
|
|
+ }
|
|
|
+ Tag::PubKey(key, relayer_url, pet_name) => {
|
|
|
+ seq.serialize_element(&key.to_string())?;
|
|
|
+ if let Some(relayer) = &relayer_url {
|
|
|
+ seq.serialize_element(&relayer.to_string())?;
|
|
|
+ if let Some(pet_name) = &pet_name {
|
|
|
+ seq.serialize_element(pet_name)?;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ Tag::Relay(url, access) => {
|
|
|
+ seq.serialize_element(url)?;
|
|
|
+
|
|
|
+ if let Some(access) = match access {
|
|
|
+ RelayAccessType::Read => Some("read"),
|
|
|
+ RelayAccessType::Write => Some("write"),
|
|
|
+ RelayAccessType::Both => None,
|
|
|
+ } {
|
|
|
+ seq.serialize_element(access)?;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ Tag::Unknown(_, extra) => {
|
|
|
+ for extra in extra.iter() {
|
|
|
+ seq.serialize_element(extra)?;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ Tag::Image(image, dimensions) => {
|
|
|
+ seq.serialize_element(image)?;
|
|
|
+ if let Some(dimensions) = dimensions {
|
|
|
+ seq.serialize_element(dimensions)?;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ Tag::Empty => unreachable!(),
|
|
|
+ }
|
|
|
+ seq.end()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+impl<'de> Deserialize<'de> for Tag {
|
|
|
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
|
+ where
|
|
|
+ D: Deserializer<'de>,
|
|
|
+ {
|
|
|
+ let mut parts: VecDeque<String> = Deserialize::deserialize(deserializer)?;
|
|
|
+
|
|
|
+ match parts.len() {
|
|
|
+ 0 => return Ok(Tag::Empty),
|
|
|
+ 1 => {
|
|
|
+ let tag_name = parts.pop_front().unwrap_or_default();
|
|
|
+ match tag_name.as_str() {
|
|
|
+ "encrypted" => return Ok(Tag::Encrypted),
|
|
|
+ _ => {}
|
|
|
+ }
|
|
|
+ return Ok(Tag::Unknown(tag_name, vec![]));
|
|
|
+ }
|
|
|
+ _ => {}
|
|
|
+ }
|
|
|
+
|
|
|
+ let tag_type = parts.pop_front().unwrap_or_default();
|
|
|
+ let default = Tag::Unknown(tag_type.clone(), parts.clone().into());
|
|
|
+
|
|
|
+ let tag: Result<_, D::Error> = match tag_type.as_str() {
|
|
|
+ "e" | "goal" | "p" => parts
|
|
|
+ .pop_front()
|
|
|
+ .ok_or_else::<D::Error, _>(|| de::Error::custom("missing argument"))
|
|
|
+ .and_then(|id| id.parse().map_err(de::Error::custom))
|
|
|
+ .and_then(|id| {
|
|
|
+ let relayer_url = parts
|
|
|
+ .pop_front()
|
|
|
+ .map(|value| {
|
|
|
+ if value.is_empty() {
|
|
|
+ Ok(UrlOrEmpty::Empty)
|
|
|
+ } else {
|
|
|
+ value.parse().map(UrlOrEmpty::Url)
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .transpose()
|
|
|
+ .map_err(de::Error::custom);
|
|
|
+
|
|
|
+ relayer_url.map(|relayer_url| {
|
|
|
+ let extra = parts.pop_front();
|
|
|
+ match tag_type.as_str() {
|
|
|
+ "e" => Tag::Event(id, relayer_url, extra.map(|x| x.as_str().into())),
|
|
|
+ "goal" => Tag::ZapGoal(id, relayer_url),
|
|
|
+ "p" => Tag::PubKey(id, relayer_url, extra),
|
|
|
+ _ => unreachable!(),
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }),
|
|
|
+ "expiration" => {
|
|
|
+ let timestamp = parts
|
|
|
+ .pop_front()
|
|
|
+ .ok_or_else(|| de::Error::custom("missing argument"))?
|
|
|
+ .parse::<i64>()
|
|
|
+ .map_err(|_| de::Error::custom("invalid timestamp"))?;
|
|
|
+
|
|
|
+ DateTime::<Utc>::from_timestamp(timestamp, 0)
|
|
|
+ .map(Tag::Expiration)
|
|
|
+ .ok_or_else(|| de::Error::custom("invalid timestamp"))
|
|
|
+ }
|
|
|
+ "title" => parts
|
|
|
+ .pop_front()
|
|
|
+ .map(Tag::Title)
|
|
|
+ .ok_or_else(|| de::Error::custom("missing argument")),
|
|
|
+ "t" => parts
|
|
|
+ .pop_front()
|
|
|
+ .map(Tag::Hashtag)
|
|
|
+ .ok_or_else(|| de::Error::custom("missing argument")),
|
|
|
+ "i" => {
|
|
|
+ let external_id = parts
|
|
|
+ .pop_front()
|
|
|
+ .ok_or_else::<D::Error, _>(|| de::Error::custom("missing external id"));
|
|
|
+ let url = parts
|
|
|
+ .pop_front()
|
|
|
+ .map(|url| url.parse::<Url>().map_err::<D::Error, _>(de::Error::custom))
|
|
|
+ .transpose();
|
|
|
+
|
|
|
+ if let Ok(external_id) = external_id {
|
|
|
+ url.map(|url| Tag::ExternalContentId(external_id, url))
|
|
|
+ } else {
|
|
|
+ Err(de::Error::custom("invalid external id"))
|
|
|
+ }
|
|
|
+ }
|
|
|
+ "r" => {
|
|
|
+ let url = parts
|
|
|
+ .pop_front()
|
|
|
+ .ok_or_else::<D::Error, _>(|| de::Error::custom("missing url"))
|
|
|
+ .and_then(|url| url.parse().map_err(de::Error::custom));
|
|
|
+
|
|
|
+ let access = parts
|
|
|
+ .pop_front()
|
|
|
+ .map(|x| match x.as_str() {
|
|
|
+ "read" => Ok(RelayAccessType::Read),
|
|
|
+ "write" => Ok(RelayAccessType::Write),
|
|
|
+ _ => Err(de::Error::custom("invalid relay access type")),
|
|
|
+ })
|
|
|
+ .unwrap_or(Ok(RelayAccessType::Both));
|
|
|
+
|
|
|
+ if let Ok(url) = url {
|
|
|
+ access.map(|access| Tag::Relay(url, access))
|
|
|
+ } else {
|
|
|
+ Err(de::Error::custom("invalid url"))
|
|
|
+ }
|
|
|
+ }
|
|
|
+ _ => Ok(default.clone()),
|
|
|
+ };
|
|
|
+
|
|
|
+ if !parts.is_empty() {
|
|
|
+ return Ok(default);
|
|
|
+ }
|
|
|
+
|
|
|
+ Ok(tag.unwrap_or(default))
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+#[cfg(test)]
|
|
|
+mod test {
|
|
|
+ use serde_json::json;
|
|
|
+
|
|
|
+ use super::*;
|
|
|
+ use crate::Response;
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn bug_01() {
|
|
|
+ let json = r#"["EVENT","d45a98f898820258a3313f5cb14f5fe8a9263437931ac6309f23ae0324833f39",{"content":"Will fight under the light of lightnings! No darkness on nostr! 🐶🐾😂","created_at":1678685477,"id":"5462822032c16d32267ba40536409fd51ea188b20e7dd5f9e7a0aa5561346f79","kind":1,"pubkey":"8fb140b4e8ddef97ce4b821d247278a1a4353362623f64021484b372f948000c","sig":"62c8e09a0fedd8096a313ef12436b0f0bcad56e9058d4bc12f61ae6094c099bb966dce47efa2d721c5bfdf923614db46d1d1c248105cb99c4ec495292cc875b1","tags":[["e","5f63f9e7d37673e76ddf7448cd67c3d74f9be96c240a40199b59a30db32d7f43"],["e","c70894331986c66a2baf6fc12dd5c86280e4616cee0e57bbee90972ebbb4b735"],["p","ac3fb436a663b25893657f4b6a3d9d2f02d1974bb5ced603f4d0c8ee32d7e0a2"]]}]"#;
|
|
|
+ let message: Result<Response, _> = serde_json::from_str(json);
|
|
|
+ assert!(message.is_ok());
|
|
|
+ }
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn parse_unexpected_tag() {
|
|
|
+ let json = r#"["EVENT","f4e62282fda9c7d93a6e3b03fd1f1a3a34936f74584b0e021edf9659fd7da9d6",{"content":"1678682368","created_at":1678682368,"id":"7506ccd8ce4de835c61c04fd16f8489b2acb8cc052ed6730cba203188dfedf57","kind":30000,"pubkey":"228cc1e37a8fec2eee3dda3a3dbd04a60968086d8f42751a7632499d938eda8f","sig":"73ec91ee5d23a93287700f31274a57d84f46c0aa06523138c8f5b0cf2f20bd8a7db72346ecae9c717d9642cea81d72c35ea95ec615146b1640f62de5e3fbbb69","tags":[["d","chats/null/lastOpened"]]}]"#;
|
|
|
+ let message: Result<Response, _> = serde_json::from_str(json);
|
|
|
+ assert!(message.is_ok());
|
|
|
+ }
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn serialize_deserialize() {
|
|
|
+ let json = vec![
|
|
|
+ json!(["p", "d45a98f8988"]),
|
|
|
+ json!([
|
|
|
+ "p",
|
|
|
+ "d45a98f898820258a3313f5cb14f5fe8a9263437931ac6309f23ae0324833f39"
|
|
|
+ ]),
|
|
|
+ json!([
|
|
|
+ "p",
|
|
|
+ "8fe53b37518e3dbe9bab26d912292001d8b882de9456b7b08b615f912dc8bf4a",
|
|
|
+ "",
|
|
|
+ "mention"
|
|
|
+ ]),
|
|
|
+ json!([
|
|
|
+ "e",
|
|
|
+ "eb278e983fcedbb0d143c4250c879d078d037586c5dca8e1cf1a104f9846a460",
|
|
|
+ ]),
|
|
|
+ json!([
|
|
|
+ "p",
|
|
|
+ "2bda4f03446bc1c6ff00594e350a1e7ec57fb9cdc4a7b52694223d56ce0599e9",
|
|
|
+ ]),
|
|
|
+ ]
|
|
|
+ .into_iter()
|
|
|
+ .map(|x| serde_json::from_value(x).unwrap())
|
|
|
+ .collect::<Vec<Tag>>();
|
|
|
+
|
|
|
+ let expected = vec![
|
|
|
+ Tag::Unknown("p".to_owned(), vec![String::from("d45a98f8988")]),
|
|
|
+ Tag::PubKey(
|
|
|
+ "d45a98f898820258a3313f5cb14f5fe8a9263437931ac6309f23ae0324833f39"
|
|
|
+ .parse()
|
|
|
+ .unwrap(),
|
|
|
+ None,
|
|
|
+ None,
|
|
|
+ ),
|
|
|
+ Tag::PubKey(
|
|
|
+ "8fe53b37518e3dbe9bab26d912292001d8b882de9456b7b08b615f912dc8bf4a"
|
|
|
+ .parse()
|
|
|
+ .unwrap(),
|
|
|
+ Some(UrlOrEmpty::Empty),
|
|
|
+ Some("mention".to_owned()),
|
|
|
+ ),
|
|
|
+ Tag::Event(
|
|
|
+ "eb278e983fcedbb0d143c4250c879d078d037586c5dca8e1cf1a104f9846a460"
|
|
|
+ .parse()
|
|
|
+ .unwrap(),
|
|
|
+ None,
|
|
|
+ None,
|
|
|
+ ),
|
|
|
+ Tag::PubKey(
|
|
|
+ "2bda4f03446bc1c6ff00594e350a1e7ec57fb9cdc4a7b52694223d56ce0599e9"
|
|
|
+ .parse()
|
|
|
+ .unwrap(),
|
|
|
+ None,
|
|
|
+ None,
|
|
|
+ ),
|
|
|
+ ];
|
|
|
+
|
|
|
+ assert_eq!(json, expected);
|
|
|
+ }
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn test_relay() {
|
|
|
+ let json = json!(["r", "https://example.com", "read"]);
|
|
|
+ assert_eq!(
|
|
|
+ Tag::Relay(
|
|
|
+ "https://example.com".parse().expect("valid url"),
|
|
|
+ RelayAccessType::Read
|
|
|
+ ),
|
|
|
+ serde_json::from_value(json).expect("valid json"),
|
|
|
+ );
|
|
|
+
|
|
|
+ let json = json!(["r", "https://example.com", "write"]);
|
|
|
+ assert_eq!(
|
|
|
+ Tag::Relay(
|
|
|
+ "https://example.com".parse().expect("valid url"),
|
|
|
+ RelayAccessType::Write
|
|
|
+ ),
|
|
|
+ serde_json::from_value(json).expect("valid json"),
|
|
|
+ );
|
|
|
+
|
|
|
+ let json = json!(["r", "https://example.com"]);
|
|
|
+ assert_eq!(
|
|
|
+ Tag::Relay(
|
|
|
+ "https://example.com".parse().expect("valid url"),
|
|
|
+ RelayAccessType::Both,
|
|
|
+ ),
|
|
|
+ serde_json::from_value(json).expect("valid json"),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn test_relay_invalid_url() {
|
|
|
+ let json = json!(["r", "example.com", "read"]);
|
|
|
+ assert_eq!(
|
|
|
+ Tag::Unknown(
|
|
|
+ "r".to_string(),
|
|
|
+ vec!["example.com".to_string(), "read".to_string()],
|
|
|
+ ),
|
|
|
+ serde_json::from_value(json).expect("valid json"),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn malformed_tag() {
|
|
|
+ let json = vec![
|
|
|
+ json!(["r"]),
|
|
|
+ json!(["t"]),
|
|
|
+ json!(["p"]),
|
|
|
+ json!(["e"]),
|
|
|
+ json!(["i"]),
|
|
|
+ json!(["title"]),
|
|
|
+ ];
|
|
|
+
|
|
|
+ for json in json {
|
|
|
+ let tag: Tag = serde_json::from_value(json).expect("valid");
|
|
|
+ assert!(matches!(tag, Tag::Unknown(_, _)));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn test_relay_invalid_access() {
|
|
|
+ let json = json!(["r", "https://example.com", "invalid"]);
|
|
|
+ assert_eq!(
|
|
|
+ Tag::Unknown(
|
|
|
+ "r".to_string(),
|
|
|
+ vec!["https://example.com".to_string(), "invalid".to_string()],
|
|
|
+ ),
|
|
|
+ serde_json::from_value(json).expect("valid json"),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn hashtag() {
|
|
|
+ let json = json!(["t", "rust"]);
|
|
|
+ assert_eq!(
|
|
|
+ Tag::Hashtag("rust".to_string()),
|
|
|
+ serde_json::from_value(json).expect("valid json"),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn title() {
|
|
|
+ let json = json!(["title", "Rust"]);
|
|
|
+ assert_eq!(
|
|
|
+ Tag::Title("Rust".to_string()),
|
|
|
+ serde_json::from_value(json).expect("valid json"),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn external_content_id() {
|
|
|
+ let json = json!(["i", "123"]);
|
|
|
+ assert_eq!(
|
|
|
+ serde_json::from_value::<Tag>(json).expect("valid"),
|
|
|
+ Tag::ExternalContentId("123".to_string(), None,),
|
|
|
+ );
|
|
|
+ let json = json!(["i", "123", "https://example.com"]);
|
|
|
+ assert_eq!(
|
|
|
+ serde_json::from_value::<Tag>(json).expect("valid"),
|
|
|
+ Tag::ExternalContentId(
|
|
|
+ "123".to_string(),
|
|
|
+ Some("https://example.com".parse().expect("valid url"))
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn external_content_id_invalid_args() {
|
|
|
+ let json = json!(["i", "123", "https://example.com", "unexpected"]);
|
|
|
+ assert_eq!(
|
|
|
+ serde_json::from_value::<Tag>(json).expect("valid"),
|
|
|
+ Tag::Unknown(
|
|
|
+ "i".to_owned(),
|
|
|
+ vec![
|
|
|
+ "123".to_owned(),
|
|
|
+ "https://example.com".to_owned(),
|
|
|
+ "unexpected".to_owned()
|
|
|
+ ]
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn encrypted_tag() {
|
|
|
+ let json = json!(["encrypted"]);
|
|
|
+ assert_eq!(Tag::Encrypted, serde_json::from_value(json).expect("valid"));
|
|
|
+ }
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn external_content_id_invalid_url() {
|
|
|
+ let json = json!(["i", "123", "facebook.com"]);
|
|
|
+ assert_eq!(
|
|
|
+ serde_json::from_value::<Tag>(json).expect("valid"),
|
|
|
+ Tag::Unknown(
|
|
|
+ "i".to_owned(),
|
|
|
+ vec!["123".to_owned(), "facebook.com".to_owned()]
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn goal() {
|
|
|
+ let json = json!([
|
|
|
+ "goal",
|
|
|
+ "d45a98f898820258a3313f5cb14f5fe8a9263437931ac6309f23ae0324833f39"
|
|
|
+ ]);
|
|
|
+ assert_eq!(
|
|
|
+ serde_json::from_value::<Tag>(json).expect("valid"),
|
|
|
+ Tag::ZapGoal(
|
|
|
+ "d45a98f898820258a3313f5cb14f5fe8a9263437931ac6309f23ae0324833f39"
|
|
|
+ .parse()
|
|
|
+ .unwrap(),
|
|
|
+ None
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn timestamp() {
|
|
|
+ let json = json!(["expiration", "1678682368"]);
|
|
|
+ assert_eq!(
|
|
|
+ serde_json::from_value::<Tag>(json).expect("valid"),
|
|
|
+ Tag::Expiration(DateTime::<Utc>::from_timestamp(1678682368, 0).unwrap()),
|
|
|
+ );
|
|
|
+ }
|
|
|
+}
|