lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

post.rs (24534B)


      1 use radroots_events::{
      2     farm::RadrootsFarmRef,
      3     kinds::{KIND_ARTICLE, KIND_COMMENT, KIND_FARM, KIND_POST},
      4     post::RadrootsPost,
      5     social::{
      6         RadrootsSocialFarmAnchor, RadrootsSocialLocation, RadrootsSocialMediaDimensions,
      7         RadrootsSocialMediaMetadata, RadrootsSocialMediaThumbnail, RadrootsSocialTarget,
      8     },
      9     tags::{TAG_A, TAG_G, TAG_IMETA, TAG_LOCATION, TAG_Q, TAG_T},
     10 };
     11 use radroots_events_codec::error::{EventEncodeError, EventParseError};
     12 use radroots_events_codec::post::decode::{
     13     data_from_event, parsed_from_event, post_from_content, post_from_event,
     14 };
     15 use radroots_events_codec::post::encode::{
     16     post_build_tags, to_wire_parts, to_wire_parts_with_kind,
     17 };
     18 
     19 const QUOTE_ID: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
     20 const FARM_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA";
     21 const ARTICLE_D_TAG: &str = "BBBBBBBBBBBBBBBBBBBBBA";
     22 
     23 fn content_post() -> RadrootsPost {
     24     RadrootsPost {
     25         content: "field update".to_string(),
     26         farm: None,
     27         address_refs: None,
     28         location: None,
     29         topics: None,
     30         quote_refs: None,
     31         media: None,
     32     }
     33 }
     34 
     35 #[test]
     36 fn post_to_wire_parts_requires_content() {
     37     let post = RadrootsPost {
     38         content: "   ".to_string(),
     39         farm: None,
     40         address_refs: None,
     41         location: None,
     42         topics: None,
     43         quote_refs: None,
     44         media: None,
     45     };
     46 
     47     let err = to_wire_parts(&post).unwrap_err();
     48     assert!(matches!(
     49         err,
     50         EventEncodeError::EmptyRequiredField("content")
     51     ));
     52 }
     53 
     54 #[test]
     55 fn post_to_wire_parts_sets_kind_and_content() {
     56     let post = RadrootsPost {
     57         content: "hello".to_string(),
     58         farm: None,
     59         address_refs: None,
     60         location: None,
     61         topics: None,
     62         quote_refs: None,
     63         media: None,
     64     };
     65 
     66     let parts = to_wire_parts(&post).unwrap();
     67     assert_eq!(parts.kind, KIND_POST);
     68     assert_eq!(parts.content, "hello");
     69     assert!(parts.tags.is_empty());
     70 }
     71 
     72 #[test]
     73 fn post_to_wire_parts_with_kind_rejects_non_post_kind() {
     74     let post = RadrootsPost {
     75         content: "hello".to_string(),
     76         farm: None,
     77         address_refs: None,
     78         location: None,
     79         topics: None,
     80         quote_refs: None,
     81         media: None,
     82     };
     83 
     84     assert!(matches!(
     85         to_wire_parts_with_kind(&post, KIND_ARTICLE),
     86         Err(EventEncodeError::InvalidKind(KIND_ARTICLE))
     87     ));
     88 }
     89 
     90 #[test]
     91 fn post_to_wire_parts_roundtrips_optional_social_tags() {
     92     let post = RadrootsPost {
     93         content: "field update".to_string(),
     94         farm: Some(RadrootsSocialFarmAnchor {
     95             farm: RadrootsFarmRef {
     96                 pubkey: "farm_pubkey".to_string(),
     97                 d_tag: FARM_D_TAG.to_string(),
     98             },
     99             relays: Some(vec!["wss://farm-relay.example.test".to_string()]),
    100         }),
    101         address_refs: Some(vec![RadrootsSocialTarget::Address {
    102             address: format!("30023:article_author:{ARTICLE_D_TAG}"),
    103             author: Some("article_author".to_string()),
    104             event_kind: Some(30023),
    105             relays: Some(vec!["wss://article-relay.example.test".to_string()]),
    106         }]),
    107         location: Some(RadrootsSocialLocation {
    108             name: Some("North field".to_string()),
    109             geohash: Some("c23nb62w20st".to_string()),
    110         }),
    111         topics: Some(vec!["soil".to_string(), "cover-crops".to_string()]),
    112         quote_refs: Some(vec![
    113             RadrootsSocialTarget::Event {
    114                 id: QUOTE_ID.to_string(),
    115                 author: None,
    116                 event_kind: None,
    117                 relays: Some(vec!["wss://quote-relay.example.test".to_string()]),
    118             },
    119             RadrootsSocialTarget::Address {
    120                 address: format!("30023:quote_author:{ARTICLE_D_TAG}"),
    121                 author: Some("quote_author".to_string()),
    122                 event_kind: Some(30023),
    123                 relays: None,
    124             },
    125         ]),
    126         media: Some(vec![RadrootsSocialMediaMetadata {
    127             imeta: Some(vec![vec![
    128                 "url https://media.example.test/field.jpg".to_string(),
    129                 "m image/jpeg".to_string(),
    130                 format!("x {QUOTE_ID}"),
    131                 "dim 1200x800".to_string(),
    132                 "alt Field rows".to_string(),
    133                 "service https://media.example.test".to_string(),
    134             ]]),
    135             ..RadrootsSocialMediaMetadata::default()
    136         }]),
    137     };
    138 
    139     let parts = to_wire_parts(&post).unwrap();
    140     assert_eq!(parts.kind, KIND_POST);
    141     assert!(parts.tags.iter().any(|tag| {
    142         tag.first().map(|value| value.as_str()) == Some(TAG_A)
    143             && tag.get(1).map(|value| value.as_str())
    144                 == Some("30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA")
    145     }));
    146     assert!(parts.tags.iter().any(|tag| {
    147         tag.first().map(|value| value.as_str()) == Some(TAG_A)
    148             && tag.get(1).map(|value| value.as_str())
    149                 == Some("30023:article_author:BBBBBBBBBBBBBBBBBBBBBA")
    150     }));
    151     assert!(parts.tags.iter().any(|tag| {
    152         tag.first().map(|value| value.as_str()) == Some(TAG_LOCATION)
    153             && tag.get(1).map(|value| value.as_str()) == Some("North field")
    154     }));
    155     assert!(parts.tags.iter().any(|tag| {
    156         tag.first().map(|value| value.as_str()) == Some(TAG_G)
    157             && tag.get(1).map(|value| value.as_str()) == Some("c23nb62w20st")
    158     }));
    159     assert!(parts.tags.iter().any(|tag| {
    160         tag.first().map(|value| value.as_str()) == Some(TAG_T)
    161             && tag.get(1).map(|value| value.as_str()) == Some("soil")
    162     }));
    163     assert!(parts.tags.iter().any(|tag| {
    164         tag.first().map(|value| value.as_str()) == Some(TAG_Q)
    165             && tag.get(1).map(|value| value.as_str()) == Some(QUOTE_ID)
    166     }));
    167     assert!(parts.tags.iter().any(|tag| {
    168         tag.first().map(|value| value.as_str()) == Some(TAG_IMETA)
    169             && tag
    170                 .iter()
    171                 .any(|value| value == "url https://media.example.test/field.jpg")
    172     }));
    173 
    174     let decoded = post_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
    175     assert_eq!(decoded.content, "field update");
    176     assert_eq!(
    177         decoded.farm.as_ref().map(|farm| farm.farm.pubkey.as_str()),
    178         Some("farm_pubkey")
    179     );
    180     assert_eq!(decoded.address_refs.as_ref().map(Vec::len), Some(1));
    181     assert_eq!(
    182         decoded
    183             .location
    184             .as_ref()
    185             .and_then(|location| location.name.as_deref()),
    186         Some("North field")
    187     );
    188     assert_eq!(decoded.topics.as_ref().map(Vec::len), Some(2));
    189     assert_eq!(decoded.quote_refs.as_ref().map(Vec::len), Some(2));
    190     let media = decoded.media.as_ref().expect("media");
    191     assert_eq!(
    192         media[0].url.as_deref(),
    193         Some("https://media.example.test/field.jpg")
    194     );
    195     assert_eq!(media[0].mime_type.as_deref(), Some("image/jpeg"));
    196     assert_eq!(
    197         media[0].dimensions.as_ref().map(|value| value.width),
    198         Some(1200)
    199     );
    200     assert_eq!(media[0].alt.as_deref(), Some("Field rows"));
    201     assert_eq!(media[0].services.as_ref().map(Vec::len), Some(1));
    202 }
    203 
    204 #[test]
    205 fn post_build_tags_covers_optional_social_encode_branches() {
    206     let mut post = content_post();
    207     post.farm = Some(RadrootsSocialFarmAnchor {
    208         farm: RadrootsFarmRef {
    209             pubkey: "farm_pubkey".to_string(),
    210             d_tag: FARM_D_TAG.to_string(),
    211         },
    212         relays: Some(vec!["wss://farm-relay.example.test".to_string()]),
    213     });
    214     post.address_refs = Some(vec![RadrootsSocialTarget::Address {
    215         address: format!("30023:article_author:{ARTICLE_D_TAG}"),
    216         author: None,
    217         event_kind: None,
    218         relays: Some(vec!["wss://article-relay.example.test".to_string()]),
    219     }]);
    220     post.quote_refs = Some(vec![
    221         RadrootsSocialTarget::Event {
    222             id: QUOTE_ID.to_string(),
    223             author: None,
    224             event_kind: None,
    225             relays: Some(vec!["wss://quote-relay.example.test".to_string()]),
    226         },
    227         RadrootsSocialTarget::Address {
    228             address: format!("30023:quote_author:{ARTICLE_D_TAG}"),
    229             author: None,
    230             event_kind: None,
    231             relays: Some(vec!["wss://quote-address-relay.example.test".to_string()]),
    232         },
    233     ]);
    234     post.media = Some(vec![RadrootsSocialMediaMetadata {
    235         thumbnails: Some(vec![RadrootsSocialMediaThumbnail {
    236             url: "https://media.example.test/thumb.jpg".to_string(),
    237             dimensions: Some(RadrootsSocialMediaDimensions {
    238                 width: 120,
    239                 height: 80,
    240             }),
    241         }]),
    242         ..RadrootsSocialMediaMetadata::default()
    243     }]);
    244 
    245     let tags = post_build_tags(&post).unwrap();
    246     assert!(tags.iter().any(|tag| {
    247         tag.first().map(|value| value.as_str()) == Some(TAG_A)
    248             && tag
    249                 .iter()
    250                 .any(|value| value == "wss://farm-relay.example.test")
    251     }));
    252     assert!(tags.iter().any(|tag| {
    253         tag.first().map(|value| value.as_str()) == Some(TAG_A)
    254             && tag
    255                 .iter()
    256                 .any(|value| value == "wss://article-relay.example.test")
    257     }));
    258     assert!(tags.iter().any(|tag| {
    259         tag.first().map(|value| value.as_str()) == Some(TAG_Q)
    260             && tag
    261                 .iter()
    262                 .any(|value| value == "wss://quote-relay.example.test")
    263     }));
    264     assert!(tags.iter().any(|tag| {
    265         tag.first().map(|value| value.as_str()) == Some(TAG_Q)
    266             && tag
    267                 .iter()
    268                 .any(|value| value == "wss://quote-address-relay.example.test")
    269     }));
    270     assert!(tags.iter().any(|tag| {
    271         tag.first().map(|value| value.as_str()) == Some(TAG_IMETA)
    272             && tag.iter().any(|value| value == "dim 120x80")
    273     }));
    274 
    275     let mut no_relay_post = content_post();
    276     no_relay_post.farm = Some(RadrootsSocialFarmAnchor {
    277         farm: RadrootsFarmRef {
    278             pubkey: "farm_pubkey".to_string(),
    279             d_tag: FARM_D_TAG.to_string(),
    280         },
    281         relays: None,
    282     });
    283     no_relay_post.address_refs = Some(vec![RadrootsSocialTarget::Address {
    284         address: format!("30023:article_author:{ARTICLE_D_TAG}"),
    285         author: None,
    286         event_kind: None,
    287         relays: None,
    288     }]);
    289     no_relay_post.quote_refs = Some(vec![RadrootsSocialTarget::Event {
    290         id: QUOTE_ID.to_string(),
    291         author: None,
    292         event_kind: None,
    293         relays: None,
    294     }]);
    295     no_relay_post.media = Some(vec![RadrootsSocialMediaMetadata {
    296         thumbnails: Some(vec![RadrootsSocialMediaThumbnail {
    297             url: "https://media.example.test/thumb-no-dim.jpg".to_string(),
    298             dimensions: None,
    299         }]),
    300         ..RadrootsSocialMediaMetadata::default()
    301     }]);
    302 
    303     let tags = post_build_tags(&no_relay_post).unwrap();
    304     let farm_tag = tags
    305         .iter()
    306         .find(|tag| {
    307             tag.first().map(String::as_str) == Some(TAG_A)
    308                 && tag.get(1).map(String::as_str)
    309                     == Some("30340:farm_pubkey:AAAAAAAAAAAAAAAAAAAAAA")
    310         })
    311         .expect("farm tag");
    312     assert_eq!(farm_tag.len(), 2);
    313     let address_tag = tags
    314         .iter()
    315         .find(|tag| {
    316             tag.first().map(String::as_str) == Some(TAG_A)
    317                 && tag.get(1).map(String::as_str)
    318                     == Some("30023:article_author:BBBBBBBBBBBBBBBBBBBBBA")
    319         })
    320         .expect("address tag");
    321     assert_eq!(address_tag.len(), 2);
    322     let quote_tag = tags
    323         .iter()
    324         .find(|tag| tag.first().map(String::as_str) == Some(TAG_Q))
    325         .expect("quote tag");
    326     assert_eq!(quote_tag.len(), 2);
    327     let imeta = tags
    328         .iter()
    329         .find(|tag| tag.first().map(String::as_str) == Some(TAG_IMETA))
    330         .expect("imeta tag");
    331     assert!(
    332         imeta
    333             .iter()
    334             .any(|value| value == "thumb https://media.example.test/thumb-no-dim.jpg")
    335     );
    336     assert!(!imeta.iter().any(|value| value.starts_with("dim ")));
    337 }
    338 
    339 #[test]
    340 fn post_social_tags_reject_malformed_supported_structures() {
    341     let mut post = content_post();
    342     post.address_refs = Some(vec![RadrootsSocialTarget::Event {
    343         id: QUOTE_ID.to_string(),
    344         author: None,
    345         event_kind: None,
    346         relays: None,
    347     }]);
    348     assert!(matches!(
    349         post_build_tags(&post),
    350         Err(EventEncodeError::InvalidField("address_refs"))
    351     ));
    352 
    353     post.address_refs = Some(vec![RadrootsSocialTarget::Address {
    354         address: "not-an-address".to_string(),
    355         author: None,
    356         event_kind: None,
    357         relays: None,
    358     }]);
    359     assert!(matches!(
    360         post_build_tags(&post),
    361         Err(EventEncodeError::InvalidField("address_refs"))
    362     ));
    363 
    364     post.address_refs = Some(vec![RadrootsSocialTarget::Address {
    365         address: format!("30340:farm_pubkey:{FARM_D_TAG}"),
    366         author: Some("farm_pubkey".to_string()),
    367         event_kind: Some(30340),
    368         relays: None,
    369     }]);
    370     assert!(matches!(
    371         post_build_tags(&post),
    372         Err(EventEncodeError::InvalidField("address_refs"))
    373     ));
    374 
    375     post.address_refs = Some(vec![RadrootsSocialTarget::Address {
    376         address: format!("30023:article_author:{ARTICLE_D_TAG}"),
    377         author: Some("other_author".to_string()),
    378         event_kind: Some(30023),
    379         relays: None,
    380     }]);
    381     assert!(matches!(
    382         post_build_tags(&post),
    383         Err(EventEncodeError::InvalidField("address_refs"))
    384     ));
    385 
    386     post.address_refs = Some(vec![RadrootsSocialTarget::Address {
    387         address: format!("30023:article_author:{ARTICLE_D_TAG}"),
    388         author: Some("article_author".to_string()),
    389         event_kind: Some(30024),
    390         relays: None,
    391     }]);
    392     assert!(matches!(
    393         post_build_tags(&post),
    394         Err(EventEncodeError::InvalidField("address_refs"))
    395     ));
    396 
    397     post.address_refs = None;
    398     post.farm = Some(RadrootsSocialFarmAnchor {
    399         farm: RadrootsFarmRef {
    400             pubkey: String::new(),
    401             d_tag: FARM_D_TAG.to_string(),
    402         },
    403         relays: None,
    404     });
    405     assert!(matches!(
    406         post_build_tags(&post),
    407         Err(EventEncodeError::EmptyRequiredField("farm.pubkey"))
    408     ));
    409 
    410     post.farm = Some(RadrootsSocialFarmAnchor {
    411         farm: RadrootsFarmRef {
    412             pubkey: "farm_pubkey".to_string(),
    413             d_tag: String::new(),
    414         },
    415         relays: None,
    416     });
    417     assert!(matches!(
    418         post_build_tags(&post),
    419         Err(EventEncodeError::EmptyRequiredField("farm.d_tag"))
    420     ));
    421 
    422     post.farm = Some(RadrootsSocialFarmAnchor {
    423         farm: RadrootsFarmRef {
    424             pubkey: "farm_pubkey".to_string(),
    425             d_tag: "bad d".to_string(),
    426         },
    427         relays: None,
    428     });
    429     assert!(matches!(
    430         post_build_tags(&post),
    431         Err(EventEncodeError::InvalidField("farm"))
    432     ));
    433 
    434     post.farm = None;
    435     post.quote_refs = Some(vec![RadrootsSocialTarget::Event {
    436         id: "not-hex".to_string(),
    437         author: None,
    438         event_kind: None,
    439         relays: None,
    440     }]);
    441     assert!(matches!(
    442         post_build_tags(&post),
    443         Err(EventEncodeError::InvalidField("quote_refs"))
    444     ));
    445 
    446     post.quote_refs = Some(vec![RadrootsSocialTarget::Address {
    447         address: "not-an-address".to_string(),
    448         author: None,
    449         event_kind: None,
    450         relays: None,
    451     }]);
    452     assert!(matches!(
    453         post_build_tags(&post),
    454         Err(EventEncodeError::InvalidField("quote_refs"))
    455     ));
    456 
    457     post.quote_refs = Some(vec![RadrootsSocialTarget::Address {
    458         address: format!("30023:quote_author:{ARTICLE_D_TAG}"),
    459         author: None,
    460         event_kind: Some(30024),
    461         relays: None,
    462     }]);
    463     assert!(matches!(
    464         post_build_tags(&post),
    465         Err(EventEncodeError::InvalidField("quote_refs"))
    466     ));
    467 
    468     post.quote_refs = Some(vec![RadrootsSocialTarget::External {
    469         id: "https://example.test/object".to_string(),
    470         external_kind: "web".to_string(),
    471         hint: None,
    472     }]);
    473     assert!(matches!(
    474         post_build_tags(&post),
    475         Err(EventEncodeError::InvalidField("quote_refs"))
    476     ));
    477 
    478     post.quote_refs = None;
    479     post.media = Some(vec![RadrootsSocialMediaMetadata {
    480         imeta: Some(vec![Vec::new()]),
    481         ..RadrootsSocialMediaMetadata::default()
    482     }]);
    483     assert!(matches!(
    484         post_build_tags(&post),
    485         Err(EventEncodeError::InvalidField("imeta"))
    486     ));
    487 
    488     post.media = Some(vec![RadrootsSocialMediaMetadata {
    489         imeta: Some(vec![vec![" ".to_string()]]),
    490         ..RadrootsSocialMediaMetadata::default()
    491     }]);
    492     assert!(matches!(
    493         post_build_tags(&post),
    494         Err(EventEncodeError::InvalidField("imeta"))
    495     ));
    496 
    497     post.media = Some(vec![RadrootsSocialMediaMetadata {
    498         thumbnails: Some(vec![RadrootsSocialMediaThumbnail {
    499             url: " ".to_string(),
    500             dimensions: None,
    501         }]),
    502         ..RadrootsSocialMediaMetadata::default()
    503     }]);
    504     assert!(matches!(
    505         post_build_tags(&post),
    506         Err(EventEncodeError::InvalidField("imeta"))
    507     ));
    508 
    509     let err = post_from_event(
    510         KIND_POST,
    511         &[vec![TAG_IMETA.to_string(), "bad-imeta-entry".to_string()]],
    512         "hello",
    513     )
    514     .unwrap_err();
    515     assert!(matches!(err, EventParseError::InvalidTag(TAG_IMETA)));
    516 }
    517 
    518 #[test]
    519 fn post_media_structured_fields_encode_and_decode_imeta() {
    520     let mut post = content_post();
    521     post.topics = Some(vec![
    522         "soil".to_string(),
    523         " ".to_string(),
    524         "market".to_string(),
    525     ]);
    526     post.media = Some(vec![
    527         RadrootsSocialMediaMetadata::default(),
    528         RadrootsSocialMediaMetadata {
    529             url: Some("https://media.example.test/field.jpg".to_string()),
    530             mime_type: Some("image/jpeg".to_string()),
    531             sha256: Some(QUOTE_ID.to_string()),
    532             original_sha256: Some(QUOTE_ID.to_string()),
    533             size: Some(42),
    534             dimensions: Some(RadrootsSocialMediaDimensions {
    535                 width: 1200,
    536                 height: 800,
    537             }),
    538             blurhash: Some("LEHV6nWB2yk8pyo0adR*.7kCMdnj".to_string()),
    539             thumbnails: Some(vec![RadrootsSocialMediaThumbnail {
    540                 url: "https://media.example.test/thumb.jpg".to_string(),
    541                 dimensions: Some(RadrootsSocialMediaDimensions {
    542                     width: 120,
    543                     height: 80,
    544                 }),
    545             }]),
    546             image: Some("https://media.example.test/poster.jpg".to_string()),
    547             summary: Some("Field row image".to_string()),
    548             alt: Some("rows in field".to_string()),
    549             fallback: Some("https://media.example.test/fallback.jpg".to_string()),
    550             magnet: Some("magnet:?xt=urn:btih:fixture".to_string()),
    551             content_hashes: Some(vec!["hash-a".to_string(), "hash-b".to_string()]),
    552             services: Some(vec!["https://media.example.test".to_string()]),
    553             imeta: None,
    554         },
    555     ]);
    556 
    557     let parts = to_wire_parts(&post).unwrap();
    558     let topic_tags = parts
    559         .tags
    560         .iter()
    561         .filter(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_T))
    562         .count();
    563     assert_eq!(topic_tags, 2);
    564 
    565     let imeta = parts
    566         .tags
    567         .iter()
    568         .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_IMETA))
    569         .expect("imeta tag");
    570     for expected in [
    571         "url https://media.example.test/field.jpg",
    572         "m image/jpeg",
    573         "size 42",
    574         "dim 1200x800",
    575         "blurhash LEHV6nWB2yk8pyo0adR*.7kCMdnj",
    576         "thumb https://media.example.test/thumb.jpg",
    577         "dim 120x80",
    578         "image https://media.example.test/poster.jpg",
    579         "summary Field row image",
    580         "alt rows in field",
    581         "fallback https://media.example.test/fallback.jpg",
    582         "magnet magnet:?xt=urn:btih:fixture",
    583         "i hash-a",
    584         "i hash-b",
    585         "service https://media.example.test",
    586     ] {
    587         assert!(imeta.iter().any(|value| value == expected), "{expected}");
    588     }
    589 
    590     let decoded = post_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
    591     let media = decoded.media.expect("media");
    592     assert_eq!(media.len(), 1);
    593     assert_eq!(media[0].original_sha256.as_deref(), Some(QUOTE_ID));
    594     assert_eq!(media[0].size, Some(42));
    595     assert_eq!(
    596         media[0].blurhash.as_deref(),
    597         Some("LEHV6nWB2yk8pyo0adR*.7kCMdnj")
    598     );
    599     assert_eq!(
    600         media[0].image.as_deref(),
    601         Some("https://media.example.test/poster.jpg")
    602     );
    603     assert_eq!(media[0].summary.as_deref(), Some("Field row image"));
    604     assert_eq!(
    605         media[0].fallback.as_deref(),
    606         Some("https://media.example.test/fallback.jpg")
    607     );
    608     assert_eq!(
    609         media[0].magnet.as_deref(),
    610         Some("magnet:?xt=urn:btih:fixture")
    611     );
    612     assert_eq!(media[0].content_hashes.as_ref().map(Vec::len), Some(2));
    613 }
    614 
    615 #[test]
    616 fn post_decode_rejects_more_invalid_imeta_shapes() {
    617     for tags in [
    618         vec![TAG_IMETA.to_string()],
    619         vec![TAG_IMETA.to_string(), " ".to_string()],
    620     ] {
    621         let err = post_from_event(KIND_POST, &[tags], "hello").unwrap_err();
    622         assert!(matches!(err, EventParseError::InvalidTag(TAG_IMETA)));
    623     }
    624 
    625     for entry in ["url ", "size not-a-number", "dim bad", "dim 0x10"] {
    626         let err = post_from_event(
    627             KIND_POST,
    628             &[vec![TAG_IMETA.to_string(), entry.to_string()]],
    629             "hello",
    630         )
    631         .unwrap_err();
    632         assert!(matches!(
    633             err,
    634             EventParseError::InvalidTag(TAG_IMETA) | EventParseError::InvalidNumber(TAG_IMETA, _)
    635         ));
    636     }
    637 }
    638 
    639 #[test]
    640 fn post_decode_handles_non_farm_address_refs_without_relays() {
    641     let article = format!("30023:article_author:{ARTICLE_D_TAG}");
    642     let farm = format!("{KIND_FARM}:farm_pubkey:{FARM_D_TAG}");
    643     let decoded = post_from_event(
    644         KIND_POST,
    645         &[
    646             vec![TAG_A.to_string(), farm.clone()],
    647             vec![TAG_A.to_string(), article.clone()],
    648         ],
    649         "address only",
    650     )
    651     .unwrap();
    652 
    653     let anchor = decoded.farm.expect("farm anchor");
    654     assert_eq!(anchor.farm.d_tag, FARM_D_TAG);
    655     assert_eq!(anchor.relays, None);
    656     let refs = decoded.address_refs.expect("address refs");
    657     assert_eq!(refs.len(), 1);
    658     match &refs[0] {
    659         RadrootsSocialTarget::Address {
    660             address,
    661             author,
    662             event_kind,
    663             relays,
    664         } => {
    665             assert_eq!(address, &article);
    666             assert_eq!(author.as_deref(), Some("article_author"));
    667             assert_eq!(*event_kind, Some(30023));
    668             assert_eq!(relays, &None);
    669         }
    670         _ => panic!("expected address target"),
    671     }
    672 }
    673 
    674 #[test]
    675 fn post_from_content_requires_kind_and_content() {
    676     let err = post_from_content(KIND_COMMENT, "hello").unwrap_err();
    677     assert!(matches!(
    678         err,
    679         EventParseError::InvalidKind {
    680             expected: "1",
    681             got: KIND_COMMENT
    682         }
    683     ));
    684 
    685     let err = post_from_content(KIND_POST, "   ").unwrap_err();
    686     assert!(matches!(err, EventParseError::InvalidTag("content")));
    687 }
    688 
    689 #[test]
    690 fn post_metadata_and_index_from_event_roundtrip() {
    691     let metadata = data_from_event(
    692         "id".to_string(),
    693         "author".to_string(),
    694         77,
    695         KIND_POST,
    696         "hello".to_string(),
    697         Vec::new(),
    698     )
    699     .unwrap();
    700     assert_eq!(metadata.id, "id");
    701     assert_eq!(metadata.author, "author");
    702     assert_eq!(metadata.published_at, 77);
    703     assert_eq!(metadata.kind, KIND_POST);
    704     assert_eq!(metadata.data.content, "hello");
    705 
    706     let index = parsed_from_event(
    707         "id".to_string(),
    708         "author".to_string(),
    709         77,
    710         KIND_POST,
    711         "hello".to_string(),
    712         Vec::new(),
    713         "sig".to_string(),
    714     )
    715     .unwrap();
    716     assert_eq!(index.event.id, "id");
    717     assert_eq!(index.event.author, "author");
    718     assert_eq!(index.event.created_at, 77);
    719     assert_eq!(index.event.kind, KIND_POST);
    720     assert_eq!(index.event.content, "hello");
    721     assert_eq!(index.event.sig, "sig");
    722     assert_eq!(index.data.data.content, "hello");
    723 }
    724 
    725 #[test]
    726 fn post_index_from_event_propagates_parse_errors() {
    727     let err = parsed_from_event(
    728         "id".to_string(),
    729         "author".to_string(),
    730         77,
    731         KIND_COMMENT,
    732         "hello".to_string(),
    733         Vec::new(),
    734         "sig".to_string(),
    735     )
    736     .unwrap_err();
    737     assert!(matches!(
    738         err,
    739         EventParseError::InvalidKind {
    740             expected: "1",
    741             got: KIND_COMMENT
    742         }
    743     ));
    744 }